diff --git a/rust/loki-rs/src/gguf.rs b/rust/loki-rs/src/gguf.rs index 134237e..f548cc8 100644 --- a/rust/loki-rs/src/gguf.rs +++ b/rust/loki-rs/src/gguf.rs @@ -1 +1,1491 @@ -//! Lot 4 : module en cours de portage. +//! Lecteur des métadonnées GGUF (port de `backend_gguf.go`), résolution du +//! modèle à lancer (`resolveServeModelPath`, `backend_model_dirs.go`), familles +//! de tranches (`backend_models_shards.go`) et réponse de +//! `GET /api/model/layers` (`handleModelLayers`, `engine_cmdline.go`). +//! +//! Plusieurs réglages du moteur dépendent de ce que le fichier contient +//! VRAIMENT : une tête MTP, le nombre de têtes KV par couche, une architecture +//! hybride à état récurrent. On ne lit que l'en-tête, la section clé/valeur et +//! la table des tenseurs : les poids ne sont jamais touchés. Le fichier peut +//! être en cours de téléchargement, tronqué, ou pas un GGUF : chaque longueur +//! lue est bornée par ce qui reste du fichier, et la moindre incohérence donne +//! une erreur, jamais un plantage ni une allocation géante. Une erreur veut +//! dire « on ne sait pas » : l'appelant reste sur le comportement prudent. +//! +//! Données du store : la liste des dossiers de modèles ajoutés vit dans le +//! bucket `state`, clé `model_dirs` (JSON `[]string`, voir +//! [`STATE_MODEL_DIRS_KEY`] et [`parse_stored_model_dirs`]) ; l'appelant la lit +//! par `crate::store::Store` et la passe aux fonctions de résolution, avec +//! `$LOKI_MODEL_DIRS`. + +use serde_json::{Value, json}; +use std::collections::HashMap; +use std::fmt::Write as _; +use std::fs::File; +use std::io::{self, BufReader, Read}; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::paths::{base_name, go_abs, go_clean, go_dir, go_is_abs, go_join, os_err}; + +/// Bucket `state`, clé des dossiers de modèles ajoutés (`extraModelDirs`). +pub const STATE_MODEL_DIRS_KEY: &str = "model_dirs"; +/// Variable d'environnement des dossiers de modèles supplémentaires. +pub const MODEL_DIRS_ENV: &str = "LOKI_MODEL_DIRS"; + +/// Ce que Loki retient d'un modèle GGUF (`GGUFInfo` du Go). Les champs absents +/// du fichier restent à zéro. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct GgufMeta { + /// version du format (1 à 3) + pub version: i64, + /// general.architecture + pub arch: String, + /// `.block_count` + pub block_count: i64, + /// `.nextn_predict_layers` (couches MTP annoncées) + pub next_n: i64, + /// `.context_length` (contexte d'entraînement) + pub context_length: i64, + /// `.expert_count` (0 = modèle dense) + pub expert_count: i64, + /// `.attention.key_length` + pub key_len: i64, + /// `.attention.value_length` + pub val_len: i64, + /// Valeur unique, ou maximum sur les couches quand le fichier donne un + /// tableau par couche (`head_count_kv_layers`, `None` sinon). Sur un + /// hybride, les couches récurrentes y valent 0. + pub head_count_kv: i64, + pub head_count_kv_layers: Option>, + /// `.full_attention_interval`, si présent + pub full_attn_interval: i64, + /// au moins une clé `.ssm.*` : couches à état récurrent + pub hybrid: bool, + /// `.embedding_length` (repli de llama.cpp : n_embd / n_head) + pub embedding_length: i64, + /// `.attention.head_count` (valeur unique seulement) + pub head_count: i64, + /// `.ssm.conv_kernel` + pub ssm_conv: i64, + /// `.ssm.inner_size` + pub ssm_inner: i64, + /// `.ssm.state_size` + pub ssm_state: i64, + /// `.ssm.group_count` + pub ssm_groups: i64, + /// Le tenseur `blk.{block_count-1}.nextn.eh_proj.weight` existe dans l'une + /// des tranches : le test de llama.cpp lui-même pour reconnaître une tête + /// MTP (la clé nextn_predict_layers seule ne suffit pas). + pub has_next_n_tensor: bool, + /// total sur toutes les tranches + pub tensor_count: i64, +} + +// Bornes de lecture : larges devant les vrais modèles, elles servent seulement +// à refuser un fichier aberrant avant d'allouer quoi que ce soit. +const GGUF_MAX_STRING: u64 = 64 << 20; +const GGUF_MAX_KEY_LEN: u64 = 64 << 10; +const GGUF_MAX_TENSOR_NAME: u64 = 4096; // GGML_MAX_NAME vaut 64 +const GGUF_MAX_KV: u64 = 1 << 20; +const GGUF_MAX_TENSORS: u64 = 1 << 22; +const GGUF_MAX_DIMS: u32 = 8; // GGML_MAX_DIMS vaut 4 +const GGUF_MAX_ARRAY_DEPTH: u32 = 4; +const GGUF_MAX_LAYER_ARRAY: u64 = 1 << 16; + +// Types de valeur GGUF (gguf.h). +pub const GGUF_UINT8: u32 = 0; +pub const GGUF_INT8: u32 = 1; +pub const GGUF_UINT16: u32 = 2; +pub const GGUF_INT16: u32 = 3; +pub const GGUF_UINT32: u32 = 4; +pub const GGUF_INT32: u32 = 5; +pub const GGUF_FLOAT32: u32 = 6; +pub const GGUF_BOOL: u32 = 7; +pub const GGUF_STRING: u32 = 8; +pub const GGUF_ARRAY: u32 = 9; +pub const GGUF_UINT64: u32 = 10; +pub const GGUF_INT64: u32 = 11; +pub const GGUF_FLOAT64: u32 = 12; + +const ERR_TRUNCATED: &str = "GGUF tronqué"; + +fn truncated() -> Result { + Err(ERR_TRUNCATED.to_string()) +} + +/// Taille en octets d'un type de taille fixe, 0 sinon. +fn scalar_size(t: u32) -> i64 { + match t { + GGUF_UINT8 | GGUF_INT8 | GGUF_BOOL => 1, + GGUF_UINT16 | GGUF_INT16 => 2, + GGUF_UINT32 | GGUF_INT32 | GGUF_FLOAT32 => 4, + GGUF_UINT64 | GGUF_INT64 | GGUF_FLOAT64 => 8, + _ => 0, + } +} + +/// Lecture séquentielle en comptant ce qui reste du fichier : toute longueur +/// annoncée au-delà est une incohérence, détectée AVANT de lire. +struct Reader { + r: BufReader, + be: bool, // gros-boutiste + v1: bool, // GGUF v1 : compteurs et longueurs sur 32 bits + size: i64, // taille totale du fichier + left: i64, // octets restants dans le fichier +} + +impl Reader { + fn fixed(&mut self, n: usize) -> Result<[u8; 8], String> { + if n as i64 > self.left { + return truncated(); + } + let mut b = [0u8; 8]; + if self.r.read_exact(&mut b[..n]).is_err() { + return truncated(); + } + self.left -= n as i64; + Ok(b) + } + + fn u16_of(&self, b: &[u8; 8]) -> u16 { + let a = [b[0], b[1]]; + if self.be { + u16::from_be_bytes(a) + } else { + u16::from_le_bytes(a) + } + } + + fn u32_of(&self, b: &[u8; 8]) -> u32 { + let a = [b[0], b[1], b[2], b[3]]; + if self.be { + u32::from_be_bytes(a) + } else { + u32::from_le_bytes(a) + } + } + + fn u64_of(&self, b: &[u8; 8]) -> u64 { + if self.be { + u64::from_be_bytes(*b) + } else { + u64::from_le_bytes(*b) + } + } + + fn u32(&mut self) -> Result { + let b = self.fixed(4)?; + Ok(self.u32_of(&b)) + } + + fn u64(&mut self) -> Result { + let b = self.fixed(8)?; + Ok(self.u64_of(&b)) + } + + /// Compteur ou longueur : 32 bits en v1, 64 bits ensuite. + fn count(&mut self) -> Result { + if self.v1 { + self.u32().map(u64::from) + } else { + self.u64() + } + } + + /// Taille sur disque d'un compteur, pour borner les tableaux de chaînes. + fn count_size(&self) -> i64 { + if self.v1 { 4 } else { 8 } + } + + fn skip(&mut self, n: i64) -> Result<(), String> { + if n < 0 || n > self.left { + return truncated(); + } + let mut lim = Read::by_ref(&mut self.r).take(n as u64); + match io::copy(&mut lim, &mut io::sink()) { + Ok(c) if c == n as u64 => {} + _ => return truncated(), + } + self.left -= n; + Ok(()) + } + + /// Chaîne d'au plus `max` octets. + fn str(&mut self, max: u64) -> Result { + let n = self.count()?; + if n > max { + return Err(format!("chaîne GGUF de {n} octets")); + } + if n as i64 > self.left { + return truncated(); + } + let mut b = vec![0u8; n as usize]; + if self.r.read_exact(&mut b).is_err() { + return truncated(); + } + self.left -= n as i64; + Ok(String::from_utf8_lossy(&b).into_owned()) + } + + fn skip_str(&mut self) -> Result<(), String> { + let n = self.count()?; + if n > GGUF_MAX_STRING { + return Err(format!("chaîne GGUF de {n} octets")); + } + self.skip(n as i64) + } + + /// Valeur entière (ou booléenne) de type `t`. `None` pour les flottants, + /// lus et ignorés : aucune des clés retenues n'en est un. + fn int_val(&mut self, t: u32) -> Result, String> { + let size = scalar_size(t); + if size == 0 { + return Err(format!("type GGUF {t} inattendu")); + } + let b = self.fixed(size as usize)?; + Ok(match t { + GGUF_UINT8 | GGUF_BOOL => Some(i64::from(b[0])), + GGUF_INT8 => Some(i64::from(b[0] as i8)), + GGUF_UINT16 => Some(i64::from(self.u16_of(&b))), + GGUF_INT16 => Some(i64::from(self.u16_of(&b) as i16)), + GGUF_UINT32 => Some(i64::from(self.u32_of(&b))), + GGUF_INT32 => Some(i64::from(self.u32_of(&b) as i32)), + GGUF_UINT64 => Some(self.u64_of(&b).min(i64::MAX as u64) as i64), + GGUF_INT64 => Some(self.u64_of(&b) as i64), + _ => None, // flottant + }) + } + + /// Saute `n` éléments de type `et`, tableaux imbriqués compris (profondeur + /// bornée). Le vocabulaire (150 000 chaînes) passe par ici sans rien + /// allouer. + fn skip_array(&mut self, et: u32, n: u64, depth: u32) -> Result<(), String> { + if depth > GGUF_MAX_ARRAY_DEPTH { + return Err("tableaux GGUF trop imbriqués".to_string()); + } + let size = scalar_size(et); + if size > 0 { + if n > (self.left / size) as u64 { + return truncated(); + } + return self.skip(n as i64 * size); + } + match et { + GGUF_STRING => { + if n > (self.left / self.count_size()) as u64 { + return truncated(); + } + for _ in 0..n { + self.skip_str()?; + } + Ok(()) + } + GGUF_ARRAY => { + // Chaque sous-tableau coûte au moins son type et son compteur. + if n > (self.left / (4 + self.count_size())) as u64 { + return truncated(); + } + for _ in 0..n { + let sub = self.u32()?; + let sn = self.count()?; + self.skip_array(sub, sn, depth + 1)?; + } + Ok(()) + } + _ => Err(format!("type GGUF {et} inconnu")), + } + } +} + +/// Ce qu'on retient d'UNE tranche avant de résoudre les clés selon +/// l'architecture (general.architecture n'est pas forcément la première clé). +#[derive(Debug, Clone, Default, PartialEq)] +pub struct GgufFile { + pub version: i64, + pub arch: String, + pub ints: HashMap, + /// tableaux d'entiers retenus (têtes KV par couche) + pub arrays: HashMap>, + /// noms de toutes les clés, pour repérer `.ssm.*` + pub keys: Vec, + pub tensors: i64, + /// noms des tenseurs `*.nextn.eh_proj.weight` + pub nextn: Vec, +} + +/// Les seuls tableaux qu'on lit au lieu de les sauter. +fn keep_int_array(key: &str) -> bool { + key.ends_with(".attention.head_count_kv") +} + +/// Lit l'en-tête, les clés et la table des tenseurs d'un fichier. +fn read_gguf_header(path: &str) -> Result { + let fh = match File::open(path) { + Ok(f) => f, + // Windows refuse d'ouvrir un dossier là où le Go y arrive : même + // message que le Go dans ce cas. + Err(e) => { + if std::fs::metadata(path).is_ok_and(|m| m.is_dir()) { + return Err(format!("{} est un dossier", base_name(path))); + } + return Err(os_err("open", path, &e)); + } + }; + let st = fh.metadata().map_err(|e| os_err("stat", path, &e))?; + if st.is_dir() { + return Err(format!("{} est un dossier", base_name(path))); + } + parse_gguf(fh, st.len() as i64).map_err(|e| format!("{} : {e}", base_name(path))) +} + +/// Lit `size` octets au plus de `r`. Un panic éventuel (fichier pathologique +/// non prévu par les bornes) est rattrapé en erreur : lire des métadonnées ne +/// doit jamais faire tomber Loki. +pub fn parse_gguf(r: R, size: i64) -> Result { + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| parse_inner(r, size))) { + Ok(res) => res, + Err(p) => { + let msg = p + .downcast_ref::<&str>() + .map(|s| s.to_string()) + .or_else(|| p.downcast_ref::().cloned()) + .unwrap_or_default(); + Err(format!("GGUF illisible : {msg}")) + } + } +} + +fn parse_inner(r: R, size: i64) -> Result { + let mut g = Reader { + r: BufReader::with_capacity(1 << 20, r), + be: false, + v1: false, + size, + left: size, + }; + let magic = g.fixed(4)?; + if &magic[..4] != b"GGUF" { + return Err("pas un fichier GGUF".to_string()); + } + let mut ver = g.u32()?; + // Les GGUF gros-boutistes (s390x) gardent la même signature : seule la + // version lue à l'envers trahit l'ordre des octets. + if ver & 0xFFFF == 0 { + let sw = ver.swap_bytes(); + if (1..=3).contains(&sw) { + g.be = true; + ver = sw; + } + } + if !(1..=3).contains(&ver) { + return Err(format!("version GGUF {ver} non prise en charge")); + } + g.v1 = ver == 1; + let n_tensors = g.count()?; + let n_kv = g.count()?; + if n_kv > GGUF_MAX_KV || n_tensors > GGUF_MAX_TENSORS { + return Err(format!( + "compteurs GGUF aberrants ({n_kv} clés, {n_tensors} tenseurs)" + )); + } + let mut f = GgufFile { + version: i64::from(ver), + tensors: n_tensors as i64, + ..Default::default() + }; + + for _ in 0..n_kv { + let key = g.str(GGUF_MAX_KEY_LEN)?; + let t = g.u32()?; + f.keys.push(key.clone()); + match t { + GGUF_STRING => { + if key == "general.architecture" { + f.arch = g.str(GGUF_MAX_KEY_LEN)?; + } else { + g.skip_str()?; + } + } + GGUF_ARRAY => { + let et = g.u32()?; + let n = g.count()?; + if keep_int_array(&key) + && et != GGUF_FLOAT32 + && et != GGUF_FLOAT64 + && scalar_size(et) > 0 + && n <= GGUF_MAX_LAYER_ARRAY + { + let mut vals = Vec::with_capacity(n.min(g.left.max(0) as u64) as usize); + for _ in 0..n { + vals.push(g.int_val(et)?.unwrap_or(0)); + } + f.arrays.insert(key, vals); + } else { + g.skip_array(et, n, 1)?; + } + } + _ => { + if let Some(v) = g.int_val(t)? { + f.ints.insert(key, v); + } + } + } + } + + let dim_size: i64 = if g.v1 { 4 } else { 8 }; + let mut max_off: u64 = 0; + for _ in 0..n_tensors { + let name = g.str(GGUF_MAX_TENSOR_NAME)?; + let nd = g.u32()?; + if nd > GGUF_MAX_DIMS { + return Err(format!("tenseur {name:?} à {nd} dimensions")); + } + // dimensions et type (u32), puis position des données (u64) + g.skip(i64::from(nd) * dim_size + 4)?; + let off = g.u64()?; + max_off = max_off.max(off); + if name.ends_with(".nextn.eh_proj.weight") { + f.nextn.push(name); + } + } + + // Les données commencent au prochain multiple de general.alignment. Le + // début du dernier tenseur doit tenir dans le fichier : un GGUF coupé dans + // ses poids a un en-tête intact, il faut le voir quand même. + let mut align: i64 = 32; + if let Some(&a) = f.ints.get("general.alignment") { + if a <= 0 || a > 1 << 20 || a & (a - 1) != 0 { + return Err(format!("alignement GGUF {a} invalide")); + } + align = a; + } + if n_tensors > 0 { + let pos = g.size - g.left; + let data_start = (pos + align - 1) / align * align; + if max_off >= g.size as u64 || data_start + max_off as i64 >= g.size { + return truncated(); + } + } + Ok(f) +} + +/// Ramène une valeur de métadonnée dans [0, i32::MAX] : un champ négatif ou +/// démesuré n'a pas de sens pour un compte de couches ou de têtes. +fn clamp_int(v: i64) -> i64 { + v.clamp(0, i64::from(i32::MAX)) +} + +/// Résout les clés de la première tranche selon l'architecture et cherche la +/// tête MTP dans les tenseurs de toutes les tranches (`all` inclut `first`). +pub fn gguf_info_from(first: &GgufFile, all: &[GgufFile]) -> GgufMeta { + let p = format!("{}.", first.arch); + let get = |k: &str| clamp_int(first.ints.get(&format!("{p}{k}")).copied().unwrap_or(0)); + let mut m = GgufMeta { + version: first.version, + arch: first.arch.clone(), + block_count: get("block_count"), + next_n: get("nextn_predict_layers"), + context_length: get("context_length"), + expert_count: get("expert_count"), + key_len: get("attention.key_length"), + val_len: get("attention.value_length"), + full_attn_interval: get("full_attention_interval"), + embedding_length: get("embedding_length"), + head_count: get("attention.head_count"), + ssm_conv: get("ssm.conv_kernel"), + ssm_inner: get("ssm.inner_size"), + ssm_state: get("ssm.state_size"), + ssm_groups: get("ssm.group_count"), + head_count_kv: get("attention.head_count_kv"), + ..Default::default() + }; + if let Some(arr) = first.arrays.get(&format!("{p}attention.head_count_kv")) { + let layers: Vec = arr.iter().map(|&v| clamp_int(v)).collect(); + m.head_count_kv = layers.iter().copied().max().unwrap_or(0); + m.head_count_kv_layers = Some(layers); + } + if !first.arch.is_empty() { + let ssm = format!("{p}ssm."); + m.hybrid = first.keys.iter().any(|k| k.starts_with(&ssm)); + } + let want = if m.block_count > 0 { + format!("blk.{}.nextn.eh_proj.weight", m.block_count - 1) + } else { + String::new() + }; + for f in all { + m.tensor_count += f.tensors; + if f.nextn.contains(&want) { + m.has_next_n_tensor = true; + } + } + m +} + +// Cache par chemin : la même liste de modèles est consultée à chaque démarrage +// et à chaque affichage. L'empreinte (taille et date de chaque tranche) suffit +// à voir un fichier remplacé ou un téléchargement terminé ; les erreurs ne sont +// pas gardées. +const GGUF_CACHE_MAX: usize = 256; + +type GgufCache = Mutex>; + +fn gguf_cache() -> &'static GgufCache { + static CACHE: OnceLock = OnceLock::new(); + CACHE.get_or_init(Default::default) +} + +fn unix_nanos(t: SystemTime) -> i128 { + match t.duration_since(UNIX_EPOCH) { + Ok(d) => d.as_nanos() as i128, + Err(e) => -(e.duration().as_nanos() as i128), + } +} + +/// `ggufMeta` : métadonnées du modèle désigné par `path`. Pour un modèle en +/// tranches, `path` peut désigner n'importe laquelle : les clés viennent de la +/// première, les tenseurs de toutes. Une tranche manquante est une erreur. +/// +/// Entrées/sorties bloquantes : à appeler hors du runtime async. +pub fn gguf_meta(path: &Path) -> Result { + let path = go_clean(&path.to_string_lossy()); + let dir = go_dir(&path); + let fam = shard_family(&path); + let mut paths = Vec::with_capacity(fam.len()); + let mut stamp = String::new(); + for n in &fam { + let p = go_join(&[&dir, n]); + let st = std::fs::metadata(&p).map_err(|e| os_err("stat", &p, &e))?; + if st.is_dir() { + return Err(format!("{n} est un dossier")); + } + let mt = st.modified().map(unix_nanos).unwrap_or(0); + let _ = write!(stamp, "{}:{};", st.len(), mt); + paths.push(p); + } + let key = paths[0].clone(); + + let cached = gguf_cache() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get(&key) + .cloned(); + if let Some((s, info)) = cached + && s == stamp + { + return Ok(info); + } + + let files = paths + .iter() + .map(|p| read_gguf_header(p)) + .collect::, _>>()?; + let info = gguf_info_from(&files[0], &files); + + let mut c = gguf_cache().lock().unwrap_or_else(|e| e.into_inner()); + if c.len() >= GGUF_CACHE_MAX { + c.clear(); + } + c.insert(key, (stamp, info.clone())); + Ok(info) +} + +// --- tranches (backend_models_shards.go) ----------------------------------- + +/// Équivalent de `(?i)-(\d{2,5})-of-(\d{2,5})\.gguf$` : positions (début, fin) +/// du rang et du total. +fn shard_match(name: &str) -> Option<((usize, usize), (usize, usize))> { + let b = name.as_bytes(); + let n = b.len(); + if n < 5 || !b[n - 5..].eq_ignore_ascii_case(b".gguf") { + return None; + } + let e2 = n - 5; + let mut s2 = e2; + while s2 > 0 && b[s2 - 1].is_ascii_digit() { + s2 -= 1; + } + if !(2..=5).contains(&(e2 - s2)) || s2 < 4 || !b[s2 - 4..s2].eq_ignore_ascii_case(b"-of-") { + return None; + } + let e1 = s2 - 4; + let mut s1 = e1; + while s1 > 0 && b[s1 - 1].is_ascii_digit() { + s1 -= 1; + } + if !(2..=5).contains(&(e1 - s1)) || s1 < 1 || b[s1 - 1] != b'-' { + return None; + } + Some(((s1, e1), (s2, e2))) +} + +/// `shardInfo` : rang (à partir de 1) et nombre total ; `None` pour un modèle +/// d'un seul fichier ou une numérotation incohérente. +pub fn shard_info(name: &str) -> Option<(i64, i64)> { + let base = base_name(name); + let ((s1, e1), (s2, e2)) = shard_match(&base)?; + let idx: i64 = base[s1..e1].parse().ok()?; + let total: i64 = base[s2..e2].parse().ok()?; + if idx < 1 || total < 1 || idx > total { + return None; + } + Some((idx, total)) +} + +/// `shardNameAt` : nom de la tranche de rang `i` dans la même famille, en +/// conservant la largeur de la numérotation d'origine. +fn shard_name_at(name: &str, i: i64) -> String { + match shard_match(name) { + Some(((s1, e1), (s2, e2))) => format!( + "{}{:0width$}-of-{}{}", + &name[..s1], + i, + &name[s2..e2], + &name[e2..], + width = e1 - s1 + ), + None => name.to_string(), + } +} + +/// `isFollowerShard` : tranche 2..N. +pub fn is_follower_shard(name: &str) -> bool { + shard_info(name).is_some_and(|(idx, _)| idx > 1) +} + +/// `shardFamily` : noms de TOUTES les tranches de la famille de `name` (y +/// compris lui), dans l'ordre. Un fichier simple se renvoie lui-même. +pub fn shard_family(name: &str) -> Vec { + let base = base_name(name); + match shard_info(&base) { + None => vec![base], + Some((_, total)) => (1..=total).map(|i| shard_name_at(&base, i)).collect(), + } +} + +// --- dossiers de modèles (backend_model_dirs.go) --------------------------- + +/// `modelsDir()` : `LOKI_HOME/models`. +pub fn models_dir(home: &Path) -> String { + go_join(&[&home.to_string_lossy(), "models"]) +} + +/// Séparateur de `$LOKI_MODEL_DIRS` (comme le PATH). +fn model_dirs_list_sep() -> char { + if cfg!(windows) { ';' } else { ':' } +} + +/// Décodage de `state/model_dirs` comme `getJSON` dans un `[]string` : +/// absent, vide ou pas un tableau → rien ; éléments non textuels ignorés (le +/// Go les laisse vides, et `dedup_dirs` écarte les vides). +pub fn parse_stored_model_dirs(raw: Option<&[u8]>) -> Vec { + match raw.and_then(|b| serde_json::from_slice::(b).ok()) { + Some(Value::Array(a)) => a + .into_iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect(), + _ => Vec::new(), + } +} + +/// `normDir` : chemin sous forme comparable (séparateurs unifiés en `/`, casse +/// neutralisée sous Windows, slash final retiré). +pub fn norm_dir(p: &str) -> String { + let p = go_clean(p.trim()).replace('\\', "/"); + if cfg!(windows) { p.to_lowercase() } else { p } +} + +/// `dedupDirs` : vides retirés, chemins absolus normalisés, doublons et `skip` +/// écartés. +pub fn dedup_dirs(list: &[String], skip: &str) -> Vec { + let mut seen = std::collections::HashSet::from([norm_dir(skip)]); + let mut out = Vec::new(); + for d in list { + let d = d.trim(); + if d.is_empty() { + continue; + } + let Some(abs) = go_abs(d) else { continue }; + if seen.insert(norm_dir(&abs)) { + out.push(abs); + } + } + out +} + +/// `extraModelDirs` : dossiers déclarés par l'utilisateur (base puis +/// `$LOKI_MODEL_DIRS`), nettoyés et dédoublonnés, models/ exclu. +pub fn extra_model_dirs(home: &Path, stored: &[String], env_dirs: Option<&str>) -> Vec { + let mut raw = stored.to_vec(); + if let Some(v) = env_dirs.filter(|v| !v.is_empty()) { + raw.extend(v.split(model_dirs_list_sep()).map(str::to_string)); + } + dedup_dirs(&raw, &models_dir(home)) +} + +/// `modelDirs` : models/ d'abord, puis les dossiers ajoutés. +pub fn model_dirs(home: &Path, stored: &[String], env_dirs: Option<&str>) -> Vec { + let mut v = vec![models_dir(home)]; + v.extend(extra_model_dirs(home, stored, env_dirs)); + v +} + +/// `pathWithin` : le fichier `p` est-il dans le dossier `dir` (ou dessous) ? +pub fn path_within(dir: &str, p: &str) -> bool { + let (d, f) = (norm_dir(dir), norm_dir(p)); + f == d || f.starts_with(&format!("{d}/")) +} + +fn trim_model_name(name: &str) -> &str { + name.trim().trim_matches('"').trim() +} + +fn is_file(p: &str) -> bool { + std::fs::metadata(p).is_ok_and(|m| !m.is_dir()) +} + +/// `resolveModelPath` : valeur MODEL= (nom de fichier OU chemin absolu) → +/// chemin absolu d'un .gguf. Un chemin absolu n'est accepté que dans un des +/// dossiers déclarés ; un simple nom est cherché dans chaque dossier, models/ +/// en premier (introuvable : chemin dans models/, l'appelant décidera). +pub fn resolve_model_path( + name: &str, + home: &Path, + stored_dirs: &[String], + env_model_dirs: Option<&str>, +) -> Result { + let s = trim_model_name(name); + if s.is_empty() { + return Err("nom de modèle invalide".to_string()); + } + if !s.to_lowercase().ends_with(".gguf") { + return Err("le modèle doit être un fichier .gguf".to_string()); + } + let dirs = model_dirs(home, stored_dirs, env_model_dirs); + if go_is_abs(s) { + let abs = go_clean(s); + if dirs.iter().any(|d| path_within(d, &abs)) { + return Ok(PathBuf::from(abs)); + } + return Err(format!( + "dossier non autorisé : {} — ajoute-le dans « Dossiers de modèles »", + go_dir(&abs) + )); + } + let base = base_name(s); + if base.is_empty() || base == "." { + return Err("nom de modèle invalide".to_string()); + } + for d in &dirs { + let p = go_join(&[d, &base]); + if is_file(&p) { + return Ok(PathBuf::from(p)); + } + } + Ok(PathBuf::from(go_join(&[&models_dir(home), &base]))) +} + +/// `resolveServeModelPath` : comme [`resolve_model_path`], mais un chemin +/// absolu qui pointe sur un .gguf existant est accepté même hors des dossiers +/// déclarés (lancer le moteur n'est qu'une lecture ; l'allowlist protège les +/// opérations destructrices pilotées depuis l'UI). +/// +/// - `stored_dirs` : [`parse_stored_model_dirs`] de `state/model_dirs` ; +/// - `env_model_dirs` : `$LOKI_MODEL_DIRS`. +pub fn resolve_serve_model_path( + model: &str, + home: &Path, + stored_dirs: &[String], + env_model_dirs: Option<&str>, +) -> Result { + let err = match resolve_model_path(model, home, stored_dirs, env_model_dirs) { + Ok(p) => return Ok(p), + Err(e) => e, + }; + let s = trim_model_name(model); + if go_is_abs(s) && s.to_lowercase().ends_with(".gguf") { + let abs = go_clean(s); + if is_file(&abs) { + return Ok(PathBuf::from(abs)); + } + } + Err(err) +} + +/// Corps JSON de `GET /api/model/layers` (`handleModelLayers`, toujours en +/// 200) : nombre de blocs du modèle ; llama.cpp en offloade `block_count + 1` +/// (la couche de sortie en plus), d'où `ngl_max`. +/// +/// - `query_model` : paramètre `?model=` ; s'il est vide après nettoyage des +/// espaces, `config_model` (clé `MODEL` du bucket `config`) prend le relais ; +/// - `stored_dirs` / `env_model_dirs` : voir [`resolve_serve_model_path`]. +/// +/// Entrées/sorties bloquantes : à appeler hors du runtime async. +pub fn model_layers_json( + query_model: Option<&str>, + config_model: Option<&str>, + home: &Path, + stored_dirs: &[String], + env_model_dirs: Option<&str>, +) -> Value { + let mut model = query_model.unwrap_or("").trim(); + if model.is_empty() { + model = config_model.unwrap_or("").trim(); + } + if model.is_empty() { + return json!({"ok": false, "error": "aucun modèle"}); + } + let path = match resolve_serve_model_path(model, home, stored_dirs, env_model_dirs) { + Ok(p) => p, + Err(e) => return json!({"ok": false, "error": e}), + }; + match gguf_meta(&path) { + Ok(info) if info.block_count > 0 => json!({ + "ok": true, + "layers": info.block_count, + "ngl_max": info.block_count + 1, + }), + Ok(_) => json!({"ok": false, "error": "nombre de couches absent de l'en-tête"}), + Err(e) => json!({"ok": false, "error": e}), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs::FileTimes; + use std::time::Duration; + + /// Écrit un GGUF synthétique : en-tête, clés typées, table des tenseurs, + /// sans aucune donnée de poids (le lecteur n'en lit jamais). + struct Builder { + be: bool, + version: u32, + n_kv: usize, + n_t: usize, + kv: Vec, + tensors: Vec, + } + + fn new_gguf() -> Builder { + Builder { + be: false, + version: 3, + n_kv: 0, + n_t: 0, + kv: Vec::new(), + tensors: Vec::new(), + } + } + + fn put_u32(be: bool, w: &mut Vec, v: u32) { + w.extend_from_slice(&if be { v.to_be_bytes() } else { v.to_le_bytes() }); + } + + fn put_u64(be: bool, w: &mut Vec, v: u64) { + w.extend_from_slice(&if be { v.to_be_bytes() } else { v.to_le_bytes() }); + } + + impl Builder { + fn cnt(&self, w: &mut Vec, n: u64) { + if self.version == 1 { + put_u32(self.be, w, n as u32); + } else { + put_u64(self.be, w, n); + } + } + + fn str_to(&self, w: &mut Vec, s: &str) { + self.cnt(w, s.len() as u64); + w.extend_from_slice(s.as_bytes()); + } + + fn key(&mut self, k: &str, t: u32) { + self.n_kv += 1; + let mut kv = std::mem::take(&mut self.kv); + self.str_to(&mut kv, k); + put_u32(self.be, &mut kv, t); + self.kv = kv; + } + + fn u32(mut self, k: &str, v: u32) -> Self { + self.key(k, GGUF_UINT32); + put_u32(self.be, &mut self.kv, v); + self + } + + fn i64(mut self, k: &str, v: i64) -> Self { + self.key(k, GGUF_INT64); + put_u64(self.be, &mut self.kv, v as u64); + self + } + + fn f32(mut self, k: &str, v: f32) -> Self { + self.key(k, GGUF_FLOAT32); + put_u32(self.be, &mut self.kv, v.to_bits()); + self + } + + fn boolean(mut self, k: &str, v: bool) -> Self { + self.key(k, GGUF_BOOL); + self.kv.push(u8::from(v)); + self + } + + fn s(mut self, k: &str, v: &str) -> Self { + self.key(k, GGUF_STRING); + let mut kv = std::mem::take(&mut self.kv); + self.str_to(&mut kv, v); + self.kv = kv; + self + } + + fn str_arr(mut self, k: &str, vs: &[&str]) -> Self { + self.key(k, GGUF_ARRAY); + let mut kv = std::mem::take(&mut self.kv); + put_u32(self.be, &mut kv, GGUF_STRING); + self.cnt(&mut kv, vs.len() as u64); + for v in vs { + self.str_to(&mut kv, v); + } + self.kv = kv; + self + } + + fn i32_arr(mut self, k: &str, vs: &[i32]) -> Self { + self.key(k, GGUF_ARRAY); + let mut kv = std::mem::take(&mut self.kv); + put_u32(self.be, &mut kv, GGUF_INT32); + self.cnt(&mut kv, vs.len() as u64); + for &v in vs { + put_u32(self.be, &mut kv, v as u32); + } + self.kv = kv; + self + } + + /// Tableau de deux tableaux de u8, pour vérifier le saut récursif. + fn nested(mut self, k: &str) -> Self { + self.key(k, GGUF_ARRAY); + let mut kv = std::mem::take(&mut self.kv); + put_u32(self.be, &mut kv, GGUF_ARRAY); + self.cnt(&mut kv, 2); + for _ in 0..2 { + put_u32(self.be, &mut kv, GGUF_UINT8); + self.cnt(&mut kv, 3); + kv.extend_from_slice(&[1, 2, 3]); + } + self.kv = kv; + self + } + + fn tensor(mut self, name: &str, dims: &[u64]) -> Self { + let mut t = std::mem::take(&mut self.tensors); + self.str_to(&mut t, name); + put_u32(self.be, &mut t, dims.len() as u32); + for &d in dims { + if self.version == 1 { + put_u32(self.be, &mut t, d as u32); + } else { + put_u64(self.be, &mut t, d); + } + } + put_u32(self.be, &mut t, 0); // type F32 + put_u64(self.be, &mut t, (self.n_t * 32) as u64); // position des données + self.tensors = t; + self.n_t += 1; + self + } + + fn bytes(&self) -> Vec { + let mut out = b"GGUF".to_vec(); + put_u32(self.be, &mut out, self.version); + self.cnt(&mut out, self.n_t as u64); + self.cnt(&mut out, self.n_kv as u64); + out.extend_from_slice(&self.kv); + out.extend_from_slice(&self.tensors); + // bourrage jusqu'à l'alignement par défaut, puis 32 octets de + // « poids » par tenseur + if self.n_t > 0 { + let pad = (32 - out.len() % 32) % 32 + 32 * self.n_t; + out.resize(out.len() + pad, 0); + } + out + } + } + + fn write_gguf(dir: &Path, name: &str, data: &[u8]) -> PathBuf { + let p = dir.join(name); + std::fs::write(&p, data).unwrap(); + p + } + + /// Modèle hybride à 4 couches, têtes KV par couche (0 sur les couches + /// récurrentes), tête MTP sur la dernière couche, vocabulaire à sauter. + fn qwen_mtp(b: Builder) -> Builder { + let long = "c".repeat(300); + b.u32("general.alignment", 32) + .str_arr("tokenizer.ggml.tokens", &["", "a", "bb", &long]) + .s("general.architecture", "qwen35") + .s("general.name", "Qwen synthétique") + .u32("qwen35.block_count", 4) + .u32("qwen35.nextn_predict_layers", 1) + .i64("qwen35.context_length", 262144) + .u32("qwen35.attention.key_length", 256) + .u32("qwen35.attention.value_length", 256) + .i32_arr("qwen35.attention.head_count_kv", &[0, 0, 0, 4]) + .u32("qwen35.full_attention_interval", 4) + .u32("qwen35.ssm.conv_kernel", 4) + .f32("qwen35.rope.freq_base", 1e7) + .boolean("tokenizer.ggml.add_bos_token", false) + .nested("bidon.imbrique") + .tensor("token_embd.weight", &[1024, 32000]) + .tensor("blk.0.attn_norm.weight", &[1024]) + .tensor("blk.3.nextn.eh_proj.weight", &[2048, 1024]) + } + + #[test] + fn gguf_meta_qwen_hybride_mtp() { + let dir = tempfile::tempdir().unwrap(); + let p = write_gguf(dir.path(), "qwen.gguf", &qwen_mtp(new_gguf()).bytes()); + let got = gguf_meta(&p).unwrap(); + let want = GgufMeta { + version: 3, + arch: "qwen35".into(), + block_count: 4, + next_n: 1, + context_length: 262144, + key_len: 256, + val_len: 256, + head_count_kv: 4, + head_count_kv_layers: Some(vec![0, 0, 0, 4]), + full_attn_interval: 4, + hybrid: true, + ssm_conv: 4, + has_next_n_tensor: true, + tensor_count: 3, + ..Default::default() + }; + assert_eq!(got, want); + } + + /// La clé nextn_predict_layers ne prouve rien : seul le tenseur de la + /// DERNIÈRE couche compte, comme dans llama.cpp. + #[test] + fn gguf_meta_tete_mtp_absente_ou_mal_placee() { + let dir = tempfile::tempdir().unwrap(); + let base = || { + new_gguf() + .s("general.architecture", "glm4moe") + .u32("glm4moe.block_count", 47) + .u32("glm4moe.nextn_predict_layers", 1) + .u32("glm4moe.expert_count", 128) + .u32("glm4moe.attention.head_count_kv", 8) + }; + let cases = [ + ( + "sans-tenseur.gguf", + base().tensor("blk.45.ffn_up.weight", &[8, 8]), + ), + ( + "mauvaise-couche.gguf", + base().tensor("blk.45.nextn.eh_proj.weight", &[8, 8]), + ), + ]; + for (name, b) in cases { + let got = gguf_meta(&write_gguf(dir.path(), name, &b.bytes())) + .unwrap_or_else(|e| panic!("{name} : {e}")); + assert!( + !(got.has_next_n_tensor + || got.next_n != 1 + || got.expert_count != 128 + || got.head_count_kv != 8 + || got.head_count_kv_layers.is_some() + || got.hybrid), + "{name} : {got:?}" + ); + } + let got = gguf_meta(&write_gguf( + dir.path(), + "ok.gguf", + &base() + .tensor("blk.46.nextn.eh_proj.weight", &[8, 8]) + .bytes(), + )); + assert!( + got.as_ref().is_ok_and(|g| g.has_next_n_tensor), + "tête MTP en dernière couche non vue : {got:?}" + ); + } + + /// Modèle en tranches : les clés sont dans la première, la tête MTP dans + /// la dernière. N'importe quelle tranche peut être désignée. + #[test] + fn gguf_meta_tranches() { + let dir = tempfile::tempdir().unwrap(); + let d = dir.path(); + let first = || { + new_gguf() + .s("general.architecture", "qwen35") + .u32("qwen35.block_count", 4) + .u32("split.count", 2) + .tensor("token_embd.weight", &[8, 8]) + }; + let second = new_gguf() + .u32("split.no", 1) + .u32("split.count", 2) + .tensor("blk.3.nextn.eh_proj.weight", &[8, 8]) + .tensor("output.weight", &[8, 8]); + let p1 = write_gguf(d, "m-00001-of-00002.gguf", &first().bytes()); + write_gguf(d, "m-00002-of-00002.gguf", &second.bytes()); + for p in [p1, d.join("m-00002-of-00002.gguf")] { + let got = gguf_meta(&p).unwrap(); + assert!( + got.arch == "qwen35" && got.has_next_n_tensor && got.tensor_count == 3, + "{} : {got:?}", + base_name(&p.to_string_lossy()) + ); + } + // tranche manquante : on ne sait pas, donc erreur + write_gguf(d, "n-00001-of-00002.gguf", &first().bytes()); + assert!( + gguf_meta(&d.join("n-00001-of-00002.gguf")).is_err(), + "tranche manquante acceptée" + ); + // gguf-split --no-tensor-first-split : la première tranche ne porte + // que les clés, aucun tenseur ni aucune donnée. Elle doit passer. + let meta = new_gguf() + .s("general.architecture", "glm4moe") + .u32("glm4moe.block_count", 47) + .u32("split.count", 2); + let rest = new_gguf() + .u32("split.no", 1) + .tensor("blk.46.nextn.eh_proj.weight", &[8, 8]); + write_gguf(d, "s-00001-of-00002.gguf", &meta.bytes()); + write_gguf(d, "s-00002-of-00002.gguf", &rest.bytes()); + let got = gguf_meta(&d.join("s-00001-of-00002.gguf")); + assert!( + got.as_ref() + .is_ok_and(|g| g.arch == "glm4moe" && g.has_next_n_tensor && g.tensor_count == 1), + "première tranche sans tenseur : {got:?}" + ); + } + + /// v1 (compteurs 32 bits) et gros-boutiste : même résultat que le v3. + #[test] + fn gguf_meta_versions_et_ordre() { + let dir = tempfile::tempdir().unwrap(); + let mut v1 = new_gguf(); + v1.version = 1; + let mut v2 = new_gguf(); + v2.version = 2; + let mut be = new_gguf(); + be.be = true; + for (name, b) in [("v1.gguf", v1), ("v2.gguf", v2), ("be.gguf", be)] { + let version = i64::from(b.version); + let got = gguf_meta(&write_gguf(dir.path(), name, &qwen_mtp(b).bytes())) + .unwrap_or_else(|e| panic!("{name} : {e}")); + assert!( + got.arch == "qwen35" + && got.block_count == 4 + && got.has_next_n_tensor + && got.head_count_kv_layers == Some(vec![0, 0, 0, 4]) + && got.version == version, + "{name} : {got:?}" + ); + } + } + + /// Fichier en cours de téléchargement : chaque coupure donne une erreur, + /// jamais un panic ni un faux résultat. + #[test] + fn gguf_meta_tronque() { + let dir = tempfile::tempdir().unwrap(); + let full = qwen_mtp(new_gguf()).bytes(); + // Toutes les coupures jusqu'au premier octet du dernier tenseur. + for cut in 0..=full.len() - 32 { + assert!( + parse_gguf(&full[..cut], cut as i64).is_err(), + "coupure à {cut}/{} acceptée", + full.len() + ); + } + if let Err(e) = parse_gguf(&full[..], full.len() as i64) { + panic!("fichier complet refusé : {e}"); + } + // et par le chemin fichier, pour une coupure dans l'en-tête + let p = write_gguf(dir.path(), "cut.gguf", &full[..full.len() / 3]); + assert!(gguf_meta(&p).is_err(), "fichier coupé accepté"); + } + + #[test] + fn gguf_meta_fichiers_aberrants() { + let dir = tempfile::tempdir().unwrap(); + let hdr = |ver: u32, n_t: u64, n_kv: u64| { + let mut w = b"GGUF".to_vec(); + put_u32(false, &mut w, ver); + put_u64(false, &mut w, n_t); + put_u64(false, &mut w, n_kv); + w + }; + // clé annoncée à 1 Eo + let mut huge_key = hdr(3, 0, 1); + put_u64(false, &mut huge_key, 1 << 60); + // tableau de chaînes annoncé à 2^62 éléments + let mut huge_arr = hdr(3, 0, 1); + put_u64(false, &mut huge_arr, 1); + huge_arr.push(b'k'); + put_u32(false, &mut huge_arr, GGUF_ARRAY); + put_u32(false, &mut huge_arr, GGUF_STRING); + put_u64(false, &mut huge_arr, 1 << 62); + // type de valeur inconnu + let mut bad_type = hdr(3, 0, 1); + put_u64(false, &mut bad_type, 1); + bad_type.push(b'k'); + put_u32(false, &mut bad_type, 99); + put_u64(false, &mut bad_type, 0); + // tenseur à 1000 dimensions + let mut bad_dims = hdr(3, 1, 0); + put_u64(false, &mut bad_dims, 1); + bad_dims.push(b't'); + put_u32(false, &mut bad_dims, 1000); + bad_dims.extend_from_slice(&[0u8; 64]); + + let mut signature = b"GGML\x03\x00\x00\x00".to_vec(); + signature.extend_from_slice(&[0u8; 16]); + let cases: Vec<(&str, Vec)> = vec![ + ("vide", Vec::new()), + ( + "alignement", + new_gguf() + .u32("general.alignment", 24) + .tensor("t", &[1]) + .bytes(), + ), + ("signature", signature), + ("version", hdr(4, 0, 0)), + ("compteurs", hdr(3, 1 << 40, 1 << 40)), + ("cle-geante", huge_key), + ("tableau", huge_arr), + ("type-inconnu", bad_type), + ("dimensions", bad_dims), + ]; + for (name, data) in cases { + let p = write_gguf(dir.path(), &format!("{name}.gguf"), &data); + assert!(gguf_meta(&p).is_err(), "{name} : accepté"); + } + assert!( + gguf_meta(&dir.path().join("absent.gguf")).is_err(), + "fichier absent accepté" + ); + } + + /// Octet corrompu n'importe où dans l'en-tête (v1 et v3) : le lecteur doit + /// rendre la main vite, sans panic remonté ni allocation démesurée. + #[test] + fn gguf_meta_octet_corrompu() { + let mut v1 = new_gguf(); + v1.version = 1; + for b in [new_gguf(), v1] { + let version = b.version; + let full = qwen_mtp(b).bytes(); + let hdr = full.len() - 3 * 32; // bourrage et « poids » jamais lus + for i in 0..hdr { + for v in [0xFFu8, 0x7F, 0x00] { + let mut m = full.clone(); + m[i] = v; + // le filet de sécurité existe, mais aucune entrée ne doit + // y tomber + if let Err(e) = parse_gguf(&m[..], m.len() as i64) { + assert!( + !e.contains("illisible"), + "v{version}, octet {i} = {v:#x} : {e}" + ); + } + } + } + } + } + + fn set_mtime(p: &Path, t: SystemTime) { + let f = File::options().write(true).open(p).unwrap(); + f.set_times(FileTimes::new().set_accessed(t).set_modified(t)) + .unwrap(); + } + + /// Le cache tient tant que taille et date sont les mêmes, et se renouvelle + /// dès qu'elles changent. + #[test] + fn gguf_meta_cache() { + let dir = tempfile::tempdir().unwrap(); + let data = qwen_mtp(new_gguf()).bytes(); + let p = write_gguf(dir.path(), "c.gguf", &data); + // 2026-01-02T03:04:05Z + let stamp = UNIX_EPOCH + Duration::from_secs(1_767_323_045); + set_mtime(&p, stamp); + let mut a = gguf_meta(&p).unwrap(); + a.head_count_kv_layers.as_mut().unwrap()[0] = 99; // ne doit pas atteindre le cache + + // même taille, même date, contenu illisible : le cache répond + write_gguf(dir.path(), "c.gguf", &vec![0u8; data.len()]); + set_mtime(&p, stamp); + let b = gguf_meta(&p); + assert!( + b.as_ref().is_ok_and( + |b| b.head_count_kv_layers.as_ref().unwrap()[0] == 0 && b.has_next_n_tensor + ), + "cache non utilisé ou altéré : {b:?}" + ); + + // date différente : relecture, donc erreur sur le contenu illisible + set_mtime(&p, stamp + Duration::from_secs(1)); + assert!( + gguf_meta(&p).is_err(), + "fichier modifié servi depuis le cache" + ); + } + + // --- tranches (backend_models_shards_test.go) --- + + #[test] + fn shard_info_cas() { + let cases = [ + ("Laguna-S-2.1-UD-IQ4_XS-00001-of-00003.gguf", Some((1, 3))), + ("Laguna-S-2.1-UD-IQ4_XS-00002-of-00003.gguf", Some((2, 3))), + ("m-01-of-03.gguf", Some((1, 3))), + ("Qwen2.5-7B-Instruct-Q4_K_M.gguf", None), + ("bidon-00004-of-00003.gguf", None), // numérotation incohérente + ("pas-un-gguf-00001-of-00002.bin", None), + ]; + for (name, want) in cases { + assert_eq!(shard_info(name), want, "shard_info({name:?})"); + } + } + + #[test] + fn shard_family_garde_la_largeur() { + assert_eq!( + shard_family("Laguna-00002-of-00003.gguf"), + [ + "Laguna-00001-of-00003.gguf", + "Laguna-00002-of-00003.gguf", + "Laguna-00003-of-00003.gguf", + ] + ); + assert_eq!(shard_family("simple.gguf"), ["simple.gguf"]); + assert!(is_follower_shard("x-02-OF-03.GGUF")); + assert!(!is_follower_shard("x-01-of-03.gguf")); + assert!(shard_info("x-000001-of-00003.gguf").is_none()); + } + + // --- résolution du modèle (backend_model_dirs_test.go) --- + + /// Un modèle posé hors de models/ doit être utilisable dès que son + /// dossier est déclaré — et refusé tant qu'il ne l'est pas. + #[test] + fn resolve_model_path_extra_dir() { + let home = tempfile::tempdir().unwrap(); + let ext = tempfile::tempdir().unwrap(); + let ext_s = ext.path().to_string_lossy().into_owned(); + let ext_model = go_join(&[&ext_s, "gros.gguf"]); + std::fs::write(&ext_model, b"x").unwrap(); + + assert!( + resolve_model_path(&ext_model, home.path(), &[], Some("")).is_err(), + "un dossier non déclaré devrait être refusé" + ); + // … sauf pour LANCER le moteur : le fichier existe + assert_eq!( + resolve_serve_model_path(&ext_model, home.path(), &[], None), + Ok(PathBuf::from(&ext_model)) + ); + + let stored = + parse_stored_model_dirs(Some(serde_json::to_string(&[&ext_s]).unwrap().as_bytes())); + let got = resolve_model_path(&ext_model, home.path(), &stored, Some("")); + assert_eq!(got, Ok(PathBuf::from(go_clean(&ext_model)))); + // Le simple nom de fichier doit aussi être retrouvé dans le dossier ajouté. + let got = resolve_model_path("gros.gguf", home.path(), &stored, Some("")); + assert_eq!(got, Ok(PathBuf::from(&ext_model))); + } + + /// models/ garde la priorité et les non-.gguf restent refusés. + #[test] + fn resolve_model_path_home_first() { + let home = tempfile::tempdir().unwrap(); + let ext = tempfile::tempdir().unwrap(); + let ext_s = ext.path().to_string_lossy().into_owned(); + let models = models_dir(home.path()); + std::fs::create_dir_all(&models).unwrap(); + for d in [&models, &ext_s] { + std::fs::write(go_join(&[d, "m.gguf"]), b"x").unwrap(); + } + let got = resolve_model_path("m.gguf", home.path(), &[], Some(&ext_s)); + assert_eq!(got, Ok(PathBuf::from(go_join(&[&models, "m.gguf"])))); + assert!(resolve_model_path("/etc/passwd", home.path(), &[], Some(&ext_s)).is_err()); + assert!(resolve_serve_model_path("/etc/passwd", home.path(), &[], None).is_err()); + } + + #[test] + fn dossiers_stockes() { + assert!(parse_stored_model_dirs(None).is_empty()); + assert!(parse_stored_model_dirs(Some(b"")).is_empty()); + assert!(parse_stored_model_dirs(Some(b"{}")).is_empty()); + assert_eq!( + parse_stored_model_dirs(Some(br#"["/a", 1, "/b"]"#)), + ["/a", "/b"] + ); + let home = Path::new(if cfg!(windows) { r"C:\h" } else { "/h" }); + let models = models_dir(home); + let extra = extra_model_dirs( + home, + &[models.clone(), " ".into(), "/x/".into(), "/x".into()], + None, + ); + assert_eq!(extra, [go_abs("/x").unwrap()]); + assert!(path_within(&models, &go_join(&[&models, "sous", "m.gguf"]))); + assert!(!path_within(&models, &format!("{models}2/m.gguf"))); + } + + #[test] + fn couches_du_modele() { + let home = tempfile::tempdir().unwrap(); + let models = models_dir(home.path()); + std::fs::create_dir_all(&models).unwrap(); + write_gguf(Path::new(&models), "q.gguf", &qwen_mtp(new_gguf()).bytes()); + write_gguf( + Path::new(&models), + "sans.gguf", + &new_gguf().s("general.architecture", "x").bytes(), + ); + let h = home.path(); + assert_eq!( + model_layers_json(Some(" "), Some(" q.gguf "), h, &[], None), + json!({"ok": true, "layers": 4, "ngl_max": 5}) + ); + assert_eq!( + model_layers_json(Some("q.gguf"), None, h, &[], None), + json!({"ok": true, "layers": 4, "ngl_max": 5}) + ); + assert_eq!( + model_layers_json(None, Some(""), h, &[], None), + json!({"ok": false, "error": "aucun modèle"}) + ); + assert_eq!( + model_layers_json(Some("x.bin"), None, h, &[], None), + json!({"ok": false, "error": "le modèle doit être un fichier .gguf"}) + ); + assert_eq!( + model_layers_json(Some("sans.gguf"), None, h, &[], None), + json!({"ok": false, "error": "nombre de couches absent de l'en-tête"}) + ); + let absent = model_layers_json(Some("absent.gguf"), None, h, &[], None); + assert_eq!(absent["ok"], false); + assert!( + absent["error"].as_str().unwrap().starts_with("stat "), + "{absent}" + ); + } +} diff --git a/rust/loki-rs/src/paths.rs b/rust/loki-rs/src/paths.rs index 134237e..6293938 100644 --- a/rust/loki-rs/src/paths.rs +++ b/rust/loki-rs/src/paths.rs @@ -1 +1,541 @@ -//! Lot 4 : module en cours de portage. +//! Emplacements de Loki (port de `sys_paths.go`) : ce que sert +//! `GET /api/paths` au bloc « Emplacements » de l'UI. +//! +//! « Où sont mes fichiers ? » — un binaire unique qui s'auto-installe rend les +//! emplacements invisibles : on ne sait plus si le binaire lancé EST +//! l'application ou une simple copie, ni où atterrissent la config, les +//! modèles et les fichiers de l'agent. La réponse est centralisée ici. +//! +//! Ce que le Go lit dans son environnement est passé en paramètre : `$LOKI_HOME` +//! (`home`), le binaire en cours (`go_exe` : tant que la migration n'est pas +//! finie, « binaire en cours » désigne le serveur Go supervisé), le projet +//! actif (bucket `state`, clé `active_project`) et `$LOKI_WORKSPACE`. +//! +//! Le module porte aussi les équivalents de `path/filepath` dont les ports +//! ont besoin pour produire EXACTEMENT les mêmes chaînes que le Go +//! (`Clean`, `Join`, `Dir`, `Abs`, `IsAbs`) et `baseName`. + +use serde::Serialize; +use std::collections::HashMap; +use std::io; +use std::path::Path; +use std::sync::{Mutex, OnceLock}; + +/// Bucket `state`, clé du slug du projet actif (`stActiveProjectKey`). +pub const ST_ACTIVE_PROJECT_KEY: &str = "active_project"; +/// Projet par défaut quand aucun n'est enregistré (`defaultProjectSlug`). +pub const DEFAULT_PROJECT_SLUG: &str = "generale"; +/// Variable qui impose le dossier de travail de l'agent (`workspaceEnv`). +pub const WORKSPACE_ENV: &str = "LOKI_WORKSPACE"; + +/// Réponse de `GET /api/paths` (type `lokiPaths` du Go, mêmes noms JSON, même +/// ordre). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +pub struct LokiPaths { + /// LOKI_HOME : racine des données + #[serde(rename = "home")] + pub home: String, + /// loki.db : config, préférences, conversation, clés + #[serde(rename = "database")] + pub database: String, + /// binaire en cours d'exécution (le serveur Go pendant la migration) + #[serde(rename = "exe")] + pub exe: String, + /// binaire installé (peut différer de `exe`) + #[serde(rename = "installed")] + pub installed: String, + /// dossier de travail du mode agent + #[serde(rename = "workspace")] + pub workspace: String, + #[serde(rename = "memory")] + pub memory: String, + /// scripts durables de l'agent (hors workspace jetable) + #[serde(rename = "scripts")] + pub scripts: String, + #[serde(rename = "presets")] + pub presets: String, + #[serde(rename = "models")] + pub models: String, + #[serde(rename = "backends")] + pub backends: String, +} + +/// `currentPaths()` du Go. +/// +/// - `home` : `$LOKI_HOME` (rendu tel quel, comme `LokiHome()`) ; +/// - `go_exe` : binaire Go supervisé (équivalent de `os.Executable()`), +/// symlinks résolus s'il existe ; +/// - `active_project` : valeur brute de `state/active_project` (vide ou +/// absente → projet « generale ») ; +/// - `workspace_env` : `$LOKI_WORKSPACE`, s'il est défini. +/// +/// Fait des entrées/sorties (création et sonde du dossier de travail, comme +/// `agentWorkspace`) : à appeler hors du runtime async (`spawn_blocking`). +pub fn current_paths( + home: &Path, + go_exe: &Path, + active_project: &str, + workspace_env: Option<&str>, +) -> LokiPaths { + let h = home.to_string_lossy().into_owned(); + let exe = eval_symlinks(go_exe).unwrap_or_else(|| go_exe.to_string_lossy().into_owned()); + LokiPaths { + home: h.clone(), + database: go_join(&[&h, "loki.db"]), + exe, + installed: installed_exe_path(home), + workspace: agent_workspace(home, workspace_env), + memory: project_memory_dir(home, active_project), + scripts: go_join(&[&h, "scripts"]), + presets: go_join(&[&h, "presets"]), + models: go_join(&[&h, "models"]), + backends: go_join(&[&h, "backends"]), + } +} + +/// `installedExePath()` : sous Unix les installateurs posent +/// `/usr/local/bin/loki` ; sous Windows, `LOKI_HOME\bin\loki.exe`. +pub fn installed_exe_path(home: &Path) -> String { + if cfg!(windows) { + go_join(&[&home.to_string_lossy(), "bin", "loki.exe"]) + } else { + "/usr/local/bin/loki".to_string() + } +} + +/// `memoryDir()` = `projectMemoryDir(activeProjectSlug())` : dossier mémoire +/// du projet actif. `active_project` est la valeur stockée, nettoyée ici comme +/// dans `activeProjectSlug` (espaces retirés, vide → « generale »). +pub fn project_memory_dir(home: &Path, active_project: &str) -> String { + let mut slug = active_project.trim(); + if slug.is_empty() { + slug = DEFAULT_PROJECT_SLUG; + } + go_join(&[&home.to_string_lossy(), "memory", slug]) +} + +/// `workspaceCandidates()` : `$LOKI_WORKSPACE`, `LOKI_HOME/workspace`, +/// `~/loki/workspace`, puis `/loki-workspace`. +pub fn workspace_candidates(home: &Path, workspace_env: Option<&str>) -> Vec { + let mut c = Vec::new(); + if let Some(v) = workspace_env.map(str::trim).filter(|v| !v.is_empty()) { + c.push(v.to_string()); + } + c.push(go_join(&[&home.to_string_lossy(), "workspace"])); + if let Some(h) = user_home_dir() { + c.push(go_join(&[&h, "loki", "workspace"])); + } + c.push(go_join(&[ + &std::env::temp_dir().to_string_lossy(), + "loki-workspace", + ])); + c +} + +/// `agentWorkspace()` : premier candidat qu'on peut créer ET où l'on peut +/// écrire (sonde réelle : un dossier existant peut être en lecture seule), +/// sinon le répertoire courant. Calculé une fois par processus et par +/// entrée, comme le `sync.Once` du Go. +pub fn agent_workspace(home: &Path, workspace_env: Option<&str>) -> String { + type Cache = Mutex), String>>; + static CACHE: OnceLock = OnceLock::new(); + let key = ( + home.to_string_lossy().into_owned(), + workspace_env.map(str::to_string), + ); + let cache = CACHE.get_or_init(Default::default); + if let Some(v) = cache.lock().unwrap_or_else(|e| e.into_inner()).get(&key) { + return v.clone(); + } + let found = workspace_candidates(home, workspace_env) + .into_iter() + .filter(|d| !d.is_empty()) + .find(|d| mkdir_all(d).is_ok() && probe_writable(d)) + .unwrap_or_else(|| { + // Dernier recours : le répertoire courant, comportement historique. + std::env::current_dir() + .map(|d| d.to_string_lossy().into_owned()) + .unwrap_or_default() + }); + cache + .lock() + .unwrap_or_else(|e| e.into_inner()) + .entry(key) + .or_insert(found) + .clone() +} + +fn mkdir_all(dir: &str) -> io::Result<()> { + let mut b = std::fs::DirBuilder::new(); + b.recursive(true); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + b.mode(0o755); + } + b.create(dir) +} + +/// Équivalent de `os.CreateTemp(dir, ".probe-*")` suivi de sa suppression. +fn probe_writable(dir: &str) -> bool { + for _ in 0..16 { + let mut r = [0u8; 8]; + if getrandom::fill(&mut r).is_err() { + return false; + } + let p = Path::new(dir).join(format!(".probe-{}", hex::encode(r))); + match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&p) + { + Ok(f) => { + drop(f); + let _ = std::fs::remove_file(&p); + return true; + } + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue, + Err(_) => return false, + } + } + false +} + +/// `os.UserHomeDir()` : `$HOME` sous Unix, `%USERPROFILE%` sous Windows ; +/// vide = inconnu. +fn user_home_dir() -> Option { + let var = if cfg!(windows) { "USERPROFILE" } else { "HOME" }; + std::env::var(var).ok().filter(|h| !h.is_empty()) +} + +/// `filepath.EvalSymlinks` : chemin résolu, ou `None` s'il n'existe pas. +pub fn eval_symlinks(p: &Path) -> Option { + let r = std::fs::canonicalize(p).ok()?; + let s = r.to_string_lossy().into_owned(); + // Windows : canonicalize rend un chemin « verbatim » (\\?\C:\…) que le Go + // ne produit jamais. + let s = if let Some(rest) = s.strip_prefix(r"\\?\UNC\") { + format!(r"\\{rest}") + } else if let Some(rest) = s.strip_prefix(r"\\?\") { + rest.to_string() + } else { + s + }; + Some(go_clean(&s)) +} + +// --- équivalents de path/filepath ----------------------------------------- + +/// Séparateur natif (`filepath.Separator`). +const SEP: u8 = if cfg!(windows) { b'\\' } else { b'/' }; + +fn is_sep(c: u8) -> bool { + c == b'/' || (cfg!(windows) && c == b'\\') +} + +fn from_slash(s: &str) -> String { + if cfg!(windows) { + s.replace('/', "\\") + } else { + s.to_string() + } +} + +/// Longueur du volume en tête du chemin (`C:` ou `\\hôte\partage`) ; toujours +/// 0 hors Windows. +pub fn volume_name_len(p: &str) -> usize { + if !cfg!(windows) { + return 0; + } + let b = p.as_bytes(); + let l = b.len(); + if l >= 2 && b[1] == b':' && b[0].is_ascii_alphabetic() { + return 2; + } + if l >= 5 && is_sep(b[0]) && is_sep(b[1]) && !is_sep(b[2]) && b[2] != b'.' { + let mut n = 3; + while n < l - 1 { + if is_sep(b[n]) { + n += 1; + if !is_sep(b[n]) { + if b[n] == b'.' { + break; + } + while n < l && !is_sep(b[n]) { + n += 1; + } + return n; + } + break; + } + n += 1; + } + } + 0 +} + +/// `filepath.Clean` : forme lexicale la plus courte (séparateurs répétés, +/// `.` et `..` résolus sans toucher au disque). +pub fn go_clean(path: &str) -> String { + let vol_len = volume_name_len(path); + let vol = &path[..vol_len]; + let rest = &path.as_bytes()[vol_len..]; + if rest.is_empty() { + let b = path.as_bytes(); + if vol_len > 1 && is_sep(b[0]) && is_sep(b[1]) { + return from_slash(path); // UNC + } + return format!("{path}."); + } + let rooted = is_sep(rest[0]); + let n = rest.len(); + let mut out: Vec = Vec::with_capacity(n); + let (mut r, mut dotdot) = (0usize, 0usize); + if rooted { + out.push(SEP); + r = 1; + dotdot = 1; + } + while r < n { + if is_sep(rest[r]) || (rest[r] == b'.' && (r + 1 == n || is_sep(rest[r + 1]))) { + // séparateur vide ou élément « . » + r += 1; + } else if rest[r] == b'.' + && r + 1 < n + && rest[r + 1] == b'.' + && (r + 2 == n || is_sep(rest[r + 2])) + { + r += 2; + if out.len() > dotdot { + let mut w = out.len() - 1; + while w > dotdot && !is_sep(out[w]) { + w -= 1; + } + out.truncate(w); + } else if !rooted { + if !out.is_empty() { + out.push(SEP); + } + out.extend_from_slice(b".."); + dotdot = out.len(); + } + } else { + if (rooted && out.len() != 1) || (!rooted && !out.is_empty()) { + out.push(SEP); + } + while r < n && !is_sep(rest[r]) { + out.push(rest[r]); + r += 1; + } + } + } + if out.is_empty() { + out.push(b'.'); + } + from_slash(&format!("{vol}{}", String::from_utf8_lossy(&out))) +} + +/// `filepath.Join` : éléments vides en tête ignorés, résultat nettoyé ; "" +/// si tout est vide. +pub fn go_join(elems: &[&str]) -> String { + match elems.iter().position(|e| !e.is_empty()) { + Some(i) => go_clean(&elems[i..].join(&(SEP as char).to_string())), + None => String::new(), + } +} + +/// `filepath.Dir` : tout sauf le dernier élément, nettoyé. +pub fn go_dir(path: &str) -> String { + let vol_len = volume_name_len(path); + let b = path.as_bytes(); + let mut i = b.len(); + while i > vol_len && !is_sep(b[i - 1]) { + i -= 1; + } + let dir = go_clean(&path[vol_len..i]); + if dir == "." && vol_len > 2 { + return path[..vol_len].to_string(); // UNC + } + format!("{}{dir}", &path[..vol_len]) +} + +/// `filepath.IsAbs`. +pub fn go_is_abs(path: &str) -> bool { + if !cfg!(windows) { + return path.starts_with('/'); + } + let l = volume_name_len(path); + if l == 0 { + return false; + } + let b = path.as_bytes(); + if is_sep(b[0]) && is_sep(b[1]) { + return true; + } + b.get(l).is_some_and(|&c| is_sep(c)) +} + +/// `filepath.Abs` : chemin absolu nettoyé ; `None` si le répertoire courant +/// est illisible. +pub fn go_abs(path: &str) -> Option { + if go_is_abs(path) && !cfg!(windows) { + return Some(go_clean(path)); + } + let p = if path.is_empty() { "." } else { path }; + let abs = std::path::absolute(p).ok()?; + Some(go_clean(&abs.to_string_lossy())) +} + +/// `baseName` du Go : nom de fichier d'un chemin en acceptant les DEUX +/// séparateurs (un config.env écrit sous Windows peut être relu sous Linux). +pub fn base_name(p: &str) -> String { + let p = p.trim().trim_end_matches(['/', '\\']); + match p.rfind(['/', '\\']) { + Some(i) => p[i + 1..].to_string(), + None => p.to_string(), + } +} + +/// Message d'une `*os.PathError` du Go : « op chemin: raison ». La raison +/// suit la forme des `syscall.Errno` du Go sous Unix (minuscule en tête, sans +/// le « (os error N) » de Rust). +pub fn os_err(op: &str, path: &str, e: &io::Error) -> String { + let mut msg = e.to_string(); + if let Some(i) = msg.rfind(" (os error ") { + msg.truncate(i); + } + if cfg!(unix) { + let mut c = msg.chars(); + if let Some(f) = c.next() { + msg = f.to_lowercase().chain(c).collect(); + } + } + format!("{op} {path}: {msg}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn json_identique_au_go() { + let p = LokiPaths { + home: "h".into(), + database: "d".into(), + exe: "e".into(), + installed: "i".into(), + workspace: "w".into(), + memory: "m".into(), + scripts: "s".into(), + presets: "p".into(), + models: "mo".into(), + backends: "b".into(), + }; + assert_eq!( + serde_json::to_string(&p).unwrap(), + r#"{"home":"h","database":"d","exe":"e","installed":"i","workspace":"w","memory":"m","scripts":"s","presets":"p","models":"mo","backends":"b"}"# + ); + } + + #[test] + fn chemins_derives_de_home() { + let home = tempfile::tempdir().unwrap(); + let h = home.path().to_string_lossy().into_owned(); + let exe = home.path().join("loki-go"); + std::fs::write(&exe, b"x").unwrap(); + let ws = home.path().join("ws-impose"); + let ws_s = ws.to_string_lossy().into_owned(); + let p = current_paths(home.path(), &exe, " projet-x \n", Some(&ws_s)); + assert_eq!(p.home, h); + assert_eq!(p.database, go_join(&[&h, "loki.db"])); + assert_eq!(p.models, go_join(&[&h, "models"])); + assert_eq!(p.presets, go_join(&[&h, "presets"])); + assert_eq!(p.scripts, go_join(&[&h, "scripts"])); + assert_eq!(p.backends, go_join(&[&h, "backends"])); + assert_eq!(p.memory, go_join(&[&h, "memory", "projet-x"])); + assert_eq!(p.workspace, ws_s); + assert!(ws.is_dir(), "dossier de travail non créé"); + assert_eq!( + std::fs::read_dir(&ws).unwrap().count(), + 0, + "sonde non supprimée" + ); + assert!(p.exe.ends_with("loki-go"), "{}", p.exe); + assert!(go_is_abs(&p.exe), "{}", p.exe); + if cfg!(windows) { + assert_eq!(p.installed, go_join(&[&h, "bin", "loki.exe"])); + } else { + assert_eq!(p.installed, "/usr/local/bin/loki"); + } + // projet absent → « generale » ; binaire absent → gardé tel quel + let absent = home.path().join("absent"); + let q = current_paths(home.path(), &absent, "", None); + assert_eq!(q.memory, go_join(&[&h, "memory", "generale"])); + assert_eq!(q.exe, absent.to_string_lossy()); + assert_eq!(q.workspace, go_join(&[&h, "workspace"])); + } + + #[test] + fn candidats_dans_l_ordre() { + let c = workspace_candidates(Path::new("/srv/loki"), Some(" ")); + assert_eq!(c[0], go_join(&["/srv/loki", "workspace"])); + assert!(c.last().unwrap().ends_with("loki-workspace")); + let c = workspace_candidates(Path::new("/srv/loki"), Some(" /x/ws ")); + assert_eq!(c[0], "/x/ws"); + } + + #[test] + fn base_name_deux_separateurs() { + assert_eq!(base_name(r"C:\models\x.gguf"), "x.gguf"); + assert_eq!(base_name("/m/y.gguf/ "), "y.gguf"); + assert_eq!(base_name("z.gguf"), "z.gguf"); + assert_eq!(base_name(""), ""); + } + + #[cfg(unix)] + #[test] + fn clean_comme_go() { + for (i, o) in [ + ("", "."), + ("abc", "abc"), + ("abc/def/", "abc/def"), + ("a//b", "a/b"), + ("/../abc", "/abc"), + ("abc/./../def", "def"), + ("../../abc", "../../abc"), + ("abc/../../..", "../.."), + ("/", "/"), + ("//abc//", "/abc"), + ] { + assert_eq!(go_clean(i), o, "Clean({i:?})"); + } + assert_eq!(go_dir("/a/b/c.gguf"), "/a/b"); + assert_eq!(go_dir("c.gguf"), "."); + assert_eq!(go_dir("/c.gguf"), "/"); + assert_eq!(go_join(&["", "a", "", "b/"]), "a/b"); + assert_eq!(go_join(&["", ""]), ""); + assert!(go_is_abs("/x") && !go_is_abs("x")); + assert_eq!(go_abs("/a/../b").as_deref(), Some("/b")); + } + + #[cfg(windows)] + #[test] + fn clean_comme_go() { + for (i, o) in [ + ("", "."), + ("abc/def/", r"abc\def"), + (r"C:\a\..\b", r"C:\b"), + ("C:", "C:."), + (r"C:\", r"C:\"), + (r"\\srv\share", r"\\srv\share"), + (r"\\srv\share\x\..", r"\\srv\share\"), + (r"c:/a//b/./", r"c:\a\b"), + ] { + assert_eq!(go_clean(i), o, "Clean({i:?})"); + } + assert_eq!(go_dir(r"C:\m\x.gguf"), r"C:\m"); + assert_eq!(go_dir(r"\\srv\share\x.gguf"), r"\\srv\share\"); + assert!(go_is_abs(r"C:\x") && go_is_abs(r"\\srv\share") && !go_is_abs(r"\x")); + assert!(!go_is_abs("C:x")); + } +}