diff --git a/docs/superpowers/specs/2026-10-15-rust-migration-lot7-outils-design.md b/docs/superpowers/specs/2026-10-15-rust-migration-lot7-outils-design.md index cc93c9f..e54f210 100644 --- a/docs/superpowers/specs/2026-10-15-rust-migration-lot7-outils-design.md +++ b/docs/superpowers/specs/2026-10-15-rust-migration-lot7-outils-design.md @@ -71,11 +71,23 @@ Code rend leur état au résumé, comme `compactMessages`. (`engineSideJob`) reste au Go, qui ne voit pas passer les requêtes de la façade. +## Étape 3 — git (fait) + +`git_status`, `git_diff`, `git_clone` (`rust/loki-rs/src/tools/git.rs`) : +dépôt deviné comme le Go (sous-dossier demandé, racine, unique sous-dossier +dépôt), sorties mêlées et bornées, jamais d'invite, clone limité à https:// et +git@hôte: et rangé dans le dossier de la discussion. Toujours sur cette +machine, cible distante ou non (comme le Go). Journal de référence +`tests/parity_tools/git.json` (`TestPariteOutilsGit`, deux dépôts, aucun appel +réseau), rejoué par `tests/tools_git.rs`. + ## Étapes suivantes -1. Web (`web_search`, `web_open`, `web_read`, `web_grep`, `web_images`) : - configuration seule, pas d'état partagé. -2. Shell d'arrière-plan (`bash_bg`, `bash_tail`) et git (`git_status`, - `git_diff`, `git_clone`) : jobs tenus par le Go aujourd'hui. -3. Mémoire (`mem_*`, `recall*`) : avec le portage du chiffrement. -4. Tâches, navigateur (CDP), MCP, sous-agents, critères et mode Code. +1. Web (`web_search`, `web_open`, `web_read`, `web_grep`, `web_images`) : le + cache de pages et la conversion HTML → Markdown (bibliothèques Go) ne se + reproduisent pas à l'octet ; à porter avec un journal de référence + structurel plutôt qu'exact. +2. Shell d'arrière-plan (`bash_bg`, `bash_tail`) : ses travaux sont lus par + l'optimiseur et la suppression d'une discussion, au Go. +3. Mémoire (`mem_*`, `recall*`, `tracker`) : avec le portage du chiffrement. +4. Tâches, navigateur (CDP), MCP, sous-agents, critères. diff --git a/internal/loki/parity_tools_test.go b/internal/loki/parity_tools_test.go index 96a6fe0..c04b616 100644 --- a/internal/loki/parity_tools_test.go +++ b/internal/loki/parity_tools_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "os" + "os/exec" "path/filepath" "regexp" "runtime" @@ -24,7 +25,10 @@ import ( // // LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteOutils -const parityToolsFile = "../../rust/loki-rs/tests/parity_tools/outils.json" +const ( + parityToolsFile = "../../rust/loki-rs/tests/parity_tools/outils.json" + parityToolsGitFile = "../../rust/loki-rs/tests/parity_tools/git.json" +) type parityFile struct { Path string `json:"path"` @@ -49,13 +53,24 @@ type parityToolCase struct { Ask *AskEvent `json:"ask,omitempty"` } +// parityGitCmd : une commande git de préparation, lancée dans Dir (relatif +// à {{ROOT}}) avant les appels. +type parityGitCmd struct { + Dir string `json:"dir"` + Args []string `json:"args"` +} + type parityToolsFixture struct { // Dossier de travail et dossier mémoire, relatifs à {{ROOT}}. - Cwd string `json:"cwd"` - MemoryDir string `json:"memory_dir"` - Files []parityFile `json:"files"` - Cases []parityToolCase `json:"cases"` - After map[string]string `json:"after"` + Cwd string `json:"cwd"` + MemoryDir string `json:"memory_dir"` + Files []parityFile `json:"files"` + GitSetup []parityGitCmd `json:"git_setup,omitempty"` + // Edits : fichiers écrits APRÈS la préparation git (ce que montreront + // status et diff). + Edits []parityFile `json:"edits,omitempty"` + Cases []parityToolCase `json:"cases"` + After map[string]string `json:"after"` } func parityToolTree() []parityFile { @@ -180,6 +195,58 @@ func parityNorm(s, root string) string { } func TestPariteOutils(t *testing.T) { + runParityTools(t, parityToolsFile, parityToolsFixture{Files: parityToolTree(), Cases: parityToolCases()}) +} + +// Outils git (mode Code) : deux dépôts, l'un avec un commit et des +// modifications, l'autre vide ; aucun appel ne sort sur le réseau. +func TestPariteOutilsGit(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git absent") + } + // Ni configuration globale ni système : sorties de git identiques partout. + t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull) + t.Setenv("GIT_CONFIG_NOSYSTEM", "1") + id := []string{"-c", "user.name=Loki", "-c", "user.email=loki@test"} + c := func(name, tool string, args map[string]any) parityToolCase { + return parityToolCase{Name: name, Tool: tool, Args: args} + } + fx := parityToolsFixture{ + Files: []parityFile{ + {Path: "ws/depot/a.txt", Content: "un\ndeux\ntrois\n", Age: 30}, + {Path: "ws/depot/b.txt", Content: "bêta\n", Age: 30}, + {Path: "ws/vide/x.txt", Content: "x\n", Age: 30}, + }, + GitSetup: []parityGitCmd{ + {Dir: "ws/depot", Args: []string{"init", "-q", "-b", "main"}}, + {Dir: "ws/depot", Args: []string{"add", "a.txt", "b.txt"}}, + {Dir: "ws/depot", Args: append(append([]string{}, id...), "commit", "-q", "-m", "départ")}, + {Dir: "ws/vide", Args: []string{"init", "-q", "-b", "main"}}, + }, + Edits: []parityFile{ + {Path: "ws/depot/a.txt", Content: "un\n2\ntrois\n", Age: 10}, + {Path: "ws/depot/neuf.txt", Content: "neuf\n", Age: 10}, + }, + Cases: []parityToolCase{ + c("statut_ambigu", "git_status", map[string]any{}), + c("statut", "git_status", map[string]any{"dir": "depot"}), + c("diff", "git_diff", map[string]any{"dir": "depot"}), + c("diff_fichier", "git_diff", map[string]any{"dir": "depot", "file": "b.txt"}), + c("diff_sans_commit", "git_diff", map[string]any{"dir": "vide"}), + c("statut_sans_commit", "git_status", map[string]any{"dir": "vide"}), + c("dossier_sortant", "git_status", map[string]any{"dir": "../x"}), + c("dossier_absent", "git_status", map[string]any{"dir": "nulle-part"}), + c("clone_url_locale", "git_clone", map[string]any{"url": "file:///etc"}), + c("clone_commande", "git_clone", map[string]any{"url": "ext::sh -c id"}), + c("clone_existant", "git_clone", map[string]any{"url": "https://exemple.invalid/u/depot.git"}), + c("clone_dossier_sortant", "git_clone", map[string]any{"url": "git@exemple.invalid:u/r", "dir": "../r"}), + }, + } + runParityTools(t, parityToolsGitFile, fx) +} + +func runParityTools(t *testing.T, file string, fx parityToolsFixture) { + t.Helper() if runtime.GOOS == "windows" { t.Skip("journal tiré sous Unix") } @@ -190,7 +257,7 @@ func TestPariteOutils(t *testing.T) { t.Setenv("LOKI_HOME", root) ws := filepath.Join(root, "ws") now := time.Now() - for _, f := range parityToolTree() { + for _, f := range fx.Files { p := filepath.Join(root, filepath.FromSlash(f.Path)) if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { t.Fatal(err) @@ -203,6 +270,23 @@ func TestPariteOutils(t *testing.T) { t.Fatal(err) } } + for _, g := range fx.GitSetup { + cmd := exec.Command("git", g.Args...) + cmd.Dir = filepath.Join(root, filepath.FromSlash(g.Dir)) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v : %v\n%s", g.Args, err, out) + } + } + for _, f := range fx.Edits { + p := filepath.Join(root, filepath.FromSlash(f.Path)) + if err := os.WriteFile(p, []byte(f.Content), 0o644); err != nil { + t.Fatal(err) + } + mt := now.Add(-time.Duration(f.Age) * time.Second) + if err := os.Chtimes(p, mt, mt); err != nil { + t.Fatal(err) + } + } setTaskWorkspace(ws) t.Cleanup(func() { setTaskWorkspace("") }) mem := memoryDir() @@ -211,8 +295,10 @@ func TestPariteOutils(t *testing.T) { t.Fatalf("dossier mémoire hors de la racine : %s", mem) } - fx := parityToolsFixture{Cwd: "ws", MemoryDir: filepath.ToSlash(relMem), Files: parityToolTree()} - for _, pc := range parityToolCases() { + fx.Cwd, fx.MemoryDir = "ws", filepath.ToSlash(relMem) + cases := fx.Cases + fx.Cases = nil + for _, pc := range cases { args := paritySubst(pc.Args, root, mem).(map[string]any) // Les nombres passent par JSON, comme ceux d'un vrai appel. b, _ := json.Marshal(args) @@ -235,6 +321,9 @@ func TestPariteOutils(t *testing.T) { if !strings.HasPrefix(rel, "ws/") && rel != "dehors.txt" && rel != "evade.txt" { return nil // base, configuration : propres au Go } + if strings.Contains(rel, "/.git/") { + return nil // index, objets : datés + } b, _ := os.ReadFile(p) fx.After[rel] = string(b) return nil @@ -244,21 +333,21 @@ func TestPariteOutils(t *testing.T) { t.Fatal(err) } if os.Getenv("LOKI_PARITY_UPDATE") == "1" { - if err := os.MkdirAll(filepath.Dir(parityToolsFile), 0o755); err != nil { + if err := os.MkdirAll(filepath.Dir(file), 0o755); err != nil { t.Fatal(err) } - if err := os.WriteFile(parityToolsFile, got, 0o644); err != nil { + if err := os.WriteFile(file, got, 0o644); err != nil { t.Fatal(err) } return } - want, err := os.ReadFile(parityToolsFile) + want, err := os.ReadFile(file) if err != nil { t.Fatalf("journal de référence absent (%v) : LOKI_PARITY_UPDATE=1 pour le créer", err) } if string(got) != string(want) { t.Errorf("les outils ne produisent plus le journal rangé dans %s.\n"+ "Si c'est voulu : LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteOutils,\n"+ - "puis cargo test --test tools dit si le port Rust suit.\n--- obtenu ---\n%s", parityToolsFile, got) + "puis cargo test --test tools dit si le port Rust suit.\n--- obtenu ---\n%s", file, got) } } diff --git a/rust/loki-rs/src/tools/git.rs b/rust/loki-rs/src/tools/git.rs new file mode 100644 index 0000000..7f32018 --- /dev/null +++ b/rust/loki-rs/src/tools/git.rs @@ -0,0 +1,353 @@ +//! Outils git du mode Code — port de `code_git.go` : `git_status`, +//! `git_diff`, `git_clone`. Sortie bornée, jamais d'invite interactive, et un +//! clone qui atterrit TOUJOURS dans le dossier de la discussion. + +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::{Arc, LazyLock, Mutex}; +use std::time::Duration; + +use regex::Regex; +use serde_json::{Map, Value}; + +use super::files::arg_str; +use super::policy::clean; + +/// Sortie gardée (sa fin). +const GIT_MAX_OUTPUT: usize = 8000; +const STATUS_TIMEOUT: Duration = Duration::from_secs(20); +const DIFF_TIMEOUT: Duration = Duration::from_secs(30); +const CLONE_TIMEOUT: Duration = Duration::from_secs(5 * 60); + +/// `str` du Go : chaîne telle quelle, nombre sans décimales, rien → "". +fn go_str(v: Option<&Value>) -> String { + match v { + None | Some(Value::Null) => String::new(), + Some(Value::String(s)) => s.clone(), + Some(Value::Number(n)) => match n.as_f64() { + Some(f) => format!("{f:.0}"), + None => n.to_string(), + }, + Some(other) => other.to_string(), + } +} + +/// `exec.LookPath` : le programme est-il dans le PATH ? +fn look_path(name: &str) -> bool { + let Some(path) = std::env::var_os("PATH") else { + return false; + }; + let exts: Vec = if cfg!(windows) { + std::env::var("PATHEXT") + .unwrap_or_else(|_| ".EXE;.CMD;.BAT;.COM".into()) + .split(';') + .map(str::to_string) + .collect() + } else { + vec![String::new()] + }; + std::env::split_paths(&path).any(|dir| { + exts.iter().any(|e| { + let p = dir.join(format!("{name}{e}")); + p.is_file() && is_executable(&p) + }) + }) +} + +#[cfg(unix)] +fn is_executable(p: &Path) -> bool { + use std::os::unix::fs::PermissionsExt; + std::fs::metadata(p).is_ok_and(|m| m.permissions().mode() & 0o111 != 0) +} + +#[cfg(not(unix))] +fn is_executable(_p: &Path) -> bool { + true +} + +/// `(*exec.ExitError).Error()` : « exit status N », « signal: killed ». +fn exit_text(st: &std::process::ExitStatus) -> String { + if let Some(c) = st.code() { + return format!("exit status {c}"); + } + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + if let Some(sig) = st.signal() { + let name = match sig { + 2 => "interrupt".to_string(), + 9 => "killed".to_string(), + 15 => "terminated".to_string(), + n => format!("signal {n}"), + }; + return format!("signal: {name}"); + } + } + st.to_string() +} + +/// La fin d'un texte, `n` caractères au plus (`tailRunes`). +fn tail_chars(s: &str, n: usize) -> &str { + let count = s.chars().count(); + if count <= n { + return s; + } + let start = s.char_indices().nth(count - n).map_or(0, |(i, _)| i); + &s[start..] +} + +/// `runGitIn` : git dans `dir`, sorties mêlées (comme `CombinedOutput`), +/// délai borné, jamais d'invite (mot de passe, hôte inconnu). +pub async fn run_git_in>(dir: &Path, timeout: Duration, args: &[S]) -> String { + let args: Vec<&str> = args.iter().map(AsRef::as_ref).collect(); + if !look_path("git") { + return "[erreur] git n'est pas installé sur cette machine".into(); + } + // Dossier absent : le message de Go (exec, Dir). + match std::fs::metadata(dir) { + Err(e) => { + return format!( + "[erreur] chdir {}: {}", + dir.display(), + super::files::os_err_text(&e) + ); + } + Ok(m) if !m.is_dir() => { + return format!("[erreur] chdir {}: not a directory", dir.display()); + } + Ok(_) => {} + } + let (reader, writer) = match std::io::pipe() { + Ok(p) => p, + Err(e) => return format!("[erreur] {e}"), + }; + let writer2 = match writer.try_clone() { + Ok(w) => w, + Err(e) => return format!("[erreur] {e}"), + }; + let mut cmd = tokio::process::Command::new("git"); + cmd.args(&args) + .current_dir(dir) + .stdin(Stdio::null()) + .stdout(writer) + .stderr(writer2) + .kill_on_drop(true) + .env("GIT_TERMINAL_PROMPT", "0") + .env("GIT_SSH_COMMAND", "ssh -oBatchMode=yes") + .env_remove(crate::auth::INTERNAL_ENV); + let child = cmd.spawn(); + // Les extrémités d'écriture gardées par la commande : fermées, sinon la + // lecture n'aurait jamais de fin. + drop(cmd); + let mut child = match child { + Ok(c) => c, + Err(e) => return format!("[erreur] {e}"), + }; + let buf = Arc::new(Mutex::new(Vec::new())); + let b = buf.clone(); + let read = tokio::task::spawn_blocking(move || { + let mut r = reader; + let mut chunk = [0u8; 8192]; + while let Ok(n) = r.read(&mut chunk) { + if n == 0 { + break; + } + b.lock() + .unwrap_or_else(|e| e.into_inner()) + .extend_from_slice(&chunk[..n]); + } + }); + let status = match tokio::time::timeout(timeout, child.wait()).await { + Ok(st) => st, + Err(_) => { + let _ = child.start_kill(); + let _ = child.wait().await; + return format!("[timeout] git {}", args.join(" ")); + } + }; + // Sortie fermée par git ; un petit-enfant qui la tiendrait encore ne + // bloque pas le tour. + let _ = tokio::time::timeout(Duration::from_secs(2), read).await; + let out = String::from_utf8_lossy(&buf.lock().unwrap_or_else(|e| e.into_inner())).into_owned(); + let mut s = tail_chars(out.trim(), GIT_MAX_OUTPUT).to_string(); + match status { + Ok(st) if st.success() => {} + Ok(st) => { + if s.is_empty() { + s = exit_text(&st); + } + return format!("[erreur] {s}"); + } + Err(e) => return format!("[erreur] {e}"), + } + if s.is_empty() { + s = "(aucune sortie)".into(); + } + s +} + +/// `gitRepoDir` : le sous-dossier demandé, la racine si c'est un dépôt, +/// sinon l'unique sous-dossier qui en est un (plusieurs : à préciser). +pub fn git_repo_dir(base: &Path, dir: &str) -> Result { + let dir = dir.trim(); + if !dir.is_empty() { + if dir.contains("..") || Path::new(dir).is_absolute() { + return Err(format!("dossier invalide : {dir}")); + } + return Ok(clean(&base.join(dir))); + } + if base.join(".git").exists() { + return Ok(base.to_path_buf()); + } + let mut repos: Vec = std::fs::read_dir(base) + .map(|rd| { + rd.filter_map(Result::ok) + .filter(|e| e.file_type().is_ok_and(|t| t.is_dir())) + .filter(|e| e.path().join(".git").exists()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect() + }) + .unwrap_or_default(); + repos.sort(); + match repos.len() { + 0 => Ok(base.to_path_buf()), // git dira « not a git repository » + 1 => Ok(base.join(&repos[0])), + _ => Err(format!( + "plusieurs dépôts ici ({}) : précise dir", + repos.join(", ") + )), + } +} + +/// `git_status`. +pub async fn tool_git_status(cwd: &Path, args: &Map) -> String { + match git_repo_dir(cwd, &go_str(args.get("dir"))) { + Ok(dir) => { + run_git_in( + &dir, + STATUS_TIMEOUT, + &["status", "--porcelain=v1", "--branch"], + ) + .await + } + Err(e) => format!("[erreur] {e}"), + } +} + +/// `git_diff` : index et arbre de travail ensemble (`diff HEAD`) ; dépôt +/// sans commit : le diff simple. +pub async fn tool_git_diff(cwd: &Path, args: &Map) -> String { + let dir = match git_repo_dir(cwd, &go_str(args.get("dir"))) { + Ok(d) => d, + Err(e) => return format!("[erreur] {e}"), + }; + let file = arg_str(args, "file"); + let with_file = |head: &[&str]| -> Vec { + let mut a: Vec = head.iter().map(|s| s.to_string()).collect(); + if !file.trim().is_empty() { + a.extend(["--".to_string(), file.to_string()]); + } + a + }; + let out = run_git_in(&dir, DIFF_TIMEOUT, &with_file(&["diff", "HEAD"])).await; + if out.starts_with("[erreur]") && out.contains("HEAD") { + return run_git_in(&dir, DIFF_TIMEOUT, &with_file(&["diff"])).await; + } + out +} + +/// https:// ou git@hôte: — ni chemin local ni protocole exotique (file://, +/// ext:: qui exécute une commande). +static GIT_URL: LazyLock = LazyLock::new(|| { + Regex::new(r"^(https://[A-Za-z0-9_.\-]+(:[0-9]+)?/|git@[A-Za-z0-9_.\-]+:)[A-Za-z0-9_.\-~/]+$") + .expect("motif d'URL git") +}); + +/// `filepath.Base`. +fn go_base(p: &str) -> String { + let seps: &[char] = if cfg!(windows) { &['/', '\\'] } else { &['/'] }; + let t = p.trim_end_matches(seps); + if t.is_empty() { + return if p.is_empty() { ".".into() } else { "/".into() }; + } + t.rsplit(seps).next().unwrap_or(t).to_string() +} + +/// `git_clone` : dans le dossier de la discussion, quoi qu'il arrive. +pub async fn tool_git_clone(cwd: &Path, args: &Map) -> String { + let url = arg_str(args, "url").trim(); + let url = format!("{}.git", url.strip_suffix(".git").unwrap_or(url).trim()); + let bare = url.strip_suffix(".git").unwrap_or(&url); + if !GIT_URL.is_match(bare) && !GIT_URL.is_match(&url) { + return "[erreur] URL refusée — seuls https:// et git@hôte: sont acceptés".into(); + } + let mut dir = arg_str(args, "dir").trim().to_string(); + if dir.is_empty() { + dir = go_base(bare); + } + if dir.contains("..") || Path::new(&dir).is_absolute() { + return "[erreur] dossier cible invalide".into(); + } + let target = clean(&cwd.join(&dir)); + if std::fs::metadata(&target).is_ok() { + return format!("[erreur] le dossier {dir} existe déjà"); + } + let target_s = target.to_string_lossy().into_owned(); + let out = run_git_in(cwd, CLONE_TIMEOUT, &["clone", "--", &url, &target_s]).await; + if out.starts_with("[erreur]") || out.starts_with("[timeout]") { + return out; + } + format!("[ok] cloné dans {dir}/\n{out}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn base_et_str_comme_le_go() { + assert_eq!(go_base("https://h/u/depot"), "depot"); + assert_eq!(go_base("git@h:u/depot"), "depot"); + assert_eq!(go_base("a/"), "a"); + assert_eq!(go_base(""), "."); + assert_eq!(go_str(Some(&serde_json::json!(3.0))), "3"); + assert_eq!(go_str(None), ""); + } + + #[test] + fn urls_acceptees() { + for u in [ + "https://github.com/a/b", + "https://h:8443/a/b.git", + "git@github.com:a/b.git", + ] { + assert!(GIT_URL.is_match(u), "{u}"); + } + for u in [ + "file:///etc", + "ext::sh -c id", + "/srv/depot", + "https://h/a b", + "https://hé/a", + ] { + assert!(!GIT_URL.is_match(u), "{u}"); + } + } + + #[test] + fn depot_trouve() { + let d = tempfile::tempdir().unwrap(); + let b = d.path(); + assert_eq!(git_repo_dir(b, "").unwrap(), b); + std::fs::create_dir_all(b.join("un/.git")).unwrap(); + assert_eq!(git_repo_dir(b, "").unwrap(), b.join("un")); + std::fs::create_dir_all(b.join("deux/.git")).unwrap(); + assert_eq!( + git_repo_dir(b, "").unwrap_err(), + "plusieurs dépôts ici (deux, un) : précise dir" + ); + assert_eq!(git_repo_dir(b, "deux").unwrap(), b.join("deux")); + assert!(git_repo_dir(b, "../x").is_err() && git_repo_dir(b, "/x").is_err()); + } +} diff --git a/rust/loki-rs/src/tools/mod.rs b/rust/loki-rs/src/tools/mod.rs index fdee2f4..8e9f1ac 100644 --- a/rust/loki-rs/src/tools/mod.rs +++ b/rust/loki-rs/src/tools/mod.rs @@ -1,8 +1,9 @@ //! Outils de l'agent exécutés par la façade (lot 7 de la migration). //! //! Quand la façade tient la conversation (`LOKI_CHAT=rust`), les outils -//! fichiers et le shell s'exécutent ici : `read`, `grep`, `glob`, `write`, -//! `edit`, `bash`, `ask`. Les autres (mémoire, web, tâches, navigateur, MCP, +//! fichiers, le shell et git s'exécutent ici : `read`, `grep`, `glob`, +//! `write`, `edit`, `bash`, `ask`, `git_status`, `git_diff`, `git_clone`. Les +//! autres (mémoire, web, tâches, navigateur, MCP, //! sous-agents, critères…) restent au Go (`/internal/v1/tool/exec`), de même //! que `write`/`edit`/`bash` quand l'agent vise un poste distant. //! @@ -14,6 +15,7 @@ pub mod diff; pub mod files; +pub mod git; pub mod policy; pub mod shell; @@ -55,7 +57,16 @@ pub trait ToolState: Send + Sync { pub fn is_local(name: &str) -> bool { matches!( name, - "read" | "grep" | "glob" | "write" | "edit" | "bash" | "ask" + "read" + | "grep" + | "glob" + | "write" + | "edit" + | "bash" + | "ask" + | "git_status" + | "git_diff" + | "git_clone" ) } @@ -139,6 +150,11 @@ pub async fn exec_local( .unwrap_or_else(|e| e), ) } + // git tourne toujours sur cette machine, cible distante ou non (comme + // le Go). + "git_status" => outcome(git::tool_git_status(&ctx.cwd, args).await), + "git_diff" => outcome(git::tool_git_diff(&ctx.cwd, args).await), + "git_clone" => outcome(git::tool_git_clone(&ctx.cwd, args).await), "ask" => { let result = files::tool_ask(args); let ask = (!result.starts_with("[erreur]")).then(|| AskEvent { diff --git a/rust/loki-rs/tests/common/mod.rs b/rust/loki-rs/tests/common/mod.rs index 2d19fa4..919e867 100644 --- a/rust/loki-rs/tests/common/mod.rs +++ b/rust/loki-rs/tests/common/mod.rs @@ -3,6 +3,7 @@ #![allow(dead_code)] pub mod fakego; +pub mod toolsfx; use bytes::Bytes; use http_body_util::Full; diff --git a/rust/loki-rs/tests/common/toolsfx.rs b/rust/loki-rs/tests/common/toolsfx.rs new file mode 100644 index 0000000..4b0880c --- /dev/null +++ b/rust/loki-rs/tests/common/toolsfx.rs @@ -0,0 +1,250 @@ +//! Rejeu des journaux de référence des outils (`tests/parity_tools/*.json`, +//! produits par `internal/loki/parity_tools_test.go`) dans +//! `tools::exec_local`. Partagé par `tests/tools.rs` et `tests/tools_git.rs`. + +#![allow(dead_code)] + +use std::collections::{BTreeMap, HashMap}; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::{Duration, SystemTime}; + +use regex::Regex; +use serde::Deserialize; +use serde_json::{Map, Value, json}; + +use loki_rs::chat::stream::tool_call_label; +use loki_rs::chat::types::{AskEvent, DiffLine}; +use loki_rs::tools::{ToolCtx, ToolState, exec_local}; + +#[derive(Deserialize)] +pub struct GitCmd { + dir: String, + args: Vec, +} + +#[derive(Deserialize)] +pub struct FileFx { + path: String, + content: String, + age: u64, +} + +#[derive(Deserialize)] +pub struct CaseFx { + name: String, + tool: String, + args: Map, + #[serde(default)] + code: bool, + #[serde(default)] + unix: bool, + label: String, + result: String, + #[serde(default)] + diff: Vec, + #[serde(default)] + added: i64, + #[serde(default)] + removed: i64, + #[serde(default)] + ask: Option, +} + +#[derive(Deserialize)] +pub struct Fixture { + cwd: String, + memory_dir: String, + files: Vec, + #[serde(default)] + git_setup: Vec, + #[serde(default)] + edits: Vec, + cases: Vec, + after: BTreeMap, +} + +/// Le suivi « lu avant d'écrire » du Go (`code_tracker.go`), en mémoire. +pub struct FakeState { + pub ctx: ToolCtx, + pub reads: Mutex>, +} + +impl ToolState for FakeState { + async fn context(&self) -> Result { + Ok(self.ctx.clone()) + } + + async fn note_read(&self, path: &Path) { + self.reads + .lock() + .unwrap() + .insert(path.to_path_buf(), SystemTime::now()); + } + + async fn check(&self, path: &Path, _for_write: bool) -> Option { + let mtime = std::fs::metadata(path).ok()?.modified().ok()?; + let p = path.display(); + match self.reads.lock().unwrap().get(path) { + None => Some(format!( + "[refusé] {p} existe mais tu ne l'as pas lu dans cette discussion. Lis-le d'abord (outil read), puis modifie-le." + )), + Some(at) if mtime > *at => Some(format!( + "[refusé] {p} a changé sur le disque depuis ta lecture. Relis-le (outil read) avant de le modifier." + )), + Some(_) => None, + } + } + + async fn wrote(&self, path: &Path) -> String { + self.note_read(path).await; + String::new() + } +} + +fn subst(v: &Value, root: &str, mem: &str) -> Value { + match v { + Value::String(s) => Value::String(s.replace("{{MEM}}", mem).replace("{{ROOT}}", root)), + Value::Object(m) => Value::Object( + m.iter() + .map(|(k, e)| (k.clone(), subst(e, root, mem))) + .collect(), + ), + Value::Array(a) => Value::Array(a.iter().map(|e| subst(e, root, mem)).collect()), + _ => v.clone(), + } +} + +fn norm(s: &str, root: &str) -> String { + static DUR: std::sync::LazyLock = std::sync::LazyLock::new(|| { + Regex::new(r"(exit: -?\d+) · [0-9.]+[a-zµ]+(?:[0-9.]+[a-zµ]+)*").unwrap() + }); + DUR.replace_all(&s.replace(root, "{{ROOT}}"), "$1 · {{DUR}}") + .into_owned() +} + +fn tree(root: &Path) -> BTreeMap { + fn walk(root: &Path, dir: &Path, out: &mut BTreeMap) { + for e in std::fs::read_dir(dir).unwrap().flatten() { + let p = e.path(); + if p.is_dir() { + walk(root, &p, out); + continue; + } + let rel = p + .strip_prefix(root) + .unwrap() + .to_string_lossy() + .replace('\\', "/"); + if rel.contains("/.git/") { + continue; + } + if rel.starts_with("ws/") || rel == "dehors.txt" || rel == "evade.txt" { + out.insert( + rel, + String::from_utf8_lossy(&std::fs::read(&p).unwrap()).into_owned(), + ); + } + } + } + let mut out = BTreeMap::new(); + walk(root, root, &mut out); + out +} + +/// Rejoue `tests/parity_tools/.json` et rend les écarts avec le Go. +pub async fn replay(name: &str) -> Vec { + let path = + Path::new(env!("CARGO_MANIFEST_DIR")).join(format!("tests/parity_tools/{name}.json")); + let fx: Fixture = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap(); + let tmp = tempfile::tempdir().unwrap(); + let root = std::fs::canonicalize(tmp.path()).unwrap(); + let root_s = root.display().to_string(); + let now = SystemTime::now(); + for f in &fx.files { + let p = root.join(&f.path); + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(&p, &f.content).unwrap(); + std::fs::File::options() + .write(true) + .open(&p) + .unwrap() + .set_modified(now - Duration::from_secs(f.age)) + .unwrap(); + } + for g in &fx.git_setup { + let out = std::process::Command::new("git") + .args(&g.args) + .current_dir(root.join(&g.dir)) + .output() + .unwrap(); + assert!( + out.status.success(), + "git {:?} : {}", + g.args, + String::from_utf8_lossy(&out.stderr) + ); + } + for f in &fx.edits { + let p = root.join(&f.path); + std::fs::write(&p, &f.content).unwrap(); + std::fs::File::options() + .write(true) + .open(&p) + .unwrap() + .set_modified(now - Duration::from_secs(f.age)) + .unwrap(); + } + let mem = root.join(&fx.memory_dir); + let st = FakeState { + ctx: ToolCtx { + cwd: root.join(&fx.cwd), + memory_dir: mem.clone(), + target: String::new(), + code: false, + }, + reads: Mutex::new(HashMap::new()), + }; + let mem_s = mem.display().to_string(); + let mut diffs = Vec::new(); + for c in &fx.cases { + if c.unix && cfg!(windows) { + continue; + } + let args = match subst(&Value::Object(c.args.clone()), &root_s, &mem_s) { + Value::Object(m) => m, + _ => unreachable!(), + }; + let label = tool_call_label(&c.tool, &args); + let out = exec_local(&st, c.code, &c.tool, &args, &label) + .await + .unwrap_or_else(|| panic!("{} : non exécuté ici", c.name)); + let got = json!({ + "label": norm(&label, &root_s), + "result": norm(&out.result, &root_s), + "diff": out.diff, + "added": out.added, + "removed": out.removed, + "ask": out.ask, + }); + let want = json!({ + "label": c.label, + "result": c.result, + "diff": c.diff, + "added": c.added, + "removed": c.removed, + "ask": c.ask, + }); + if got != want { + diffs.push(format!("{} :\n obtenu {got}\n attendu {want}", c.name)); + } + } + let after = tree(&root); + if after != fx.after { + diffs.push(format!( + "arborescence finale :\n obtenu {after:?}\n attendu {:?}", + fx.after + )); + } + diffs +} diff --git a/rust/loki-rs/tests/parity_tools/git.json b/rust/loki-rs/tests/parity_tools/git.json new file mode 100644 index 0000000..44c1c68 --- /dev/null +++ b/rust/loki-rs/tests/parity_tools/git.json @@ -0,0 +1,190 @@ +{ + "cwd": "ws", + "memory_dir": "memory/generale", + "files": [ + { + "path": "ws/depot/a.txt", + "content": "un\ndeux\ntrois\n", + "age": 30 + }, + { + "path": "ws/depot/b.txt", + "content": "bêta\n", + "age": 30 + }, + { + "path": "ws/vide/x.txt", + "content": "x\n", + "age": 30 + } + ], + "git_setup": [ + { + "dir": "ws/depot", + "args": [ + "init", + "-q", + "-b", + "main" + ] + }, + { + "dir": "ws/depot", + "args": [ + "add", + "a.txt", + "b.txt" + ] + }, + { + "dir": "ws/depot", + "args": [ + "-c", + "user.name=Loki", + "-c", + "user.email=loki@test", + "commit", + "-q", + "-m", + "départ" + ] + }, + { + "dir": "ws/vide", + "args": [ + "init", + "-q", + "-b", + "main" + ] + } + ], + "edits": [ + { + "path": "ws/depot/a.txt", + "content": "un\n2\ntrois\n", + "age": 10 + }, + { + "path": "ws/depot/neuf.txt", + "content": "neuf\n", + "age": 10 + } + ], + "cases": [ + { + "name": "statut_ambigu", + "tool": "git_status", + "args": {}, + "label": "", + "result": "[erreur] plusieurs dépôts ici (depot, vide) : précise dir" + }, + { + "name": "statut", + "tool": "git_status", + "args": { + "dir": "depot" + }, + "label": "", + "result": "## main\n M a.txt\n?? neuf.txt" + }, + { + "name": "diff", + "tool": "git_diff", + "args": { + "dir": "depot" + }, + "label": "", + "result": "diff --git a/a.txt b/a.txt\nindex 3d5dd13..283e2c0 100644\n--- a/a.txt\n+++ b/a.txt\n@@ -1,3 +1,3 @@\n un\n-deux\n+2\n trois" + }, + { + "name": "diff_fichier", + "tool": "git_diff", + "args": { + "dir": "depot", + "file": "b.txt" + }, + "label": "b.txt", + "result": "(aucune sortie)" + }, + { + "name": "diff_sans_commit", + "tool": "git_diff", + "args": { + "dir": "vide" + }, + "label": "", + "result": "(aucune sortie)" + }, + { + "name": "statut_sans_commit", + "tool": "git_status", + "args": { + "dir": "vide" + }, + "label": "", + "result": "## No commits yet on main\n?? x.txt" + }, + { + "name": "dossier_sortant", + "tool": "git_status", + "args": { + "dir": "../x" + }, + "label": "", + "result": "[erreur] dossier invalide : ../x" + }, + { + "name": "dossier_absent", + "tool": "git_status", + "args": { + "dir": "nulle-part" + }, + "label": "", + "result": "[erreur] chdir {{ROOT}}/ws/nulle-part: no such file or directory" + }, + { + "name": "clone_url_locale", + "tool": "git_clone", + "args": { + "url": "file:///etc" + }, + "label": "file:///etc", + "result": "[erreur] URL refusée — seuls https:// et git@hôte: sont acceptés" + }, + { + "name": "clone_commande", + "tool": "git_clone", + "args": { + "url": "ext::sh -c id" + }, + "label": "ext::sh -c id", + "result": "[erreur] URL refusée — seuls https:// et git@hôte: sont acceptés" + }, + { + "name": "clone_existant", + "tool": "git_clone", + "args": { + "url": "https://exemple.invalid/u/depot.git" + }, + "label": "https://exemple.invalid/u/depot.git", + "result": "[erreur] le dossier depot existe déjà" + }, + { + "name": "clone_dossier_sortant", + "tool": "git_clone", + "args": { + "dir": "../r", + "url": "git@exemple.invalid:u/r" + }, + "label": "git@exemple.invalid:u/r", + "result": "[erreur] dossier cible invalide" + } + ], + "after": { + "ws/depot/a.txt": "un\n2\ntrois\n", + "ws/depot/b.txt": "bêta\n", + "ws/depot/neuf.txt": "neuf\n", + "ws/vide/x.txt": "x\n" + } +} diff --git a/rust/loki-rs/tests/tools.rs b/rust/loki-rs/tests/tools.rs index f92f8b7..0664cdc 100644 --- a/rust/loki-rs/tests/tools.rs +++ b/rust/loki-rs/tests/tools.rs @@ -4,216 +4,24 @@ //! (`internal/loki/parity_tools_test.go`, régénéré par //! `LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteOutils`). La //! même arborescence est recréée ici et les mêmes appels passent, dans le même -//! ordre, par `tools::exec_local` : libellés, résultats, diffs, questions et -//! arborescence finale doivent être ceux du Go. L'état partagé (suivi « lu -//! avant d'écrire ») est tenu par le Go en service ; ici un faux qui en -//! reprend les règles et les messages. +//! ordre, par `tools::exec_local` (`common::toolsfx`) : libellés, résultats, +//! diffs, questions et arborescence finale doivent être ceux du Go. -use std::collections::{BTreeMap, HashMap}; -use std::path::{Path, PathBuf}; +mod common; + +use std::collections::HashMap; +use std::path::PathBuf; use std::sync::Mutex; -use std::time::{Duration, SystemTime}; -use regex::Regex; -use serde::Deserialize; -use serde_json::{Map, Value, json}; +use serde_json::{Value, json}; +use common::toolsfx::{FakeState, replay}; use loki_rs::chat::stream::tool_call_label; -use loki_rs::chat::types::{AskEvent, DiffLine}; -use loki_rs::tools::{ToolCtx, ToolState, exec_local}; - -#[derive(Deserialize)] -struct FileFx { - path: String, - content: String, - age: u64, -} - -#[derive(Deserialize)] -struct CaseFx { - name: String, - tool: String, - args: Map, - #[serde(default)] - code: bool, - #[serde(default)] - unix: bool, - label: String, - result: String, - #[serde(default)] - diff: Vec, - #[serde(default)] - added: i64, - #[serde(default)] - removed: i64, - #[serde(default)] - ask: Option, -} - -#[derive(Deserialize)] -struct Fixture { - cwd: String, - memory_dir: String, - files: Vec, - cases: Vec, - after: BTreeMap, -} - -/// Le suivi « lu avant d'écrire » du Go (`code_tracker.go`), en mémoire. -struct FakeState { - ctx: ToolCtx, - reads: Mutex>, -} - -impl ToolState for FakeState { - async fn context(&self) -> Result { - Ok(self.ctx.clone()) - } - - async fn note_read(&self, path: &Path) { - self.reads - .lock() - .unwrap() - .insert(path.to_path_buf(), SystemTime::now()); - } - - async fn check(&self, path: &Path, _for_write: bool) -> Option { - let mtime = std::fs::metadata(path).ok()?.modified().ok()?; - let p = path.display(); - match self.reads.lock().unwrap().get(path) { - None => Some(format!( - "[refusé] {p} existe mais tu ne l'as pas lu dans cette discussion. Lis-le d'abord (outil read), puis modifie-le." - )), - Some(at) if mtime > *at => Some(format!( - "[refusé] {p} a changé sur le disque depuis ta lecture. Relis-le (outil read) avant de le modifier." - )), - Some(_) => None, - } - } - - async fn wrote(&self, path: &Path) -> String { - self.note_read(path).await; - String::new() - } -} - -fn subst(v: &Value, root: &str, mem: &str) -> Value { - match v { - Value::String(s) => Value::String(s.replace("{{MEM}}", mem).replace("{{ROOT}}", root)), - Value::Object(m) => Value::Object( - m.iter() - .map(|(k, e)| (k.clone(), subst(e, root, mem))) - .collect(), - ), - Value::Array(a) => Value::Array(a.iter().map(|e| subst(e, root, mem)).collect()), - _ => v.clone(), - } -} - -fn norm(s: &str, root: &str) -> String { - static DUR: std::sync::LazyLock = std::sync::LazyLock::new(|| { - Regex::new(r"(exit: -?\d+) · [0-9.]+[a-zµ]+(?:[0-9.]+[a-zµ]+)*").unwrap() - }); - DUR.replace_all(&s.replace(root, "{{ROOT}}"), "$1 · {{DUR}}") - .into_owned() -} - -fn tree(root: &Path) -> BTreeMap { - fn walk(root: &Path, dir: &Path, out: &mut BTreeMap) { - for e in std::fs::read_dir(dir).unwrap().flatten() { - let p = e.path(); - if p.is_dir() { - walk(root, &p, out); - continue; - } - let rel = p - .strip_prefix(root) - .unwrap() - .to_string_lossy() - .replace('\\', "/"); - if rel.starts_with("ws/") || rel == "dehors.txt" || rel == "evade.txt" { - out.insert( - rel, - String::from_utf8_lossy(&std::fs::read(&p).unwrap()).into_owned(), - ); - } - } - } - let mut out = BTreeMap::new(); - walk(root, root, &mut out); - out -} +use loki_rs::tools::{ToolCtx, exec_local}; #[tokio::test] async fn outils_comme_le_go() { - let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/parity_tools/outils.json"); - let fx: Fixture = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap(); - let tmp = tempfile::tempdir().unwrap(); - let root = std::fs::canonicalize(tmp.path()).unwrap(); - let root_s = root.display().to_string(); - let now = SystemTime::now(); - for f in &fx.files { - let p = root.join(&f.path); - std::fs::create_dir_all(p.parent().unwrap()).unwrap(); - std::fs::write(&p, &f.content).unwrap(); - std::fs::File::options() - .write(true) - .open(&p) - .unwrap() - .set_modified(now - Duration::from_secs(f.age)) - .unwrap(); - } - let mem = root.join(&fx.memory_dir); - let st = FakeState { - ctx: ToolCtx { - cwd: root.join(&fx.cwd), - memory_dir: mem.clone(), - target: String::new(), - code: false, - }, - reads: Mutex::new(HashMap::new()), - }; - let mem_s = mem.display().to_string(); - let mut diffs = Vec::new(); - for c in &fx.cases { - if c.unix && cfg!(windows) { - continue; - } - let args = match subst(&Value::Object(c.args.clone()), &root_s, &mem_s) { - Value::Object(m) => m, - _ => unreachable!(), - }; - let label = tool_call_label(&c.tool, &args); - let out = exec_local(&st, c.code, &c.tool, &args, &label) - .await - .unwrap_or_else(|| panic!("{} : non exécuté ici", c.name)); - let got = json!({ - "label": norm(&label, &root_s), - "result": norm(&out.result, &root_s), - "diff": out.diff, - "added": out.added, - "removed": out.removed, - "ask": out.ask, - }); - let want = json!({ - "label": c.label, - "result": c.result, - "diff": c.diff, - "added": c.added, - "removed": c.removed, - "ask": c.ask, - }); - if got != want { - diffs.push(format!("{} :\n obtenu {got}\n attendu {want}", c.name)); - } - } - let after = tree(&root); - if after != fx.after { - diffs.push(format!( - "arborescence finale :\n obtenu {after:?}\n attendu {:?}", - fx.after - )); - } + let diffs = replay("outils").await; assert!( diffs.is_empty(), "{} écart(s) avec le Go :\n\n{}", diff --git a/rust/loki-rs/tests/tools_git.rs b/rust/loki-rs/tests/tools_git.rs new file mode 100644 index 0000000..0c64302 --- /dev/null +++ b/rust/loki-rs/tests/tools_git.rs @@ -0,0 +1,33 @@ +//! Parité des outils git avec le Go (lot 7) : `tests/parity_tools/git.json` +//! (`TestPariteOutilsGit`), rejoué dans `tools::exec_local`. Binaire à part : +//! la configuration de git de la machine est écartée par des variables +//! d'environnement, posées avant tout autre fil. + +mod common; + +#[tokio::test] +async fn outils_git_comme_le_go() { + if std::process::Command::new("git") + .arg("--version") + .output() + .is_err() + { + eprintln!("git absent : test sauté"); + return; + } + // SAFETY : seul test de ce binaire, rien d'autre ne lit l'environnement. + unsafe { + std::env::set_var( + "GIT_CONFIG_GLOBAL", + if cfg!(windows) { "NUL" } else { "/dev/null" }, + ); + std::env::set_var("GIT_CONFIG_NOSYSTEM", "1"); + } + let diffs = common::toolsfx::replay("git").await; + assert!( + diffs.is_empty(), + "{} écart(s) avec le Go :\n\n{}", + diffs.len(), + diffs.join("\n\n") + ); +}