Lot 7 : outils git exécutés par la façade

git_status, git_diff et git_clone (rust/loki-rs/src/tools/git.rs) quand la
façade tient la conversation : 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.

Parité : TestPariteOutilsGit (deux dépôts, l'un modifié, l'autre sans
commit ; aucun appel réseau) range tests/parity_tools/git.json ;
tests/tools_git.rs le retrouve à l'identique. Le rejeu des journaux des
outils est partagé (tests/common/toolsfx.rs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
This commit is contained in:
Claude committed 2026-10-15 12:19:07 +00:00
1 parent 8fdef5455b
commit f11f667a4c
9 files changed
+976 -224

No files matched your search

+1
View File
@@ -3,6 +3,7 @@
#![allow(dead_code)]
pub mod fakego;
pub mod toolsfx;
use bytes::Bytes;
use http_body_util::Full;
+250
View File
@@ -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<String>,
}
#[derive(Deserialize)]
pub struct FileFx {
path: String,
content: String,
age: u64,
}
#[derive(Deserialize)]
pub struct CaseFx {
name: String,
tool: String,
args: Map<String, Value>,
#[serde(default)]
code: bool,
#[serde(default)]
unix: bool,
label: String,
result: String,
#[serde(default)]
diff: Vec<DiffLine>,
#[serde(default)]
added: i64,
#[serde(default)]
removed: i64,
#[serde(default)]
ask: Option<AskEvent>,
}
#[derive(Deserialize)]
pub struct Fixture {
cwd: String,
memory_dir: String,
files: Vec<FileFx>,
#[serde(default)]
git_setup: Vec<GitCmd>,
#[serde(default)]
edits: Vec<FileFx>,
cases: Vec<CaseFx>,
after: BTreeMap<String, String>,
}
/// Le suivi « lu avant d'écrire » du Go (`code_tracker.go`), en mémoire.
pub struct FakeState {
pub ctx: ToolCtx,
pub reads: Mutex<HashMap<PathBuf, SystemTime>>,
}
impl ToolState for FakeState {
async fn context(&self) -> Result<ToolCtx, String> {
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<String> {
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<Regex> = 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<String, String> {
fn walk(root: &Path, dir: &Path, out: &mut BTreeMap<String, String>) {
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/<name>.json` et rend les écarts avec le Go.
pub async fn replay(name: &str) -> Vec<String> {
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
}
+190
View File
@@ -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"
}
}
+10 -202
View File
@@ -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<String, Value>,
#[serde(default)]
code: bool,
#[serde(default)]
unix: bool,
label: String,
result: String,
#[serde(default)]
diff: Vec<DiffLine>,
#[serde(default)]
added: i64,
#[serde(default)]
removed: i64,
#[serde(default)]
ask: Option<AskEvent>,
}
#[derive(Deserialize)]
struct Fixture {
cwd: String,
memory_dir: String,
files: Vec<FileFx>,
cases: Vec<CaseFx>,
after: BTreeMap<String, String>,
}
/// Le suivi « lu avant d'écrire » du Go (`code_tracker.go`), en mémoire.
struct FakeState {
ctx: ToolCtx,
reads: Mutex<HashMap<PathBuf, SystemTime>>,
}
impl ToolState for FakeState {
async fn context(&self) -> Result<ToolCtx, String> {
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<String> {
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<Regex> = 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<String, String> {
fn walk(root: &Path, dir: &Path, out: &mut BTreeMap<String, String>) {
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{}",
+33
View File
@@ -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")
);
}