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)]
29struct ReadRecord {
30    hash: u64,
31    full_read: bool,
32    /// Line ranges `[start, end)` observed of this exact content by paged
33    /// reads. Once they cover every line the record counts as a full read, so
34    /// a file too large for one read can still be read whole, page by page.
35    lines_seen: Vec<(usize, usize)>,
36}
37
38/// Per-session record of which paths an agent has read (or written), keyed by
39/// the path string as the model passed it (lexical `.` components are normalized
40/// away — see [`ReadLedger::normalize_key`]) and valued by a content hash of the
41/// FULL file text plus whether the agent actually observed that full text. It
42/// backs the read-before-edit / staleness guard on the
43/// built-in `edit_file`/`write_file` tools: an edit (or an overwrite/append onto
44/// an existing file) is licensed only once the session has observed that path's
45/// current content, so the model can't blind-edit a file it never read or clobber
46/// one that changed underneath it.
47///
48/// Interior mutability is a plain `std::sync::Mutex` (never held across an
49/// `.await`), so a `&ReadLedger` can live behind a shared `Arc<dyn ToolExecutor>`.
50#[derive(Debug, Default)]
51pub struct ReadLedger {
52    seen: Mutex<HashMap<String, ReadRecord>>,
53    mutation_locks: Arc<Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>>,
54}
55
56impl ReadLedger {
57    pub fn new() -> Self {
58        Self::default()
59    }
60
61    fn with_mutation_locks(
62        mutation_locks: Arc<Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>>,
63    ) -> Self {
64        Self {
65            seen: Mutex::new(HashMap::new()),
66            mutation_locks,
67        }
68    }
69
70    /// Content hash of the FULL file text. `std::hash::DefaultHasher` is used
71    /// deliberately: the ledger only needs same-process change detection, not a
72    /// cryptographic digest.
73    fn hash(content: &str) -> u64 {
74        let mut hasher = std::collections::hash_map::DefaultHasher::new();
75        content.hash(&mut hasher);
76        hasher.finish()
77    }
78
79    /// Normalize a path into its ledger key. Lexical `.` components are removed
80    /// so clamped `root/./src/x.rs` and `root/src/x.rs` share an observation.
81    /// Parent components remain intact: the substrate owns actual path
82    /// resolution and authorization.
83    fn normalize_key(path: &str) -> String {
84        let mut normalized = PathBuf::new();
85        for component in Path::new(path).components() {
86            if !matches!(component, Component::CurDir) {
87                normalized.push(component.as_os_str());
88            }
89        }
90        if normalized.as_os_str().is_empty() {
91            ".".to_string()
92        } else {
93            normalized.to_string_lossy().into_owned()
94        }
95    }
96
97    /// Record that `path` currently holds `content` (its FULL text). Called on a
98    /// successful read and after a successful write/edit, so a later edit is
99    /// licensed and staleness compares against this snapshot. `full_read` says
100    /// whether the agent observed all of `content`, which whole-file writes and
101    /// replace-all edits require.
102    pub fn record(&self, path: &str, content: &str, full_read: bool) {
103        let record = ReadRecord {
104            hash: Self::hash(content),
105            full_read,
106            lines_seen: Vec::new(),
107        };
108        self.seen
109            .lock()
110            .expect("read ledger mutex poisoned")
111            .insert(Self::normalize_key(path), record);
112    }
113
114    /// Record a read that showed lines `[start, end)` of `content`'s
115    /// `total_lines`. Pages of the same content accumulate; a new content hash
116    /// starts over. Returns whether the session has now seen every line.
117    pub fn record_lines(
118        &self,
119        path: &str,
120        content: &str,
121        start: usize,
122        end: usize,
123        total_lines: usize,
124    ) -> bool {
125        let hash = Self::hash(content);
126        let mut guard = self.seen.lock().expect("read ledger mutex poisoned");
127        let mut lines_seen = match guard.get(&Self::normalize_key(path)) {
128            Some(prior) if prior.hash == hash && prior.full_read => return true,
129            Some(prior) if prior.hash == hash => prior.lines_seen.clone(),
130            _ => Vec::new(),
131        };
132        if start < end {
133            lines_seen.push((start, end));
134        }
135        lines_seen.sort_unstable();
136        let mut merged: Vec<(usize, usize)> = Vec::new();
137        for (a, b) in lines_seen {
138            match merged.last_mut() {
139                Some(last) if a <= last.1 => last.1 = last.1.max(b),
140                _ => merged.push((a, b)),
141            }
142        }
143        let full_read =
144            merged.first() == Some(&(0, total_lines)) || (total_lines == 0 && start == 0);
145        guard.insert(
146            Self::normalize_key(path),
147            ReadRecord {
148                hash,
149                full_read,
150                lines_seen: merged,
151            },
152        );
153        full_read
154    }
155
156    /// The line ranges `[start, end)` of `content` this session has seen, when
157    /// its record is of this exact content: everything after a full read, the
158    /// merged pages after paged reads, nothing otherwise.
159    pub fn lines_seen(&self, path: &str, content: &str) -> Vec<(usize, usize)> {
160        let guard = self.seen.lock().expect("read ledger mutex poisoned");
161        match guard.get(&Self::normalize_key(path)) {
162            Some(record) if record.hash == Self::hash(content) && record.full_read => {
163                vec![(0, usize::MAX)]
164            }
165            Some(record) if record.hash == Self::hash(content) => record.lines_seen.clone(),
166            _ => Vec::new(),
167        }
168    }
169
170    /// Record `content` with exactly these seen line ranges (merged; full when
171    /// they cover every line) — for an edit to a paged read, whose pages move.
172    pub fn record_seen(&self, path: &str, content: &str, ranges: Vec<(usize, usize)>) {
173        self.record(path, content, false);
174        // Counted as read_file counts them: an empty file has no lines.
175        let total = if content.is_empty() {
176            0
177        } else {
178            content.split('\n').count() - usize::from(content.ends_with('\n'))
179        };
180        for (a, b) in ranges {
181            self.record_lines(path, content, a, b.min(total), total);
182        }
183    }
184
185    /// Classify `path` against `content` (its current FULL text).
186    pub fn check(&self, path: &str, content: &str) -> ReadState {
187        let guard = self.seen.lock().expect("read ledger mutex poisoned");
188        match guard.get(&Self::normalize_key(path)) {
189            None => ReadState::Unread,
190            Some(recorded) if recorded.hash == Self::hash(content) && recorded.full_read => {
191                ReadState::FreshFull
192            }
193            Some(recorded) if recorded.hash == Self::hash(content) => ReadState::FreshPartial,
194            Some(_) => ReadState::Stale,
195        }
196    }
197
198    /// Serialize guarded mutations of one lexical path. The guard spans the
199    /// read/check/write/record sequence so two same-session edits cannot both
200    /// pass the stale check against one snapshot and lose one update.
201    pub fn mutation_lock(&self, path: &str) -> Arc<tokio::sync::Mutex<()>> {
202        let key = Self::normalize_key(path);
203        let mut locks = self
204            .mutation_locks
205            .lock()
206            .expect("read ledger mutation-lock mutex poisoned");
207        locks
208            .entry(key)
209            .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
210            .clone()
211    }
212
213    pub fn clear(&self) {
214        self.seen
215            .lock()
216            .expect("read ledger mutex poisoned")
217            .clear();
218    }
219}
220
221/// A default ledger plus lazily-created ledgers keyed by execution session.
222/// Executors use this so a shared executor never lets one conversation's read
223/// authorize another conversation's edit.
224#[derive(Debug)]
225pub struct SessionReadLedgers {
226    default: Arc<ReadLedger>,
227    sessions: Mutex<HashMap<String, Arc<ReadLedger>>>,
228    mutation_locks: Arc<Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>>,
229}
230
231impl Default for SessionReadLedgers {
232    fn default() -> Self {
233        Self::new()
234    }
235}
236
237impl SessionReadLedgers {
238    pub fn new() -> Self {
239        let mutation_locks = Arc::new(Mutex::new(HashMap::new()));
240        Self {
241            default: Arc::new(ReadLedger::with_mutation_locks(mutation_locks.clone())),
242            sessions: Mutex::new(HashMap::new()),
243            mutation_locks,
244        }
245    }
246
247    pub fn ledger_for(&self, session_id: Option<&str>) -> Arc<ReadLedger> {
248        let Some(session_id) = session_id else {
249            return self.default.clone();
250        };
251        let mut sessions = self
252            .sessions
253            .lock()
254            .expect("session read-ledger mutex poisoned");
255        sessions
256            .entry(session_id.to_string())
257            .or_insert_with(|| {
258                Arc::new(ReadLedger::with_mutation_locks(self.mutation_locks.clone()))
259            })
260            .clone()
261    }
262
263    pub fn remove(&self, session_id: &str) {
264        self.sessions
265            .lock()
266            .expect("session read-ledger mutex poisoned")
267            .remove(session_id);
268    }
269
270    pub fn clear(&self) {
271        self.default.clear();
272        let ledgers: Vec<Arc<ReadLedger>> = self
273            .sessions
274            .lock()
275            .expect("session read-ledger mutex poisoned")
276            .values()
277            .cloned()
278            .collect();
279        for ledger in ledgers {
280            ledger.clear();
281        }
282    }
283}
284
285/// Prescriptive "read the file before you modify it" error the staleness guard
286/// returns for an unread path.
287fn read_first_error(display_path: &str, verb: &str) -> String {
288    format!("you must read '{display_path}' before {verb} it — call read_file first")
289}
290
291/// Prescriptive "the file changed under you" error for a stale edit.
292fn stale_error(display_path: &str) -> String {
293    format!("'{display_path}' changed since you last read it — re-read it and retry")
294}
295
296fn full_read_error(display_path: &str, verb: &str) -> String {
297    format!(
298        "you must read the full current content of '{display_path}' before {verb} it — call \
299         read_file without offset or limit first; if the file is too large for one read, keep \
300         reading from the offset each page names until you have seen every line, or make a \
301         unique targeted edit instead"
302    )
303}
304
305/// True when `old_text` is shaped like read_file's line-number prefix — leading
306/// spaces, then one or more digits, then a tab (`^\s*\d+\t`). Pasting that prefix
307/// into `old_text` is a common mistake that guarantees a no-match, so the
308/// no-match error can point the model straight at it. (H1/F4-remainder review.)
309fn looks_like_pasted_line_number(old_text: &str) -> bool {
310    let after_spaces = old_text.trim_start_matches(' ');
311    let digits = after_spaces
312        .bytes()
313        .take_while(|b| b.is_ascii_digit())
314        .count();
315    digits > 0 && after_spaces.as_bytes().get(digits) == Some(&b'\t')
316}
317
318/// Recover only our exact numbered display of whole current lines. Never trim
319/// file whitespace, guess a location, or accept forged/out-of-date line numbers.
320fn unnumbered_read_text(content: &str, displayed: &str) -> Option<(String, usize, usize)> {
321    let mut source: Vec<&str> = content.split('\n').collect();
322    if source.last() == Some(&"") {
323        source.pop();
324    }
325    let mut first = None;
326    let mut plain = String::new();
327    for (index, line) in displayed.split_inclusive('\n').enumerate() {
328        let (prefix, body) = line.split_once('\t')?;
329        let number = prefix.trim_start_matches(' ').parse::<usize>().ok()?;
330        if prefix != format!("{number:>6}") {
331            return None;
332        }
333        let start = *first.get_or_insert(number.checked_sub(1)?);
334        let position = start.checked_add(index)?;
335        if number != position.checked_add(1)?
336            || source.get(position)? != &body.strip_suffix('\n').unwrap_or(body)
337        {
338            return None;
339        }
340        plain.push_str(body);
341    }
342    if plain.is_empty() {
343        return None;
344    }
345    let first = first?;
346    let offset: usize = source.iter().take(first).map(|line| line.len() + 1).sum();
347    let lines = plain.split_inclusive('\n').count();
348    content
349        .get(offset..)?
350        .starts_with(&plain)
351        .then_some((plain, first, first + lines))
352}
353
354const HINT_BEGIN: &str = "----- exact existing block -----\n";
355const HINT_END: &str = "\n----- end of block -----";
356
357/// Seen line ranges after replacing lines `[start, old_end)` with lines
358/// `[start, new_end)`: ranges before the edit are kept, ranges after it shift,
359/// and the replaced lines stay seen only if every one of them was.
360fn shift_seen_lines(
361    seen: &[(usize, usize)],
362    start: usize,
363    old_end: usize,
364    new_end: usize,
365) -> Vec<(usize, usize)> {
366    // Only ever applied to lines at or after `old_end`.
367    let moved = |line: usize| line + new_end - old_end;
368    let mut out = Vec::new();
369    for &(a, b) in seen {
370        if b <= start {
371            out.push((a, b));
372        } else if a >= old_end {
373            out.push((moved(a), moved(b)));
374        } else {
375            if a < start {
376                out.push((a, start));
377            }
378            if b > old_end {
379                out.push((new_end, moved(b)));
380            }
381        }
382    }
383    if seen_covers(seen, start, old_end) {
384        out.push((start, new_end));
385    }
386    out
387}
388
389/// Whether the seen line ranges cover every line in `[start, end)`.
390fn seen_covers(seen: &[(usize, usize)], start: usize, end: usize) -> bool {
391    seen.iter().any(|&(a, b)| a <= start && end <= b)
392}
393
394/// Suggest exact source bytes for a small, unique whole-line block that differs
395/// only in leading indentation or line endings. This is diagnostic evidence,
396/// never replacement text: the caller must propose a new literal edit.
397fn indentation_hint<'a>(content: &'a str, old_text: &str) -> Option<(&'a str, usize, usize)> {
398    if old_text.len() > 2048 {
399        return None;
400    }
401    let wanted: Vec<_> = old_text.lines().collect();
402    if wanted.is_empty() || wanted.len() > 12 || wanted.iter().all(|line| line.trim().is_empty()) {
403        return None;
404    }
405    let mut offset = 0;
406    let source: Vec<_> = content
407        .split_inclusive('\n')
408        .map(|line| {
409            let start = offset;
410            offset += line.len();
411            (start, line)
412        })
413        .collect();
414    let mut found = None;
415    for (first_line, block) in source.windows(wanted.len()).enumerate() {
416        if !block.iter().zip(&wanted).all(|((_, line), wanted)| {
417            line.lines()
418                .next()
419                .unwrap_or("")
420                .trim_start_matches([' ', '\t'])
421                == wanted.trim_start_matches([' ', '\t'])
422        }) {
423            continue;
424        }
425        // Preserve the file's actual bytes, including CRLF and tabs. An
426        // old_text without a final newline gets a candidate without one too.
427        let (last_offset, last_line) = block.last()?;
428        let last_len = if old_text.ends_with('\n') {
429            last_line.len()
430        } else {
431            last_line.lines().next().unwrap_or("").len()
432        };
433        let candidate = content.get(block[0].0..last_offset + last_len)?;
434        if found.is_some() || candidate.len() > 4096 {
435            return None;
436        }
437        found = Some((candidate, first_line, first_line + wanted.len()));
438    }
439    found
440}
441
442pub fn entries() -> Vec<ToolEntry> {
443    vec![
444        ToolEntry::builtin(car_ir::builtins::read_file()).with_category("filesystem"),
445        ToolEntry::builtin(car_ir::builtins::list_dir()).with_category("filesystem"),
446        ToolEntry::builtin(car_ir::builtins::find_files()).with_category("filesystem"),
447        ToolEntry::builtin(car_ir::builtins::grep_files()).with_category("filesystem"),
448        ToolEntry::builtin(car_ir::builtins::calculate()).with_category("utility"),
449        ToolEntry::builtin(car_ir::builtins::write_file())
450            .with_permission(ToolPermission::AskUser)
451            .with_side_effects(true)
452            .with_category("filesystem"),
453        ToolEntry::builtin(car_ir::builtins::edit_file())
454            .with_permission(ToolPermission::AskUser)
455            .with_side_effects(true)
456            .with_category("filesystem"),
457    ]
458}
459
460/// Execute a built-in commodity tool against the runtime's bound `substrate`.
461///
462/// The side-effecting file tools (`read_file`/`write_file`/`edit_file`/
463/// `list_dir`/`find_files`/`grep_files`) resolve and act against `substrate`,
464/// so the agent acts within **one** environment. `calculate` is pure — it
465/// never consults the substrate. Returns `None` for unknown tools (so the
466/// caller can fall through), `Some(result)` otherwise.
467///
468/// This entrypoint runs with the read-before-edit / staleness guard **disabled**
469/// (no session ledger). It keeps its historic signature and behavior because it
470/// is published crates.io API; new callers that want the guard use
471/// [`execute_with_ledger`]. In particular, its `read_file` output remains raw
472/// file text for existing callers; the guarded agent path returns numbered
473/// display text.
474pub async fn execute(
475    substrate: &Arc<dyn Substrate>,
476    tool: &str,
477    params: &Value,
478) -> Option<Result<Value, String>> {
479    execute_inner(substrate, None, tool, params, FileToolOptions::default()).await
480}
481
482/// Like [`execute`], but threads a per-session [`ReadLedger`] so the built-in
483/// file tools enforce read-before-edit and content staleness: `edit_file` (and
484/// `write_file` over an existing file) require the session to have read the path
485/// first, and a successful read/write/edit records the current content so the
486/// next edit is licensed. Opt-in — every in-repo executor call site uses this so
487/// its agent gets the guard; the plain [`execute`] stays ungated for external
488/// consumers of the stable API.
489pub async fn execute_with_ledger(
490    substrate: &Arc<dyn Substrate>,
491    ledger: &ReadLedger,
492    tool: &str,
493    params: &Value,
494) -> Option<Result<Value, String>> {
495    execute_inner(
496        substrate,
497        Some(ledger),
498        tool,
499        params,
500        FileToolOptions::default(),
501    )
502    .await
503}
504
505/// How the directory-walking tools treat symbolic links on the local host.
506#[derive(Debug, Clone, Copy, PartialEq, Eq)]
507pub struct FileToolOptions {
508    /// Let links into the listing and the walk. `true` is the long-standing
509    /// behavior: a walk does not descend into a linked entry, but a search
510    /// rooted at a link goes through it, and `list_dir` lists links. A caller
511    /// that promises its agent sees only one folder sets `false`: `list_dir`
512    /// leaves links out and `find_files` / `grep_files` never start at,
513    /// enter or report one.
514    pub follow_symlinks: bool,
515}
516
517impl Default for FileToolOptions {
518    fn default() -> Self {
519        Self {
520            follow_symlinks: true,
521        }
522    }
523}
524
525/// [`execute_with_ledger`] with explicit [`FileToolOptions`].
526pub async fn execute_with_ledger_options(
527    substrate: &Arc<dyn Substrate>,
528    ledger: &ReadLedger,
529    tool: &str,
530    params: &Value,
531    options: FileToolOptions,
532) -> Option<Result<Value, String>> {
533    execute_inner(substrate, Some(ledger), tool, params, options).await
534}
535
536/// Refuses a built-in file tool's access to one host path.
537///
538/// Called with an absolute path (relative spellings are already joined to the
539/// host working directory, as [`crate::substrate::LocalSubstrate`] does) and
540/// returns the refusal message, or `None` to let the tool go ahead. The guard
541/// owns resolution: it decides what a symlink or `..` in that path names.
542pub type LocalPathGuard = Arc<dyn Fn(&Path) -> Option<String> + Send + Sync>;
543
544/// [`execute_with_ledger`], with `guard` applied to every host path the file
545/// tools act on: the target of each of the six path-taking tools, and each file
546/// `grep_files` reads during its walk (a refused file is left out of the
547/// matches, so a search over a folder that contains one still runs). It applies
548/// only while the substrate is the local host; any other substrate owns its
549/// own filesystem and is passed through unchanged.
550pub async fn execute_with_ledger_guarded(
551    substrate: &Arc<dyn Substrate>,
552    ledger: &ReadLedger,
553    tool: &str,
554    params: &Value,
555    guard: &LocalPathGuard,
556) -> Option<Result<Value, String>> {
557    let guard = substrate.is_local().then_some(guard);
558    if let Some(refusal) = guard.and_then(|guard| guarded_target_refusal(guard, tool, params)) {
559        return Some(Err(refusal));
560    }
561    execute_inner_guarded(
562        substrate,
563        Some(ledger),
564        tool,
565        params,
566        FileToolOptions::default(),
567        guard,
568        None,
569    )
570    .await
571}
572
573/// [`execute_with_ledger_guarded`], and the `find_files` walk does not enter a
574/// folder `hidden` refuses, so no name inside one is reported, and hidden names
575/// never count toward `max_results`. Use it
576/// where a folder's names are protected as well as its contents; the paths
577/// `guard` refuses for content stay listable by name unless `hidden` refuses
578/// them too. Local host only, like `guard`.
579pub async fn execute_with_ledger_guarded_hiding(
580    substrate: &Arc<dyn Substrate>,
581    ledger: &ReadLedger,
582    tool: &str,
583    params: &Value,
584    guard: &LocalPathGuard,
585    hidden: &LocalPathGuard,
586) -> Option<Result<Value, String>> {
587    let local = substrate.is_local();
588    let guard = local.then_some(guard);
589    if let Some(refusal) = guard.and_then(|guard| guarded_target_refusal(guard, tool, params)) {
590        return Some(Err(refusal));
591    }
592    execute_inner_guarded(
593        substrate,
594        Some(ledger),
595        tool,
596        params,
597        FileToolOptions::default(),
598        guard,
599        local.then_some(hidden),
600    )
601    .await
602}
603
604/// The guard's verdict on the path a tool names, or on the working directory
605/// the directory tools default to. A tool with no usable path is left to report
606/// its own error.
607fn guarded_target_refusal(guard: &LocalPathGuard, tool: &str, params: &Value) -> Option<String> {
608    let default = match tool {
609        "read_file" | "write_file" | "edit_file" => None,
610        "list_dir" | "find_files" | "grep_files" => Some("."),
611        _ => return None,
612    };
613    let raw = params.get("path").and_then(Value::as_str).or(default)?;
614    guard(&local_resolve(raw).ok()?)
615}
616
617async fn execute_inner(
618    substrate: &Arc<dyn Substrate>,
619    ledger: Option<&ReadLedger>,
620    tool: &str,
621    params: &Value,
622    options: FileToolOptions,
623) -> Option<Result<Value, String>> {
624    execute_inner_guarded(substrate, ledger, tool, params, options, None, None).await
625}
626
627async fn execute_inner_guarded(
628    substrate: &Arc<dyn Substrate>,
629    ledger: Option<&ReadLedger>,
630    tool: &str,
631    params: &Value,
632    options: FileToolOptions,
633    guard: Option<&LocalPathGuard>,
634    hidden: Option<&LocalPathGuard>,
635) -> Option<Result<Value, String>> {
636    let follow = options.follow_symlinks;
637    let result = match tool {
638        "read_file" => exec_read_file(substrate, ledger, params).await,
639        "write_file" => exec_write_file(substrate, ledger, params).await,
640        "edit_file" => exec_edit_file(substrate, ledger, params).await,
641        "list_dir" => exec_list_dir(substrate, params, follow).await,
642        "find_files" => exec_find_files(substrate, params, follow, hidden).await,
643        "grep_files" => exec_grep_files(substrate, params, follow, guard).await,
644        "calculate" => exec_calculate(params),
645        _ => return None,
646    };
647    Some(result)
648}
649
650async fn exec_read_file(
651    substrate: &Arc<dyn Substrate>,
652    ledger: Option<&ReadLedger>,
653    params: &Value,
654) -> Result<Value, String> {
655    let path = params
656        .get("path")
657        .and_then(|v| v.as_str())
658        .ok_or("missing 'path' parameter")?;
659    let offset = params.get("offset").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
660    let limit = params
661        .get("limit")
662        .and_then(|v| v.as_u64())
663        .map(|v| v as usize);
664    // Not advertised to models: a caller that shows the result to a model
665    // through a fixed budget passes it, so the read stops where the model's
666    // view would, and the ledger records what was actually shown.
667    let max_output_bytes = params
668        .get("max_output_bytes")
669        .and_then(|v| v.as_u64())
670        .map(|v| v as usize);
671
672    let content = substrate.read_text(path).await?;
673    let size_bytes = content.len();
674    let total_lines = content.lines().count();
675
676    // Number each returned line `cat -n` style so the model can cite exact line
677    // numbers (matching grep_files' `line`). Numbering is 1-based and starts at
678    // `offset + 1` when a slice is requested. The prefixes are display-only —
679    // the tool descriptions warn the model to strip them before reusing text.
680    //
681    // Split on '\n' ONLY — never `str::lines()`, which strips a trailing `\r`.
682    // On a CRLF file that would render an LF-only view, so any multi-line
683    // `old_text` the model builds from read output could never match the
684    // on-disk bytes and every multi-line edit would fail as "not found".
685    // Keeping `\r` in the displayed line (like real `cat -n`) means text
686    // copied across lines round-trips byte-exact. A single trailing empty
687    // segment (file ending in '\n') is dropped, matching `cat -n`.
688    let mut lines: Vec<&str> = content.split('\n').collect();
689    if lines.last() == Some(&"") {
690        lines.pop();
691    }
692    let start = offset.min(lines.len());
693    let end = limit
694        .map(|line_count| (start + line_count).min(lines.len()))
695        .unwrap_or(lines.len());
696
697    // Record a hash of the full current file for staleness detection, but keep
698    // whether the agent actually saw every line. A slice can license a narrow,
699    // unique edit; it cannot license a whole-file overwrite, append, or
700    // replace-all that would change unseen content.
701    let mut truncated = None;
702    let returned = if let Some(ledger) = ledger {
703        let mut shown = Vec::new();
704        let mut used = 0usize;
705        let mut shown_end = start;
706        for (i, line) in lines[start..end].iter().enumerate() {
707            let rendered = format!("{:>6}\t{}", start + i + 1, line);
708            // The joining newline only precedes a line that has a predecessor.
709            let cost = rendered.len() + usize::from(!shown.is_empty());
710            match max_output_bytes {
711                Some(max) if used + cost > max => {
712                    if shown.is_empty() {
713                        // One line longer than the whole budget: show its head,
714                        // and do not count the line as seen.
715                        let mut cut = max.min(rendered.len());
716                        while !rendered.is_char_boundary(cut) {
717                            cut -= 1;
718                        }
719                        shown.push(rendered[..cut].to_string());
720                        truncated = Some(format!(
721                            "line {} is longer than one read can show and was cut after {cut} \
722                             bytes; inspect it with shell (for example `cut -c` or python), \
723                             or call read_file with offset={} to continue after it",
724                            start + i + 1,
725                            start + i + 1
726                        ));
727                    } else {
728                        truncated = Some(format!(
729                            "showing lines {}-{shown_end} of {}; call read_file with \
730                             offset={shown_end} to read further",
731                            start + 1,
732                            lines.len()
733                        ));
734                    }
735                    break;
736                }
737                _ => {}
738            }
739            used += cost;
740            shown.push(rendered);
741            shown_end = start + i + 1;
742        }
743        ledger.record_lines(path, &content, start, shown_end, lines.len());
744        shown.join("\n")
745    } else if offset > 0 || limit.is_some() {
746        lines[start..end].join("\n")
747    } else {
748        content.clone()
749    };
750
751    if let Some(note) = truncated {
752        return Ok(json!({
753            "path": substrate.display_path(path),
754            "truncated": note,
755            "content": returned,
756            "size_bytes": size_bytes,
757            "total_lines": total_lines,
758        }));
759    }
760    Ok(json!({
761        "path": substrate.display_path(path),
762        "content": returned,
763        "size_bytes": size_bytes,
764        "total_lines": total_lines,
765    }))
766}
767
768async fn exec_write_file(
769    substrate: &Arc<dyn Substrate>,
770    ledger: Option<&ReadLedger>,
771    params: &Value,
772) -> Result<Value, String> {
773    let path = params
774        .get("path")
775        .and_then(|v| v.as_str())
776        .ok_or("missing 'path' parameter")?;
777    let content = params
778        .get("content")
779        .and_then(|v| v.as_str())
780        .ok_or("missing 'content' parameter")?;
781    let append = params
782        .get("append")
783        .and_then(|v| v.as_bool())
784        .unwrap_or(false);
785
786    let _mutation_guard = match ledger {
787        Some(ledger) => Some(ledger.mutation_lock(path).lock_owned().await),
788        None => None,
789    };
790
791    let existing = if ledger.is_some() {
792        match substrate.read_text(path).await {
793            Ok(content) => Some(content),
794            Err(read_error) => match substrate.path_state(path).await {
795                crate::substrate::PathState::Missing => None,
796                crate::substrate::PathState::Exists => {
797                    return Err(format!(
798                        "cannot modify existing file '{}' because it cannot be read as UTF-8: {read_error}",
799                        substrate.display_path(path)
800                    ));
801                }
802                crate::substrate::PathState::Unknown(reason) => {
803                    return Err(format!(
804                        "cannot determine whether '{}' is safe to create: {reason}",
805                        substrate.display_path(path)
806                    ));
807                }
808            },
809        }
810    } else {
811        None
812    };
813
814    let combined = if append {
815        // Compose append on top of the substrate's read/write primitives so
816        // behavior is environment-agnostic. A missing file starts empty,
817        // matching the historic OpenOptions(create=true, append=true).
818        let existed = existing.is_some();
819        let existing = match existing {
820            Some(existing) => existing,
821            None if ledger.is_some() => String::new(),
822            None => substrate.read_text(path).await.unwrap_or_default(),
823        };
824        // Read-before-edit + staleness guard: an append that lands on an
825        // existing file the session never read is refused, same as a plain
826        // overwrite — and an append onto content that changed since the last
827        // read is refused as stale (the new bytes would land after content the
828        // session has never seen). A missing file (empty existing) is a
829        // creation and stays ungated.
830        if let Some(ledger) = ledger.filter(|_| existed) {
831            match ledger.check(path, &existing) {
832                ReadState::Unread => {
833                    return Err(read_first_error(
834                        &substrate.display_path(path),
835                        "overwriting",
836                    ));
837                }
838                ReadState::Stale => {
839                    return Err(stale_error(&substrate.display_path(path)));
840                }
841                ReadState::FreshPartial => {
842                    return Err(full_read_error(
843                        &substrate.display_path(path),
844                        "appending to",
845                    ));
846                }
847                ReadState::FreshFull => {}
848            }
849        }
850        let mut combined = existing;
851        combined.push_str(content);
852        substrate.write_text(path, &combined).await?;
853        combined
854    } else {
855        // Read-before-edit + staleness guard: overwriting a file that already
856        // exists but was never read this session is refused (a blind clobber),
857        // and overwriting one that CHANGED since the last read is refused as
858        // stale — the session would silently destroy content it has never
859        // observed (e.g. a change made by its own shell command or an external
860        // process). Re-reading shows the current bytes and re-licenses the
861        // write. Creating a NEW file is always allowed.
862        if let Some(ledger) = ledger {
863            if let Some(existing) = existing {
864                match ledger.check(path, &existing) {
865                    ReadState::Unread => {
866                        return Err(read_first_error(
867                            &substrate.display_path(path),
868                            "overwriting",
869                        ));
870                    }
871                    ReadState::Stale => {
872                        return Err(stale_error(&substrate.display_path(path)));
873                    }
874                    ReadState::FreshPartial => {
875                        return Err(full_read_error(
876                            &substrate.display_path(path),
877                            "overwriting",
878                        ));
879                    }
880                    ReadState::FreshFull => {}
881                }
882            }
883        }
884        substrate.write_text(path, content).await?;
885        content.to_string()
886    };
887
888    // Self-record the new content so a subsequent edit_file is licensed and the
889    // session's snapshot of this path stays current.
890    if let Some(ledger) = ledger {
891        ledger.record(path, &combined, true);
892    }
893
894    Ok(json!({
895        "path": substrate.display_path(path),
896        "bytes_written": content.len(),
897        "append": append,
898    }))
899}
900
901async fn exec_edit_file(
902    substrate: &Arc<dyn Substrate>,
903    ledger: Option<&ReadLedger>,
904    params: &Value,
905) -> Result<Value, String> {
906    let path = params
907        .get("path")
908        .and_then(|v| v.as_str())
909        .ok_or("missing 'path' parameter")?;
910    let old_text = params
911        .get("old_text")
912        .and_then(|v| v.as_str())
913        .ok_or("missing 'old_text' parameter")?;
914    let new_text = params
915        .get("new_text")
916        .and_then(|v| v.as_str())
917        .ok_or("missing 'new_text' parameter")?;
918    let replace_all = params
919        .get("replace_all")
920        .and_then(|v| v.as_bool())
921        .unwrap_or(false);
922
923    let _mutation_guard = match ledger {
924        Some(ledger) => Some(ledger.mutation_lock(path).lock_owned().await),
925        None => None,
926    };
927
928    let content = substrate.read_text(path).await?;
929
930    // Read-before-edit / staleness guard: the session must have read this exact
931    // path, and its content must still match what was read (a write self-records,
932    // so a write→edit chain is fine). (H1/F4-remainder, audit 2026-07-06.)
933    let full_read = if let Some(ledger) = ledger {
934        match ledger.check(path, &content) {
935            ReadState::Unread => {
936                return Err(read_first_error(&substrate.display_path(path), "editing"));
937            }
938            ReadState::Stale => {
939                return Err(stale_error(&substrate.display_path(path)));
940            }
941            ReadState::FreshPartial if replace_all => {
942                return Err(full_read_error(
943                    &substrate.display_path(path),
944                    "replacing every occurrence in",
945                ));
946            }
947            ReadState::FreshPartial => false,
948            ReadState::FreshFull => true,
949        }
950    } else {
951        false
952    };
953
954    // An empty old_text is never a real edit request: `str::matches("")`
955    // yields char_count+1 hits, and with replace_all that would interleave
956    // new_text at every char boundary — silent whole-file corruption reported
957    // as success. Reject up front. (H1/F4-remainder review, audit 2026-07-06.)
958    if old_text.is_empty() {
959        return Err(
960            "old_text must be non-empty — pass the exact existing text to replace \
961             (use write_file to replace a whole file)"
962                .to_string(),
963        );
964    }
965
966    // Literal file bytes always take precedence. Numbered-display recovery is
967    // limited to lines this session read of the current content and to a
968    // single unique edit; existing guards still reject stale reads, ambiguous
969    // matches and replace-all expansion.
970    // Recovery and hints act only on lines this session has seen: every line
971    // after a full read, and the pages it read otherwise — so a large file,
972    // which is read in pages, keeps them.
973    let seen = ledger
974        .map(|ledger| ledger.lines_seen(path, &content))
975        .unwrap_or_default();
976    let recovered = if !replace_all && !content.contains(old_text) {
977        unnumbered_read_text(&content, old_text)
978            .filter(|(_, start, end)| seen_covers(&seen, *start, *end))
979            .map(|(plain, _, _)| plain)
980    } else {
981        None
982    };
983    let old_text = recovered.as_deref().unwrap_or(old_text);
984    // A CRLF file read through a renderer that shows raw text hides the `\r`,
985    // so a model's multi-line old_text arrives with bare `\n`. The only
986    // difference allowed here is the line ending: new_text gets the file's
987    // endings too, and the match must still be unique.
988    let crlf = (!content.contains(old_text)
989        && old_text.contains('\n')
990        && !old_text.contains('\r')
991        && content.contains("\r\n"))
992    .then(|| {
993        (
994            old_text.replace('\n', "\r\n"),
995            new_text.replace("\r\n", "\n").replace('\n', "\r\n"),
996        )
997    })
998    // Uniqueness (or replace_all) is then checked below like any other edit.
999    .filter(|(old, _)| content.contains(old.as_str()));
1000    let (old_text, new_text) = match &crlf {
1001        Some((old, new)) => (old.as_str(), new.as_str()),
1002        None => (old_text, new_text),
1003    };
1004    let count = content.matches(old_text).count();
1005    if count == 0 {
1006        let mut msg = format!("old_text not found in '{}'", substrate.display_path(path));
1007        if looks_like_pasted_line_number(old_text) {
1008            msg.push_str(
1009                " — old_text looks like it includes read_file's line-number prefixes; \
1010                 strip them and retry",
1011            );
1012        }
1013        if !replace_all {
1014            if let Some((candidate, _, _)) = indentation_hint(&content, old_text)
1015                .filter(|(_, start, end)| seen_covers(&seen, *start, *end))
1016            {
1017                // Raw, between marker lines: a model copies what it is shown, and
1018                // an escaped (JSON) block is copied with its escapes.
1019                msg.push_str("\nOne block matches after ignoring leading indentation or line endings. No edit was applied. Inspect this block and retry with exact old_text, preserving the intended indentation in new_text.\n");
1020                msg.push_str(HINT_BEGIN);
1021                msg.push_str(candidate);
1022                msg.push_str(HINT_END);
1023            }
1024        }
1025        return Err(msg);
1026    }
1027    let new_content = if replace_all {
1028        content.replace(old_text, new_text)
1029    } else {
1030        if count > 1 {
1031            return Err(format!(
1032                "old_text found {count} times in '{}' and must match uniquely — \
1033                 pass `replace_all: true` to replace every occurrence, or add \
1034                 surrounding context to old_text so it matches one place",
1035                substrate.display_path(path)
1036            ));
1037        }
1038        content.replacen(old_text, new_text, 1)
1039    };
1040    substrate.write_text(path, &new_content).await?;
1041
1042    // Self-record the post-edit content so the session's snapshot stays current
1043    // and a follow-up edit doesn't spuriously read as stale. After a paged
1044    // read, the pages move with the edit: lines before it stay, lines after it
1045    // shift by the change in line count, and the edited lines count as seen
1046    // when the lines they replaced were — so a second edit on the same page
1047    // keeps its recoveries without a re-read.
1048    if let Some(ledger) = ledger {
1049        if full_read || replace_all {
1050            ledger.record(path, &new_content, full_read);
1051        } else {
1052            let offset = content.find(old_text).unwrap_or(0);
1053            let start = content[..offset].matches('\n').count();
1054            let old_end = start + old_text.matches('\n').count() + 1;
1055            let new_end = start + new_text.matches('\n').count() + 1;
1056            let seen = shift_seen_lines(&seen, start, old_end, new_end);
1057            ledger.record_seen(path, &new_content, seen);
1058        }
1059    }
1060
1061    Ok(json!({
1062        "edited": substrate.display_path(path),
1063        "diff_summary": format!(
1064            "replaced {} lines with {} lines",
1065            old_text.lines().count(),
1066            new_text.lines().count()
1067        ),
1068        "replacements": count,
1069        "removed_display_line_numbers": recovered.is_some(),
1070    }))
1071}
1072
1073async fn exec_list_dir(
1074    substrate: &Arc<dyn Substrate>,
1075    params: &Value,
1076    follow: bool,
1077) -> Result<Value, String> {
1078    let path = params.get("path").and_then(|v| v.as_str()).unwrap_or(".");
1079
1080    if !substrate.is_local() {
1081        return list_dir_via_command(substrate, path).await;
1082    }
1083
1084    let full_path = local_resolve(path)?;
1085    let mut entries = Vec::new();
1086
1087    let read_dir = std::fs::read_dir(&full_path)
1088        .map_err(|e| format!("failed to read dir '{}': {e}", full_path.display()))?;
1089    for entry in read_dir {
1090        let entry = entry.map_err(|e| format!("failed to read dir entry: {e}"))?;
1091        let file_name = entry.file_name().to_string_lossy().to_string();
1092        if should_skip_name(&file_name) {
1093            continue;
1094        }
1095        if !follow && entry.file_type().is_ok_and(|kind| kind.is_symlink()) {
1096            continue;
1097        }
1098        let metadata = entry
1099            .metadata()
1100            .map_err(|e| format!("failed to read metadata for '{}': {e}", file_name))?;
1101        entries.push(json!({
1102            "name": file_name,
1103            "path": entry.path().display().to_string(),
1104            "is_dir": metadata.is_dir(),
1105            "size_bytes": if metadata.is_file() { Some(metadata.len()) } else { None::<u64> },
1106        }));
1107    }
1108
1109    Ok(json!({
1110        "path": full_path.display().to_string(),
1111        "entries": entries,
1112    }))
1113}
1114
1115async fn exec_find_files(
1116    substrate: &Arc<dyn Substrate>,
1117    params: &Value,
1118    follow: bool,
1119    hidden: Option<&LocalPathGuard>,
1120) -> Result<Value, String> {
1121    let pattern = params
1122        .get("pattern")
1123        .and_then(|v| v.as_str())
1124        .ok_or("missing 'pattern' parameter")?;
1125    let root = params.get("path").and_then(|v| v.as_str()).unwrap_or(".");
1126    let max_results = params
1127        .get("max_results")
1128        .and_then(|v| v.as_u64())
1129        .unwrap_or(1000) as usize;
1130
1131    if !substrate.is_local() {
1132        return find_files_via_command(substrate, pattern, root, max_results).await;
1133    }
1134
1135    let root_path = local_resolve(root)?;
1136    let matcher = glob_to_regex(pattern)?;
1137    // A pattern with a path separator is matched against each file's path
1138    // relative to the search root (so `src/**/*.rs` scopes to a subtree); a
1139    // bare pattern is matched against the basename (so `*.rs` finds every `.rs`
1140    // at any depth). Shared with the non-local path via `glob_haystack`. (F4.)
1141    let pattern_has_sep = pattern.contains('/');
1142    let root_str = root_path.to_string_lossy().to_string();
1143    let mut files = Vec::new();
1144
1145    walk_files(&root_path, follow, hidden, &mut |path| {
1146        if files.len() >= max_results {
1147            return;
1148        }
1149        if let Some(full) = path.to_str() {
1150            if matcher.is_match(&glob_haystack(pattern_has_sep, full, &root_str)) {
1151                files.push(path.display().to_string());
1152            }
1153        }
1154    })?;
1155
1156    Ok(json!({
1157        "files": files,
1158        "count": files.len(),
1159        "truncated": files.len() >= max_results,
1160    }))
1161}
1162
1163async fn exec_grep_files(
1164    substrate: &Arc<dyn Substrate>,
1165    params: &Value,
1166    follow: bool,
1167    guard: Option<&LocalPathGuard>,
1168) -> Result<Value, String> {
1169    let pattern = params
1170        .get("pattern")
1171        .and_then(|v| v.as_str())
1172        .ok_or("missing 'pattern' parameter")?;
1173    let root = params.get("path").and_then(|v| v.as_str()).unwrap_or(".");
1174    let max_results = params
1175        .get("max_results")
1176        .and_then(|v| v.as_u64())
1177        .unwrap_or(50) as usize;
1178
1179    if !substrate.is_local() {
1180        return grep_files_via_command(substrate, pattern, root, max_results).await;
1181    }
1182
1183    let root_path = local_resolve(root)?;
1184    let regex = Regex::new(pattern).map_err(|e| format!("invalid regex pattern: {e}"))?;
1185    let mut matches = Vec::new();
1186
1187    walk_files(&root_path, follow, None, &mut |path| {
1188        if matches.len() >= max_results || !is_text_file(path) {
1189            return;
1190        }
1191        if guard.is_some_and(|guard| guard(path).is_some()) {
1192            return;
1193        }
1194        let Ok(content) = std::fs::read_to_string(path) else {
1195            return;
1196        };
1197        if content.len() > MAX_FILE_BYTES {
1198            return;
1199        }
1200        for (idx, line) in content.lines().enumerate() {
1201            if regex.is_match(line) {
1202                matches.push(json!({
1203                    "path": path.display().to_string(),
1204                    "line": idx + 1,
1205                    "text": line,
1206                }));
1207                if matches.len() >= max_results {
1208                    break;
1209                }
1210            }
1211        }
1212    })?;
1213
1214    Ok(json!({
1215        "matches": matches,
1216        "count": matches.len(),
1217        "truncated": matches.len() >= max_results,
1218    }))
1219}
1220
1221fn exec_calculate(params: &Value) -> Result<Value, String> {
1222    let expression = params
1223        .get("expression")
1224        .and_then(|v| v.as_str())
1225        .ok_or("missing 'expression' parameter")?;
1226    // fasteval ships `sin/cos/abs/log/min/max/pi()/e()` and `^` (exponentiation)
1227    // as built-ins, but not `sqrt`, `ln`, or the bare `pi`/`e` constants.
1228    // fasteval consults this namespace only for names it doesn't resolve
1229    // itself, so the shim fills those gaps without shadowing any built-in —
1230    // making the `calculate` tool's supported surface explicit here rather
1231    // than inherited from the dependency.
1232    let mut ns = |name: &str, args: Vec<f64>| -> Option<f64> {
1233        match (name, args.as_slice()) {
1234            ("sqrt", [x]) => Some(x.sqrt()),
1235            ("ln", [x]) => Some(x.ln()),
1236            ("pi", []) => Some(std::f64::consts::PI),
1237            ("e", []) => Some(std::f64::consts::E),
1238            _ => None,
1239        }
1240    };
1241    let result = fasteval::ez_eval(expression, &mut ns)
1242        .map_err(|e| format!("failed to evaluate expression: {e}"))?;
1243    Ok(json!({ "result": result }))
1244}
1245
1246// ─── Local-path resolution (host CWD), used only on the local fast path ───
1247//
1248// Mirrors `LocalSubstrate::resolve_path` exactly: absolute paths pass through,
1249// relative paths join the host process current_dir(). The directory-walking
1250// convenience tools (list/find/grep) use it directly when the bound substrate
1251// is the host; non-local substrates compose on `run_command` instead.
1252fn local_resolve(path: &str) -> Result<PathBuf, String> {
1253    crate::substrate::LocalSubstrate::resolve_path(path)
1254}
1255
1256fn should_skip_name(name: &str) -> bool {
1257    name.starts_with('.') || matches!(name, "node_modules" | "__pycache__" | "target")
1258}
1259
1260fn is_text_file(path: &Path) -> bool {
1261    matches!(
1262        path.extension().and_then(|v| v.to_str()),
1263        Some(
1264            "c" | "cc"
1265                | "cpp"
1266                | "cs"
1267                | "css"
1268                | "go"
1269                | "h"
1270                | "html"
1271                | "ini"
1272                | "java"
1273                | "js"
1274                | "json"
1275                | "jsx"
1276                | "kt"
1277                | "md"
1278                | "py"
1279                | "rb"
1280                | "rs"
1281                | "sh"
1282                | "sql"
1283                | "toml"
1284                | "ts"
1285                | "tsx"
1286                | "txt"
1287                | "xml"
1288                | "yaml"
1289                | "yml"
1290        )
1291    )
1292}
1293
1294/// `prune`, when given, is asked about every folder before the walk looks
1295/// inside it, and one it refuses is never entered: nothing in it is visited,
1296/// and nothing in it can fail the walk with its path. (A linked entry is never
1297/// entered either way: its metadata is read without following the link.)
1298fn walk_files(
1299    root: &Path,
1300    follow: bool,
1301    prune: Option<&LocalPathGuard>,
1302    visit: &mut dyn FnMut(&Path),
1303) -> Result<(), String> {
1304    if !follow && std::fs::symlink_metadata(root).is_ok_and(|meta| meta.file_type().is_symlink()) {
1305        return Ok(());
1306    }
1307    if root.is_file() {
1308        visit(root);
1309        return Ok(());
1310    }
1311
1312    let read_dir = std::fs::read_dir(root)
1313        .map_err(|e| format!("failed to read dir '{}': {e}", root.display()))?;
1314    for entry in read_dir {
1315        let entry = entry.map_err(|e| format!("failed to read dir entry: {e}"))?;
1316        let path = entry.path();
1317        let file_name = entry.file_name().to_string_lossy().to_string();
1318        if should_skip_name(&file_name) {
1319            continue;
1320        }
1321        if !follow && entry.file_type().is_ok_and(|kind| kind.is_symlink()) {
1322            continue;
1323        }
1324        if prune.is_some_and(|prune| {
1325            entry.file_type().is_ok_and(|kind| kind.is_dir()) && prune(&path).is_some()
1326        }) {
1327            continue;
1328        }
1329        let metadata = entry
1330            .metadata()
1331            .map_err(|e| format!("failed to read metadata for '{}': {e}", path.display()))?;
1332        if metadata.is_dir() {
1333            walk_files(&path, follow, prune, visit)?;
1334        } else if metadata.is_file() {
1335            visit(&path);
1336        }
1337    }
1338    Ok(())
1339}
1340
1341/// Translate a glob to an anchored regex with path-segment awareness so
1342/// recursive path patterns work, not just basenames (F4, audit 2026-07-06):
1343/// - `*`   matches within one path segment (`[^/]*`)
1344/// - `**/` matches any number of leading segments, including none (`(?:.*/)?`)
1345/// - `**`  matches across segments (`.*`)
1346/// - `?`   matches a single non-separator char (`[^/]`)
1347/// A pattern with no `/` is matched against the basename by the caller, so
1348/// `*.rs` still finds `lib.rs` at any depth; a pattern with `/` (e.g.
1349/// `src/**/*.rs`) is matched against the path relative to the search root.
1350fn glob_to_regex(pattern: &str) -> Result<Regex, String> {
1351    let chars: Vec<char> = pattern.chars().collect();
1352    let mut re = String::from("^");
1353    let mut i = 0;
1354    while i < chars.len() {
1355        match chars[i] {
1356            '*' => {
1357                if i + 1 < chars.len() && chars[i + 1] == '*' {
1358                    i += 1; // consume the second '*'
1359                    if i + 1 < chars.len() && chars[i + 1] == '/' {
1360                        i += 1; // consume the '/': `**/` → optional leading segments
1361                        re.push_str("(?:.*/)?");
1362                    } else {
1363                        re.push_str(".*");
1364                    }
1365                } else {
1366                    re.push_str("[^/]*");
1367                }
1368            }
1369            '?' => re.push_str("[^/]"),
1370            c => re.push_str(&regex::escape(&c.to_string())),
1371        }
1372        i += 1;
1373    }
1374    re.push('$');
1375    Regex::new(&re).map_err(|e| format!("invalid glob pattern: {e}"))
1376}
1377
1378/// Pick the string a candidate file is matched against for a glob search: the
1379/// path relative to the search `root` when the pattern contains a separator
1380/// (so `src/**/*.rs` scopes to a subtree), else the basename (so `*.rs` finds
1381/// every match at any depth). Shared by the local directory walk AND the
1382/// non-local `find` path so both substrates honor the same path-glob semantics
1383/// the tool schema advertises — otherwise the sandbox/remote path silently
1384/// returns nothing for a `/`-bearing pattern. (F4, audit 2026-07-06.)
1385fn glob_haystack(pattern_has_sep: bool, full: &str, root: &str) -> String {
1386    if pattern_has_sep {
1387        let full = full.replace('\\', "/");
1388        let root = root.replace('\\', "/");
1389        full.strip_prefix(&root)
1390            .unwrap_or(&full)
1391            .trim_start_matches('/')
1392            .to_string()
1393    } else {
1394        full.rsplit(['/', '\\']).next().unwrap_or(full).to_string()
1395    }
1396}
1397
1398// ─── Non-local composition on run_command ─────────────────────────────────
1399//
1400// For non-host substrates (e.g. a VM bridge) the directory-walking convenience
1401// tools have no host fs to walk, so they compose on the substrate's
1402// `run_command`, mirroring the bridge's "no ls/find/grep — reduce to a shell
1403// command" design. These paths are NOT exercised by existing consumers (which
1404// all default to LocalSubstrate), so they introduce no behavior change there.
1405
1406fn shell_quote(s: &str) -> String {
1407    // POSIX single-quote escaping: ' -> '\''
1408    format!("'{}'", s.replace('\'', "'\\''"))
1409}
1410
1411async fn list_dir_via_command(substrate: &Arc<dyn Substrate>, path: &str) -> Result<Value, String> {
1412    let cmd = format!("ls -1Ap {}", shell_quote(path));
1413    let out = substrate.run_command(&cmd, Some(30.0)).await?;
1414    let entries: Vec<Value> = out
1415        .stdout
1416        .lines()
1417        .filter(|l| !l.is_empty())
1418        .filter(|name| {
1419            let bare = name.trim_end_matches('/');
1420            !should_skip_name(bare)
1421        })
1422        .map(|name| {
1423            let is_dir = name.ends_with('/');
1424            let bare = name.trim_end_matches('/');
1425            json!({
1426                "name": bare,
1427                "path": format!("{}/{}", path.trim_end_matches('/'), bare),
1428                "is_dir": is_dir,
1429                "size_bytes": Value::Null,
1430            })
1431        })
1432        .collect();
1433    Ok(json!({ "path": path, "entries": entries }))
1434}
1435
1436async fn find_files_via_command(
1437    substrate: &Arc<dyn Substrate>,
1438    pattern: &str,
1439    root: &str,
1440    max_results: usize,
1441) -> Result<Value, String> {
1442    // List every file, then apply the SAME matcher the local walk uses, so a
1443    // path glob (`src/**/*.rs`) works here too. `find -name <pattern>` matched
1444    // the basename only and never matched a `/`-bearing pattern — the tool
1445    // schema advertises path globs on every substrate, so the non-local path
1446    // must honor them or silently return nothing. (F4, audit 2026-07-06.)
1447    let cmd = format!("find {} -type f", shell_quote(root));
1448    let out = substrate.run_command(&cmd, Some(30.0)).await?;
1449    let matcher = glob_to_regex(pattern)?;
1450    let pattern_has_sep = pattern.contains('/');
1451    let mut files: Vec<String> = Vec::new();
1452    let mut truncated = false;
1453    for line in out.stdout.lines() {
1454        if line.is_empty() {
1455            continue;
1456        }
1457        let name = line.rsplit(['/', '\\']).next().unwrap_or(line);
1458        if should_skip_name(name) {
1459            continue;
1460        }
1461        if !matcher.is_match(&glob_haystack(pattern_has_sep, line, root)) {
1462            continue;
1463        }
1464        if files.len() >= max_results {
1465            truncated = true;
1466            break;
1467        }
1468        files.push(line.to_string());
1469    }
1470    Ok(json!({
1471        "files": files,
1472        "count": files.len(),
1473        "truncated": truncated,
1474    }))
1475}
1476
1477async fn grep_files_via_command(
1478    substrate: &Arc<dyn Substrate>,
1479    pattern: &str,
1480    root: &str,
1481    max_results: usize,
1482) -> Result<Value, String> {
1483    let cmd = format!("grep -rnE {} {}", shell_quote(pattern), shell_quote(root));
1484    let out = substrate.run_command(&cmd, Some(30.0)).await?;
1485    let mut matches = Vec::new();
1486    for line in out.stdout.lines() {
1487        if matches.len() >= max_results {
1488            break;
1489        }
1490        // grep -rn format: path:line:text
1491        let mut parts = line.splitn(3, ':');
1492        let (Some(p), Some(ln), Some(text)) = (parts.next(), parts.next(), parts.next()) else {
1493            continue;
1494        };
1495        let Ok(line_no) = ln.parse::<usize>() else {
1496            continue;
1497        };
1498        matches.push(json!({ "path": p, "line": line_no, "text": text }));
1499    }
1500    let truncated = matches.len() >= max_results;
1501    Ok(json!({
1502        "matches": matches,
1503        "count": matches.len(),
1504        "truncated": truncated,
1505    }))
1506}
1507
1508#[cfg(test)]
1509mod tests {
1510    use super::*;
1511
1512    /// With `follow_symlinks: false` the walkers never step through a link:
1513    /// a search rooted at a linked directory finds nothing, where the default
1514    /// goes through it as before.
1515    #[cfg(unix)]
1516    #[test]
1517    fn walks_without_following_links_stay_out_of_linked_directories() {
1518        let dir = tempfile::tempdir().unwrap();
1519        let root = dir.path().join("root");
1520        let outside = dir.path().join("outside");
1521        std::fs::create_dir_all(&root).unwrap();
1522        std::fs::create_dir_all(&outside).unwrap();
1523        std::fs::write(outside.join("far.txt"), "x").unwrap();
1524        std::fs::write(root.join("near.txt"), "x").unwrap();
1525        std::os::unix::fs::symlink(&outside, root.join("link")).unwrap();
1526        let mut seen = Vec::new();
1527        walk_files(&root, false, None, &mut |path| {
1528            seen.push(path.to_path_buf())
1529        })
1530        .unwrap();
1531        assert_eq!(seen, vec![root.join("near.txt")]);
1532        let mut through_link = Vec::new();
1533        walk_files(&root.join("link"), false, None, &mut |path| {
1534            through_link.push(path.to_path_buf())
1535        })
1536        .unwrap();
1537        assert!(through_link.is_empty(), "{through_link:?}");
1538        let mut followed = Vec::new();
1539        walk_files(&root.join("link"), true, None, &mut |path| {
1540            followed.push(path.to_path_buf())
1541        })
1542        .unwrap();
1543        assert_eq!(followed, vec![root.join("link").join("far.txt")]);
1544    }
1545
1546    #[test]
1547    fn glob_patterns_match_file_names() {
1548        let regex = glob_to_regex("*.rs").unwrap();
1549        assert!(regex.is_match("lib.rs"));
1550        assert!(!regex.is_match("lib.ts"));
1551    }
1552
1553    /// F4 (audit 2026-07-06): the local walk and the non-local `find` path
1554    /// share `glob_haystack`, so a `/`-bearing pattern matches the path
1555    /// relative to the search root on EVERY substrate (the non-local path used
1556    /// to `find -name` and silently returned nothing for a path glob). This
1557    /// exercises the shared matching logic both branches now depend on.
1558    #[test]
1559    fn glob_haystack_supports_path_and_basename_matching() {
1560        // path pattern → relative-to-root (normalized to '/'); bare → basename
1561        assert_eq!(
1562            glob_haystack(true, "/root/src/inner/deep.rs", "/root"),
1563            "src/inner/deep.rs"
1564        );
1565        assert_eq!(
1566            glob_haystack(false, "/root/src/inner/deep.rs", "/root"),
1567            "deep.rs"
1568        );
1569
1570        // Combined with the translator, a recursive path glob scopes to the
1571        // subtree and rejects the wrong extension — the behavior the non-local
1572        // substrate previously could not produce.
1573        let m = glob_to_regex("src/**/*.rs").unwrap();
1574        assert!(m.is_match(&glob_haystack(true, "/root/src/top.rs", "/root")));
1575        assert!(m.is_match(&glob_haystack(true, "/root/src/inner/deep.rs", "/root")));
1576        assert!(!m.is_match(&glob_haystack(true, "/root/src/inner/note.txt", "/root")));
1577    }
1578
1579    /// F4 (audit 2026-07-06): the file search must support recursive path globs
1580    /// (`src/**/*.rs`), not just basename matching — otherwise the agent cannot
1581    /// scope a search to a subtree and resorts to guessing.
1582    #[tokio::test]
1583    async fn find_files_supports_recursive_path_globs() {
1584        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1585        let dir = std::env::temp_dir().join(format!(
1586            "car-find-glob-{}",
1587            std::time::SystemTime::now()
1588                .duration_since(std::time::UNIX_EPOCH)
1589                .unwrap()
1590                .as_nanos()
1591        ));
1592        std::fs::create_dir_all(dir.join("src").join("inner")).unwrap();
1593        std::fs::write(dir.join("src").join("top.rs"), "x").unwrap();
1594        std::fs::write(dir.join("src").join("inner").join("deep.rs"), "x").unwrap();
1595        std::fs::write(dir.join("src").join("inner").join("note.txt"), "x").unwrap();
1596        let root = dir.to_string_lossy().to_string();
1597
1598        let r = exec_find_files(
1599            &substrate,
1600            &json!({ "path": root, "pattern": "src/**/*.rs" }),
1601            true,
1602            None,
1603        )
1604        .await
1605        .unwrap();
1606        let joined = r["files"]
1607            .as_array()
1608            .unwrap()
1609            .iter()
1610            .map(|v| v.as_str().unwrap())
1611            .collect::<Vec<_>>()
1612            .join("\n");
1613
1614        assert!(
1615            joined.contains("deep.rs"),
1616            "recursive glob missed nested file:\n{joined}"
1617        );
1618        assert!(
1619            joined.contains("top.rs"),
1620            "recursive glob missed top-level file:\n{joined}"
1621        );
1622        assert!(
1623            !joined.contains("note.txt"),
1624            "glob matched the wrong extension:\n{joined}"
1625        );
1626
1627        let _ = std::fs::remove_dir_all(&dir);
1628    }
1629
1630    /// F4 (audit 2026-07-06): the NON-LOCAL substrate path (`find_files_via_command`)
1631    /// must honor recursive path globs too — it lists every file via `run_command`
1632    /// then applies the SAME matcher as the local walk. Previously untested; a mock
1633    /// substrate (`is_local()` defaults to false) with scripted `find` stdout pins
1634    /// it, so a regression that reverts to basename-only `-name` matching is caught.
1635    #[tokio::test]
1636    async fn find_files_non_local_substrate_applies_path_glob() {
1637        use crate::substrate::CommandOutput;
1638
1639        struct RemoteStub {
1640            stdout: String,
1641        }
1642        #[async_trait::async_trait]
1643        impl Substrate for RemoteStub {
1644            fn name(&self) -> &str {
1645                "test-remote"
1646            }
1647            // is_local() defaults to false -> exercises the non-local path.
1648            async fn run_command(
1649                &self,
1650                _cmd: &str,
1651                _timeout_s: Option<f64>,
1652            ) -> Result<CommandOutput, String> {
1653                Ok(CommandOutput {
1654                    stdout: self.stdout.clone(),
1655                    stderr: String::new(),
1656                    exit_code: 0,
1657                })
1658            }
1659            async fn read_text(&self, _path: &str) -> Result<String, String> {
1660                Err("unused".into())
1661            }
1662            async fn write_text(&self, _path: &str, _content: &str) -> Result<(), String> {
1663                Err("unused".into())
1664            }
1665            async fn read_bytes(
1666                &self,
1667                _path: &str,
1668                _offset: Option<u64>,
1669                _len: Option<u64>,
1670            ) -> Result<Vec<u8>, String> {
1671                Err("unused".into())
1672            }
1673            async fn write_bytes(&self, _path: &str, _bytes: &[u8]) -> Result<(), String> {
1674                Err("unused".into())
1675            }
1676        }
1677
1678        // Scripted `find <root> -type f` output — matching + non-matching paths.
1679        let substrate: Arc<dyn Substrate> = Arc::new(RemoteStub {
1680            stdout: [
1681                "/root/src/top.rs",
1682                "/root/src/inner/deep.rs",
1683                "/root/src/inner/note.txt",
1684                "/root/README.md",
1685            ]
1686            .join("\n"),
1687        });
1688
1689        let r = exec_find_files(
1690            &substrate,
1691            &json!({ "path": "/root", "pattern": "src/**/*.rs" }),
1692            true,
1693            None,
1694        )
1695        .await
1696        .unwrap();
1697        let files: Vec<&str> = r["files"]
1698            .as_array()
1699            .unwrap()
1700            .iter()
1701            .map(|v| v.as_str().unwrap())
1702            .collect();
1703
1704        // The recursive path glob scopes to src/ and rejects the wrong extension —
1705        // the exact behavior the non-local path silently lacked before F4.
1706        assert_eq!(files, vec!["/root/src/top.rs", "/root/src/inner/deep.rs"]);
1707        assert_eq!(r["count"], 2);
1708    }
1709
1710    /// Unique nanos-named temp dir for a test, created and returned.
1711    fn fresh_dir(tag: &str) -> PathBuf {
1712        let dir = std::env::temp_dir().join(format!(
1713            "car-agent-basics-{tag}-{}",
1714            std::time::SystemTime::now()
1715                .duration_since(std::time::UNIX_EPOCH)
1716                .unwrap()
1717                .as_nanos()
1718        ));
1719        std::fs::create_dir_all(&dir).unwrap();
1720        dir
1721    }
1722
1723    #[tokio::test]
1724    async fn read_write_roundtrip_against_local_substrate() {
1725        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1726        let dir = fresh_dir("roundtrip");
1727        let path = dir.join("note.txt").to_string_lossy().to_string();
1728
1729        let w = exec_write_file(&substrate, None, &json!({ "path": path, "content": "abc" }))
1730            .await
1731            .unwrap();
1732        assert_eq!(w["bytes_written"], 3);
1733
1734        // The published, ungated execute path retains its historic raw content.
1735        let r = exec_read_file(&substrate, None, &json!({ "path": path }))
1736            .await
1737            .unwrap();
1738        assert_eq!(r["content"], "abc");
1739        assert_eq!(r["size_bytes"], 3);
1740        assert_eq!(r["total_lines"], 1);
1741
1742        // append composes correctly through the substrate
1743        exec_write_file(
1744            &substrate,
1745            None,
1746            &json!({ "path": path, "content": "def", "append": true }),
1747        )
1748        .await
1749        .unwrap();
1750        let r2 = exec_read_file(&substrate, None, &json!({ "path": path }))
1751            .await
1752            .unwrap();
1753        assert_eq!(r2["content"], "abcdef");
1754
1755        // edit unique-match
1756        let e = exec_edit_file(
1757            &substrate,
1758            None,
1759            &json!({ "path": path, "old_text": "abc", "new_text": "XYZ" }),
1760        )
1761        .await
1762        .unwrap();
1763        assert!(e["edited"].is_string());
1764        assert_eq!(e["replacements"], 1);
1765        let r3 = exec_read_file(&substrate, None, &json!({ "path": path }))
1766            .await
1767            .unwrap();
1768        assert_eq!(r3["content"], "XYZdef");
1769
1770        std::fs::remove_dir_all(&dir).ok();
1771    }
1772
1773    /// (a) read output is line-numbered `cat -n` style, 1-based, and when an
1774    /// `offset` is given the numbering starts at `offset + 1` while
1775    /// `size_bytes`/`total_lines` still describe the FULL file.
1776    #[tokio::test]
1777    async fn read_file_output_is_line_numbered() {
1778        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1779        let ledger = ReadLedger::new();
1780        let dir = fresh_dir("linenum");
1781        let path = dir.join("f.txt").to_string_lossy().to_string();
1782        exec_write_file(
1783            &substrate,
1784            None,
1785            &json!({ "path": path, "content": "alpha\nbeta\ngamma" }),
1786        )
1787        .await
1788        .unwrap();
1789
1790        let r = execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1791            .await
1792            .unwrap()
1793            .unwrap();
1794        assert_eq!(r["content"], "     1\talpha\n     2\tbeta\n     3\tgamma");
1795        assert_eq!(r["total_lines"], 3);
1796
1797        // Offset slice: numbering continues from offset + 1, full-file metadata.
1798        let r2 = execute_with_ledger(
1799            &substrate,
1800            &ledger,
1801            "read_file",
1802            &json!({ "path": path, "offset": 1, "limit": 1 }),
1803        )
1804        .await
1805        .unwrap()
1806        .unwrap();
1807        assert_eq!(r2["content"], "     2\tbeta");
1808        assert_eq!(r2["total_lines"], 3);
1809
1810        std::fs::remove_dir_all(&dir).ok();
1811    }
1812
1813    #[tokio::test]
1814    async fn plain_execute_preserves_legacy_raw_read_output() {
1815        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1816        let dir = fresh_dir("rawread");
1817        let path = dir.join("f.txt").to_string_lossy().to_string();
1818        std::fs::write(&path, "alpha\nbeta\n").unwrap();
1819
1820        let result = execute(&substrate, "read_file", &json!({ "path": path }))
1821            .await
1822            .unwrap()
1823            .unwrap();
1824        assert_eq!(result["content"], "alpha\nbeta\n");
1825
1826        std::fs::remove_dir_all(&dir).ok();
1827    }
1828
1829    /// (b) `edit_file` refuses a path the session never read, then succeeds once
1830    /// `read_file` has run — the read-before-edit guard.
1831    #[tokio::test]
1832    async fn edit_requires_prior_read() {
1833        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1834        let ledger = ReadLedger::new();
1835        let dir = fresh_dir("editread");
1836        let path = dir.join("f.txt").to_string_lossy().to_string();
1837        // Create on disk directly — the ledger has no record of this path.
1838        std::fs::write(dir.join("f.txt"), "hello world").unwrap();
1839
1840        let err = execute_with_ledger(
1841            &substrate,
1842            &ledger,
1843            "edit_file",
1844            &json!({ "path": path, "old_text": "hello", "new_text": "hi" }),
1845        )
1846        .await
1847        .unwrap()
1848        .unwrap_err();
1849        assert!(err.contains("before editing it"), "{err}");
1850        assert!(err.contains("read_file"), "{err}");
1851
1852        // Reading licenses the edit.
1853        execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1854            .await
1855            .unwrap()
1856            .unwrap();
1857        let ok = execute_with_ledger(
1858            &substrate,
1859            &ledger,
1860            "edit_file",
1861            &json!({ "path": path, "old_text": "hello", "new_text": "hi" }),
1862        )
1863        .await
1864        .unwrap()
1865        .unwrap();
1866        assert_eq!(ok["replacements"], 1);
1867
1868        std::fs::remove_dir_all(&dir).ok();
1869    }
1870
1871    /// (b) `edit_file` refuses a stale file — one that changed on disk after the
1872    /// session read it — until it is re-read.
1873    #[tokio::test]
1874    async fn edit_detects_stale_file() {
1875        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1876        let ledger = ReadLedger::new();
1877        let dir = fresh_dir("stale");
1878        let path = dir.join("f.txt").to_string_lossy().to_string();
1879        std::fs::write(dir.join("f.txt"), "version one").unwrap();
1880
1881        // Read records the current content; then the file changes underneath.
1882        execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1883            .await
1884            .unwrap()
1885            .unwrap();
1886        std::fs::write(dir.join("f.txt"), "version two changed").unwrap();
1887
1888        let err = execute_with_ledger(
1889            &substrate,
1890            &ledger,
1891            "edit_file",
1892            &json!({ "path": path, "old_text": "version", "new_text": "v" }),
1893        )
1894        .await
1895        .unwrap()
1896        .unwrap_err();
1897        assert!(err.contains("changed since you last read it"), "{err}");
1898
1899        // Re-reading clears the staleness.
1900        execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1901            .await
1902            .unwrap()
1903            .unwrap();
1904        let ok = execute_with_ledger(
1905            &substrate,
1906            &ledger,
1907            "edit_file",
1908            &json!({ "path": path, "old_text": "version", "new_text": "v" }),
1909        )
1910        .await
1911        .unwrap()
1912        .unwrap();
1913        assert!(ok["edited"].is_string());
1914
1915        std::fs::remove_dir_all(&dir).ok();
1916    }
1917
1918    /// (review) CRLF files keep their `\r` in the numbered display so multi-line
1919    /// `old_text` copied from read output matches the on-disk bytes exactly.
1920    /// `str::lines()` would strip the `\r` and make every multi-line edit on a
1921    /// CRLF file unmatchable.
1922    #[tokio::test]
1923    async fn read_file_preserves_crlf_line_endings() {
1924        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1925        let ledger = ReadLedger::new();
1926        let dir = fresh_dir("crlf");
1927        let path = dir.join("f.txt").to_string_lossy().to_string();
1928        std::fs::write(dir.join("f.txt"), "line one\r\nline two\r\n").unwrap();
1929
1930        let out = execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1931            .await
1932            .unwrap()
1933            .unwrap();
1934        let shown = out["content"].as_str().unwrap();
1935        assert_eq!(
1936            shown, "     1\tline one\r\n     2\tline two\r",
1937            "\\r must survive into the numbered display"
1938        );
1939
1940        // The round-trip proof: multi-line old_text reconstructed from the
1941        // display (prefixes stripped, \r kept) matches the file and edits it.
1942        let ok = execute_with_ledger(
1943            &substrate,
1944            &ledger,
1945            "edit_file",
1946            &json!({ "path": path, "old_text": "line one\r\nline two", "new_text": "merged" }),
1947        )
1948        .await
1949        .unwrap()
1950        .unwrap();
1951        assert_eq!(ok["replacements"], 1);
1952
1953        std::fs::remove_dir_all(&dir).ok();
1954    }
1955
1956    /// (review) An empty `old_text` is rejected up front — `matches("")` is
1957    /// char_count+1, so with `replace_all` it would interleave `new_text` at
1958    /// every char boundary (silent whole-file corruption reported as success).
1959    #[tokio::test]
1960    async fn edit_rejects_empty_old_text() {
1961        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1962        let dir = fresh_dir("emptyold");
1963        let path = dir.join("f.txt").to_string_lossy().to_string();
1964        exec_write_file(&substrate, None, &json!({ "path": path, "content": "abc" }))
1965            .await
1966            .unwrap();
1967
1968        for replace_all in [false, true] {
1969            let err = exec_edit_file(
1970                &substrate,
1971                None,
1972                &json!({
1973                    "path": path,
1974                    "old_text": "",
1975                    "new_text": "X",
1976                    "replace_all": replace_all
1977                }),
1978            )
1979            .await
1980            .unwrap_err();
1981            assert!(err.contains("old_text must be non-empty"), "{err}");
1982        }
1983        // The file is untouched.
1984        assert_eq!(std::fs::read_to_string(dir.join("f.txt")).unwrap(), "abc");
1985
1986        std::fs::remove_dir_all(&dir).ok();
1987    }
1988
1989    /// (review) `write_file` over an existing file refuses when the file changed
1990    /// since the session's last read — a blind overwrite would destroy content
1991    /// the session never observed. Re-reading re-licenses the write. This is the
1992    /// staleness half the docs promise alongside the read-first gate.
1993    #[tokio::test]
1994    async fn write_existing_rejects_stale_after_external_change() {
1995        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1996        let ledger = ReadLedger::new();
1997        let dir = fresh_dir("stalewrite");
1998        let path = dir.join("f.txt").to_string_lossy().to_string();
1999        std::fs::write(dir.join("f.txt"), "original").unwrap();
2000
2001        execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
2002            .await
2003            .unwrap()
2004            .unwrap();
2005        // The file changes underneath (external process / shell command).
2006        std::fs::write(dir.join("f.txt"), "changed underneath").unwrap();
2007
2008        let err = execute_with_ledger(
2009            &substrate,
2010            &ledger,
2011            "write_file",
2012            &json!({ "path": path, "content": "clobber" }),
2013        )
2014        .await
2015        .unwrap()
2016        .unwrap_err();
2017        assert!(err.contains("changed since you last read it"), "{err}");
2018
2019        // Append over stale content is refused the same way.
2020        let err = execute_with_ledger(
2021            &substrate,
2022            &ledger,
2023            "write_file",
2024            &json!({ "path": path, "content": " + more", "append": true }),
2025        )
2026        .await
2027        .unwrap()
2028        .unwrap_err();
2029        assert!(err.contains("changed since you last read it"), "{err}");
2030
2031        // Re-reading shows the current bytes and re-licenses the write.
2032        execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
2033            .await
2034            .unwrap()
2035            .unwrap();
2036        execute_with_ledger(
2037            &substrate,
2038            &ledger,
2039            "write_file",
2040            &json!({ "path": path, "content": "rewritten" }),
2041        )
2042        .await
2043        .unwrap()
2044        .unwrap();
2045        assert_eq!(
2046            std::fs::read_to_string(dir.join("f.txt")).unwrap(),
2047            "rewritten"
2048        );
2049
2050        std::fs::remove_dir_all(&dir).ok();
2051    }
2052
2053    /// (c) `replace_all: true` replaces every occurrence and reports the count;
2054    /// the default path still refuses a non-unique match and points at the flag.
2055    #[tokio::test]
2056    async fn replace_all_replaces_every_occurrence() {
2057        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2058        let dir = fresh_dir("replaceall");
2059        let path = dir.join("f.txt").to_string_lossy().to_string();
2060        exec_write_file(
2061            &substrate,
2062            None,
2063            &json!({ "path": path, "content": "a x a x a" }),
2064        )
2065        .await
2066        .unwrap();
2067
2068        // Default (unique) edit refuses the 3-way match and names the remedy.
2069        let err = exec_edit_file(
2070            &substrate,
2071            None,
2072            &json!({ "path": path, "old_text": "a", "new_text": "b" }),
2073        )
2074        .await
2075        .unwrap_err();
2076        assert!(err.contains("replace_all"), "{err}");
2077
2078        // replace_all replaces every occurrence.
2079        let ok = exec_edit_file(
2080            &substrate,
2081            None,
2082            &json!({ "path": path, "old_text": "a", "new_text": "b", "replace_all": true }),
2083        )
2084        .await
2085        .unwrap();
2086        assert_eq!(ok["replacements"], 3);
2087        let r = exec_read_file(&substrate, None, &json!({ "path": path }))
2088            .await
2089            .unwrap();
2090        assert_eq!(r["content"], "b x b x b");
2091
2092        std::fs::remove_dir_all(&dir).ok();
2093    }
2094
2095    /// (d) Creating a brand-new file needs no prior read.
2096    #[tokio::test]
2097    async fn write_new_file_allowed() {
2098        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2099        let ledger = ReadLedger::new();
2100        let dir = fresh_dir("writenew");
2101        let path = dir.join("new.txt").to_string_lossy().to_string();
2102
2103        let ok = execute_with_ledger(
2104            &substrate,
2105            &ledger,
2106            "write_file",
2107            &json!({ "path": path, "content": "fresh" }),
2108        )
2109        .await
2110        .unwrap()
2111        .unwrap();
2112        assert_eq!(ok["bytes_written"], 5);
2113
2114        std::fs::remove_dir_all(&dir).ok();
2115    }
2116
2117    /// (d) Overwriting an EXISTING file the session never read is refused.
2118    #[tokio::test]
2119    async fn write_existing_requires_prior_read() {
2120        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2121        let ledger = ReadLedger::new();
2122        let dir = fresh_dir("writeexisting");
2123        let path = dir.join("f.txt").to_string_lossy().to_string();
2124        std::fs::write(dir.join("f.txt"), "existing content").unwrap();
2125
2126        let err = execute_with_ledger(
2127            &substrate,
2128            &ledger,
2129            "write_file",
2130            &json!({ "path": path, "content": "clobber" }),
2131        )
2132        .await
2133        .unwrap()
2134        .unwrap_err();
2135        assert!(err.contains("before overwriting it"), "{err}");
2136        assert!(err.contains("read_file"), "{err}");
2137
2138        // Reading licenses the overwrite.
2139        execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
2140            .await
2141            .unwrap()
2142            .unwrap();
2143        let ok = execute_with_ledger(
2144            &substrate,
2145            &ledger,
2146            "write_file",
2147            &json!({ "path": path, "content": "clobber" }),
2148        )
2149        .await
2150        .unwrap()
2151        .unwrap();
2152        assert_eq!(ok["bytes_written"], 7);
2153
2154        std::fs::remove_dir_all(&dir).ok();
2155    }
2156
2157    /// (d) A successful write self-records the new content, so a follow-up
2158    /// `edit_file` on the same path is licensed WITHOUT an intervening read —
2159    /// the "session knows the current content" contract the loops rely on.
2160    #[tokio::test]
2161    async fn write_self_records_enabling_edit() {
2162        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2163        let ledger = ReadLedger::new();
2164        let dir = fresh_dir("writeedits");
2165        let path = dir.join("f.txt").to_string_lossy().to_string();
2166
2167        execute_with_ledger(
2168            &substrate,
2169            &ledger,
2170            "write_file",
2171            &json!({ "path": path, "content": "one two three" }),
2172        )
2173        .await
2174        .unwrap()
2175        .unwrap();
2176        // No read_file in between — the write recorded the content.
2177        let ok = execute_with_ledger(
2178            &substrate,
2179            &ledger,
2180            "edit_file",
2181            &json!({ "path": path, "old_text": "two", "new_text": "TWO" }),
2182        )
2183        .await
2184        .unwrap()
2185        .unwrap();
2186        assert_eq!(ok["replacements"], 1);
2187
2188        std::fs::remove_dir_all(&dir).ok();
2189    }
2190
2191    /// (#2) A successful edit self-records the new content, so a SECOND edit with
2192    /// no intervening read is licensed. Deleting the edit self-record makes the
2193    /// ledger stale here and fails this test.
2194    #[tokio::test]
2195    async fn edit_self_records_enabling_second_edit_without_reread() {
2196        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2197        let ledger = ReadLedger::new();
2198        let dir = fresh_dir("editselfrec");
2199        let path = dir.join("f.txt").to_string_lossy().to_string();
2200        std::fs::write(dir.join("f.txt"), "one two three").unwrap();
2201
2202        execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
2203            .await
2204            .unwrap()
2205            .unwrap();
2206        execute_with_ledger(
2207            &substrate,
2208            &ledger,
2209            "edit_file",
2210            &json!({ "path": path, "old_text": "one", "new_text": "1" }),
2211        )
2212        .await
2213        .unwrap()
2214        .unwrap();
2215
2216        // No read between the two edits — the first edit recorded the new content.
2217        let ok = execute_with_ledger(
2218            &substrate,
2219            &ledger,
2220            "edit_file",
2221            &json!({ "path": path, "old_text": "two", "new_text": "2" }),
2222        )
2223        .await
2224        .unwrap()
2225        .unwrap();
2226        assert_eq!(ok["replacements"], 1);
2227        assert_eq!(
2228            std::fs::read_to_string(dir.join("f.txt")).unwrap(),
2229            "1 2 three"
2230        );
2231
2232        std::fs::remove_dir_all(&dir).ok();
2233    }
2234
2235    /// (#3) A sliced read (offset/limit) records the FULL file's hash, not the
2236    /// returned slice — so editing a line OUTSIDE the slice is licensed AND
2237    /// Fresh. If the slice were hashed, `check()` would return Stale.
2238    #[tokio::test]
2239    async fn sliced_read_hashes_full_file_licensing_edit() {
2240        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2241        let ledger = ReadLedger::new();
2242        let dir = fresh_dir("slicedread");
2243        let path = dir.join("f.txt").to_string_lossy().to_string();
2244        std::fs::write(dir.join("f.txt"), "l1\nl2\nl3\nl4").unwrap();
2245
2246        let r = execute_with_ledger(
2247            &substrate,
2248            &ledger,
2249            "read_file",
2250            &json!({ "path": path, "offset": 1, "limit": 1 }),
2251        )
2252        .await
2253        .unwrap()
2254        .unwrap();
2255        assert_eq!(r["content"], "     2\tl2"); // only the slice is returned
2256
2257        // l4 is outside the returned slice; the edit is still licensed + Fresh.
2258        let ok = execute_with_ledger(
2259            &substrate,
2260            &ledger,
2261            "edit_file",
2262            &json!({ "path": path, "old_text": "l4", "new_text": "L4" }),
2263        )
2264        .await
2265        .unwrap()
2266        .unwrap();
2267        assert_eq!(ok["replacements"], 1);
2268
2269        std::fs::remove_dir_all(&dir).ok();
2270    }
2271
2272    /// (#4) Appending onto an EXISTING file the session never read is refused
2273    /// (same read-first guard as an overwrite); reading it licenses the append.
2274    #[tokio::test]
2275    async fn append_to_existing_unread_requires_prior_read() {
2276        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2277        let ledger = ReadLedger::new();
2278        let dir = fresh_dir("appendunread");
2279        let path = dir.join("f.txt").to_string_lossy().to_string();
2280        std::fs::write(dir.join("f.txt"), "existing content").unwrap();
2281
2282        let err = execute_with_ledger(
2283            &substrate,
2284            &ledger,
2285            "write_file",
2286            &json!({ "path": path, "content": " more", "append": true }),
2287        )
2288        .await
2289        .unwrap()
2290        .unwrap_err();
2291        assert!(err.contains("before overwriting it"), "{err}");
2292
2293        execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
2294            .await
2295            .unwrap()
2296            .unwrap();
2297        let ok = execute_with_ledger(
2298            &substrate,
2299            &ledger,
2300            "write_file",
2301            &json!({ "path": path, "content": " more", "append": true }),
2302        )
2303        .await
2304        .unwrap()
2305        .unwrap();
2306        assert_eq!(ok["append"], true);
2307        assert_eq!(
2308            std::fs::read_to_string(dir.join("f.txt")).unwrap(),
2309            "existing content more"
2310        );
2311
2312        std::fs::remove_dir_all(&dir).ok();
2313    }
2314
2315    #[tokio::test]
2316    async fn append_to_existing_empty_file_requires_prior_read() {
2317        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2318        let ledger = ReadLedger::new();
2319        let dir = fresh_dir("appendempty");
2320        let path = dir.join("f.txt").to_string_lossy().to_string();
2321        std::fs::write(dir.join("f.txt"), "").unwrap();
2322
2323        let err = execute_with_ledger(
2324            &substrate,
2325            &ledger,
2326            "write_file",
2327            &json!({ "path": path, "content": "new", "append": true }),
2328        )
2329        .await
2330        .unwrap()
2331        .unwrap_err();
2332        assert!(err.contains("before overwriting it"), "{err}");
2333        assert!(std::fs::read_to_string(dir.join("f.txt"))
2334            .unwrap()
2335            .is_empty());
2336
2337        std::fs::remove_dir_all(&dir).ok();
2338    }
2339
2340    #[tokio::test]
2341    async fn partial_read_cannot_authorize_whole_file_mutation() {
2342        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2343        let ledger = ReadLedger::new();
2344        let dir = fresh_dir("partialread");
2345        let path = dir.join("f.txt").to_string_lossy().to_string();
2346        std::fs::write(dir.join("f.txt"), "first\nsecond\nthird").unwrap();
2347
2348        execute_with_ledger(
2349            &substrate,
2350            &ledger,
2351            "read_file",
2352            &json!({ "path": path, "offset": 0, "limit": 1 }),
2353        )
2354        .await
2355        .unwrap()
2356        .unwrap();
2357
2358        let overwrite = execute_with_ledger(
2359            &substrate,
2360            &ledger,
2361            "write_file",
2362            &json!({ "path": path, "content": "replacement" }),
2363        )
2364        .await
2365        .unwrap()
2366        .unwrap_err();
2367        assert!(overwrite.contains("full current content"), "{overwrite}");
2368
2369        let replace_all = execute_with_ledger(
2370            &substrate,
2371            &ledger,
2372            "edit_file",
2373            &json!({ "path": path, "old_text": "i", "new_text": "I", "replace_all": true }),
2374        )
2375        .await
2376        .unwrap()
2377        .unwrap_err();
2378        assert!(
2379            replace_all.contains("full current content"),
2380            "{replace_all}"
2381        );
2382
2383        // A normal unique edit remains usable after the focused read.
2384        let edit = execute_with_ledger(
2385            &substrate,
2386            &ledger,
2387            "edit_file",
2388            &json!({ "path": path, "old_text": "first", "new_text": "FIRST" }),
2389        )
2390        .await
2391        .unwrap()
2392        .unwrap();
2393        assert_eq!(edit["replacements"], 1);
2394
2395        std::fs::remove_dir_all(&dir).ok();
2396    }
2397
2398    #[tokio::test]
2399    async fn guarded_write_refuses_existing_non_utf8_file() {
2400        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2401        let ledger = ReadLedger::new();
2402        let dir = fresh_dir("nonutf8write");
2403        let path = dir.join("f.bin").to_string_lossy().to_string();
2404        std::fs::write(dir.join("f.bin"), [0xff, 0x00, 0xfe]).unwrap();
2405
2406        let err = execute_with_ledger(
2407            &substrate,
2408            &ledger,
2409            "write_file",
2410            &json!({ "path": path, "content": "text" }),
2411        )
2412        .await
2413        .unwrap()
2414        .unwrap_err();
2415        assert!(err.contains("cannot modify existing file"), "{err}");
2416        assert_eq!(
2417            std::fs::read(dir.join("f.bin")).unwrap(),
2418            [0xff, 0x00, 0xfe]
2419        );
2420
2421        std::fs::remove_dir_all(&dir).ok();
2422    }
2423
2424    /// (#4) Appending onto a STALE record (file changed on disk after the read)
2425    /// is refused with the stale error until re-read — the append arm gates
2426    /// staleness, not just presence.
2427    #[tokio::test]
2428    async fn append_detects_stale_after_external_change() {
2429        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2430        let ledger = ReadLedger::new();
2431        let dir = fresh_dir("appendstale");
2432        let path = dir.join("f.txt").to_string_lossy().to_string();
2433        std::fs::write(dir.join("f.txt"), "v1").unwrap();
2434
2435        execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
2436            .await
2437            .unwrap()
2438            .unwrap();
2439        std::fs::write(dir.join("f.txt"), "v2 changed").unwrap();
2440
2441        let err = execute_with_ledger(
2442            &substrate,
2443            &ledger,
2444            "write_file",
2445            &json!({ "path": path, "content": " appended", "append": true }),
2446        )
2447        .await
2448        .unwrap()
2449        .unwrap_err();
2450        assert!(err.contains("changed since you last read it"), "{err}");
2451
2452        std::fs::remove_dir_all(&dir).ok();
2453    }
2454
2455    /// (#5) The read-before-edit gate sits above BOTH edit arms: a `replace_all`
2456    /// edit is licensed by a read, self-records, and is refused as stale after an
2457    /// external change — exactly like the unique-match arm.
2458    #[tokio::test]
2459    async fn replace_all_edit_is_gated_by_the_ledger() {
2460        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2461        let ledger = ReadLedger::new();
2462        let dir = fresh_dir("replaceallgate");
2463        let path = dir.join("f.txt").to_string_lossy().to_string();
2464        std::fs::write(dir.join("f.txt"), "a a a").unwrap();
2465
2466        execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
2467            .await
2468            .unwrap()
2469            .unwrap();
2470        let ok = execute_with_ledger(
2471            &substrate,
2472            &ledger,
2473            "edit_file",
2474            &json!({ "path": path, "old_text": "a", "new_text": "b", "replace_all": true }),
2475        )
2476        .await
2477        .unwrap()
2478        .unwrap();
2479        assert_eq!(ok["replacements"], 3);
2480
2481        // Self-recorded: a second replace_all with no read succeeds.
2482        let ok2 = execute_with_ledger(
2483            &substrate,
2484            &ledger,
2485            "edit_file",
2486            &json!({ "path": path, "old_text": "b", "new_text": "c", "replace_all": true }),
2487        )
2488        .await
2489        .unwrap()
2490        .unwrap();
2491        assert_eq!(ok2["replacements"], 3);
2492
2493        // External change → the replace_all edit is refused as stale.
2494        std::fs::write(dir.join("f.txt"), "c c c c").unwrap();
2495        let err = execute_with_ledger(
2496            &substrate,
2497            &ledger,
2498            "edit_file",
2499            &json!({ "path": path, "old_text": "c", "new_text": "d", "replace_all": true }),
2500        )
2501        .await
2502        .unwrap()
2503        .unwrap_err();
2504        assert!(err.contains("changed since you last read it"), "{err}");
2505
2506        std::fs::remove_dir_all(&dir).ok();
2507    }
2508
2509    /// (#6a) Every builtin name is HANDLED (returns `Some`), and no error it
2510    /// produces — including the deliberate gate errors — starts with "unknown
2511    /// tool". That prefix is the ONLY fall-through trigger at every call site, so
2512    /// this pins that a builtin can never be re-dispatched to a second ledger.
2513    #[tokio::test]
2514    async fn builtin_names_never_return_unknown_tool_prefix() {
2515        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2516        let ledger = ReadLedger::new();
2517
2518        for name in [
2519            "read_file",
2520            "write_file",
2521            "edit_file",
2522            "list_dir",
2523            "find_files",
2524            "grep_files",
2525            "calculate",
2526        ] {
2527            let result = execute_with_ledger(&substrate, &ledger, name, &json!({})).await;
2528            let inner = result.unwrap_or_else(|| panic!("{name} must be handled, not None"));
2529            if let Err(e) = &inner {
2530                assert!(
2531                    !e.starts_with("unknown tool"),
2532                    "{name} error must not start with 'unknown tool': {e}"
2533                );
2534            }
2535        }
2536
2537        // Only a genuinely unknown name is None (→ "unknown tool" at the caller).
2538        assert!(
2539            execute_with_ledger(&substrate, &ledger, "no_such_tool", &json!({}))
2540                .await
2541                .is_none()
2542        );
2543
2544        // The deliberate gate errors also never masquerade as "unknown tool".
2545        let dir = fresh_dir("unknownprefix");
2546        let path = dir.join("f.txt").to_string_lossy().to_string();
2547        std::fs::write(dir.join("f.txt"), "content").unwrap();
2548        let edit_err = execute_with_ledger(
2549            &substrate,
2550            &ledger,
2551            "edit_file",
2552            &json!({ "path": path, "old_text": "content", "new_text": "x" }),
2553        )
2554        .await
2555        .unwrap()
2556        .unwrap_err();
2557        assert!(!edit_err.starts_with("unknown tool"), "{edit_err}");
2558        assert!(edit_err.contains("before editing it"), "{edit_err}");
2559        let write_err = execute_with_ledger(
2560            &substrate,
2561            &ledger,
2562            "write_file",
2563            &json!({ "path": path, "content": "y" }),
2564        )
2565        .await
2566        .unwrap()
2567        .unwrap_err();
2568        assert!(!write_err.starts_with("unknown tool"), "{write_err}");
2569
2570        std::fs::remove_dir_all(&dir).ok();
2571    }
2572
2573    /// (#7) Lexical `.` components are normalized in the ledger key, so rooted
2574    /// `./x` and `x` paths do not alias into a spurious Unread.
2575    #[test]
2576    fn ledger_normalizes_dot_components() {
2577        let ledger = ReadLedger::new();
2578        ledger.record("./src/x.rs", "content", true);
2579        assert_eq!(ledger.check("src/x.rs", "content"), ReadState::FreshFull);
2580        assert_eq!(ledger.check("./src/x.rs", "content"), ReadState::FreshFull);
2581
2582        let ledger2 = ReadLedger::new();
2583        ledger2.record("src/x.rs", "content", true);
2584        assert_eq!(ledger2.check("./src/x.rs", "content"), ReadState::FreshFull);
2585
2586        // Unrelated paths are still Unread, while adapter-rooted interior dots
2587        // normalize to the same ledger key.
2588        assert_eq!(ledger.check("other.rs", "content"), ReadState::Unread);
2589        let ledger3 = ReadLedger::new();
2590        ledger3.record("/root/a/./b", "c", true);
2591        assert_eq!(ledger3.check("/root/a/b", "c"), ReadState::FreshFull);
2592    }
2593
2594    #[test]
2595    fn session_ledgers_share_mutation_locks_without_sharing_observations() {
2596        let ledgers = SessionReadLedgers::new();
2597        let first = ledgers.ledger_for(Some("first"));
2598        let second = ledgers.ledger_for(Some("second"));
2599        first.record("f.txt", "first view", true);
2600
2601        assert_eq!(
2602            second.check("f.txt", "first view"),
2603            ReadState::Unread,
2604            "one session's read must not authorize another session"
2605        );
2606        assert!(Arc::ptr_eq(
2607            &first.mutation_lock("f.txt"),
2608            &second.mutation_lock("f.txt")
2609        ));
2610    }
2611
2612    #[tokio::test]
2613    async fn indentation_error_provides_exact_bytes_for_a_literal_retry_without_mutation() {
2614        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2615        let dir = fresh_dir("indentation-retry");
2616        let path = dir.join("test.py").to_string_lossy().to_string();
2617        for (content, old_text, exact) in [
2618            (
2619                "# tests\nif __name__ == '__main__':\n    unittest.main()\n",
2620                "    if __name__ == '__main__':\n        unittest.main()",
2621                "if __name__ == '__main__':\n    unittest.main()",
2622            ),
2623            (
2624                "# tests\r\nif __name__ == '__main__':\r\n\tunittest.main()\r\n",
2625                "    if __name__ == '__main__':\n    unittest.main()",
2626                "if __name__ == '__main__':\r\n\tunittest.main()",
2627            ),
2628            ("α\nβ\n", "  β\n", "β\n"),
2629        ] {
2630            std::fs::write(&path, content).unwrap();
2631            let ledger = ReadLedger::new();
2632            exec_read_file(&substrate, Some(&ledger), &json!({"path":path}))
2633                .await
2634                .unwrap();
2635            let error = exec_edit_file(
2636                &substrate,
2637                Some(&ledger),
2638                &json!({
2639                    "path":path, "old_text":old_text, "new_text":"WRONG"
2640                }),
2641            )
2642            .await
2643            .unwrap_err();
2644            assert_eq!(std::fs::read_to_string(&path).unwrap(), content);
2645            let suggested = error
2646                .split_once(HINT_BEGIN)
2647                .and_then(|(_, rest)| rest.split_once(HINT_END))
2648                .expect(&error)
2649                .0
2650                .to_string();
2651            assert_eq!(suggested, exact);
2652            exec_edit_file(
2653                &substrate,
2654                Some(&ledger),
2655                &json!({
2656                    "path":path, "old_text":suggested, "new_text":"replacement"
2657                }),
2658            )
2659            .await
2660            .unwrap();
2661            assert_eq!(
2662                std::fs::read_to_string(&path).unwrap(),
2663                content.replacen(exact, "replacement", 1)
2664            );
2665        }
2666        std::fs::remove_dir_all(dir).unwrap();
2667    }
2668
2669    #[test]
2670    fn indentation_hints_do_not_guess_among_duplicates_or_unrelated_text() {
2671        assert!(indentation_hint("  call()\n\tcall()\n", "    call()").is_none());
2672        assert_eq!(
2673            indentation_hint("a\n  call()\n", "call()"),
2674            Some(("  call()", 1, 2))
2675        );
2676        assert!(indentation_hint("    call()\n", "  different()").is_none());
2677        assert!(indentation_hint("  \n", " \n").is_none());
2678        assert!(indentation_hint(&"line\n".repeat(13), &"  line\n".repeat(13)).is_none());
2679        assert!(indentation_hint(&"a".repeat(2048), &format!(" {}", "a".repeat(2048))).is_none());
2680    }
2681
2682    #[tokio::test]
2683    async fn indentation_hints_require_full_fresh_observation_and_a_single_edit() {
2684        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2685        let dir = fresh_dir("indentation-guards");
2686        let path = dir.join("f.txt").to_string_lossy().to_string();
2687        let content = "first\ncall()\n";
2688        for (partial, stale, replace_all) in [
2689            (true, false, false),
2690            (false, true, false),
2691            (false, false, true),
2692        ] {
2693            std::fs::write(&path, content).unwrap();
2694            let ledger = ReadLedger::new();
2695            let mut read = json!({"path":path});
2696            if partial {
2697                read["limit"] = json!(1);
2698            }
2699            exec_read_file(&substrate, Some(&ledger), &read)
2700                .await
2701                .unwrap();
2702            if stale {
2703                std::fs::write(&path, "changed\ncall()\n").unwrap();
2704            }
2705            let before = std::fs::read_to_string(&path).unwrap();
2706            let error = exec_edit_file(&substrate, Some(&ledger), &json!({
2707                "path":path, "old_text":"    call()", "new_text":"WRONG", "replace_all":replace_all
2708            })).await.unwrap_err();
2709            assert!(!error.contains(HINT_BEGIN), "{error}");
2710            assert_eq!(std::fs::read_to_string(&path).unwrap(), before);
2711        }
2712        std::fs::remove_dir_all(dir).unwrap();
2713    }
2714
2715    #[tokio::test]
2716    async fn a_paged_read_keeps_recovery_and_hints_for_the_lines_it_showed() {
2717        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2718        let dir = fresh_dir("paged-recovery");
2719        let path = dir.join("big.py").to_string_lossy().to_string();
2720        let content = "a = 1\n    b = 2\nc = 3\n    d = 4\n";
2721        std::fs::write(&path, content).unwrap();
2722        let ledger = ReadLedger::new();
2723        // A page of lines 1-2 only.
2724        exec_read_file(
2725            &substrate,
2726            Some(&ledger),
2727            &json!({"path": path, "limit": 2}),
2728        )
2729        .await
2730        .unwrap();
2731        // A numbered paste of a line the page showed is recovered …
2732        exec_edit_file(
2733            &substrate,
2734            Some(&ledger),
2735            &json!({"path": path, "old_text": "     1\ta = 1", "new_text": "a = 10"}),
2736        )
2737        .await
2738        .unwrap();
2739        exec_read_file(
2740            &substrate,
2741            Some(&ledger),
2742            &json!({"path": path, "limit": 2}),
2743        )
2744        .await
2745        .unwrap();
2746        // … an indentation hint is offered for a line it showed …
2747        let error = exec_edit_file(
2748            &substrate,
2749            Some(&ledger),
2750            &json!({"path": path, "old_text": "a = 10\n  b = 2", "new_text": "x"}),
2751        )
2752        .await
2753        .unwrap_err();
2754        assert!(
2755            error.contains(&format!("{HINT_BEGIN}a = 10\n    b = 2{HINT_END}")),
2756            "{error}"
2757        );
2758        // … and neither reaches a line it did not show.
2759        let error = exec_edit_file(
2760            &substrate,
2761            Some(&ledger),
2762            &json!({"path": path, "old_text": "c = 3\n  d = 4", "new_text": "x"}),
2763        )
2764        .await
2765        .unwrap_err();
2766        assert!(!error.contains(HINT_BEGIN), "{error}");
2767        let error = exec_edit_file(
2768            &substrate,
2769            Some(&ledger),
2770            &json!({"path": path, "old_text": "     3\tc = 3", "new_text": "x"}),
2771        )
2772        .await
2773        .unwrap_err();
2774        assert!(error.contains("not found"), "{error}");
2775        std::fs::remove_dir_all(dir).unwrap();
2776    }
2777
2778    #[tokio::test]
2779    async fn a_page_keeps_its_recoveries_across_an_edit() {
2780        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2781        let dir = fresh_dir("paged-after-edit");
2782        let path = dir.join("f.py").to_string_lossy().to_string();
2783        std::fs::write(&path, "a = 1\nb = 2\nc = 3\nd = 4\ne = 5\n").unwrap();
2784        let ledger = ReadLedger::new();
2785        exec_read_file(
2786            &substrate,
2787            Some(&ledger),
2788            &json!({"path": path, "limit": 4}),
2789        )
2790        .await
2791        .unwrap();
2792        // One edit that adds a line on the page …
2793        exec_edit_file(
2794            &substrate,
2795            Some(&ledger),
2796            &json!({"path": path, "old_text": "b = 2", "new_text": "b = 2\nb2 = 2"}),
2797        )
2798        .await
2799        .unwrap();
2800        // … and a numbered paste of a later line on the same page, now line 5,
2801        // is still recovered without a re-read.
2802        exec_edit_file(
2803            &substrate,
2804            Some(&ledger),
2805            &json!({"path": path, "old_text": "     5\td = 4", "new_text": "d = 40"}),
2806        )
2807        .await
2808        .unwrap();
2809        assert_eq!(
2810            std::fs::read_to_string(&path).unwrap(),
2811            "a = 1\nb = 2\nb2 = 2\nc = 3\nd = 40\ne = 5\n"
2812        );
2813        // Line 6 was never on the page.
2814        let err = exec_edit_file(
2815            &substrate,
2816            Some(&ledger),
2817            &json!({"path": path, "old_text": "     6\te = 5", "new_text": "x"}),
2818        )
2819        .await
2820        .unwrap_err();
2821        assert!(err.contains("not found"), "{err}");
2822        std::fs::remove_dir_all(dir).unwrap();
2823    }
2824
2825    #[test]
2826    fn seen_ranges_move_with_an_edit() {
2827        // Lines 0..4 seen; lines 1..2 replaced by three lines.
2828        assert_eq!(
2829            shift_seen_lines(&[(0, 4)], 1, 2, 4),
2830            [(0, 1), (4, 6), (1, 4)]
2831        );
2832        // An edit outside the seen page leaves it and shifts nothing before it.
2833        assert_eq!(shift_seen_lines(&[(0, 2)], 5, 6, 8), [(0, 2)]);
2834        // A page after the edit shifts; the edited lines were unseen, so stay unseen.
2835        assert_eq!(shift_seen_lines(&[(6, 9)], 1, 3, 2), [(5, 8)]);
2836    }
2837
2838    #[tokio::test]
2839    async fn edit_accepts_exact_numbered_display_after_full_fresh_read() {
2840        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2841        let dir = fresh_dir("numbered-edit");
2842        let path = dir.join("f.txt").to_string_lossy().to_string();
2843        for content in ["first\n\nlast\n", "first\r\n\r\nlast\r\n", "first\n\nlast"] {
2844            std::fs::write(&path, content).unwrap();
2845            let ledger = ReadLedger::new();
2846            let read = exec_read_file(&substrate, Some(&ledger), &json!({"path":path}))
2847                .await
2848                .unwrap();
2849            let display = read["content"].as_str().unwrap();
2850            let result = exec_edit_file(
2851                &substrate,
2852                Some(&ledger),
2853                &json!({
2854                    "path":path, "old_text":display, "new_text":"changed"
2855                }),
2856            )
2857            .await
2858            .unwrap();
2859            assert_eq!(result["removed_display_line_numbers"], true);
2860            // Only display prefixes are removed: omitted trailing LF stays
2861            // outside the replaced range, just as with a literal old_text.
2862            assert_eq!(
2863                std::fs::read_to_string(&path).unwrap(),
2864                if content.ends_with('\n') {
2865                    "changed\n"
2866                } else {
2867                    "changed"
2868                }
2869            );
2870        }
2871        std::fs::write(&path, "α\nβ\tdata\nγ\n").unwrap();
2872        let ledger = ReadLedger::new();
2873        exec_read_file(&substrate, Some(&ledger), &json!({"path":path}))
2874            .await
2875            .unwrap();
2876        exec_edit_file(
2877            &substrate,
2878            Some(&ledger),
2879            &json!({
2880                "path":path, "old_text":"     2\tβ\tdata", "new_text":"δ\tdata"
2881            }),
2882        )
2883        .await
2884        .unwrap();
2885        assert_eq!(std::fs::read_to_string(&path).unwrap(), "α\nδ\tdata\nγ\n");
2886        std::fs::remove_dir_all(dir).unwrap();
2887    }
2888
2889    #[tokio::test]
2890    async fn numbered_edit_keeps_location_freshness_and_uniqueness_guards() {
2891        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2892        let dir = fresh_dir("numbered-edit-guards");
2893        let path = dir.join("f.txt").to_string_lossy().to_string();
2894        for (content, old, replace_all) in [
2895            ("first\nlast\n", "     1\tlast", false),
2896            ("first\nlast\n", "     1\tfirst\n     3\tlast", false),
2897            ("first  \nlast\n", "     1\tfirst", false),
2898            ("same\nsame\n", "     2\tsame", false),
2899            ("first\nlast\n", "     1\tfirst", true),
2900        ] {
2901            std::fs::write(&path, content).unwrap();
2902            let ledger = ReadLedger::new();
2903            exec_read_file(&substrate, Some(&ledger), &json!({"path":path}))
2904                .await
2905                .unwrap();
2906            assert!(exec_edit_file(
2907                &substrate,
2908                Some(&ledger),
2909                &json!({
2910                    "path":path, "old_text":old, "new_text":"changed", "replace_all":replace_all
2911                })
2912            )
2913            .await
2914            .is_err());
2915            assert_eq!(std::fs::read_to_string(&path).unwrap(), content);
2916        }
2917        std::fs::write(&path, "first\nlast\n").unwrap();
2918        let ledger = ReadLedger::new();
2919        exec_read_file(&substrate, Some(&ledger), &json!({"path":path, "limit":1}))
2920            .await
2921            .unwrap();
2922        // A page recovers only the lines it showed: line 2 was not on it.
2923        assert!(exec_edit_file(
2924            &substrate,
2925            Some(&ledger),
2926            &json!({
2927                "path":path, "old_text":"     2\tlast", "new_text":"changed"
2928            })
2929        )
2930        .await
2931        .is_err());
2932        exec_read_file(&substrate, Some(&ledger), &json!({"path":path}))
2933            .await
2934            .unwrap();
2935        std::fs::write(&path, "external change").unwrap();
2936        assert!(exec_edit_file(
2937            &substrate,
2938            Some(&ledger),
2939            &json!({
2940                "path":path, "old_text":"     1\tfirst", "new_text":"changed"
2941            })
2942        )
2943        .await
2944        .unwrap_err()
2945        .contains("changed since"));
2946        // Literal numbered source must not be mistaken for display metadata.
2947        std::fs::write(&path, "     1\tliteral").unwrap();
2948        exec_read_file(&substrate, Some(&ledger), &json!({"path":path}))
2949            .await
2950            .unwrap();
2951        let result = exec_edit_file(
2952            &substrate,
2953            Some(&ledger),
2954            &json!({
2955                "path":path, "old_text":"     1\tliteral", "new_text":"changed"
2956            }),
2957        )
2958        .await
2959        .unwrap();
2960        assert_eq!(result["removed_display_line_numbers"], false);
2961        assert_eq!(std::fs::read_to_string(&path).unwrap(), "changed");
2962        std::fs::remove_dir_all(dir).unwrap();
2963    }
2964
2965    #[tokio::test]
2966    async fn a_multi_line_edit_without_carriage_returns_lands_in_a_crlf_file() {
2967        let dir = tempfile::tempdir().unwrap();
2968        let path = dir.path().join("w.py");
2969        std::fs::write(&path, "a = 1\r\nb = 2\r\nc = 3\r\n").unwrap();
2970        let path = path.to_string_lossy().into_owned();
2971        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2972        let ledger = ReadLedger::new();
2973        execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
2974            .await
2975            .unwrap()
2976            .unwrap();
2977        execute_with_ledger(
2978            &substrate,
2979            &ledger,
2980            "edit_file",
2981            &json!({ "path": path, "old_text": "a = 1\nb = 2", "new_text": "a = 1\nb = 20" }),
2982        )
2983        .await
2984        .unwrap()
2985        .unwrap();
2986        assert_eq!(
2987            std::fs::read_to_string(&path).unwrap(),
2988            "a = 1\r\nb = 20\r\nc = 3\r\n"
2989        );
2990    }
2991
2992    /// (#8) A no-match `old_text` shaped like read_file's line-number prefix gets
2993    /// a targeted hint; an ordinary no-match does not.
2994    #[tokio::test]
2995    async fn edit_no_match_hints_pasted_line_number() {
2996        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2997        let dir = fresh_dir("linenumhint");
2998        let path = dir.join("f.txt").to_string_lossy().to_string();
2999        exec_write_file(
3000            &substrate,
3001            None,
3002            &json!({ "path": path, "content": "hello world" }),
3003        )
3004        .await
3005        .unwrap();
3006
3007        let err = exec_edit_file(
3008            &substrate,
3009            None,
3010            &json!({ "path": path, "old_text": "     1\thello world", "new_text": "hi" }),
3011        )
3012        .await
3013        .unwrap_err();
3014        assert!(err.contains("line-number prefixes"), "{err}");
3015
3016        let plain = exec_edit_file(
3017            &substrate,
3018            None,
3019            &json!({ "path": path, "old_text": "absent", "new_text": "x" }),
3020        )
3021        .await
3022        .unwrap_err();
3023        assert!(!plain.contains("line-number prefixes"), "{plain}");
3024        assert!(plain.contains("old_text not found"), "{plain}");
3025
3026        std::fs::remove_dir_all(&dir).ok();
3027    }
3028
3029    #[tokio::test]
3030    async fn calculate_is_pure() {
3031        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
3032        let out = execute(
3033            &substrate,
3034            "calculate",
3035            &json!({ "expression": "2 + 3 * 4" }),
3036        )
3037        .await
3038        .unwrap()
3039        .unwrap();
3040        assert_eq!(out["result"], 14.0);
3041    }
3042
3043    #[tokio::test]
3044    async fn unknown_tool_returns_none() {
3045        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
3046        assert!(execute(&substrate, "nope", &json!({})).await.is_none());
3047    }
3048
3049    /// Evaluate via the public `calculate` tool contract and return the
3050    /// `result` float, so the test exercises the same path the runtime does.
3051    fn calc(expr: &str) -> f64 {
3052        exec_calculate(&json!({ "expression": expr }))
3053            .unwrap_or_else(|e| panic!("calculate({expr:?}) failed: {e}"))
3054            .get("result")
3055            .and_then(|v| v.as_f64())
3056            .unwrap_or_else(|| panic!("calculate({expr:?}) returned no numeric result"))
3057    }
3058
3059    /// Pins the math semantics of the `calculate` builtin independently of the
3060    /// backend evaluator. The load-bearing case is `^`: this tool is a
3061    /// calculator, so `^` MUST mean exponentiation (2^3 == 8), not the
3062    /// bitwise-XOR meaning it carries in C-family languages. The tool
3063    /// description documents this contract for the model; this test enforces
3064    /// it for the implementation.
3065    #[test]
3066    fn calculate_contract_semantics() {
3067        assert_eq!(calc("2 + 3 * 4"), 14.0, "operator precedence");
3068        assert_eq!(calc("(1 + 2) * 3"), 9.0, "parentheses override precedence");
3069        assert_eq!(calc("2^3"), 8.0, "^ is exponentiation, not XOR");
3070        assert_eq!(calc("2^10"), 1024.0, "^ is exponentiation");
3071        assert_eq!(calc("10 % 3"), 1.0, "modulo");
3072        assert_eq!(calc("-5 + 2"), -3.0, "unary minus");
3073        // Built-in functions (fasteval-native).
3074        assert_eq!(calc("sin(0)"), 0.0, "native function: sin");
3075        assert_eq!(calc("abs(-3)"), 3.0, "native function: abs");
3076        // Functions/constants filled by our namespace shim.
3077        assert_eq!(calc("sqrt(16)"), 4.0, "shim function: sqrt");
3078        assert!((calc("ln(e)") - 1.0).abs() < 1e-12, "shim: ln + e constant");
3079        assert!(
3080            (calc("pi") - std::f64::consts::PI).abs() < 1e-12,
3081            "shim: pi constant"
3082        );
3083    }
3084
3085    #[test]
3086    fn calculate_rejects_invalid_expression() {
3087        assert!(exec_calculate(&json!({ "expression": "2 +" })).is_err());
3088        assert!(exec_calculate(&json!({})).is_err(), "missing parameter");
3089    }
3090}