mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-17 12:16:21 +02:00
La façade sert /api/transcribe et /api/dictate/* quand LOKI_DICTATE=rust (expérimental ; le Go reste le défaut) et supervise elle-même le serveur de transcription sherpa-onnx : lancé à la demande sur la boucle locale, GPU masqués et sans le jeton interne, prêt dès que sa socket répond, mort au démarrage expliquée, éteint après dix minutes d'inactivité ou quand les réglages changent. Modèles Parakeet téléchargés et décompressés au fil de l'eau (tar.bz2, entrées hors du dossier refusées, liens ignorés), protocole WebSocket identique (en-tête de 8 octets, trames de 10 Kio). Côté Go, facade_dictate.go remet ces routes à la façade quand elles arrivent par le tunnel du relais, et asrShutdownVite (libération de la VRAM, optimiseur) lui demande l'arrêt par l'API interne. Parité : parity_dictate_test.go tire du Go la lecture de treize WAV, les chemins d'archive, réglages, catalogue, arguments et treize appels de route, rejoués par tests/dictate.rs ; supervision testée contre un faux sherpa-onnx qui parle le protocole. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
563 lines
19 KiB
Rust
563 lines
19 KiB
Rust
//! 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 `<tmp>/loki-workspace`.
|
|
pub fn workspace_candidates(home: &Path, workspace_env: Option<&str>) -> Vec<String> {
|
|
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<HashMap<(String, Option<String>), String>>;
|
|
static CACHE: OnceLock<Cache> = 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<String> {
|
|
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<String> {
|
|
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<u8> = 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<String> {
|
|
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(),
|
|
}
|
|
}
|
|
|
|
/// `exec.LookPath` : le chemin du programme dans le PATH (exécutable).
|
|
pub fn look_path(name: &str) -> Option<String> {
|
|
let path = std::env::var_os("PATH")?;
|
|
std::env::split_paths(&path)
|
|
.map(|dir| dir.join(name))
|
|
.find(|p| {
|
|
std::fs::metadata(p).is_ok_and(|m| {
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
m.is_file() && m.permissions().mode() & 0o111 != 0
|
|
}
|
|
#[cfg(not(unix))]
|
|
{
|
|
m.is_file()
|
|
}
|
|
})
|
|
})
|
|
.map(|p| p.to_string_lossy().into_owned())
|
|
}
|
|
|
|
/// 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"));
|
|
}
|
|
}
|