Skip to main content

car_engine/
agent_basics.rs

1use crate::registry::{ToolEntry, ToolPermission};
2use crate::substrate::Substrate;
3use regex::Regex;
4use serde_json::{json, Value};
5use std::collections::HashMap;
6use std::hash::{Hash, Hasher};
7use std::path::{Component, Path, PathBuf};
8use std::sync::{Arc, Mutex};
9
10const MAX_FILE_BYTES: usize = 512 * 1024;
11
12/// How a path stands relative to what an agent session has already observed —
13/// the input to the read-before-edit / staleness guard (H1/F4-remainder,
14/// audit 2026-07-06).
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum ReadState {
17    /// The session has never read or written this path.
18    Unread,
19    /// The session recorded this path, but the current on-disk content differs
20    /// from what it recorded — the file changed since it was last seen.
21    Stale,
22    /// The recorded content hash matches, but the agent observed only a slice.
23    FreshPartial,
24    /// The recorded content hash matches and the agent observed the full file.
25    FreshFull,
26}
27
28#[derive(Debug, Clone, Copy)]
29struct ReadRecord {
30    hash: u64,
31    full_read: bool,
32}
33
34/// Per-session record of which paths an agent has read (or written), keyed by
35/// the path string as the model passed it (lexical `.` components are normalized
36/// away — see [`ReadLedger::normalize_key`]) and valued by a content hash of the
37/// FULL file text plus whether the agent actually observed that full text. It
38/// backs the read-before-edit / staleness guard on the
39/// built-in `edit_file`/`write_file` tools: an edit (or an overwrite/append onto
40/// an existing file) is licensed only once the session has observed that path's
41/// current content, so the model can't blind-edit a file it never read or clobber
42/// one that changed underneath it.
43///
44/// Interior mutability is a plain `std::sync::Mutex` (never held across an
45/// `.await`), so a `&ReadLedger` can live behind a shared `Arc<dyn ToolExecutor>`.
46#[derive(Debug, Default)]
47pub struct ReadLedger {
48    seen: Mutex<HashMap<String, ReadRecord>>,
49    mutation_locks: Arc<Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>>,
50}
51
52impl ReadLedger {
53    pub fn new() -> Self {
54        Self::default()
55    }
56
57    fn with_mutation_locks(
58        mutation_locks: Arc<Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>>,
59    ) -> Self {
60        Self {
61            seen: Mutex::new(HashMap::new()),
62            mutation_locks,
63        }
64    }
65
66    /// Content hash of the FULL file text. `std::hash::DefaultHasher` is used
67    /// deliberately: the ledger only needs same-process change detection, not a
68    /// cryptographic digest.
69    fn hash(content: &str) -> u64 {
70        let mut hasher = std::collections::hash_map::DefaultHasher::new();
71        content.hash(&mut hasher);
72        hasher.finish()
73    }
74
75    /// Normalize a path into its ledger key. Lexical `.` components are removed
76    /// so clamped `root/./src/x.rs` and `root/src/x.rs` share an observation.
77    /// Parent components remain intact: the substrate owns actual path
78    /// resolution and authorization.
79    fn normalize_key(path: &str) -> String {
80        let mut normalized = PathBuf::new();
81        for component in Path::new(path).components() {
82            if !matches!(component, Component::CurDir) {
83                normalized.push(component.as_os_str());
84            }
85        }
86        if normalized.as_os_str().is_empty() {
87            ".".to_string()
88        } else {
89            normalized.to_string_lossy().into_owned()
90        }
91    }
92
93    /// Record that `path` currently holds `content` (its FULL text). Called on a
94    /// successful read and after a successful write/edit, so a later edit is
95    /// licensed and staleness compares against this snapshot. `full_read` says
96    /// whether the agent observed all of `content`, which whole-file writes and
97    /// replace-all edits require.
98    pub fn record(&self, path: &str, content: &str, full_read: bool) {
99        let record = ReadRecord {
100            hash: Self::hash(content),
101            full_read,
102        };
103        self.seen
104            .lock()
105            .expect("read ledger mutex poisoned")
106            .insert(Self::normalize_key(path), record);
107    }
108
109    /// Classify `path` against `content` (its current FULL text).
110    pub fn check(&self, path: &str, content: &str) -> ReadState {
111        let guard = self.seen.lock().expect("read ledger mutex poisoned");
112        match guard.get(&Self::normalize_key(path)) {
113            None => ReadState::Unread,
114            Some(recorded) if recorded.hash == Self::hash(content) && recorded.full_read => {
115                ReadState::FreshFull
116            }
117            Some(recorded) if recorded.hash == Self::hash(content) => ReadState::FreshPartial,
118            Some(_) => ReadState::Stale,
119        }
120    }
121
122    /// Serialize guarded mutations of one lexical path. The guard spans the
123    /// read/check/write/record sequence so two same-session edits cannot both
124    /// pass the stale check against one snapshot and lose one update.
125    pub fn mutation_lock(&self, path: &str) -> Arc<tokio::sync::Mutex<()>> {
126        let key = Self::normalize_key(path);
127        let mut locks = self
128            .mutation_locks
129            .lock()
130            .expect("read ledger mutation-lock mutex poisoned");
131        locks
132            .entry(key)
133            .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
134            .clone()
135    }
136
137    pub fn clear(&self) {
138        self.seen
139            .lock()
140            .expect("read ledger mutex poisoned")
141            .clear();
142    }
143}
144
145/// A default ledger plus lazily-created ledgers keyed by execution session.
146/// Executors use this so a shared executor never lets one conversation's read
147/// authorize another conversation's edit.
148#[derive(Debug)]
149pub struct SessionReadLedgers {
150    default: Arc<ReadLedger>,
151    sessions: Mutex<HashMap<String, Arc<ReadLedger>>>,
152    mutation_locks: Arc<Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>>,
153}
154
155impl Default for SessionReadLedgers {
156    fn default() -> Self {
157        Self::new()
158    }
159}
160
161impl SessionReadLedgers {
162    pub fn new() -> Self {
163        let mutation_locks = Arc::new(Mutex::new(HashMap::new()));
164        Self {
165            default: Arc::new(ReadLedger::with_mutation_locks(mutation_locks.clone())),
166            sessions: Mutex::new(HashMap::new()),
167            mutation_locks,
168        }
169    }
170
171    pub fn ledger_for(&self, session_id: Option<&str>) -> Arc<ReadLedger> {
172        let Some(session_id) = session_id else {
173            return self.default.clone();
174        };
175        let mut sessions = self
176            .sessions
177            .lock()
178            .expect("session read-ledger mutex poisoned");
179        sessions
180            .entry(session_id.to_string())
181            .or_insert_with(|| {
182                Arc::new(ReadLedger::with_mutation_locks(self.mutation_locks.clone()))
183            })
184            .clone()
185    }
186
187    pub fn remove(&self, session_id: &str) {
188        self.sessions
189            .lock()
190            .expect("session read-ledger mutex poisoned")
191            .remove(session_id);
192    }
193
194    pub fn clear(&self) {
195        self.default.clear();
196        let ledgers: Vec<Arc<ReadLedger>> = self
197            .sessions
198            .lock()
199            .expect("session read-ledger mutex poisoned")
200            .values()
201            .cloned()
202            .collect();
203        for ledger in ledgers {
204            ledger.clear();
205        }
206    }
207}
208
209/// Prescriptive "read the file before you modify it" error the staleness guard
210/// returns for an unread path.
211fn read_first_error(display_path: &str, verb: &str) -> String {
212    format!("you must read '{display_path}' before {verb} it — call read_file first")
213}
214
215/// Prescriptive "the file changed under you" error for a stale edit.
216fn stale_error(display_path: &str) -> String {
217    format!("'{display_path}' changed since you last read it — re-read it and retry")
218}
219
220fn full_read_error(display_path: &str, verb: &str) -> String {
221    format!(
222        "you must read the full current content of '{display_path}' before {verb} it — call read_file without offset or limit first"
223    )
224}
225
226/// True when `old_text` is shaped like read_file's line-number prefix — leading
227/// spaces, then one or more digits, then a tab (`^\s*\d+\t`). Pasting that prefix
228/// into `old_text` is a common mistake that guarantees a no-match, so the
229/// no-match error can point the model straight at it. (H1/F4-remainder review.)
230fn looks_like_pasted_line_number(old_text: &str) -> bool {
231    let after_spaces = old_text.trim_start_matches(' ');
232    let digits = after_spaces
233        .bytes()
234        .take_while(|b| b.is_ascii_digit())
235        .count();
236    digits > 0 && after_spaces.as_bytes().get(digits) == Some(&b'\t')
237}
238
239pub fn entries() -> Vec<ToolEntry> {
240    vec![
241        ToolEntry::builtin(car_ir::builtins::read_file()).with_category("filesystem"),
242        ToolEntry::builtin(car_ir::builtins::list_dir()).with_category("filesystem"),
243        ToolEntry::builtin(car_ir::builtins::find_files()).with_category("filesystem"),
244        ToolEntry::builtin(car_ir::builtins::grep_files()).with_category("filesystem"),
245        ToolEntry::builtin(car_ir::builtins::calculate()).with_category("utility"),
246        ToolEntry::builtin(car_ir::builtins::write_file())
247            .with_permission(ToolPermission::AskUser)
248            .with_side_effects(true)
249            .with_category("filesystem"),
250        ToolEntry::builtin(car_ir::builtins::edit_file())
251            .with_permission(ToolPermission::AskUser)
252            .with_side_effects(true)
253            .with_category("filesystem"),
254    ]
255}
256
257/// Execute a built-in commodity tool against the runtime's bound `substrate`.
258///
259/// The side-effecting file tools (`read_file`/`write_file`/`edit_file`/
260/// `list_dir`/`find_files`/`grep_files`) resolve and act against `substrate`,
261/// so the agent acts within **one** environment. `calculate` is pure — it
262/// never consults the substrate. Returns `None` for unknown tools (so the
263/// caller can fall through), `Some(result)` otherwise.
264///
265/// This entrypoint runs with the read-before-edit / staleness guard **disabled**
266/// (no session ledger). It keeps its historic signature and behavior because it
267/// is published crates.io API; new callers that want the guard use
268/// [`execute_with_ledger`]. In particular, its `read_file` output remains raw
269/// file text for existing callers; the guarded agent path returns numbered
270/// display text.
271pub async fn execute(
272    substrate: &Arc<dyn Substrate>,
273    tool: &str,
274    params: &Value,
275) -> Option<Result<Value, String>> {
276    execute_inner(substrate, None, tool, params).await
277}
278
279/// Like [`execute`], but threads a per-session [`ReadLedger`] so the built-in
280/// file tools enforce read-before-edit and content staleness: `edit_file` (and
281/// `write_file` over an existing file) require the session to have read the path
282/// first, and a successful read/write/edit records the current content so the
283/// next edit is licensed. Opt-in — every in-repo executor call site uses this so
284/// its agent gets the guard; the plain [`execute`] stays ungated for external
285/// consumers of the stable API.
286pub async fn execute_with_ledger(
287    substrate: &Arc<dyn Substrate>,
288    ledger: &ReadLedger,
289    tool: &str,
290    params: &Value,
291) -> Option<Result<Value, String>> {
292    execute_inner(substrate, Some(ledger), tool, params).await
293}
294
295async fn execute_inner(
296    substrate: &Arc<dyn Substrate>,
297    ledger: Option<&ReadLedger>,
298    tool: &str,
299    params: &Value,
300) -> Option<Result<Value, String>> {
301    let result = match tool {
302        "read_file" => exec_read_file(substrate, ledger, params).await,
303        "write_file" => exec_write_file(substrate, ledger, params).await,
304        "edit_file" => exec_edit_file(substrate, ledger, params).await,
305        "list_dir" => exec_list_dir(substrate, params).await,
306        "find_files" => exec_find_files(substrate, params).await,
307        "grep_files" => exec_grep_files(substrate, params).await,
308        "calculate" => exec_calculate(params),
309        _ => return None,
310    };
311    Some(result)
312}
313
314async fn exec_read_file(
315    substrate: &Arc<dyn Substrate>,
316    ledger: Option<&ReadLedger>,
317    params: &Value,
318) -> Result<Value, String> {
319    let path = params
320        .get("path")
321        .and_then(|v| v.as_str())
322        .ok_or("missing 'path' parameter")?;
323    let offset = params.get("offset").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
324    let limit = params
325        .get("limit")
326        .and_then(|v| v.as_u64())
327        .map(|v| v as usize);
328
329    let content = substrate.read_text(path).await?;
330    let size_bytes = content.len();
331    let total_lines = content.lines().count();
332
333    // Number each returned line `cat -n` style so the model can cite exact line
334    // numbers (matching grep_files' `line`). Numbering is 1-based and starts at
335    // `offset + 1` when a slice is requested. The prefixes are display-only —
336    // the tool descriptions warn the model to strip them before reusing text.
337    //
338    // Split on '\n' ONLY — never `str::lines()`, which strips a trailing `\r`.
339    // On a CRLF file that would render an LF-only view, so any multi-line
340    // `old_text` the model builds from read output could never match the
341    // on-disk bytes and every multi-line edit would fail as "not found".
342    // Keeping `\r` in the displayed line (like real `cat -n`) means text
343    // copied across lines round-trips byte-exact. A single trailing empty
344    // segment (file ending in '\n') is dropped, matching `cat -n`.
345    let mut lines: Vec<&str> = content.split('\n').collect();
346    if lines.last() == Some(&"") {
347        lines.pop();
348    }
349    let start = offset.min(lines.len());
350    let end = limit
351        .map(|line_count| (start + line_count).min(lines.len()))
352        .unwrap_or(lines.len());
353    let full_read = start == 0 && end == lines.len();
354
355    // Record a hash of the full current file for staleness detection, but keep
356    // whether the agent actually saw every line. A slice can license a narrow,
357    // unique edit; it cannot license a whole-file overwrite, append, or
358    // replace-all that would change unseen content.
359    if let Some(ledger) = ledger {
360        ledger.record(path, &content, full_read);
361    }
362
363    let returned = if ledger.is_some() {
364        lines[start..end]
365            .iter()
366            .enumerate()
367            .map(|(i, line)| format!("{:>6}\t{}", start + i + 1, line))
368            .collect::<Vec<_>>()
369            .join("\n")
370    } else if offset > 0 || limit.is_some() {
371        lines[start..end].join("\n")
372    } else {
373        content.clone()
374    };
375
376    Ok(json!({
377        "path": substrate.display_path(path),
378        "content": returned,
379        "size_bytes": size_bytes,
380        "total_lines": total_lines,
381    }))
382}
383
384async fn exec_write_file(
385    substrate: &Arc<dyn Substrate>,
386    ledger: Option<&ReadLedger>,
387    params: &Value,
388) -> Result<Value, String> {
389    let path = params
390        .get("path")
391        .and_then(|v| v.as_str())
392        .ok_or("missing 'path' parameter")?;
393    let content = params
394        .get("content")
395        .and_then(|v| v.as_str())
396        .ok_or("missing 'content' parameter")?;
397    let append = params
398        .get("append")
399        .and_then(|v| v.as_bool())
400        .unwrap_or(false);
401
402    let _mutation_guard = match ledger {
403        Some(ledger) => Some(ledger.mutation_lock(path).lock_owned().await),
404        None => None,
405    };
406
407    let existing = if ledger.is_some() {
408        match substrate.read_text(path).await {
409            Ok(content) => Some(content),
410            Err(read_error) => match substrate.path_state(path).await {
411                crate::substrate::PathState::Missing => None,
412                crate::substrate::PathState::Exists => {
413                    return Err(format!(
414                        "cannot modify existing file '{}' because it cannot be read as UTF-8: {read_error}",
415                        substrate.display_path(path)
416                    ));
417                }
418                crate::substrate::PathState::Unknown(reason) => {
419                    return Err(format!(
420                        "cannot determine whether '{}' is safe to create: {reason}",
421                        substrate.display_path(path)
422                    ));
423                }
424            },
425        }
426    } else {
427        None
428    };
429
430    let combined = if append {
431        // Compose append on top of the substrate's read/write primitives so
432        // behavior is environment-agnostic. A missing file starts empty,
433        // matching the historic OpenOptions(create=true, append=true).
434        let existed = existing.is_some();
435        let existing = match existing {
436            Some(existing) => existing,
437            None if ledger.is_some() => String::new(),
438            None => substrate.read_text(path).await.unwrap_or_default(),
439        };
440        // Read-before-edit + staleness guard: an append that lands on an
441        // existing file the session never read is refused, same as a plain
442        // overwrite — and an append onto content that changed since the last
443        // read is refused as stale (the new bytes would land after content the
444        // session has never seen). A missing file (empty existing) is a
445        // creation and stays ungated.
446        if let Some(ledger) = ledger.filter(|_| existed) {
447            match ledger.check(path, &existing) {
448                ReadState::Unread => {
449                    return Err(read_first_error(
450                        &substrate.display_path(path),
451                        "overwriting",
452                    ));
453                }
454                ReadState::Stale => {
455                    return Err(stale_error(&substrate.display_path(path)));
456                }
457                ReadState::FreshPartial => {
458                    return Err(full_read_error(
459                        &substrate.display_path(path),
460                        "appending to",
461                    ));
462                }
463                ReadState::FreshFull => {}
464            }
465        }
466        let mut combined = existing;
467        combined.push_str(content);
468        substrate.write_text(path, &combined).await?;
469        combined
470    } else {
471        // Read-before-edit + staleness guard: overwriting a file that already
472        // exists but was never read this session is refused (a blind clobber),
473        // and overwriting one that CHANGED since the last read is refused as
474        // stale — the session would silently destroy content it has never
475        // observed (e.g. a change made by its own shell command or an external
476        // process). Re-reading shows the current bytes and re-licenses the
477        // write. Creating a NEW file is always allowed.
478        if let Some(ledger) = ledger {
479            if let Some(existing) = existing {
480                match ledger.check(path, &existing) {
481                    ReadState::Unread => {
482                        return Err(read_first_error(
483                            &substrate.display_path(path),
484                            "overwriting",
485                        ));
486                    }
487                    ReadState::Stale => {
488                        return Err(stale_error(&substrate.display_path(path)));
489                    }
490                    ReadState::FreshPartial => {
491                        return Err(full_read_error(
492                            &substrate.display_path(path),
493                            "overwriting",
494                        ));
495                    }
496                    ReadState::FreshFull => {}
497                }
498            }
499        }
500        substrate.write_text(path, content).await?;
501        content.to_string()
502    };
503
504    // Self-record the new content so a subsequent edit_file is licensed and the
505    // session's snapshot of this path stays current.
506    if let Some(ledger) = ledger {
507        ledger.record(path, &combined, true);
508    }
509
510    Ok(json!({
511        "path": substrate.display_path(path),
512        "bytes_written": content.len(),
513        "append": append,
514    }))
515}
516
517async fn exec_edit_file(
518    substrate: &Arc<dyn Substrate>,
519    ledger: Option<&ReadLedger>,
520    params: &Value,
521) -> Result<Value, String> {
522    let path = params
523        .get("path")
524        .and_then(|v| v.as_str())
525        .ok_or("missing 'path' parameter")?;
526    let old_text = params
527        .get("old_text")
528        .and_then(|v| v.as_str())
529        .ok_or("missing 'old_text' parameter")?;
530    let new_text = params
531        .get("new_text")
532        .and_then(|v| v.as_str())
533        .ok_or("missing 'new_text' parameter")?;
534    let replace_all = params
535        .get("replace_all")
536        .and_then(|v| v.as_bool())
537        .unwrap_or(false);
538
539    let _mutation_guard = match ledger {
540        Some(ledger) => Some(ledger.mutation_lock(path).lock_owned().await),
541        None => None,
542    };
543
544    let content = substrate.read_text(path).await?;
545
546    // Read-before-edit / staleness guard: the session must have read this exact
547    // path, and its content must still match what was read (a write self-records,
548    // so a write→edit chain is fine). (H1/F4-remainder, audit 2026-07-06.)
549    let full_read = if let Some(ledger) = ledger {
550        match ledger.check(path, &content) {
551            ReadState::Unread => {
552                return Err(read_first_error(&substrate.display_path(path), "editing"));
553            }
554            ReadState::Stale => {
555                return Err(stale_error(&substrate.display_path(path)));
556            }
557            ReadState::FreshPartial if replace_all => {
558                return Err(full_read_error(
559                    &substrate.display_path(path),
560                    "replacing every occurrence in",
561                ));
562            }
563            ReadState::FreshPartial => false,
564            ReadState::FreshFull => true,
565        }
566    } else {
567        false
568    };
569
570    // An empty old_text is never a real edit request: `str::matches("")`
571    // yields char_count+1 hits, and with replace_all that would interleave
572    // new_text at every char boundary — silent whole-file corruption reported
573    // as success. Reject up front. (H1/F4-remainder review, audit 2026-07-06.)
574    if old_text.is_empty() {
575        return Err(
576            "old_text must be non-empty — pass the exact existing text to replace \
577             (use write_file to replace a whole file)"
578                .to_string(),
579        );
580    }
581
582    let count = content.matches(old_text).count();
583    if count == 0 {
584        let mut msg = format!("old_text not found in '{}'", substrate.display_path(path));
585        if looks_like_pasted_line_number(old_text) {
586            msg.push_str(
587                " — old_text looks like it includes read_file's line-number prefixes; \
588                 strip them and retry",
589            );
590        }
591        return Err(msg);
592    }
593    let new_content = if replace_all {
594        content.replace(old_text, new_text)
595    } else {
596        if count > 1 {
597            return Err(format!(
598                "old_text found {count} times in '{}' and must match uniquely — \
599                 pass `replace_all: true` to replace every occurrence, or add \
600                 surrounding context to old_text so it matches one place",
601                substrate.display_path(path)
602            ));
603        }
604        content.replacen(old_text, new_text, 1)
605    };
606    substrate.write_text(path, &new_content).await?;
607
608    // Self-record the post-edit content so the session's snapshot stays current
609    // and a follow-up edit doesn't spuriously read as stale.
610    if let Some(ledger) = ledger {
611        ledger.record(path, &new_content, full_read);
612    }
613
614    Ok(json!({
615        "edited": substrate.display_path(path),
616        "diff_summary": format!(
617            "replaced {} lines with {} lines",
618            old_text.lines().count(),
619            new_text.lines().count()
620        ),
621        "replacements": count,
622    }))
623}
624
625async fn exec_list_dir(substrate: &Arc<dyn Substrate>, params: &Value) -> Result<Value, String> {
626    let path = params.get("path").and_then(|v| v.as_str()).unwrap_or(".");
627
628    if !substrate.is_local() {
629        return list_dir_via_command(substrate, path).await;
630    }
631
632    let full_path = local_resolve(path)?;
633    let mut entries = Vec::new();
634
635    let read_dir = std::fs::read_dir(&full_path)
636        .map_err(|e| format!("failed to read dir '{}': {e}", full_path.display()))?;
637    for entry in read_dir {
638        let entry = entry.map_err(|e| format!("failed to read dir entry: {e}"))?;
639        let file_name = entry.file_name().to_string_lossy().to_string();
640        if should_skip_name(&file_name) {
641            continue;
642        }
643        let metadata = entry
644            .metadata()
645            .map_err(|e| format!("failed to read metadata for '{}': {e}", file_name))?;
646        entries.push(json!({
647            "name": file_name,
648            "path": entry.path().display().to_string(),
649            "is_dir": metadata.is_dir(),
650            "size_bytes": if metadata.is_file() { Some(metadata.len()) } else { None::<u64> },
651        }));
652    }
653
654    Ok(json!({
655        "path": full_path.display().to_string(),
656        "entries": entries,
657    }))
658}
659
660async fn exec_find_files(substrate: &Arc<dyn Substrate>, params: &Value) -> Result<Value, String> {
661    let pattern = params
662        .get("pattern")
663        .and_then(|v| v.as_str())
664        .ok_or("missing 'pattern' parameter")?;
665    let root = params.get("path").and_then(|v| v.as_str()).unwrap_or(".");
666    let max_results = params
667        .get("max_results")
668        .and_then(|v| v.as_u64())
669        .unwrap_or(1000) as usize;
670
671    if !substrate.is_local() {
672        return find_files_via_command(substrate, pattern, root, max_results).await;
673    }
674
675    let root_path = local_resolve(root)?;
676    let matcher = glob_to_regex(pattern)?;
677    // A pattern with a path separator is matched against each file's path
678    // relative to the search root (so `src/**/*.rs` scopes to a subtree); a
679    // bare pattern is matched against the basename (so `*.rs` finds every `.rs`
680    // at any depth). Shared with the non-local path via `glob_haystack`. (F4.)
681    let pattern_has_sep = pattern.contains('/');
682    let root_str = root_path.to_string_lossy().to_string();
683    let mut files = Vec::new();
684
685    walk_files(&root_path, &mut |path| {
686        if files.len() >= max_results {
687            return;
688        }
689        if let Some(full) = path.to_str() {
690            if matcher.is_match(&glob_haystack(pattern_has_sep, full, &root_str)) {
691                files.push(path.display().to_string());
692            }
693        }
694    })?;
695
696    Ok(json!({
697        "files": files,
698        "count": files.len(),
699        "truncated": files.len() >= max_results,
700    }))
701}
702
703async fn exec_grep_files(substrate: &Arc<dyn Substrate>, params: &Value) -> Result<Value, String> {
704    let pattern = params
705        .get("pattern")
706        .and_then(|v| v.as_str())
707        .ok_or("missing 'pattern' parameter")?;
708    let root = params.get("path").and_then(|v| v.as_str()).unwrap_or(".");
709    let max_results = params
710        .get("max_results")
711        .and_then(|v| v.as_u64())
712        .unwrap_or(50) as usize;
713
714    if !substrate.is_local() {
715        return grep_files_via_command(substrate, pattern, root, max_results).await;
716    }
717
718    let root_path = local_resolve(root)?;
719    let regex = Regex::new(pattern).map_err(|e| format!("invalid regex pattern: {e}"))?;
720    let mut matches = Vec::new();
721
722    walk_files(&root_path, &mut |path| {
723        if matches.len() >= max_results || !is_text_file(path) {
724            return;
725        }
726        let Ok(content) = std::fs::read_to_string(path) else {
727            return;
728        };
729        if content.len() > MAX_FILE_BYTES {
730            return;
731        }
732        for (idx, line) in content.lines().enumerate() {
733            if regex.is_match(line) {
734                matches.push(json!({
735                    "path": path.display().to_string(),
736                    "line": idx + 1,
737                    "text": line,
738                }));
739                if matches.len() >= max_results {
740                    break;
741                }
742            }
743        }
744    })?;
745
746    Ok(json!({
747        "matches": matches,
748        "count": matches.len(),
749        "truncated": matches.len() >= max_results,
750    }))
751}
752
753fn exec_calculate(params: &Value) -> Result<Value, String> {
754    let expression = params
755        .get("expression")
756        .and_then(|v| v.as_str())
757        .ok_or("missing 'expression' parameter")?;
758    // fasteval ships `sin/cos/abs/log/min/max/pi()/e()` and `^` (exponentiation)
759    // as built-ins, but not `sqrt`, `ln`, or the bare `pi`/`e` constants.
760    // fasteval consults this namespace only for names it doesn't resolve
761    // itself, so the shim fills those gaps without shadowing any built-in —
762    // making the `calculate` tool's supported surface explicit here rather
763    // than inherited from the dependency.
764    let mut ns = |name: &str, args: Vec<f64>| -> Option<f64> {
765        match (name, args.as_slice()) {
766            ("sqrt", [x]) => Some(x.sqrt()),
767            ("ln", [x]) => Some(x.ln()),
768            ("pi", []) => Some(std::f64::consts::PI),
769            ("e", []) => Some(std::f64::consts::E),
770            _ => None,
771        }
772    };
773    let result = fasteval::ez_eval(expression, &mut ns)
774        .map_err(|e| format!("failed to evaluate expression: {e}"))?;
775    Ok(json!({ "result": result }))
776}
777
778// ─── Local-path resolution (host CWD), used only on the local fast path ───
779//
780// Mirrors `LocalSubstrate::resolve_path` exactly: absolute paths pass through,
781// relative paths join the host process current_dir(). The directory-walking
782// convenience tools (list/find/grep) use it directly when the bound substrate
783// is the host; non-local substrates compose on `run_command` instead.
784fn local_resolve(path: &str) -> Result<PathBuf, String> {
785    crate::substrate::LocalSubstrate::resolve_path(path)
786}
787
788fn should_skip_name(name: &str) -> bool {
789    name.starts_with('.') || matches!(name, "node_modules" | "__pycache__" | "target")
790}
791
792fn is_text_file(path: &Path) -> bool {
793    matches!(
794        path.extension().and_then(|v| v.to_str()),
795        Some(
796            "c" | "cc"
797                | "cpp"
798                | "cs"
799                | "css"
800                | "go"
801                | "h"
802                | "html"
803                | "ini"
804                | "java"
805                | "js"
806                | "json"
807                | "jsx"
808                | "kt"
809                | "md"
810                | "py"
811                | "rb"
812                | "rs"
813                | "sh"
814                | "sql"
815                | "toml"
816                | "ts"
817                | "tsx"
818                | "txt"
819                | "xml"
820                | "yaml"
821                | "yml"
822        )
823    )
824}
825
826fn walk_files(root: &Path, visit: &mut dyn FnMut(&Path)) -> Result<(), String> {
827    if root.is_file() {
828        visit(root);
829        return Ok(());
830    }
831
832    let read_dir = std::fs::read_dir(root)
833        .map_err(|e| format!("failed to read dir '{}': {e}", root.display()))?;
834    for entry in read_dir {
835        let entry = entry.map_err(|e| format!("failed to read dir entry: {e}"))?;
836        let path = entry.path();
837        let file_name = entry.file_name().to_string_lossy().to_string();
838        if should_skip_name(&file_name) {
839            continue;
840        }
841        let metadata = entry
842            .metadata()
843            .map_err(|e| format!("failed to read metadata for '{}': {e}", path.display()))?;
844        if metadata.is_dir() {
845            walk_files(&path, visit)?;
846        } else if metadata.is_file() {
847            visit(&path);
848        }
849    }
850    Ok(())
851}
852
853/// Translate a glob to an anchored regex with path-segment awareness so
854/// recursive path patterns work, not just basenames (F4, audit 2026-07-06):
855/// - `*`   matches within one path segment (`[^/]*`)
856/// - `**/` matches any number of leading segments, including none (`(?:.*/)?`)
857/// - `**`  matches across segments (`.*`)
858/// - `?`   matches a single non-separator char (`[^/]`)
859/// A pattern with no `/` is matched against the basename by the caller, so
860/// `*.rs` still finds `lib.rs` at any depth; a pattern with `/` (e.g.
861/// `src/**/*.rs`) is matched against the path relative to the search root.
862fn glob_to_regex(pattern: &str) -> Result<Regex, String> {
863    let chars: Vec<char> = pattern.chars().collect();
864    let mut re = String::from("^");
865    let mut i = 0;
866    while i < chars.len() {
867        match chars[i] {
868            '*' => {
869                if i + 1 < chars.len() && chars[i + 1] == '*' {
870                    i += 1; // consume the second '*'
871                    if i + 1 < chars.len() && chars[i + 1] == '/' {
872                        i += 1; // consume the '/': `**/` → optional leading segments
873                        re.push_str("(?:.*/)?");
874                    } else {
875                        re.push_str(".*");
876                    }
877                } else {
878                    re.push_str("[^/]*");
879                }
880            }
881            '?' => re.push_str("[^/]"),
882            c => re.push_str(&regex::escape(&c.to_string())),
883        }
884        i += 1;
885    }
886    re.push('$');
887    Regex::new(&re).map_err(|e| format!("invalid glob pattern: {e}"))
888}
889
890/// Pick the string a candidate file is matched against for a glob search: the
891/// path relative to the search `root` when the pattern contains a separator
892/// (so `src/**/*.rs` scopes to a subtree), else the basename (so `*.rs` finds
893/// every match at any depth). Shared by the local directory walk AND the
894/// non-local `find` path so both substrates honor the same path-glob semantics
895/// the tool schema advertises — otherwise the sandbox/remote path silently
896/// returns nothing for a `/`-bearing pattern. (F4, audit 2026-07-06.)
897fn glob_haystack(pattern_has_sep: bool, full: &str, root: &str) -> String {
898    if pattern_has_sep {
899        let full = full.replace('\\', "/");
900        let root = root.replace('\\', "/");
901        full.strip_prefix(&root)
902            .unwrap_or(&full)
903            .trim_start_matches('/')
904            .to_string()
905    } else {
906        full.rsplit(['/', '\\']).next().unwrap_or(full).to_string()
907    }
908}
909
910// ─── Non-local composition on run_command ─────────────────────────────────
911//
912// For non-host substrates (e.g. a VM bridge) the directory-walking convenience
913// tools have no host fs to walk, so they compose on the substrate's
914// `run_command`, mirroring the bridge's "no ls/find/grep — reduce to a shell
915// command" design. These paths are NOT exercised by existing consumers (which
916// all default to LocalSubstrate), so they introduce no behavior change there.
917
918fn shell_quote(s: &str) -> String {
919    // POSIX single-quote escaping: ' -> '\''
920    format!("'{}'", s.replace('\'', "'\\''"))
921}
922
923async fn list_dir_via_command(substrate: &Arc<dyn Substrate>, path: &str) -> Result<Value, String> {
924    let cmd = format!("ls -1Ap {}", shell_quote(path));
925    let out = substrate.run_command(&cmd, Some(30.0)).await?;
926    let entries: Vec<Value> = out
927        .stdout
928        .lines()
929        .filter(|l| !l.is_empty())
930        .filter(|name| {
931            let bare = name.trim_end_matches('/');
932            !should_skip_name(bare)
933        })
934        .map(|name| {
935            let is_dir = name.ends_with('/');
936            let bare = name.trim_end_matches('/');
937            json!({
938                "name": bare,
939                "path": format!("{}/{}", path.trim_end_matches('/'), bare),
940                "is_dir": is_dir,
941                "size_bytes": Value::Null,
942            })
943        })
944        .collect();
945    Ok(json!({ "path": path, "entries": entries }))
946}
947
948async fn find_files_via_command(
949    substrate: &Arc<dyn Substrate>,
950    pattern: &str,
951    root: &str,
952    max_results: usize,
953) -> Result<Value, String> {
954    // List every file, then apply the SAME matcher the local walk uses, so a
955    // path glob (`src/**/*.rs`) works here too. `find -name <pattern>` matched
956    // the basename only and never matched a `/`-bearing pattern — the tool
957    // schema advertises path globs on every substrate, so the non-local path
958    // must honor them or silently return nothing. (F4, audit 2026-07-06.)
959    let cmd = format!("find {} -type f", shell_quote(root));
960    let out = substrate.run_command(&cmd, Some(30.0)).await?;
961    let matcher = glob_to_regex(pattern)?;
962    let pattern_has_sep = pattern.contains('/');
963    let mut files: Vec<String> = Vec::new();
964    let mut truncated = false;
965    for line in out.stdout.lines() {
966        if line.is_empty() {
967            continue;
968        }
969        let name = line.rsplit(['/', '\\']).next().unwrap_or(line);
970        if should_skip_name(name) {
971            continue;
972        }
973        if !matcher.is_match(&glob_haystack(pattern_has_sep, line, root)) {
974            continue;
975        }
976        if files.len() >= max_results {
977            truncated = true;
978            break;
979        }
980        files.push(line.to_string());
981    }
982    Ok(json!({
983        "files": files,
984        "count": files.len(),
985        "truncated": truncated,
986    }))
987}
988
989async fn grep_files_via_command(
990    substrate: &Arc<dyn Substrate>,
991    pattern: &str,
992    root: &str,
993    max_results: usize,
994) -> Result<Value, String> {
995    let cmd = format!("grep -rnE {} {}", shell_quote(pattern), shell_quote(root));
996    let out = substrate.run_command(&cmd, Some(30.0)).await?;
997    let mut matches = Vec::new();
998    for line in out.stdout.lines() {
999        if matches.len() >= max_results {
1000            break;
1001        }
1002        // grep -rn format: path:line:text
1003        let mut parts = line.splitn(3, ':');
1004        let (Some(p), Some(ln), Some(text)) = (parts.next(), parts.next(), parts.next()) else {
1005            continue;
1006        };
1007        let Ok(line_no) = ln.parse::<usize>() else {
1008            continue;
1009        };
1010        matches.push(json!({ "path": p, "line": line_no, "text": text }));
1011    }
1012    let truncated = matches.len() >= max_results;
1013    Ok(json!({
1014        "matches": matches,
1015        "count": matches.len(),
1016        "truncated": truncated,
1017    }))
1018}
1019
1020#[cfg(test)]
1021mod tests {
1022    use super::*;
1023
1024    #[test]
1025    fn glob_patterns_match_file_names() {
1026        let regex = glob_to_regex("*.rs").unwrap();
1027        assert!(regex.is_match("lib.rs"));
1028        assert!(!regex.is_match("lib.ts"));
1029    }
1030
1031    /// F4 (audit 2026-07-06): the local walk and the non-local `find` path
1032    /// share `glob_haystack`, so a `/`-bearing pattern matches the path
1033    /// relative to the search root on EVERY substrate (the non-local path used
1034    /// to `find -name` and silently returned nothing for a path glob). This
1035    /// exercises the shared matching logic both branches now depend on.
1036    #[test]
1037    fn glob_haystack_supports_path_and_basename_matching() {
1038        // path pattern → relative-to-root (normalized to '/'); bare → basename
1039        assert_eq!(
1040            glob_haystack(true, "/root/src/inner/deep.rs", "/root"),
1041            "src/inner/deep.rs"
1042        );
1043        assert_eq!(
1044            glob_haystack(false, "/root/src/inner/deep.rs", "/root"),
1045            "deep.rs"
1046        );
1047
1048        // Combined with the translator, a recursive path glob scopes to the
1049        // subtree and rejects the wrong extension — the behavior the non-local
1050        // substrate previously could not produce.
1051        let m = glob_to_regex("src/**/*.rs").unwrap();
1052        assert!(m.is_match(&glob_haystack(true, "/root/src/top.rs", "/root")));
1053        assert!(m.is_match(&glob_haystack(true, "/root/src/inner/deep.rs", "/root")));
1054        assert!(!m.is_match(&glob_haystack(true, "/root/src/inner/note.txt", "/root")));
1055    }
1056
1057    /// F4 (audit 2026-07-06): the file search must support recursive path globs
1058    /// (`src/**/*.rs`), not just basename matching — otherwise the agent cannot
1059    /// scope a search to a subtree and resorts to guessing.
1060    #[tokio::test]
1061    async fn find_files_supports_recursive_path_globs() {
1062        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1063        let dir = std::env::temp_dir().join(format!(
1064            "car-find-glob-{}",
1065            std::time::SystemTime::now()
1066                .duration_since(std::time::UNIX_EPOCH)
1067                .unwrap()
1068                .as_nanos()
1069        ));
1070        std::fs::create_dir_all(dir.join("src").join("inner")).unwrap();
1071        std::fs::write(dir.join("src").join("top.rs"), "x").unwrap();
1072        std::fs::write(dir.join("src").join("inner").join("deep.rs"), "x").unwrap();
1073        std::fs::write(dir.join("src").join("inner").join("note.txt"), "x").unwrap();
1074        let root = dir.to_string_lossy().to_string();
1075
1076        let r = exec_find_files(
1077            &substrate,
1078            &json!({ "path": root, "pattern": "src/**/*.rs" }),
1079        )
1080        .await
1081        .unwrap();
1082        let joined = r["files"]
1083            .as_array()
1084            .unwrap()
1085            .iter()
1086            .map(|v| v.as_str().unwrap())
1087            .collect::<Vec<_>>()
1088            .join("\n");
1089
1090        assert!(
1091            joined.contains("deep.rs"),
1092            "recursive glob missed nested file:\n{joined}"
1093        );
1094        assert!(
1095            joined.contains("top.rs"),
1096            "recursive glob missed top-level file:\n{joined}"
1097        );
1098        assert!(
1099            !joined.contains("note.txt"),
1100            "glob matched the wrong extension:\n{joined}"
1101        );
1102
1103        let _ = std::fs::remove_dir_all(&dir);
1104    }
1105
1106    /// F4 (audit 2026-07-06): the NON-LOCAL substrate path (`find_files_via_command`)
1107    /// must honor recursive path globs too — it lists every file via `run_command`
1108    /// then applies the SAME matcher as the local walk. Previously untested; a mock
1109    /// substrate (`is_local()` defaults to false) with scripted `find` stdout pins
1110    /// it, so a regression that reverts to basename-only `-name` matching is caught.
1111    #[tokio::test]
1112    async fn find_files_non_local_substrate_applies_path_glob() {
1113        use crate::substrate::CommandOutput;
1114
1115        struct RemoteStub {
1116            stdout: String,
1117        }
1118        #[async_trait::async_trait]
1119        impl Substrate for RemoteStub {
1120            fn name(&self) -> &str {
1121                "test-remote"
1122            }
1123            // is_local() defaults to false -> exercises the non-local path.
1124            async fn run_command(
1125                &self,
1126                _cmd: &str,
1127                _timeout_s: Option<f64>,
1128            ) -> Result<CommandOutput, String> {
1129                Ok(CommandOutput {
1130                    stdout: self.stdout.clone(),
1131                    stderr: String::new(),
1132                    exit_code: 0,
1133                })
1134            }
1135            async fn read_text(&self, _path: &str) -> Result<String, String> {
1136                Err("unused".into())
1137            }
1138            async fn write_text(&self, _path: &str, _content: &str) -> Result<(), String> {
1139                Err("unused".into())
1140            }
1141            async fn read_bytes(
1142                &self,
1143                _path: &str,
1144                _offset: Option<u64>,
1145                _len: Option<u64>,
1146            ) -> Result<Vec<u8>, String> {
1147                Err("unused".into())
1148            }
1149            async fn write_bytes(&self, _path: &str, _bytes: &[u8]) -> Result<(), String> {
1150                Err("unused".into())
1151            }
1152        }
1153
1154        // Scripted `find <root> -type f` output — matching + non-matching paths.
1155        let substrate: Arc<dyn Substrate> = Arc::new(RemoteStub {
1156            stdout: [
1157                "/root/src/top.rs",
1158                "/root/src/inner/deep.rs",
1159                "/root/src/inner/note.txt",
1160                "/root/README.md",
1161            ]
1162            .join("\n"),
1163        });
1164
1165        let r = exec_find_files(
1166            &substrate,
1167            &json!({ "path": "/root", "pattern": "src/**/*.rs" }),
1168        )
1169        .await
1170        .unwrap();
1171        let files: Vec<&str> = r["files"]
1172            .as_array()
1173            .unwrap()
1174            .iter()
1175            .map(|v| v.as_str().unwrap())
1176            .collect();
1177
1178        // The recursive path glob scopes to src/ and rejects the wrong extension —
1179        // the exact behavior the non-local path silently lacked before F4.
1180        assert_eq!(files, vec!["/root/src/top.rs", "/root/src/inner/deep.rs"]);
1181        assert_eq!(r["count"], 2);
1182    }
1183
1184    /// Unique nanos-named temp dir for a test, created and returned.
1185    fn fresh_dir(tag: &str) -> PathBuf {
1186        let dir = std::env::temp_dir().join(format!(
1187            "car-agent-basics-{tag}-{}",
1188            std::time::SystemTime::now()
1189                .duration_since(std::time::UNIX_EPOCH)
1190                .unwrap()
1191                .as_nanos()
1192        ));
1193        std::fs::create_dir_all(&dir).unwrap();
1194        dir
1195    }
1196
1197    #[tokio::test]
1198    async fn read_write_roundtrip_against_local_substrate() {
1199        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1200        let dir = fresh_dir("roundtrip");
1201        let path = dir.join("note.txt").to_string_lossy().to_string();
1202
1203        let w = exec_write_file(&substrate, None, &json!({ "path": path, "content": "abc" }))
1204            .await
1205            .unwrap();
1206        assert_eq!(w["bytes_written"], 3);
1207
1208        // The published, ungated execute path retains its historic raw content.
1209        let r = exec_read_file(&substrate, None, &json!({ "path": path }))
1210            .await
1211            .unwrap();
1212        assert_eq!(r["content"], "abc");
1213        assert_eq!(r["size_bytes"], 3);
1214        assert_eq!(r["total_lines"], 1);
1215
1216        // append composes correctly through the substrate
1217        exec_write_file(
1218            &substrate,
1219            None,
1220            &json!({ "path": path, "content": "def", "append": true }),
1221        )
1222        .await
1223        .unwrap();
1224        let r2 = exec_read_file(&substrate, None, &json!({ "path": path }))
1225            .await
1226            .unwrap();
1227        assert_eq!(r2["content"], "abcdef");
1228
1229        // edit unique-match
1230        let e = exec_edit_file(
1231            &substrate,
1232            None,
1233            &json!({ "path": path, "old_text": "abc", "new_text": "XYZ" }),
1234        )
1235        .await
1236        .unwrap();
1237        assert!(e["edited"].is_string());
1238        assert_eq!(e["replacements"], 1);
1239        let r3 = exec_read_file(&substrate, None, &json!({ "path": path }))
1240            .await
1241            .unwrap();
1242        assert_eq!(r3["content"], "XYZdef");
1243
1244        std::fs::remove_dir_all(&dir).ok();
1245    }
1246
1247    /// (a) read output is line-numbered `cat -n` style, 1-based, and when an
1248    /// `offset` is given the numbering starts at `offset + 1` while
1249    /// `size_bytes`/`total_lines` still describe the FULL file.
1250    #[tokio::test]
1251    async fn read_file_output_is_line_numbered() {
1252        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1253        let ledger = ReadLedger::new();
1254        let dir = fresh_dir("linenum");
1255        let path = dir.join("f.txt").to_string_lossy().to_string();
1256        exec_write_file(
1257            &substrate,
1258            None,
1259            &json!({ "path": path, "content": "alpha\nbeta\ngamma" }),
1260        )
1261        .await
1262        .unwrap();
1263
1264        let r = execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1265            .await
1266            .unwrap()
1267            .unwrap();
1268        assert_eq!(r["content"], "     1\talpha\n     2\tbeta\n     3\tgamma");
1269        assert_eq!(r["total_lines"], 3);
1270
1271        // Offset slice: numbering continues from offset + 1, full-file metadata.
1272        let r2 = execute_with_ledger(
1273            &substrate,
1274            &ledger,
1275            "read_file",
1276            &json!({ "path": path, "offset": 1, "limit": 1 }),
1277        )
1278        .await
1279        .unwrap()
1280        .unwrap();
1281        assert_eq!(r2["content"], "     2\tbeta");
1282        assert_eq!(r2["total_lines"], 3);
1283
1284        std::fs::remove_dir_all(&dir).ok();
1285    }
1286
1287    #[tokio::test]
1288    async fn plain_execute_preserves_legacy_raw_read_output() {
1289        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1290        let dir = fresh_dir("rawread");
1291        let path = dir.join("f.txt").to_string_lossy().to_string();
1292        std::fs::write(&path, "alpha\nbeta\n").unwrap();
1293
1294        let result = execute(&substrate, "read_file", &json!({ "path": path }))
1295            .await
1296            .unwrap()
1297            .unwrap();
1298        assert_eq!(result["content"], "alpha\nbeta\n");
1299
1300        std::fs::remove_dir_all(&dir).ok();
1301    }
1302
1303    /// (b) `edit_file` refuses a path the session never read, then succeeds once
1304    /// `read_file` has run — the read-before-edit guard.
1305    #[tokio::test]
1306    async fn edit_requires_prior_read() {
1307        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1308        let ledger = ReadLedger::new();
1309        let dir = fresh_dir("editread");
1310        let path = dir.join("f.txt").to_string_lossy().to_string();
1311        // Create on disk directly — the ledger has no record of this path.
1312        std::fs::write(dir.join("f.txt"), "hello world").unwrap();
1313
1314        let err = execute_with_ledger(
1315            &substrate,
1316            &ledger,
1317            "edit_file",
1318            &json!({ "path": path, "old_text": "hello", "new_text": "hi" }),
1319        )
1320        .await
1321        .unwrap()
1322        .unwrap_err();
1323        assert!(err.contains("before editing it"), "{err}");
1324        assert!(err.contains("read_file"), "{err}");
1325
1326        // Reading licenses the edit.
1327        execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1328            .await
1329            .unwrap()
1330            .unwrap();
1331        let ok = execute_with_ledger(
1332            &substrate,
1333            &ledger,
1334            "edit_file",
1335            &json!({ "path": path, "old_text": "hello", "new_text": "hi" }),
1336        )
1337        .await
1338        .unwrap()
1339        .unwrap();
1340        assert_eq!(ok["replacements"], 1);
1341
1342        std::fs::remove_dir_all(&dir).ok();
1343    }
1344
1345    /// (b) `edit_file` refuses a stale file — one that changed on disk after the
1346    /// session read it — until it is re-read.
1347    #[tokio::test]
1348    async fn edit_detects_stale_file() {
1349        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1350        let ledger = ReadLedger::new();
1351        let dir = fresh_dir("stale");
1352        let path = dir.join("f.txt").to_string_lossy().to_string();
1353        std::fs::write(dir.join("f.txt"), "version one").unwrap();
1354
1355        // Read records the current content; then the file changes underneath.
1356        execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1357            .await
1358            .unwrap()
1359            .unwrap();
1360        std::fs::write(dir.join("f.txt"), "version two changed").unwrap();
1361
1362        let err = execute_with_ledger(
1363            &substrate,
1364            &ledger,
1365            "edit_file",
1366            &json!({ "path": path, "old_text": "version", "new_text": "v" }),
1367        )
1368        .await
1369        .unwrap()
1370        .unwrap_err();
1371        assert!(err.contains("changed since you last read it"), "{err}");
1372
1373        // Re-reading clears the staleness.
1374        execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1375            .await
1376            .unwrap()
1377            .unwrap();
1378        let ok = execute_with_ledger(
1379            &substrate,
1380            &ledger,
1381            "edit_file",
1382            &json!({ "path": path, "old_text": "version", "new_text": "v" }),
1383        )
1384        .await
1385        .unwrap()
1386        .unwrap();
1387        assert!(ok["edited"].is_string());
1388
1389        std::fs::remove_dir_all(&dir).ok();
1390    }
1391
1392    /// (review) CRLF files keep their `\r` in the numbered display so multi-line
1393    /// `old_text` copied from read output matches the on-disk bytes exactly.
1394    /// `str::lines()` would strip the `\r` and make every multi-line edit on a
1395    /// CRLF file unmatchable.
1396    #[tokio::test]
1397    async fn read_file_preserves_crlf_line_endings() {
1398        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1399        let ledger = ReadLedger::new();
1400        let dir = fresh_dir("crlf");
1401        let path = dir.join("f.txt").to_string_lossy().to_string();
1402        std::fs::write(dir.join("f.txt"), "line one\r\nline two\r\n").unwrap();
1403
1404        let out = execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1405            .await
1406            .unwrap()
1407            .unwrap();
1408        let shown = out["content"].as_str().unwrap();
1409        assert_eq!(
1410            shown, "     1\tline one\r\n     2\tline two\r",
1411            "\\r must survive into the numbered display"
1412        );
1413
1414        // The round-trip proof: multi-line old_text reconstructed from the
1415        // display (prefixes stripped, \r kept) matches the file and edits it.
1416        let ok = execute_with_ledger(
1417            &substrate,
1418            &ledger,
1419            "edit_file",
1420            &json!({ "path": path, "old_text": "line one\r\nline two", "new_text": "merged" }),
1421        )
1422        .await
1423        .unwrap()
1424        .unwrap();
1425        assert_eq!(ok["replacements"], 1);
1426
1427        std::fs::remove_dir_all(&dir).ok();
1428    }
1429
1430    /// (review) An empty `old_text` is rejected up front — `matches("")` is
1431    /// char_count+1, so with `replace_all` it would interleave `new_text` at
1432    /// every char boundary (silent whole-file corruption reported as success).
1433    #[tokio::test]
1434    async fn edit_rejects_empty_old_text() {
1435        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1436        let dir = fresh_dir("emptyold");
1437        let path = dir.join("f.txt").to_string_lossy().to_string();
1438        exec_write_file(&substrate, None, &json!({ "path": path, "content": "abc" }))
1439            .await
1440            .unwrap();
1441
1442        for replace_all in [false, true] {
1443            let err = exec_edit_file(
1444                &substrate,
1445                None,
1446                &json!({
1447                    "path": path,
1448                    "old_text": "",
1449                    "new_text": "X",
1450                    "replace_all": replace_all
1451                }),
1452            )
1453            .await
1454            .unwrap_err();
1455            assert!(err.contains("old_text must be non-empty"), "{err}");
1456        }
1457        // The file is untouched.
1458        assert_eq!(std::fs::read_to_string(dir.join("f.txt")).unwrap(), "abc");
1459
1460        std::fs::remove_dir_all(&dir).ok();
1461    }
1462
1463    /// (review) `write_file` over an existing file refuses when the file changed
1464    /// since the session's last read — a blind overwrite would destroy content
1465    /// the session never observed. Re-reading re-licenses the write. This is the
1466    /// staleness half the docs promise alongside the read-first gate.
1467    #[tokio::test]
1468    async fn write_existing_rejects_stale_after_external_change() {
1469        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1470        let ledger = ReadLedger::new();
1471        let dir = fresh_dir("stalewrite");
1472        let path = dir.join("f.txt").to_string_lossy().to_string();
1473        std::fs::write(dir.join("f.txt"), "original").unwrap();
1474
1475        execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1476            .await
1477            .unwrap()
1478            .unwrap();
1479        // The file changes underneath (external process / shell command).
1480        std::fs::write(dir.join("f.txt"), "changed underneath").unwrap();
1481
1482        let err = execute_with_ledger(
1483            &substrate,
1484            &ledger,
1485            "write_file",
1486            &json!({ "path": path, "content": "clobber" }),
1487        )
1488        .await
1489        .unwrap()
1490        .unwrap_err();
1491        assert!(err.contains("changed since you last read it"), "{err}");
1492
1493        // Append over stale content is refused the same way.
1494        let err = execute_with_ledger(
1495            &substrate,
1496            &ledger,
1497            "write_file",
1498            &json!({ "path": path, "content": " + more", "append": true }),
1499        )
1500        .await
1501        .unwrap()
1502        .unwrap_err();
1503        assert!(err.contains("changed since you last read it"), "{err}");
1504
1505        // Re-reading shows the current bytes and re-licenses the write.
1506        execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1507            .await
1508            .unwrap()
1509            .unwrap();
1510        execute_with_ledger(
1511            &substrate,
1512            &ledger,
1513            "write_file",
1514            &json!({ "path": path, "content": "rewritten" }),
1515        )
1516        .await
1517        .unwrap()
1518        .unwrap();
1519        assert_eq!(
1520            std::fs::read_to_string(dir.join("f.txt")).unwrap(),
1521            "rewritten"
1522        );
1523
1524        std::fs::remove_dir_all(&dir).ok();
1525    }
1526
1527    /// (c) `replace_all: true` replaces every occurrence and reports the count;
1528    /// the default path still refuses a non-unique match and points at the flag.
1529    #[tokio::test]
1530    async fn replace_all_replaces_every_occurrence() {
1531        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1532        let dir = fresh_dir("replaceall");
1533        let path = dir.join("f.txt").to_string_lossy().to_string();
1534        exec_write_file(
1535            &substrate,
1536            None,
1537            &json!({ "path": path, "content": "a x a x a" }),
1538        )
1539        .await
1540        .unwrap();
1541
1542        // Default (unique) edit refuses the 3-way match and names the remedy.
1543        let err = exec_edit_file(
1544            &substrate,
1545            None,
1546            &json!({ "path": path, "old_text": "a", "new_text": "b" }),
1547        )
1548        .await
1549        .unwrap_err();
1550        assert!(err.contains("replace_all"), "{err}");
1551
1552        // replace_all replaces every occurrence.
1553        let ok = exec_edit_file(
1554            &substrate,
1555            None,
1556            &json!({ "path": path, "old_text": "a", "new_text": "b", "replace_all": true }),
1557        )
1558        .await
1559        .unwrap();
1560        assert_eq!(ok["replacements"], 3);
1561        let r = exec_read_file(&substrate, None, &json!({ "path": path }))
1562            .await
1563            .unwrap();
1564        assert_eq!(r["content"], "b x b x b");
1565
1566        std::fs::remove_dir_all(&dir).ok();
1567    }
1568
1569    /// (d) Creating a brand-new file needs no prior read.
1570    #[tokio::test]
1571    async fn write_new_file_allowed() {
1572        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1573        let ledger = ReadLedger::new();
1574        let dir = fresh_dir("writenew");
1575        let path = dir.join("new.txt").to_string_lossy().to_string();
1576
1577        let ok = execute_with_ledger(
1578            &substrate,
1579            &ledger,
1580            "write_file",
1581            &json!({ "path": path, "content": "fresh" }),
1582        )
1583        .await
1584        .unwrap()
1585        .unwrap();
1586        assert_eq!(ok["bytes_written"], 5);
1587
1588        std::fs::remove_dir_all(&dir).ok();
1589    }
1590
1591    /// (d) Overwriting an EXISTING file the session never read is refused.
1592    #[tokio::test]
1593    async fn write_existing_requires_prior_read() {
1594        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1595        let ledger = ReadLedger::new();
1596        let dir = fresh_dir("writeexisting");
1597        let path = dir.join("f.txt").to_string_lossy().to_string();
1598        std::fs::write(dir.join("f.txt"), "existing content").unwrap();
1599
1600        let err = execute_with_ledger(
1601            &substrate,
1602            &ledger,
1603            "write_file",
1604            &json!({ "path": path, "content": "clobber" }),
1605        )
1606        .await
1607        .unwrap()
1608        .unwrap_err();
1609        assert!(err.contains("before overwriting it"), "{err}");
1610        assert!(err.contains("read_file"), "{err}");
1611
1612        // Reading licenses the overwrite.
1613        execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1614            .await
1615            .unwrap()
1616            .unwrap();
1617        let ok = execute_with_ledger(
1618            &substrate,
1619            &ledger,
1620            "write_file",
1621            &json!({ "path": path, "content": "clobber" }),
1622        )
1623        .await
1624        .unwrap()
1625        .unwrap();
1626        assert_eq!(ok["bytes_written"], 7);
1627
1628        std::fs::remove_dir_all(&dir).ok();
1629    }
1630
1631    /// (d) A successful write self-records the new content, so a follow-up
1632    /// `edit_file` on the same path is licensed WITHOUT an intervening read —
1633    /// the "session knows the current content" contract the loops rely on.
1634    #[tokio::test]
1635    async fn write_self_records_enabling_edit() {
1636        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1637        let ledger = ReadLedger::new();
1638        let dir = fresh_dir("writeedits");
1639        let path = dir.join("f.txt").to_string_lossy().to_string();
1640
1641        execute_with_ledger(
1642            &substrate,
1643            &ledger,
1644            "write_file",
1645            &json!({ "path": path, "content": "one two three" }),
1646        )
1647        .await
1648        .unwrap()
1649        .unwrap();
1650        // No read_file in between — the write recorded the content.
1651        let ok = execute_with_ledger(
1652            &substrate,
1653            &ledger,
1654            "edit_file",
1655            &json!({ "path": path, "old_text": "two", "new_text": "TWO" }),
1656        )
1657        .await
1658        .unwrap()
1659        .unwrap();
1660        assert_eq!(ok["replacements"], 1);
1661
1662        std::fs::remove_dir_all(&dir).ok();
1663    }
1664
1665    /// (#2) A successful edit self-records the new content, so a SECOND edit with
1666    /// no intervening read is licensed. Deleting the edit self-record makes the
1667    /// ledger stale here and fails this test.
1668    #[tokio::test]
1669    async fn edit_self_records_enabling_second_edit_without_reread() {
1670        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1671        let ledger = ReadLedger::new();
1672        let dir = fresh_dir("editselfrec");
1673        let path = dir.join("f.txt").to_string_lossy().to_string();
1674        std::fs::write(dir.join("f.txt"), "one two three").unwrap();
1675
1676        execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1677            .await
1678            .unwrap()
1679            .unwrap();
1680        execute_with_ledger(
1681            &substrate,
1682            &ledger,
1683            "edit_file",
1684            &json!({ "path": path, "old_text": "one", "new_text": "1" }),
1685        )
1686        .await
1687        .unwrap()
1688        .unwrap();
1689
1690        // No read between the two edits — the first edit recorded the new content.
1691        let ok = execute_with_ledger(
1692            &substrate,
1693            &ledger,
1694            "edit_file",
1695            &json!({ "path": path, "old_text": "two", "new_text": "2" }),
1696        )
1697        .await
1698        .unwrap()
1699        .unwrap();
1700        assert_eq!(ok["replacements"], 1);
1701        assert_eq!(
1702            std::fs::read_to_string(dir.join("f.txt")).unwrap(),
1703            "1 2 three"
1704        );
1705
1706        std::fs::remove_dir_all(&dir).ok();
1707    }
1708
1709    /// (#3) A sliced read (offset/limit) records the FULL file's hash, not the
1710    /// returned slice — so editing a line OUTSIDE the slice is licensed AND
1711    /// Fresh. If the slice were hashed, `check()` would return Stale.
1712    #[tokio::test]
1713    async fn sliced_read_hashes_full_file_licensing_edit() {
1714        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1715        let ledger = ReadLedger::new();
1716        let dir = fresh_dir("slicedread");
1717        let path = dir.join("f.txt").to_string_lossy().to_string();
1718        std::fs::write(dir.join("f.txt"), "l1\nl2\nl3\nl4").unwrap();
1719
1720        let r = execute_with_ledger(
1721            &substrate,
1722            &ledger,
1723            "read_file",
1724            &json!({ "path": path, "offset": 1, "limit": 1 }),
1725        )
1726        .await
1727        .unwrap()
1728        .unwrap();
1729        assert_eq!(r["content"], "     2\tl2"); // only the slice is returned
1730
1731        // l4 is outside the returned slice; the edit is still licensed + Fresh.
1732        let ok = execute_with_ledger(
1733            &substrate,
1734            &ledger,
1735            "edit_file",
1736            &json!({ "path": path, "old_text": "l4", "new_text": "L4" }),
1737        )
1738        .await
1739        .unwrap()
1740        .unwrap();
1741        assert_eq!(ok["replacements"], 1);
1742
1743        std::fs::remove_dir_all(&dir).ok();
1744    }
1745
1746    /// (#4) Appending onto an EXISTING file the session never read is refused
1747    /// (same read-first guard as an overwrite); reading it licenses the append.
1748    #[tokio::test]
1749    async fn append_to_existing_unread_requires_prior_read() {
1750        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1751        let ledger = ReadLedger::new();
1752        let dir = fresh_dir("appendunread");
1753        let path = dir.join("f.txt").to_string_lossy().to_string();
1754        std::fs::write(dir.join("f.txt"), "existing content").unwrap();
1755
1756        let err = execute_with_ledger(
1757            &substrate,
1758            &ledger,
1759            "write_file",
1760            &json!({ "path": path, "content": " more", "append": true }),
1761        )
1762        .await
1763        .unwrap()
1764        .unwrap_err();
1765        assert!(err.contains("before overwriting it"), "{err}");
1766
1767        execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1768            .await
1769            .unwrap()
1770            .unwrap();
1771        let ok = execute_with_ledger(
1772            &substrate,
1773            &ledger,
1774            "write_file",
1775            &json!({ "path": path, "content": " more", "append": true }),
1776        )
1777        .await
1778        .unwrap()
1779        .unwrap();
1780        assert_eq!(ok["append"], true);
1781        assert_eq!(
1782            std::fs::read_to_string(dir.join("f.txt")).unwrap(),
1783            "existing content more"
1784        );
1785
1786        std::fs::remove_dir_all(&dir).ok();
1787    }
1788
1789    #[tokio::test]
1790    async fn append_to_existing_empty_file_requires_prior_read() {
1791        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1792        let ledger = ReadLedger::new();
1793        let dir = fresh_dir("appendempty");
1794        let path = dir.join("f.txt").to_string_lossy().to_string();
1795        std::fs::write(dir.join("f.txt"), "").unwrap();
1796
1797        let err = execute_with_ledger(
1798            &substrate,
1799            &ledger,
1800            "write_file",
1801            &json!({ "path": path, "content": "new", "append": true }),
1802        )
1803        .await
1804        .unwrap()
1805        .unwrap_err();
1806        assert!(err.contains("before overwriting it"), "{err}");
1807        assert!(std::fs::read_to_string(dir.join("f.txt"))
1808            .unwrap()
1809            .is_empty());
1810
1811        std::fs::remove_dir_all(&dir).ok();
1812    }
1813
1814    #[tokio::test]
1815    async fn partial_read_cannot_authorize_whole_file_mutation() {
1816        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1817        let ledger = ReadLedger::new();
1818        let dir = fresh_dir("partialread");
1819        let path = dir.join("f.txt").to_string_lossy().to_string();
1820        std::fs::write(dir.join("f.txt"), "first\nsecond\nthird").unwrap();
1821
1822        execute_with_ledger(
1823            &substrate,
1824            &ledger,
1825            "read_file",
1826            &json!({ "path": path, "offset": 0, "limit": 1 }),
1827        )
1828        .await
1829        .unwrap()
1830        .unwrap();
1831
1832        let overwrite = execute_with_ledger(
1833            &substrate,
1834            &ledger,
1835            "write_file",
1836            &json!({ "path": path, "content": "replacement" }),
1837        )
1838        .await
1839        .unwrap()
1840        .unwrap_err();
1841        assert!(overwrite.contains("full current content"), "{overwrite}");
1842
1843        let replace_all = execute_with_ledger(
1844            &substrate,
1845            &ledger,
1846            "edit_file",
1847            &json!({ "path": path, "old_text": "i", "new_text": "I", "replace_all": true }),
1848        )
1849        .await
1850        .unwrap()
1851        .unwrap_err();
1852        assert!(
1853            replace_all.contains("full current content"),
1854            "{replace_all}"
1855        );
1856
1857        // A normal unique edit remains usable after the focused read.
1858        let edit = execute_with_ledger(
1859            &substrate,
1860            &ledger,
1861            "edit_file",
1862            &json!({ "path": path, "old_text": "first", "new_text": "FIRST" }),
1863        )
1864        .await
1865        .unwrap()
1866        .unwrap();
1867        assert_eq!(edit["replacements"], 1);
1868
1869        std::fs::remove_dir_all(&dir).ok();
1870    }
1871
1872    #[tokio::test]
1873    async fn guarded_write_refuses_existing_non_utf8_file() {
1874        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1875        let ledger = ReadLedger::new();
1876        let dir = fresh_dir("nonutf8write");
1877        let path = dir.join("f.bin").to_string_lossy().to_string();
1878        std::fs::write(dir.join("f.bin"), [0xff, 0x00, 0xfe]).unwrap();
1879
1880        let err = execute_with_ledger(
1881            &substrate,
1882            &ledger,
1883            "write_file",
1884            &json!({ "path": path, "content": "text" }),
1885        )
1886        .await
1887        .unwrap()
1888        .unwrap_err();
1889        assert!(err.contains("cannot modify existing file"), "{err}");
1890        assert_eq!(
1891            std::fs::read(dir.join("f.bin")).unwrap(),
1892            [0xff, 0x00, 0xfe]
1893        );
1894
1895        std::fs::remove_dir_all(&dir).ok();
1896    }
1897
1898    /// (#4) Appending onto a STALE record (file changed on disk after the read)
1899    /// is refused with the stale error until re-read — the append arm gates
1900    /// staleness, not just presence.
1901    #[tokio::test]
1902    async fn append_detects_stale_after_external_change() {
1903        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1904        let ledger = ReadLedger::new();
1905        let dir = fresh_dir("appendstale");
1906        let path = dir.join("f.txt").to_string_lossy().to_string();
1907        std::fs::write(dir.join("f.txt"), "v1").unwrap();
1908
1909        execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1910            .await
1911            .unwrap()
1912            .unwrap();
1913        std::fs::write(dir.join("f.txt"), "v2 changed").unwrap();
1914
1915        let err = execute_with_ledger(
1916            &substrate,
1917            &ledger,
1918            "write_file",
1919            &json!({ "path": path, "content": " appended", "append": true }),
1920        )
1921        .await
1922        .unwrap()
1923        .unwrap_err();
1924        assert!(err.contains("changed since you last read it"), "{err}");
1925
1926        std::fs::remove_dir_all(&dir).ok();
1927    }
1928
1929    /// (#5) The read-before-edit gate sits above BOTH edit arms: a `replace_all`
1930    /// edit is licensed by a read, self-records, and is refused as stale after an
1931    /// external change — exactly like the unique-match arm.
1932    #[tokio::test]
1933    async fn replace_all_edit_is_gated_by_the_ledger() {
1934        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1935        let ledger = ReadLedger::new();
1936        let dir = fresh_dir("replaceallgate");
1937        let path = dir.join("f.txt").to_string_lossy().to_string();
1938        std::fs::write(dir.join("f.txt"), "a a a").unwrap();
1939
1940        execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1941            .await
1942            .unwrap()
1943            .unwrap();
1944        let ok = execute_with_ledger(
1945            &substrate,
1946            &ledger,
1947            "edit_file",
1948            &json!({ "path": path, "old_text": "a", "new_text": "b", "replace_all": true }),
1949        )
1950        .await
1951        .unwrap()
1952        .unwrap();
1953        assert_eq!(ok["replacements"], 3);
1954
1955        // Self-recorded: a second replace_all with no read succeeds.
1956        let ok2 = execute_with_ledger(
1957            &substrate,
1958            &ledger,
1959            "edit_file",
1960            &json!({ "path": path, "old_text": "b", "new_text": "c", "replace_all": true }),
1961        )
1962        .await
1963        .unwrap()
1964        .unwrap();
1965        assert_eq!(ok2["replacements"], 3);
1966
1967        // External change → the replace_all edit is refused as stale.
1968        std::fs::write(dir.join("f.txt"), "c c c c").unwrap();
1969        let err = execute_with_ledger(
1970            &substrate,
1971            &ledger,
1972            "edit_file",
1973            &json!({ "path": path, "old_text": "c", "new_text": "d", "replace_all": true }),
1974        )
1975        .await
1976        .unwrap()
1977        .unwrap_err();
1978        assert!(err.contains("changed since you last read it"), "{err}");
1979
1980        std::fs::remove_dir_all(&dir).ok();
1981    }
1982
1983    /// (#6a) Every builtin name is HANDLED (returns `Some`), and no error it
1984    /// produces — including the deliberate gate errors — starts with "unknown
1985    /// tool". That prefix is the ONLY fall-through trigger at every call site, so
1986    /// this pins that a builtin can never be re-dispatched to a second ledger.
1987    #[tokio::test]
1988    async fn builtin_names_never_return_unknown_tool_prefix() {
1989        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1990        let ledger = ReadLedger::new();
1991
1992        for name in [
1993            "read_file",
1994            "write_file",
1995            "edit_file",
1996            "list_dir",
1997            "find_files",
1998            "grep_files",
1999            "calculate",
2000        ] {
2001            let result = execute_with_ledger(&substrate, &ledger, name, &json!({})).await;
2002            let inner = result.unwrap_or_else(|| panic!("{name} must be handled, not None"));
2003            if let Err(e) = &inner {
2004                assert!(
2005                    !e.starts_with("unknown tool"),
2006                    "{name} error must not start with 'unknown tool': {e}"
2007                );
2008            }
2009        }
2010
2011        // Only a genuinely unknown name is None (→ "unknown tool" at the caller).
2012        assert!(
2013            execute_with_ledger(&substrate, &ledger, "no_such_tool", &json!({}))
2014                .await
2015                .is_none()
2016        );
2017
2018        // The deliberate gate errors also never masquerade as "unknown tool".
2019        let dir = fresh_dir("unknownprefix");
2020        let path = dir.join("f.txt").to_string_lossy().to_string();
2021        std::fs::write(dir.join("f.txt"), "content").unwrap();
2022        let edit_err = execute_with_ledger(
2023            &substrate,
2024            &ledger,
2025            "edit_file",
2026            &json!({ "path": path, "old_text": "content", "new_text": "x" }),
2027        )
2028        .await
2029        .unwrap()
2030        .unwrap_err();
2031        assert!(!edit_err.starts_with("unknown tool"), "{edit_err}");
2032        assert!(edit_err.contains("before editing it"), "{edit_err}");
2033        let write_err = execute_with_ledger(
2034            &substrate,
2035            &ledger,
2036            "write_file",
2037            &json!({ "path": path, "content": "y" }),
2038        )
2039        .await
2040        .unwrap()
2041        .unwrap_err();
2042        assert!(!write_err.starts_with("unknown tool"), "{write_err}");
2043
2044        std::fs::remove_dir_all(&dir).ok();
2045    }
2046
2047    /// (#7) Lexical `.` components are normalized in the ledger key, so rooted
2048    /// `./x` and `x` paths do not alias into a spurious Unread.
2049    #[test]
2050    fn ledger_normalizes_dot_components() {
2051        let ledger = ReadLedger::new();
2052        ledger.record("./src/x.rs", "content", true);
2053        assert_eq!(ledger.check("src/x.rs", "content"), ReadState::FreshFull);
2054        assert_eq!(ledger.check("./src/x.rs", "content"), ReadState::FreshFull);
2055
2056        let ledger2 = ReadLedger::new();
2057        ledger2.record("src/x.rs", "content", true);
2058        assert_eq!(ledger2.check("./src/x.rs", "content"), ReadState::FreshFull);
2059
2060        // Unrelated paths are still Unread, while adapter-rooted interior dots
2061        // normalize to the same ledger key.
2062        assert_eq!(ledger.check("other.rs", "content"), ReadState::Unread);
2063        let ledger3 = ReadLedger::new();
2064        ledger3.record("/root/a/./b", "c", true);
2065        assert_eq!(ledger3.check("/root/a/b", "c"), ReadState::FreshFull);
2066    }
2067
2068    #[test]
2069    fn session_ledgers_share_mutation_locks_without_sharing_observations() {
2070        let ledgers = SessionReadLedgers::new();
2071        let first = ledgers.ledger_for(Some("first"));
2072        let second = ledgers.ledger_for(Some("second"));
2073        first.record("f.txt", "first view", true);
2074
2075        assert_eq!(
2076            second.check("f.txt", "first view"),
2077            ReadState::Unread,
2078            "one session's read must not authorize another session"
2079        );
2080        assert!(Arc::ptr_eq(
2081            &first.mutation_lock("f.txt"),
2082            &second.mutation_lock("f.txt")
2083        ));
2084    }
2085
2086    /// (#8) A no-match `old_text` shaped like read_file's line-number prefix gets
2087    /// a targeted hint; an ordinary no-match does not.
2088    #[tokio::test]
2089    async fn edit_no_match_hints_pasted_line_number() {
2090        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2091        let dir = fresh_dir("linenumhint");
2092        let path = dir.join("f.txt").to_string_lossy().to_string();
2093        exec_write_file(
2094            &substrate,
2095            None,
2096            &json!({ "path": path, "content": "hello world" }),
2097        )
2098        .await
2099        .unwrap();
2100
2101        let err = exec_edit_file(
2102            &substrate,
2103            None,
2104            &json!({ "path": path, "old_text": "     1\thello world", "new_text": "hi" }),
2105        )
2106        .await
2107        .unwrap_err();
2108        assert!(err.contains("line-number prefixes"), "{err}");
2109
2110        let plain = exec_edit_file(
2111            &substrate,
2112            None,
2113            &json!({ "path": path, "old_text": "absent", "new_text": "x" }),
2114        )
2115        .await
2116        .unwrap_err();
2117        assert!(!plain.contains("line-number prefixes"), "{plain}");
2118        assert!(plain.contains("old_text not found"), "{plain}");
2119
2120        std::fs::remove_dir_all(&dir).ok();
2121    }
2122
2123    #[tokio::test]
2124    async fn calculate_is_pure() {
2125        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2126        let out = execute(
2127            &substrate,
2128            "calculate",
2129            &json!({ "expression": "2 + 3 * 4" }),
2130        )
2131        .await
2132        .unwrap()
2133        .unwrap();
2134        assert_eq!(out["result"], 14.0);
2135    }
2136
2137    #[tokio::test]
2138    async fn unknown_tool_returns_none() {
2139        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2140        assert!(execute(&substrate, "nope", &json!({})).await.is_none());
2141    }
2142
2143    /// Evaluate via the public `calculate` tool contract and return the
2144    /// `result` float, so the test exercises the same path the runtime does.
2145    fn calc(expr: &str) -> f64 {
2146        exec_calculate(&json!({ "expression": expr }))
2147            .unwrap_or_else(|e| panic!("calculate({expr:?}) failed: {e}"))
2148            .get("result")
2149            .and_then(|v| v.as_f64())
2150            .unwrap_or_else(|| panic!("calculate({expr:?}) returned no numeric result"))
2151    }
2152
2153    /// Pins the math semantics of the `calculate` builtin independently of the
2154    /// backend evaluator. The load-bearing case is `^`: this tool is a
2155    /// calculator, so `^` MUST mean exponentiation (2^3 == 8), not the
2156    /// bitwise-XOR meaning it carries in C-family languages. The tool
2157    /// description documents this contract for the model; this test enforces
2158    /// it for the implementation.
2159    #[test]
2160    fn calculate_contract_semantics() {
2161        assert_eq!(calc("2 + 3 * 4"), 14.0, "operator precedence");
2162        assert_eq!(calc("(1 + 2) * 3"), 9.0, "parentheses override precedence");
2163        assert_eq!(calc("2^3"), 8.0, "^ is exponentiation, not XOR");
2164        assert_eq!(calc("2^10"), 1024.0, "^ is exponentiation");
2165        assert_eq!(calc("10 % 3"), 1.0, "modulo");
2166        assert_eq!(calc("-5 + 2"), -3.0, "unary minus");
2167        // Built-in functions (fasteval-native).
2168        assert_eq!(calc("sin(0)"), 0.0, "native function: sin");
2169        assert_eq!(calc("abs(-3)"), 3.0, "native function: abs");
2170        // Functions/constants filled by our namespace shim.
2171        assert_eq!(calc("sqrt(16)"), 4.0, "shim function: sqrt");
2172        assert!((calc("ln(e)") - 1.0).abs() < 1e-12, "shim: ln + e constant");
2173        assert!(
2174            (calc("pi") - std::f64::consts::PI).abs() < 1e-12,
2175            "shim: pi constant"
2176        );
2177    }
2178
2179    #[test]
2180    fn calculate_rejects_invalid_expression() {
2181        assert!(exec_calculate(&json!({ "expression": "2 +" })).is_err());
2182        assert!(exec_calculate(&json!({})).is_err(), "missing parameter");
2183    }
2184}