mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-23 23:18:13 +02:00
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:
9 files changed
+976
-224
No files matched your search
@@ -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
|
(`engineSideJob`) reste au Go, qui ne voit pas passer les requêtes de la
|
||||||
façade.
|
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
|
## Étapes suivantes
|
||||||
|
|
||||||
1. Web (`web_search`, `web_open`, `web_read`, `web_grep`, `web_images`) :
|
1. Web (`web_search`, `web_open`, `web_read`, `web_grep`, `web_images`) : le
|
||||||
configuration seule, pas d'état partagé.
|
cache de pages et la conversion HTML → Markdown (bibliothèques Go) ne se
|
||||||
2. Shell d'arrière-plan (`bash_bg`, `bash_tail`) et git (`git_status`,
|
reproduisent pas à l'octet ; à porter avec un journal de référence
|
||||||
`git_diff`, `git_clone`) : jobs tenus par le Go aujourd'hui.
|
structurel plutôt qu'exact.
|
||||||
3. Mémoire (`mem_*`, `recall*`) : avec le portage du chiffrement.
|
2. Shell d'arrière-plan (`bash_bg`, `bash_tail`) : ses travaux sont lus par
|
||||||
4. Tâches, navigateur (CDP), MCP, sous-agents, critères et mode Code.
|
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.
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"runtime"
|
"runtime"
|
||||||
@@ -24,7 +25,10 @@ import (
|
|||||||
//
|
//
|
||||||
// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteOutils
|
// 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 {
|
type parityFile struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
@@ -49,13 +53,24 @@ type parityToolCase struct {
|
|||||||
Ask *AskEvent `json:"ask,omitempty"`
|
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 {
|
type parityToolsFixture struct {
|
||||||
// Dossier de travail et dossier mémoire, relatifs à {{ROOT}}.
|
// Dossier de travail et dossier mémoire, relatifs à {{ROOT}}.
|
||||||
Cwd string `json:"cwd"`
|
Cwd string `json:"cwd"`
|
||||||
MemoryDir string `json:"memory_dir"`
|
MemoryDir string `json:"memory_dir"`
|
||||||
Files []parityFile `json:"files"`
|
Files []parityFile `json:"files"`
|
||||||
Cases []parityToolCase `json:"cases"`
|
GitSetup []parityGitCmd `json:"git_setup,omitempty"`
|
||||||
After map[string]string `json:"after"`
|
// 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 {
|
func parityToolTree() []parityFile {
|
||||||
@@ -180,6 +195,58 @@ func parityNorm(s, root string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestPariteOutils(t *testing.T) {
|
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" {
|
if runtime.GOOS == "windows" {
|
||||||
t.Skip("journal tiré sous Unix")
|
t.Skip("journal tiré sous Unix")
|
||||||
}
|
}
|
||||||
@@ -190,7 +257,7 @@ func TestPariteOutils(t *testing.T) {
|
|||||||
t.Setenv("LOKI_HOME", root)
|
t.Setenv("LOKI_HOME", root)
|
||||||
ws := filepath.Join(root, "ws")
|
ws := filepath.Join(root, "ws")
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
for _, f := range parityToolTree() {
|
for _, f := range fx.Files {
|
||||||
p := filepath.Join(root, filepath.FromSlash(f.Path))
|
p := filepath.Join(root, filepath.FromSlash(f.Path))
|
||||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -203,6 +270,23 @@ func TestPariteOutils(t *testing.T) {
|
|||||||
t.Fatal(err)
|
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)
|
setTaskWorkspace(ws)
|
||||||
t.Cleanup(func() { setTaskWorkspace("") })
|
t.Cleanup(func() { setTaskWorkspace("") })
|
||||||
mem := memoryDir()
|
mem := memoryDir()
|
||||||
@@ -211,8 +295,10 @@ func TestPariteOutils(t *testing.T) {
|
|||||||
t.Fatalf("dossier mémoire hors de la racine : %s", mem)
|
t.Fatalf("dossier mémoire hors de la racine : %s", mem)
|
||||||
}
|
}
|
||||||
|
|
||||||
fx := parityToolsFixture{Cwd: "ws", MemoryDir: filepath.ToSlash(relMem), Files: parityToolTree()}
|
fx.Cwd, fx.MemoryDir = "ws", filepath.ToSlash(relMem)
|
||||||
for _, pc := range parityToolCases() {
|
cases := fx.Cases
|
||||||
|
fx.Cases = nil
|
||||||
|
for _, pc := range cases {
|
||||||
args := paritySubst(pc.Args, root, mem).(map[string]any)
|
args := paritySubst(pc.Args, root, mem).(map[string]any)
|
||||||
// Les nombres passent par JSON, comme ceux d'un vrai appel.
|
// Les nombres passent par JSON, comme ceux d'un vrai appel.
|
||||||
b, _ := json.Marshal(args)
|
b, _ := json.Marshal(args)
|
||||||
@@ -235,6 +321,9 @@ func TestPariteOutils(t *testing.T) {
|
|||||||
if !strings.HasPrefix(rel, "ws/") && rel != "dehors.txt" && rel != "evade.txt" {
|
if !strings.HasPrefix(rel, "ws/") && rel != "dehors.txt" && rel != "evade.txt" {
|
||||||
return nil // base, configuration : propres au Go
|
return nil // base, configuration : propres au Go
|
||||||
}
|
}
|
||||||
|
if strings.Contains(rel, "/.git/") {
|
||||||
|
return nil // index, objets : datés
|
||||||
|
}
|
||||||
b, _ := os.ReadFile(p)
|
b, _ := os.ReadFile(p)
|
||||||
fx.After[rel] = string(b)
|
fx.After[rel] = string(b)
|
||||||
return nil
|
return nil
|
||||||
@@ -244,21 +333,21 @@ func TestPariteOutils(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if os.Getenv("LOKI_PARITY_UPDATE") == "1" {
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(parityToolsFile, got, 0o644); err != nil {
|
if err := os.WriteFile(file, got, 0o644); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
want, err := os.ReadFile(parityToolsFile)
|
want, err := os.ReadFile(file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("journal de référence absent (%v) : LOKI_PARITY_UPDATE=1 pour le créer", err)
|
t.Fatalf("journal de référence absent (%v) : LOKI_PARITY_UPDATE=1 pour le créer", err)
|
||||||
}
|
}
|
||||||
if string(got) != string(want) {
|
if string(got) != string(want) {
|
||||||
t.Errorf("les outils ne produisent plus le journal rangé dans %s.\n"+
|
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"+
|
"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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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<String> = 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<S: AsRef<str>>(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<PathBuf, String> {
|
||||||
|
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<String> = 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, Value>) -> 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, Value>) -> 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<String> {
|
||||||
|
let mut a: Vec<String> = 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<Regex> = 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, Value>) -> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
//! Outils de l'agent exécutés par la façade (lot 7 de la migration).
|
//! 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
|
//! Quand la façade tient la conversation (`LOKI_CHAT=rust`), les outils
|
||||||
//! fichiers et le shell s'exécutent ici : `read`, `grep`, `glob`, `write`,
|
//! fichiers, le shell et git s'exécutent ici : `read`, `grep`, `glob`,
|
||||||
//! `edit`, `bash`, `ask`. Les autres (mémoire, web, tâches, navigateur, MCP,
|
//! `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
|
//! 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.
|
//! que `write`/`edit`/`bash` quand l'agent vise un poste distant.
|
||||||
//!
|
//!
|
||||||
@@ -14,6 +15,7 @@
|
|||||||
|
|
||||||
pub mod diff;
|
pub mod diff;
|
||||||
pub mod files;
|
pub mod files;
|
||||||
|
pub mod git;
|
||||||
pub mod policy;
|
pub mod policy;
|
||||||
pub mod shell;
|
pub mod shell;
|
||||||
|
|
||||||
@@ -55,7 +57,16 @@ pub trait ToolState: Send + Sync {
|
|||||||
pub fn is_local(name: &str) -> bool {
|
pub fn is_local(name: &str) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
name,
|
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<S: ToolState>(
|
|||||||
.unwrap_or_else(|e| e),
|
.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" => {
|
"ask" => {
|
||||||
let result = files::tool_ask(args);
|
let result = files::tool_ask(args);
|
||||||
let ask = (!result.starts_with("[erreur]")).then(|| AskEvent {
|
let ask = (!result.starts_with("[erreur]")).then(|| AskEvent {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#![allow(dead_code)]
|
#![allow(dead_code)]
|
||||||
|
|
||||||
pub mod fakego;
|
pub mod fakego;
|
||||||
|
pub mod toolsfx;
|
||||||
|
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use http_body_util::Full;
|
use http_body_util::Full;
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -4,216 +4,24 @@
|
|||||||
//! (`internal/loki/parity_tools_test.go`, régénéré par
|
//! (`internal/loki/parity_tools_test.go`, régénéré par
|
||||||
//! `LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteOutils`). La
|
//! `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
|
//! 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
|
//! ordre, par `tools::exec_local` (`common::toolsfx`) : libellés, résultats,
|
||||||
//! arborescence finale doivent être ceux du Go. L'état partagé (suivi « lu
|
//! diffs, questions et arborescence finale doivent être ceux du Go.
|
||||||
//! avant d'écrire ») est tenu par le Go en service ; ici un faux qui en
|
|
||||||
//! reprend les règles et les messages.
|
|
||||||
|
|
||||||
use std::collections::{BTreeMap, HashMap};
|
mod common;
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use std::time::{Duration, SystemTime};
|
|
||||||
|
|
||||||
use regex::Regex;
|
use serde_json::{Value, json};
|
||||||
use serde::Deserialize;
|
|
||||||
use serde_json::{Map, Value, json};
|
|
||||||
|
|
||||||
|
use common::toolsfx::{FakeState, replay};
|
||||||
use loki_rs::chat::stream::tool_call_label;
|
use loki_rs::chat::stream::tool_call_label;
|
||||||
use loki_rs::chat::types::{AskEvent, DiffLine};
|
use loki_rs::tools::{ToolCtx, exec_local};
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn outils_comme_le_go() {
|
async fn outils_comme_le_go() {
|
||||||
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/parity_tools/outils.json");
|
let diffs = replay("outils").await;
|
||||||
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
|
|
||||||
));
|
|
||||||
}
|
|
||||||
assert!(
|
assert!(
|
||||||
diffs.is_empty(),
|
diffs.is_empty(),
|
||||||
"{} écart(s) avec le Go :\n\n{}",
|
"{} écart(s) avec le Go :\n\n{}",
|
||||||
|
|||||||
@@ -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")
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user