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/// The refusal for a `write_file` or `edit_file` whose target resolves outside
1247/// `root`, the session's declared basics root (car#3406); `None` for any other
1248/// tool, or a target inside it. The target is resolved the way the write will
1249/// meet it: its deepest existing ancestor through every symlink, then the rest
1250/// of the path, with `..` refused in that rest. A root that does not exist
1251/// refuses every write.
1252pub fn root_refusal(root: &Path, tool: &str, params: &Value) -> Option<String> {
1253    if !matches!(tool, "write_file" | "edit_file") {
1254        return None;
1255    }
1256    let raw = params.get("path").and_then(Value::as_str)?;
1257    let refused = || {
1258        Some(format!(
1259            "{tool} refused: '{raw}' resolves outside the basics root {}",
1260            root.display()
1261        ))
1262    };
1263    let Ok(root) = car_winenv::canonicalize(root) else {
1264        return refused();
1265    };
1266    let Ok(target) = local_resolve(raw) else {
1267        return refused();
1268    };
1269    // The deepest component that exists, judged without following links: a
1270    // dangling symlink exists, and writing through it creates its target, so
1271    // one that does not resolve is refused rather than read as a new name.
1272    let mut existing = target.as_path();
1273    let mut rest: Vec<std::ffi::OsString> = Vec::new();
1274    while std::fs::symlink_metadata(existing).is_err() {
1275        let Some(name) = existing.file_name() else {
1276            return refused();
1277        };
1278        rest.push(name.to_os_string());
1279        let Some(parent) = existing.parent() else {
1280            return refused();
1281        };
1282        existing = parent;
1283    }
1284    let Ok(resolved) = car_winenv::canonicalize(existing) else {
1285        // It exists but does not resolve: a dangling or looping link.
1286        return refused();
1287    };
1288    let mut full = resolved;
1289    for part in rest.iter().rev() {
1290        if part == ".." || part == "." {
1291            return refused();
1292        }
1293        full.push(part);
1294    }
1295    if full.starts_with(&root) {
1296        None
1297    } else {
1298        refused()
1299    }
1300}
1301
1302// ─── Local-path resolution (host CWD), used only on the local fast path ───
1303//
1304// Mirrors `LocalSubstrate::resolve_path` exactly: absolute paths pass through,
1305// relative paths join the host process current_dir(). The directory-walking
1306// convenience tools (list/find/grep) use it directly when the bound substrate
1307// is the host; non-local substrates compose on `run_command` instead.
1308fn local_resolve(path: &str) -> Result<PathBuf, String> {
1309    crate::substrate::LocalSubstrate::resolve_path(path)
1310}
1311
1312fn should_skip_name(name: &str) -> bool {
1313    name.starts_with('.') || matches!(name, "node_modules" | "__pycache__" | "target")
1314}
1315
1316fn is_text_file(path: &Path) -> bool {
1317    matches!(
1318        path.extension().and_then(|v| v.to_str()),
1319        Some(
1320            "c" | "cc"
1321                | "cpp"
1322                | "cs"
1323                | "css"
1324                | "go"
1325                | "h"
1326                | "html"
1327                | "ini"
1328                | "java"
1329                | "js"
1330                | "json"
1331                | "jsx"
1332                | "kt"
1333                | "md"
1334                | "py"
1335                | "rb"
1336                | "rs"
1337                | "sh"
1338                | "sql"
1339                | "toml"
1340                | "ts"
1341                | "tsx"
1342                | "txt"
1343                | "xml"
1344                | "yaml"
1345                | "yml"
1346        )
1347    )
1348}
1349
1350/// `prune`, when given, is asked about every folder before the walk looks
1351/// inside it, and one it refuses is never entered: nothing in it is visited,
1352/// and nothing in it can fail the walk with its path. (A linked entry is never
1353/// entered either way: its metadata is read without following the link.)
1354fn walk_files(
1355    root: &Path,
1356    follow: bool,
1357    prune: Option<&LocalPathGuard>,
1358    visit: &mut dyn FnMut(&Path),
1359) -> Result<(), String> {
1360    if !follow && std::fs::symlink_metadata(root).is_ok_and(|meta| meta.file_type().is_symlink()) {
1361        return Ok(());
1362    }
1363    if root.is_file() {
1364        visit(root);
1365        return Ok(());
1366    }
1367
1368    let read_dir = std::fs::read_dir(root)
1369        .map_err(|e| format!("failed to read dir '{}': {e}", root.display()))?;
1370    for entry in read_dir {
1371        let entry = entry.map_err(|e| format!("failed to read dir entry: {e}"))?;
1372        let path = entry.path();
1373        let file_name = entry.file_name().to_string_lossy().to_string();
1374        if should_skip_name(&file_name) {
1375            continue;
1376        }
1377        if !follow && entry.file_type().is_ok_and(|kind| kind.is_symlink()) {
1378            continue;
1379        }
1380        if prune.is_some_and(|prune| {
1381            entry.file_type().is_ok_and(|kind| kind.is_dir()) && prune(&path).is_some()
1382        }) {
1383            continue;
1384        }
1385        let metadata = entry
1386            .metadata()
1387            .map_err(|e| format!("failed to read metadata for '{}': {e}", path.display()))?;
1388        if metadata.is_dir() {
1389            walk_files(&path, follow, prune, visit)?;
1390        } else if metadata.is_file() {
1391            visit(&path);
1392        }
1393    }
1394    Ok(())
1395}
1396
1397/// Translate a glob to an anchored regex with path-segment awareness so
1398/// recursive path patterns work, not just basenames (F4, audit 2026-07-06):
1399/// - `*`   matches within one path segment (`[^/]*`)
1400/// - `**/` matches any number of leading segments, including none (`(?:.*/)?`)
1401/// - `**`  matches across segments (`.*`)
1402/// - `?`   matches a single non-separator char (`[^/]`)
1403/// A pattern with no `/` is matched against the basename by the caller, so
1404/// `*.rs` still finds `lib.rs` at any depth; a pattern with `/` (e.g.
1405/// `src/**/*.rs`) is matched against the path relative to the search root.
1406fn glob_to_regex(pattern: &str) -> Result<Regex, String> {
1407    let chars: Vec<char> = pattern.chars().collect();
1408    let mut re = String::from("^");
1409    let mut i = 0;
1410    while i < chars.len() {
1411        match chars[i] {
1412            '*' => {
1413                if i + 1 < chars.len() && chars[i + 1] == '*' {
1414                    i += 1; // consume the second '*'
1415                    if i + 1 < chars.len() && chars[i + 1] == '/' {
1416                        i += 1; // consume the '/': `**/` → optional leading segments
1417                        re.push_str("(?:.*/)?");
1418                    } else {
1419                        re.push_str(".*");
1420                    }
1421                } else {
1422                    re.push_str("[^/]*");
1423                }
1424            }
1425            '?' => re.push_str("[^/]"),
1426            c => re.push_str(&regex::escape(&c.to_string())),
1427        }
1428        i += 1;
1429    }
1430    re.push('$');
1431    Regex::new(&re).map_err(|e| format!("invalid glob pattern: {e}"))
1432}
1433
1434/// Pick the string a candidate file is matched against for a glob search: the
1435/// path relative to the search `root` when the pattern contains a separator
1436/// (so `src/**/*.rs` scopes to a subtree), else the basename (so `*.rs` finds
1437/// every match at any depth). Shared by the local directory walk AND the
1438/// non-local `find` path so both substrates honor the same path-glob semantics
1439/// the tool schema advertises — otherwise the sandbox/remote path silently
1440/// returns nothing for a `/`-bearing pattern. (F4, audit 2026-07-06.)
1441fn glob_haystack(pattern_has_sep: bool, full: &str, root: &str) -> String {
1442    if pattern_has_sep {
1443        let full = full.replace('\\', "/");
1444        let root = root.replace('\\', "/");
1445        full.strip_prefix(&root)
1446            .unwrap_or(&full)
1447            .trim_start_matches('/')
1448            .to_string()
1449    } else {
1450        full.rsplit(['/', '\\']).next().unwrap_or(full).to_string()
1451    }
1452}
1453
1454// ─── Non-local composition on run_command ─────────────────────────────────
1455//
1456// For non-host substrates (e.g. a VM bridge) the directory-walking convenience
1457// tools have no host fs to walk, so they compose on the substrate's
1458// `run_command`, mirroring the bridge's "no ls/find/grep — reduce to a shell
1459// command" design. These paths are NOT exercised by existing consumers (which
1460// all default to LocalSubstrate), so they introduce no behavior change there.
1461
1462fn shell_quote(s: &str) -> String {
1463    // POSIX single-quote escaping: ' -> '\''
1464    format!("'{}'", s.replace('\'', "'\\''"))
1465}
1466
1467async fn list_dir_via_command(substrate: &Arc<dyn Substrate>, path: &str) -> Result<Value, String> {
1468    let cmd = format!("ls -1Ap {}", shell_quote(path));
1469    let out = substrate.run_command(&cmd, Some(30.0)).await?;
1470    let entries: Vec<Value> = out
1471        .stdout
1472        .lines()
1473        .filter(|l| !l.is_empty())
1474        .filter(|name| {
1475            let bare = name.trim_end_matches('/');
1476            !should_skip_name(bare)
1477        })
1478        .map(|name| {
1479            let is_dir = name.ends_with('/');
1480            let bare = name.trim_end_matches('/');
1481            json!({
1482                "name": bare,
1483                "path": format!("{}/{}", path.trim_end_matches('/'), bare),
1484                "is_dir": is_dir,
1485                "size_bytes": Value::Null,
1486            })
1487        })
1488        .collect();
1489    Ok(json!({ "path": path, "entries": entries }))
1490}
1491
1492async fn find_files_via_command(
1493    substrate: &Arc<dyn Substrate>,
1494    pattern: &str,
1495    root: &str,
1496    max_results: usize,
1497) -> Result<Value, String> {
1498    // List every file, then apply the SAME matcher the local walk uses, so a
1499    // path glob (`src/**/*.rs`) works here too. `find -name <pattern>` matched
1500    // the basename only and never matched a `/`-bearing pattern — the tool
1501    // schema advertises path globs on every substrate, so the non-local path
1502    // must honor them or silently return nothing. (F4, audit 2026-07-06.)
1503    let cmd = format!("find {} -type f", shell_quote(root));
1504    let out = substrate.run_command(&cmd, Some(30.0)).await?;
1505    let matcher = glob_to_regex(pattern)?;
1506    let pattern_has_sep = pattern.contains('/');
1507    let mut files: Vec<String> = Vec::new();
1508    let mut truncated = false;
1509    for line in out.stdout.lines() {
1510        if line.is_empty() {
1511            continue;
1512        }
1513        let name = line.rsplit(['/', '\\']).next().unwrap_or(line);
1514        if should_skip_name(name) {
1515            continue;
1516        }
1517        if !matcher.is_match(&glob_haystack(pattern_has_sep, line, root)) {
1518            continue;
1519        }
1520        if files.len() >= max_results {
1521            truncated = true;
1522            break;
1523        }
1524        files.push(line.to_string());
1525    }
1526    Ok(json!({
1527        "files": files,
1528        "count": files.len(),
1529        "truncated": truncated,
1530    }))
1531}
1532
1533async fn grep_files_via_command(
1534    substrate: &Arc<dyn Substrate>,
1535    pattern: &str,
1536    root: &str,
1537    max_results: usize,
1538) -> Result<Value, String> {
1539    let cmd = format!("grep -rnE {} {}", shell_quote(pattern), shell_quote(root));
1540    let out = substrate.run_command(&cmd, Some(30.0)).await?;
1541    let mut matches = Vec::new();
1542    for line in out.stdout.lines() {
1543        if matches.len() >= max_results {
1544            break;
1545        }
1546        // grep -rn format: path:line:text
1547        let mut parts = line.splitn(3, ':');
1548        let (Some(p), Some(ln), Some(text)) = (parts.next(), parts.next(), parts.next()) else {
1549            continue;
1550        };
1551        let Ok(line_no) = ln.parse::<usize>() else {
1552            continue;
1553        };
1554        matches.push(json!({ "path": p, "line": line_no, "text": text }));
1555    }
1556    let truncated = matches.len() >= max_results;
1557    Ok(json!({
1558        "matches": matches,
1559        "count": matches.len(),
1560        "truncated": truncated,
1561    }))
1562}
1563
1564#[cfg(test)]
1565mod tests {
1566    use super::*;
1567
1568    /// With `follow_symlinks: false` the walkers never step through a link:
1569    /// a search rooted at a linked directory finds nothing, where the default
1570    /// goes through it as before.
1571    #[cfg(unix)]
1572    #[test]
1573    fn walks_without_following_links_stay_out_of_linked_directories() {
1574        let dir = tempfile::tempdir().unwrap();
1575        let root = dir.path().join("root");
1576        let outside = dir.path().join("outside");
1577        std::fs::create_dir_all(&root).unwrap();
1578        std::fs::create_dir_all(&outside).unwrap();
1579        std::fs::write(outside.join("far.txt"), "x").unwrap();
1580        std::fs::write(root.join("near.txt"), "x").unwrap();
1581        std::os::unix::fs::symlink(&outside, root.join("link")).unwrap();
1582        let mut seen = Vec::new();
1583        walk_files(&root, false, None, &mut |path| {
1584            seen.push(path.to_path_buf())
1585        })
1586        .unwrap();
1587        assert_eq!(seen, vec![root.join("near.txt")]);
1588        let mut through_link = Vec::new();
1589        walk_files(&root.join("link"), false, None, &mut |path| {
1590            through_link.push(path.to_path_buf())
1591        })
1592        .unwrap();
1593        assert!(through_link.is_empty(), "{through_link:?}");
1594        let mut followed = Vec::new();
1595        walk_files(&root.join("link"), true, None, &mut |path| {
1596            followed.push(path.to_path_buf())
1597        })
1598        .unwrap();
1599        assert_eq!(followed, vec![root.join("link").join("far.txt")]);
1600    }
1601
1602    #[test]
1603    fn glob_patterns_match_file_names() {
1604        let regex = glob_to_regex("*.rs").unwrap();
1605        assert!(regex.is_match("lib.rs"));
1606        assert!(!regex.is_match("lib.ts"));
1607    }
1608
1609    /// F4 (audit 2026-07-06): the local walk and the non-local `find` path
1610    /// share `glob_haystack`, so a `/`-bearing pattern matches the path
1611    /// relative to the search root on EVERY substrate (the non-local path used
1612    /// to `find -name` and silently returned nothing for a path glob). This
1613    /// exercises the shared matching logic both branches now depend on.
1614    #[test]
1615    fn glob_haystack_supports_path_and_basename_matching() {
1616        // path pattern → relative-to-root (normalized to '/'); bare → basename
1617        assert_eq!(
1618            glob_haystack(true, "/root/src/inner/deep.rs", "/root"),
1619            "src/inner/deep.rs"
1620        );
1621        assert_eq!(
1622            glob_haystack(false, "/root/src/inner/deep.rs", "/root"),
1623            "deep.rs"
1624        );
1625
1626        // Combined with the translator, a recursive path glob scopes to the
1627        // subtree and rejects the wrong extension — the behavior the non-local
1628        // substrate previously could not produce.
1629        let m = glob_to_regex("src/**/*.rs").unwrap();
1630        assert!(m.is_match(&glob_haystack(true, "/root/src/top.rs", "/root")));
1631        assert!(m.is_match(&glob_haystack(true, "/root/src/inner/deep.rs", "/root")));
1632        assert!(!m.is_match(&glob_haystack(true, "/root/src/inner/note.txt", "/root")));
1633    }
1634
1635    /// F4 (audit 2026-07-06): the file search must support recursive path globs
1636    /// (`src/**/*.rs`), not just basename matching — otherwise the agent cannot
1637    /// scope a search to a subtree and resorts to guessing.
1638    #[tokio::test]
1639    async fn find_files_supports_recursive_path_globs() {
1640        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1641        let dir = std::env::temp_dir().join(format!(
1642            "car-find-glob-{}",
1643            std::time::SystemTime::now()
1644                .duration_since(std::time::UNIX_EPOCH)
1645                .unwrap()
1646                .as_nanos()
1647        ));
1648        std::fs::create_dir_all(dir.join("src").join("inner")).unwrap();
1649        std::fs::write(dir.join("src").join("top.rs"), "x").unwrap();
1650        std::fs::write(dir.join("src").join("inner").join("deep.rs"), "x").unwrap();
1651        std::fs::write(dir.join("src").join("inner").join("note.txt"), "x").unwrap();
1652        let root = dir.to_string_lossy().to_string();
1653
1654        let r = exec_find_files(
1655            &substrate,
1656            &json!({ "path": root, "pattern": "src/**/*.rs" }),
1657            true,
1658            None,
1659        )
1660        .await
1661        .unwrap();
1662        let joined = r["files"]
1663            .as_array()
1664            .unwrap()
1665            .iter()
1666            .map(|v| v.as_str().unwrap())
1667            .collect::<Vec<_>>()
1668            .join("\n");
1669
1670        assert!(
1671            joined.contains("deep.rs"),
1672            "recursive glob missed nested file:\n{joined}"
1673        );
1674        assert!(
1675            joined.contains("top.rs"),
1676            "recursive glob missed top-level file:\n{joined}"
1677        );
1678        assert!(
1679            !joined.contains("note.txt"),
1680            "glob matched the wrong extension:\n{joined}"
1681        );
1682
1683        let _ = std::fs::remove_dir_all(&dir);
1684    }
1685
1686    /// F4 (audit 2026-07-06): the NON-LOCAL substrate path (`find_files_via_command`)
1687    /// must honor recursive path globs too — it lists every file via `run_command`
1688    /// then applies the SAME matcher as the local walk. Previously untested; a mock
1689    /// substrate (`is_local()` defaults to false) with scripted `find` stdout pins
1690    /// it, so a regression that reverts to basename-only `-name` matching is caught.
1691    #[tokio::test]
1692    async fn find_files_non_local_substrate_applies_path_glob() {
1693        use crate::substrate::CommandOutput;
1694
1695        struct RemoteStub {
1696            stdout: String,
1697        }
1698        #[async_trait::async_trait]
1699        impl Substrate for RemoteStub {
1700            fn name(&self) -> &str {
1701                "test-remote"
1702            }
1703            // is_local() defaults to false -> exercises the non-local path.
1704            async fn run_command(
1705                &self,
1706                _cmd: &str,
1707                _timeout_s: Option<f64>,
1708            ) -> Result<CommandOutput, String> {
1709                Ok(CommandOutput {
1710                    stdout: self.stdout.clone(),
1711                    stderr: String::new(),
1712                    exit_code: 0,
1713                })
1714            }
1715            async fn read_text(&self, _path: &str) -> Result<String, String> {
1716                Err("unused".into())
1717            }
1718            async fn write_text(&self, _path: &str, _content: &str) -> Result<(), String> {
1719                Err("unused".into())
1720            }
1721            async fn read_bytes(
1722                &self,
1723                _path: &str,
1724                _offset: Option<u64>,
1725                _len: Option<u64>,
1726            ) -> Result<Vec<u8>, String> {
1727                Err("unused".into())
1728            }
1729            async fn write_bytes(&self, _path: &str, _bytes: &[u8]) -> Result<(), String> {
1730                Err("unused".into())
1731            }
1732        }
1733
1734        // Scripted `find <root> -type f` output — matching + non-matching paths.
1735        let substrate: Arc<dyn Substrate> = Arc::new(RemoteStub {
1736            stdout: [
1737                "/root/src/top.rs",
1738                "/root/src/inner/deep.rs",
1739                "/root/src/inner/note.txt",
1740                "/root/README.md",
1741            ]
1742            .join("\n"),
1743        });
1744
1745        let r = exec_find_files(
1746            &substrate,
1747            &json!({ "path": "/root", "pattern": "src/**/*.rs" }),
1748            true,
1749            None,
1750        )
1751        .await
1752        .unwrap();
1753        let files: Vec<&str> = r["files"]
1754            .as_array()
1755            .unwrap()
1756            .iter()
1757            .map(|v| v.as_str().unwrap())
1758            .collect();
1759
1760        // The recursive path glob scopes to src/ and rejects the wrong extension —
1761        // the exact behavior the non-local path silently lacked before F4.
1762        assert_eq!(files, vec!["/root/src/top.rs", "/root/src/inner/deep.rs"]);
1763        assert_eq!(r["count"], 2);
1764    }
1765
1766    /// Unique nanos-named temp dir for a test, created and returned.
1767    fn fresh_dir(tag: &str) -> PathBuf {
1768        let dir = std::env::temp_dir().join(format!(
1769            "car-agent-basics-{tag}-{}",
1770            std::time::SystemTime::now()
1771                .duration_since(std::time::UNIX_EPOCH)
1772                .unwrap()
1773                .as_nanos()
1774        ));
1775        std::fs::create_dir_all(&dir).unwrap();
1776        dir
1777    }
1778
1779    #[tokio::test]
1780    async fn read_write_roundtrip_against_local_substrate() {
1781        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1782        let dir = fresh_dir("roundtrip");
1783        let path = dir.join("note.txt").to_string_lossy().to_string();
1784
1785        let w = exec_write_file(&substrate, None, &json!({ "path": path, "content": "abc" }))
1786            .await
1787            .unwrap();
1788        assert_eq!(w["bytes_written"], 3);
1789
1790        // The published, ungated execute path retains its historic raw content.
1791        let r = exec_read_file(&substrate, None, &json!({ "path": path }))
1792            .await
1793            .unwrap();
1794        assert_eq!(r["content"], "abc");
1795        assert_eq!(r["size_bytes"], 3);
1796        assert_eq!(r["total_lines"], 1);
1797
1798        // append composes correctly through the substrate
1799        exec_write_file(
1800            &substrate,
1801            None,
1802            &json!({ "path": path, "content": "def", "append": true }),
1803        )
1804        .await
1805        .unwrap();
1806        let r2 = exec_read_file(&substrate, None, &json!({ "path": path }))
1807            .await
1808            .unwrap();
1809        assert_eq!(r2["content"], "abcdef");
1810
1811        // edit unique-match
1812        let e = exec_edit_file(
1813            &substrate,
1814            None,
1815            &json!({ "path": path, "old_text": "abc", "new_text": "XYZ" }),
1816        )
1817        .await
1818        .unwrap();
1819        assert!(e["edited"].is_string());
1820        assert_eq!(e["replacements"], 1);
1821        let r3 = exec_read_file(&substrate, None, &json!({ "path": path }))
1822            .await
1823            .unwrap();
1824        assert_eq!(r3["content"], "XYZdef");
1825
1826        std::fs::remove_dir_all(&dir).ok();
1827    }
1828
1829    /// (a) read output is line-numbered `cat -n` style, 1-based, and when an
1830    /// `offset` is given the numbering starts at `offset + 1` while
1831    /// `size_bytes`/`total_lines` still describe the FULL file.
1832    #[tokio::test]
1833    async fn read_file_output_is_line_numbered() {
1834        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1835        let ledger = ReadLedger::new();
1836        let dir = fresh_dir("linenum");
1837        let path = dir.join("f.txt").to_string_lossy().to_string();
1838        exec_write_file(
1839            &substrate,
1840            None,
1841            &json!({ "path": path, "content": "alpha\nbeta\ngamma" }),
1842        )
1843        .await
1844        .unwrap();
1845
1846        let r = execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1847            .await
1848            .unwrap()
1849            .unwrap();
1850        assert_eq!(r["content"], "     1\talpha\n     2\tbeta\n     3\tgamma");
1851        assert_eq!(r["total_lines"], 3);
1852
1853        // Offset slice: numbering continues from offset + 1, full-file metadata.
1854        let r2 = execute_with_ledger(
1855            &substrate,
1856            &ledger,
1857            "read_file",
1858            &json!({ "path": path, "offset": 1, "limit": 1 }),
1859        )
1860        .await
1861        .unwrap()
1862        .unwrap();
1863        assert_eq!(r2["content"], "     2\tbeta");
1864        assert_eq!(r2["total_lines"], 3);
1865
1866        std::fs::remove_dir_all(&dir).ok();
1867    }
1868
1869    #[tokio::test]
1870    async fn plain_execute_preserves_legacy_raw_read_output() {
1871        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1872        let dir = fresh_dir("rawread");
1873        let path = dir.join("f.txt").to_string_lossy().to_string();
1874        std::fs::write(&path, "alpha\nbeta\n").unwrap();
1875
1876        let result = execute(&substrate, "read_file", &json!({ "path": path }))
1877            .await
1878            .unwrap()
1879            .unwrap();
1880        assert_eq!(result["content"], "alpha\nbeta\n");
1881
1882        std::fs::remove_dir_all(&dir).ok();
1883    }
1884
1885    /// (b) `edit_file` refuses a path the session never read, then succeeds once
1886    /// `read_file` has run — the read-before-edit guard.
1887    #[tokio::test]
1888    async fn edit_requires_prior_read() {
1889        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1890        let ledger = ReadLedger::new();
1891        let dir = fresh_dir("editread");
1892        let path = dir.join("f.txt").to_string_lossy().to_string();
1893        // Create on disk directly — the ledger has no record of this path.
1894        std::fs::write(dir.join("f.txt"), "hello world").unwrap();
1895
1896        let err = execute_with_ledger(
1897            &substrate,
1898            &ledger,
1899            "edit_file",
1900            &json!({ "path": path, "old_text": "hello", "new_text": "hi" }),
1901        )
1902        .await
1903        .unwrap()
1904        .unwrap_err();
1905        assert!(err.contains("before editing it"), "{err}");
1906        assert!(err.contains("read_file"), "{err}");
1907
1908        // Reading licenses the edit.
1909        execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1910            .await
1911            .unwrap()
1912            .unwrap();
1913        let ok = execute_with_ledger(
1914            &substrate,
1915            &ledger,
1916            "edit_file",
1917            &json!({ "path": path, "old_text": "hello", "new_text": "hi" }),
1918        )
1919        .await
1920        .unwrap()
1921        .unwrap();
1922        assert_eq!(ok["replacements"], 1);
1923
1924        std::fs::remove_dir_all(&dir).ok();
1925    }
1926
1927    /// (b) `edit_file` refuses a stale file — one that changed on disk after the
1928    /// session read it — until it is re-read.
1929    #[tokio::test]
1930    async fn edit_detects_stale_file() {
1931        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1932        let ledger = ReadLedger::new();
1933        let dir = fresh_dir("stale");
1934        let path = dir.join("f.txt").to_string_lossy().to_string();
1935        std::fs::write(dir.join("f.txt"), "version one").unwrap();
1936
1937        // Read records the current content; then the file changes underneath.
1938        execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1939            .await
1940            .unwrap()
1941            .unwrap();
1942        std::fs::write(dir.join("f.txt"), "version two changed").unwrap();
1943
1944        let err = execute_with_ledger(
1945            &substrate,
1946            &ledger,
1947            "edit_file",
1948            &json!({ "path": path, "old_text": "version", "new_text": "v" }),
1949        )
1950        .await
1951        .unwrap()
1952        .unwrap_err();
1953        assert!(err.contains("changed since you last read it"), "{err}");
1954
1955        // Re-reading clears the staleness.
1956        execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1957            .await
1958            .unwrap()
1959            .unwrap();
1960        let ok = execute_with_ledger(
1961            &substrate,
1962            &ledger,
1963            "edit_file",
1964            &json!({ "path": path, "old_text": "version", "new_text": "v" }),
1965        )
1966        .await
1967        .unwrap()
1968        .unwrap();
1969        assert!(ok["edited"].is_string());
1970
1971        std::fs::remove_dir_all(&dir).ok();
1972    }
1973
1974    /// (review) CRLF files keep their `\r` in the numbered display so multi-line
1975    /// `old_text` copied from read output matches the on-disk bytes exactly.
1976    /// `str::lines()` would strip the `\r` and make every multi-line edit on a
1977    /// CRLF file unmatchable.
1978    #[tokio::test]
1979    async fn read_file_preserves_crlf_line_endings() {
1980        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1981        let ledger = ReadLedger::new();
1982        let dir = fresh_dir("crlf");
1983        let path = dir.join("f.txt").to_string_lossy().to_string();
1984        std::fs::write(dir.join("f.txt"), "line one\r\nline two\r\n").unwrap();
1985
1986        let out = execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1987            .await
1988            .unwrap()
1989            .unwrap();
1990        let shown = out["content"].as_str().unwrap();
1991        assert_eq!(
1992            shown, "     1\tline one\r\n     2\tline two\r",
1993            "\\r must survive into the numbered display"
1994        );
1995
1996        // The round-trip proof: multi-line old_text reconstructed from the
1997        // display (prefixes stripped, \r kept) matches the file and edits it.
1998        let ok = execute_with_ledger(
1999            &substrate,
2000            &ledger,
2001            "edit_file",
2002            &json!({ "path": path, "old_text": "line one\r\nline two", "new_text": "merged" }),
2003        )
2004        .await
2005        .unwrap()
2006        .unwrap();
2007        assert_eq!(ok["replacements"], 1);
2008
2009        std::fs::remove_dir_all(&dir).ok();
2010    }
2011
2012    /// (review) An empty `old_text` is rejected up front — `matches("")` is
2013    /// char_count+1, so with `replace_all` it would interleave `new_text` at
2014    /// every char boundary (silent whole-file corruption reported as success).
2015    #[tokio::test]
2016    async fn edit_rejects_empty_old_text() {
2017        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2018        let dir = fresh_dir("emptyold");
2019        let path = dir.join("f.txt").to_string_lossy().to_string();
2020        exec_write_file(&substrate, None, &json!({ "path": path, "content": "abc" }))
2021            .await
2022            .unwrap();
2023
2024        for replace_all in [false, true] {
2025            let err = exec_edit_file(
2026                &substrate,
2027                None,
2028                &json!({
2029                    "path": path,
2030                    "old_text": "",
2031                    "new_text": "X",
2032                    "replace_all": replace_all
2033                }),
2034            )
2035            .await
2036            .unwrap_err();
2037            assert!(err.contains("old_text must be non-empty"), "{err}");
2038        }
2039        // The file is untouched.
2040        assert_eq!(std::fs::read_to_string(dir.join("f.txt")).unwrap(), "abc");
2041
2042        std::fs::remove_dir_all(&dir).ok();
2043    }
2044
2045    /// (review) `write_file` over an existing file refuses when the file changed
2046    /// since the session's last read — a blind overwrite would destroy content
2047    /// the session never observed. Re-reading re-licenses the write. This is the
2048    /// staleness half the docs promise alongside the read-first gate.
2049    #[tokio::test]
2050    async fn write_existing_rejects_stale_after_external_change() {
2051        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2052        let ledger = ReadLedger::new();
2053        let dir = fresh_dir("stalewrite");
2054        let path = dir.join("f.txt").to_string_lossy().to_string();
2055        std::fs::write(dir.join("f.txt"), "original").unwrap();
2056
2057        execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
2058            .await
2059            .unwrap()
2060            .unwrap();
2061        // The file changes underneath (external process / shell command).
2062        std::fs::write(dir.join("f.txt"), "changed underneath").unwrap();
2063
2064        let err = execute_with_ledger(
2065            &substrate,
2066            &ledger,
2067            "write_file",
2068            &json!({ "path": path, "content": "clobber" }),
2069        )
2070        .await
2071        .unwrap()
2072        .unwrap_err();
2073        assert!(err.contains("changed since you last read it"), "{err}");
2074
2075        // Append over stale content is refused the same way.
2076        let err = execute_with_ledger(
2077            &substrate,
2078            &ledger,
2079            "write_file",
2080            &json!({ "path": path, "content": " + more", "append": true }),
2081        )
2082        .await
2083        .unwrap()
2084        .unwrap_err();
2085        assert!(err.contains("changed since you last read it"), "{err}");
2086
2087        // Re-reading shows the current bytes and re-licenses the write.
2088        execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
2089            .await
2090            .unwrap()
2091            .unwrap();
2092        execute_with_ledger(
2093            &substrate,
2094            &ledger,
2095            "write_file",
2096            &json!({ "path": path, "content": "rewritten" }),
2097        )
2098        .await
2099        .unwrap()
2100        .unwrap();
2101        assert_eq!(
2102            std::fs::read_to_string(dir.join("f.txt")).unwrap(),
2103            "rewritten"
2104        );
2105
2106        std::fs::remove_dir_all(&dir).ok();
2107    }
2108
2109    /// (c) `replace_all: true` replaces every occurrence and reports the count;
2110    /// the default path still refuses a non-unique match and points at the flag.
2111    #[tokio::test]
2112    async fn replace_all_replaces_every_occurrence() {
2113        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2114        let dir = fresh_dir("replaceall");
2115        let path = dir.join("f.txt").to_string_lossy().to_string();
2116        exec_write_file(
2117            &substrate,
2118            None,
2119            &json!({ "path": path, "content": "a x a x a" }),
2120        )
2121        .await
2122        .unwrap();
2123
2124        // Default (unique) edit refuses the 3-way match and names the remedy.
2125        let err = exec_edit_file(
2126            &substrate,
2127            None,
2128            &json!({ "path": path, "old_text": "a", "new_text": "b" }),
2129        )
2130        .await
2131        .unwrap_err();
2132        assert!(err.contains("replace_all"), "{err}");
2133
2134        // replace_all replaces every occurrence.
2135        let ok = exec_edit_file(
2136            &substrate,
2137            None,
2138            &json!({ "path": path, "old_text": "a", "new_text": "b", "replace_all": true }),
2139        )
2140        .await
2141        .unwrap();
2142        assert_eq!(ok["replacements"], 3);
2143        let r = exec_read_file(&substrate, None, &json!({ "path": path }))
2144            .await
2145            .unwrap();
2146        assert_eq!(r["content"], "b x b x b");
2147
2148        std::fs::remove_dir_all(&dir).ok();
2149    }
2150
2151    /// (d) Creating a brand-new file needs no prior read.
2152    #[tokio::test]
2153    async fn write_new_file_allowed() {
2154        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2155        let ledger = ReadLedger::new();
2156        let dir = fresh_dir("writenew");
2157        let path = dir.join("new.txt").to_string_lossy().to_string();
2158
2159        let ok = execute_with_ledger(
2160            &substrate,
2161            &ledger,
2162            "write_file",
2163            &json!({ "path": path, "content": "fresh" }),
2164        )
2165        .await
2166        .unwrap()
2167        .unwrap();
2168        assert_eq!(ok["bytes_written"], 5);
2169
2170        std::fs::remove_dir_all(&dir).ok();
2171    }
2172
2173    /// (d) Overwriting an EXISTING file the session never read is refused.
2174    #[tokio::test]
2175    async fn write_existing_requires_prior_read() {
2176        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2177        let ledger = ReadLedger::new();
2178        let dir = fresh_dir("writeexisting");
2179        let path = dir.join("f.txt").to_string_lossy().to_string();
2180        std::fs::write(dir.join("f.txt"), "existing content").unwrap();
2181
2182        let err = execute_with_ledger(
2183            &substrate,
2184            &ledger,
2185            "write_file",
2186            &json!({ "path": path, "content": "clobber" }),
2187        )
2188        .await
2189        .unwrap()
2190        .unwrap_err();
2191        assert!(err.contains("before overwriting it"), "{err}");
2192        assert!(err.contains("read_file"), "{err}");
2193
2194        // Reading licenses the overwrite.
2195        execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
2196            .await
2197            .unwrap()
2198            .unwrap();
2199        let ok = execute_with_ledger(
2200            &substrate,
2201            &ledger,
2202            "write_file",
2203            &json!({ "path": path, "content": "clobber" }),
2204        )
2205        .await
2206        .unwrap()
2207        .unwrap();
2208        assert_eq!(ok["bytes_written"], 7);
2209
2210        std::fs::remove_dir_all(&dir).ok();
2211    }
2212
2213    /// (d) A successful write self-records the new content, so a follow-up
2214    /// `edit_file` on the same path is licensed WITHOUT an intervening read —
2215    /// the "session knows the current content" contract the loops rely on.
2216    #[tokio::test]
2217    async fn write_self_records_enabling_edit() {
2218        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2219        let ledger = ReadLedger::new();
2220        let dir = fresh_dir("writeedits");
2221        let path = dir.join("f.txt").to_string_lossy().to_string();
2222
2223        execute_with_ledger(
2224            &substrate,
2225            &ledger,
2226            "write_file",
2227            &json!({ "path": path, "content": "one two three" }),
2228        )
2229        .await
2230        .unwrap()
2231        .unwrap();
2232        // No read_file in between — the write recorded the content.
2233        let ok = execute_with_ledger(
2234            &substrate,
2235            &ledger,
2236            "edit_file",
2237            &json!({ "path": path, "old_text": "two", "new_text": "TWO" }),
2238        )
2239        .await
2240        .unwrap()
2241        .unwrap();
2242        assert_eq!(ok["replacements"], 1);
2243
2244        std::fs::remove_dir_all(&dir).ok();
2245    }
2246
2247    /// (#2) A successful edit self-records the new content, so a SECOND edit with
2248    /// no intervening read is licensed. Deleting the edit self-record makes the
2249    /// ledger stale here and fails this test.
2250    #[tokio::test]
2251    async fn edit_self_records_enabling_second_edit_without_reread() {
2252        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2253        let ledger = ReadLedger::new();
2254        let dir = fresh_dir("editselfrec");
2255        let path = dir.join("f.txt").to_string_lossy().to_string();
2256        std::fs::write(dir.join("f.txt"), "one two three").unwrap();
2257
2258        execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
2259            .await
2260            .unwrap()
2261            .unwrap();
2262        execute_with_ledger(
2263            &substrate,
2264            &ledger,
2265            "edit_file",
2266            &json!({ "path": path, "old_text": "one", "new_text": "1" }),
2267        )
2268        .await
2269        .unwrap()
2270        .unwrap();
2271
2272        // No read between the two edits — the first edit recorded the new content.
2273        let ok = execute_with_ledger(
2274            &substrate,
2275            &ledger,
2276            "edit_file",
2277            &json!({ "path": path, "old_text": "two", "new_text": "2" }),
2278        )
2279        .await
2280        .unwrap()
2281        .unwrap();
2282        assert_eq!(ok["replacements"], 1);
2283        assert_eq!(
2284            std::fs::read_to_string(dir.join("f.txt")).unwrap(),
2285            "1 2 three"
2286        );
2287
2288        std::fs::remove_dir_all(&dir).ok();
2289    }
2290
2291    /// (#3) A sliced read (offset/limit) records the FULL file's hash, not the
2292    /// returned slice — so editing a line OUTSIDE the slice is licensed AND
2293    /// Fresh. If the slice were hashed, `check()` would return Stale.
2294    #[tokio::test]
2295    async fn sliced_read_hashes_full_file_licensing_edit() {
2296        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2297        let ledger = ReadLedger::new();
2298        let dir = fresh_dir("slicedread");
2299        let path = dir.join("f.txt").to_string_lossy().to_string();
2300        std::fs::write(dir.join("f.txt"), "l1\nl2\nl3\nl4").unwrap();
2301
2302        let r = execute_with_ledger(
2303            &substrate,
2304            &ledger,
2305            "read_file",
2306            &json!({ "path": path, "offset": 1, "limit": 1 }),
2307        )
2308        .await
2309        .unwrap()
2310        .unwrap();
2311        assert_eq!(r["content"], "     2\tl2"); // only the slice is returned
2312
2313        // l4 is outside the returned slice; the edit is still licensed + Fresh.
2314        let ok = execute_with_ledger(
2315            &substrate,
2316            &ledger,
2317            "edit_file",
2318            &json!({ "path": path, "old_text": "l4", "new_text": "L4" }),
2319        )
2320        .await
2321        .unwrap()
2322        .unwrap();
2323        assert_eq!(ok["replacements"], 1);
2324
2325        std::fs::remove_dir_all(&dir).ok();
2326    }
2327
2328    /// (#4) Appending onto an EXISTING file the session never read is refused
2329    /// (same read-first guard as an overwrite); reading it licenses the append.
2330    #[tokio::test]
2331    async fn append_to_existing_unread_requires_prior_read() {
2332        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2333        let ledger = ReadLedger::new();
2334        let dir = fresh_dir("appendunread");
2335        let path = dir.join("f.txt").to_string_lossy().to_string();
2336        std::fs::write(dir.join("f.txt"), "existing content").unwrap();
2337
2338        let err = execute_with_ledger(
2339            &substrate,
2340            &ledger,
2341            "write_file",
2342            &json!({ "path": path, "content": " more", "append": true }),
2343        )
2344        .await
2345        .unwrap()
2346        .unwrap_err();
2347        assert!(err.contains("before overwriting it"), "{err}");
2348
2349        execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
2350            .await
2351            .unwrap()
2352            .unwrap();
2353        let ok = execute_with_ledger(
2354            &substrate,
2355            &ledger,
2356            "write_file",
2357            &json!({ "path": path, "content": " more", "append": true }),
2358        )
2359        .await
2360        .unwrap()
2361        .unwrap();
2362        assert_eq!(ok["append"], true);
2363        assert_eq!(
2364            std::fs::read_to_string(dir.join("f.txt")).unwrap(),
2365            "existing content more"
2366        );
2367
2368        std::fs::remove_dir_all(&dir).ok();
2369    }
2370
2371    #[tokio::test]
2372    async fn append_to_existing_empty_file_requires_prior_read() {
2373        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2374        let ledger = ReadLedger::new();
2375        let dir = fresh_dir("appendempty");
2376        let path = dir.join("f.txt").to_string_lossy().to_string();
2377        std::fs::write(dir.join("f.txt"), "").unwrap();
2378
2379        let err = execute_with_ledger(
2380            &substrate,
2381            &ledger,
2382            "write_file",
2383            &json!({ "path": path, "content": "new", "append": true }),
2384        )
2385        .await
2386        .unwrap()
2387        .unwrap_err();
2388        assert!(err.contains("before overwriting it"), "{err}");
2389        assert!(std::fs::read_to_string(dir.join("f.txt"))
2390            .unwrap()
2391            .is_empty());
2392
2393        std::fs::remove_dir_all(&dir).ok();
2394    }
2395
2396    #[tokio::test]
2397    async fn partial_read_cannot_authorize_whole_file_mutation() {
2398        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2399        let ledger = ReadLedger::new();
2400        let dir = fresh_dir("partialread");
2401        let path = dir.join("f.txt").to_string_lossy().to_string();
2402        std::fs::write(dir.join("f.txt"), "first\nsecond\nthird").unwrap();
2403
2404        execute_with_ledger(
2405            &substrate,
2406            &ledger,
2407            "read_file",
2408            &json!({ "path": path, "offset": 0, "limit": 1 }),
2409        )
2410        .await
2411        .unwrap()
2412        .unwrap();
2413
2414        let overwrite = execute_with_ledger(
2415            &substrate,
2416            &ledger,
2417            "write_file",
2418            &json!({ "path": path, "content": "replacement" }),
2419        )
2420        .await
2421        .unwrap()
2422        .unwrap_err();
2423        assert!(overwrite.contains("full current content"), "{overwrite}");
2424
2425        let replace_all = execute_with_ledger(
2426            &substrate,
2427            &ledger,
2428            "edit_file",
2429            &json!({ "path": path, "old_text": "i", "new_text": "I", "replace_all": true }),
2430        )
2431        .await
2432        .unwrap()
2433        .unwrap_err();
2434        assert!(
2435            replace_all.contains("full current content"),
2436            "{replace_all}"
2437        );
2438
2439        // A normal unique edit remains usable after the focused read.
2440        let edit = execute_with_ledger(
2441            &substrate,
2442            &ledger,
2443            "edit_file",
2444            &json!({ "path": path, "old_text": "first", "new_text": "FIRST" }),
2445        )
2446        .await
2447        .unwrap()
2448        .unwrap();
2449        assert_eq!(edit["replacements"], 1);
2450
2451        std::fs::remove_dir_all(&dir).ok();
2452    }
2453
2454    #[tokio::test]
2455    async fn guarded_write_refuses_existing_non_utf8_file() {
2456        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2457        let ledger = ReadLedger::new();
2458        let dir = fresh_dir("nonutf8write");
2459        let path = dir.join("f.bin").to_string_lossy().to_string();
2460        std::fs::write(dir.join("f.bin"), [0xff, 0x00, 0xfe]).unwrap();
2461
2462        let err = execute_with_ledger(
2463            &substrate,
2464            &ledger,
2465            "write_file",
2466            &json!({ "path": path, "content": "text" }),
2467        )
2468        .await
2469        .unwrap()
2470        .unwrap_err();
2471        assert!(err.contains("cannot modify existing file"), "{err}");
2472        assert_eq!(
2473            std::fs::read(dir.join("f.bin")).unwrap(),
2474            [0xff, 0x00, 0xfe]
2475        );
2476
2477        std::fs::remove_dir_all(&dir).ok();
2478    }
2479
2480    /// (#4) Appending onto a STALE record (file changed on disk after the read)
2481    /// is refused with the stale error until re-read — the append arm gates
2482    /// staleness, not just presence.
2483    #[tokio::test]
2484    async fn append_detects_stale_after_external_change() {
2485        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2486        let ledger = ReadLedger::new();
2487        let dir = fresh_dir("appendstale");
2488        let path = dir.join("f.txt").to_string_lossy().to_string();
2489        std::fs::write(dir.join("f.txt"), "v1").unwrap();
2490
2491        execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
2492            .await
2493            .unwrap()
2494            .unwrap();
2495        std::fs::write(dir.join("f.txt"), "v2 changed").unwrap();
2496
2497        let err = execute_with_ledger(
2498            &substrate,
2499            &ledger,
2500            "write_file",
2501            &json!({ "path": path, "content": " appended", "append": true }),
2502        )
2503        .await
2504        .unwrap()
2505        .unwrap_err();
2506        assert!(err.contains("changed since you last read it"), "{err}");
2507
2508        std::fs::remove_dir_all(&dir).ok();
2509    }
2510
2511    /// (#5) The read-before-edit gate sits above BOTH edit arms: a `replace_all`
2512    /// edit is licensed by a read, self-records, and is refused as stale after an
2513    /// external change — exactly like the unique-match arm.
2514    #[tokio::test]
2515    async fn replace_all_edit_is_gated_by_the_ledger() {
2516        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2517        let ledger = ReadLedger::new();
2518        let dir = fresh_dir("replaceallgate");
2519        let path = dir.join("f.txt").to_string_lossy().to_string();
2520        std::fs::write(dir.join("f.txt"), "a a a").unwrap();
2521
2522        execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
2523            .await
2524            .unwrap()
2525            .unwrap();
2526        let ok = execute_with_ledger(
2527            &substrate,
2528            &ledger,
2529            "edit_file",
2530            &json!({ "path": path, "old_text": "a", "new_text": "b", "replace_all": true }),
2531        )
2532        .await
2533        .unwrap()
2534        .unwrap();
2535        assert_eq!(ok["replacements"], 3);
2536
2537        // Self-recorded: a second replace_all with no read succeeds.
2538        let ok2 = execute_with_ledger(
2539            &substrate,
2540            &ledger,
2541            "edit_file",
2542            &json!({ "path": path, "old_text": "b", "new_text": "c", "replace_all": true }),
2543        )
2544        .await
2545        .unwrap()
2546        .unwrap();
2547        assert_eq!(ok2["replacements"], 3);
2548
2549        // External change → the replace_all edit is refused as stale.
2550        std::fs::write(dir.join("f.txt"), "c c c c").unwrap();
2551        let err = execute_with_ledger(
2552            &substrate,
2553            &ledger,
2554            "edit_file",
2555            &json!({ "path": path, "old_text": "c", "new_text": "d", "replace_all": true }),
2556        )
2557        .await
2558        .unwrap()
2559        .unwrap_err();
2560        assert!(err.contains("changed since you last read it"), "{err}");
2561
2562        std::fs::remove_dir_all(&dir).ok();
2563    }
2564
2565    /// (#6a) Every builtin name is HANDLED (returns `Some`), and no error it
2566    /// produces — including the deliberate gate errors — starts with "unknown
2567    /// tool". That prefix is the ONLY fall-through trigger at every call site, so
2568    /// this pins that a builtin can never be re-dispatched to a second ledger.
2569    #[tokio::test]
2570    async fn builtin_names_never_return_unknown_tool_prefix() {
2571        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2572        let ledger = ReadLedger::new();
2573
2574        for name in [
2575            "read_file",
2576            "write_file",
2577            "edit_file",
2578            "list_dir",
2579            "find_files",
2580            "grep_files",
2581            "calculate",
2582        ] {
2583            let result = execute_with_ledger(&substrate, &ledger, name, &json!({})).await;
2584            let inner = result.unwrap_or_else(|| panic!("{name} must be handled, not None"));
2585            if let Err(e) = &inner {
2586                assert!(
2587                    !e.starts_with("unknown tool"),
2588                    "{name} error must not start with 'unknown tool': {e}"
2589                );
2590            }
2591        }
2592
2593        // Only a genuinely unknown name is None (→ "unknown tool" at the caller).
2594        assert!(
2595            execute_with_ledger(&substrate, &ledger, "no_such_tool", &json!({}))
2596                .await
2597                .is_none()
2598        );
2599
2600        // The deliberate gate errors also never masquerade as "unknown tool".
2601        let dir = fresh_dir("unknownprefix");
2602        let path = dir.join("f.txt").to_string_lossy().to_string();
2603        std::fs::write(dir.join("f.txt"), "content").unwrap();
2604        let edit_err = execute_with_ledger(
2605            &substrate,
2606            &ledger,
2607            "edit_file",
2608            &json!({ "path": path, "old_text": "content", "new_text": "x" }),
2609        )
2610        .await
2611        .unwrap()
2612        .unwrap_err();
2613        assert!(!edit_err.starts_with("unknown tool"), "{edit_err}");
2614        assert!(edit_err.contains("before editing it"), "{edit_err}");
2615        let write_err = execute_with_ledger(
2616            &substrate,
2617            &ledger,
2618            "write_file",
2619            &json!({ "path": path, "content": "y" }),
2620        )
2621        .await
2622        .unwrap()
2623        .unwrap_err();
2624        assert!(!write_err.starts_with("unknown tool"), "{write_err}");
2625
2626        std::fs::remove_dir_all(&dir).ok();
2627    }
2628
2629    /// (#7) Lexical `.` components are normalized in the ledger key, so rooted
2630    /// `./x` and `x` paths do not alias into a spurious Unread.
2631    #[test]
2632    fn ledger_normalizes_dot_components() {
2633        let ledger = ReadLedger::new();
2634        ledger.record("./src/x.rs", "content", true);
2635        assert_eq!(ledger.check("src/x.rs", "content"), ReadState::FreshFull);
2636        assert_eq!(ledger.check("./src/x.rs", "content"), ReadState::FreshFull);
2637
2638        let ledger2 = ReadLedger::new();
2639        ledger2.record("src/x.rs", "content", true);
2640        assert_eq!(ledger2.check("./src/x.rs", "content"), ReadState::FreshFull);
2641
2642        // Unrelated paths are still Unread, while adapter-rooted interior dots
2643        // normalize to the same ledger key.
2644        assert_eq!(ledger.check("other.rs", "content"), ReadState::Unread);
2645        let ledger3 = ReadLedger::new();
2646        ledger3.record("/root/a/./b", "c", true);
2647        assert_eq!(ledger3.check("/root/a/b", "c"), ReadState::FreshFull);
2648    }
2649
2650    #[test]
2651    fn session_ledgers_share_mutation_locks_without_sharing_observations() {
2652        let ledgers = SessionReadLedgers::new();
2653        let first = ledgers.ledger_for(Some("first"));
2654        let second = ledgers.ledger_for(Some("second"));
2655        first.record("f.txt", "first view", true);
2656
2657        assert_eq!(
2658            second.check("f.txt", "first view"),
2659            ReadState::Unread,
2660            "one session's read must not authorize another session"
2661        );
2662        assert!(Arc::ptr_eq(
2663            &first.mutation_lock("f.txt"),
2664            &second.mutation_lock("f.txt")
2665        ));
2666    }
2667
2668    #[tokio::test]
2669    async fn indentation_error_provides_exact_bytes_for_a_literal_retry_without_mutation() {
2670        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2671        let dir = fresh_dir("indentation-retry");
2672        let path = dir.join("test.py").to_string_lossy().to_string();
2673        for (content, old_text, exact) in [
2674            (
2675                "# tests\nif __name__ == '__main__':\n    unittest.main()\n",
2676                "    if __name__ == '__main__':\n        unittest.main()",
2677                "if __name__ == '__main__':\n    unittest.main()",
2678            ),
2679            (
2680                "# tests\r\nif __name__ == '__main__':\r\n\tunittest.main()\r\n",
2681                "    if __name__ == '__main__':\n    unittest.main()",
2682                "if __name__ == '__main__':\r\n\tunittest.main()",
2683            ),
2684            ("α\nβ\n", "  β\n", "β\n"),
2685        ] {
2686            std::fs::write(&path, content).unwrap();
2687            let ledger = ReadLedger::new();
2688            exec_read_file(&substrate, Some(&ledger), &json!({"path":path}))
2689                .await
2690                .unwrap();
2691            let error = exec_edit_file(
2692                &substrate,
2693                Some(&ledger),
2694                &json!({
2695                    "path":path, "old_text":old_text, "new_text":"WRONG"
2696                }),
2697            )
2698            .await
2699            .unwrap_err();
2700            assert_eq!(std::fs::read_to_string(&path).unwrap(), content);
2701            let suggested = error
2702                .split_once(HINT_BEGIN)
2703                .and_then(|(_, rest)| rest.split_once(HINT_END))
2704                .expect(&error)
2705                .0
2706                .to_string();
2707            assert_eq!(suggested, exact);
2708            exec_edit_file(
2709                &substrate,
2710                Some(&ledger),
2711                &json!({
2712                    "path":path, "old_text":suggested, "new_text":"replacement"
2713                }),
2714            )
2715            .await
2716            .unwrap();
2717            assert_eq!(
2718                std::fs::read_to_string(&path).unwrap(),
2719                content.replacen(exact, "replacement", 1)
2720            );
2721        }
2722        std::fs::remove_dir_all(dir).unwrap();
2723    }
2724
2725    #[test]
2726    fn indentation_hints_do_not_guess_among_duplicates_or_unrelated_text() {
2727        assert!(indentation_hint("  call()\n\tcall()\n", "    call()").is_none());
2728        assert_eq!(
2729            indentation_hint("a\n  call()\n", "call()"),
2730            Some(("  call()", 1, 2))
2731        );
2732        assert!(indentation_hint("    call()\n", "  different()").is_none());
2733        assert!(indentation_hint("  \n", " \n").is_none());
2734        assert!(indentation_hint(&"line\n".repeat(13), &"  line\n".repeat(13)).is_none());
2735        assert!(indentation_hint(&"a".repeat(2048), &format!(" {}", "a".repeat(2048))).is_none());
2736    }
2737
2738    #[tokio::test]
2739    async fn indentation_hints_require_full_fresh_observation_and_a_single_edit() {
2740        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2741        let dir = fresh_dir("indentation-guards");
2742        let path = dir.join("f.txt").to_string_lossy().to_string();
2743        let content = "first\ncall()\n";
2744        for (partial, stale, replace_all) in [
2745            (true, false, false),
2746            (false, true, false),
2747            (false, false, true),
2748        ] {
2749            std::fs::write(&path, content).unwrap();
2750            let ledger = ReadLedger::new();
2751            let mut read = json!({"path":path});
2752            if partial {
2753                read["limit"] = json!(1);
2754            }
2755            exec_read_file(&substrate, Some(&ledger), &read)
2756                .await
2757                .unwrap();
2758            if stale {
2759                std::fs::write(&path, "changed\ncall()\n").unwrap();
2760            }
2761            let before = std::fs::read_to_string(&path).unwrap();
2762            let error = exec_edit_file(&substrate, Some(&ledger), &json!({
2763                "path":path, "old_text":"    call()", "new_text":"WRONG", "replace_all":replace_all
2764            })).await.unwrap_err();
2765            assert!(!error.contains(HINT_BEGIN), "{error}");
2766            assert_eq!(std::fs::read_to_string(&path).unwrap(), before);
2767        }
2768        std::fs::remove_dir_all(dir).unwrap();
2769    }
2770
2771    #[tokio::test]
2772    async fn a_paged_read_keeps_recovery_and_hints_for_the_lines_it_showed() {
2773        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2774        let dir = fresh_dir("paged-recovery");
2775        let path = dir.join("big.py").to_string_lossy().to_string();
2776        let content = "a = 1\n    b = 2\nc = 3\n    d = 4\n";
2777        std::fs::write(&path, content).unwrap();
2778        let ledger = ReadLedger::new();
2779        // A page of lines 1-2 only.
2780        exec_read_file(
2781            &substrate,
2782            Some(&ledger),
2783            &json!({"path": path, "limit": 2}),
2784        )
2785        .await
2786        .unwrap();
2787        // A numbered paste of a line the page showed is recovered …
2788        exec_edit_file(
2789            &substrate,
2790            Some(&ledger),
2791            &json!({"path": path, "old_text": "     1\ta = 1", "new_text": "a = 10"}),
2792        )
2793        .await
2794        .unwrap();
2795        exec_read_file(
2796            &substrate,
2797            Some(&ledger),
2798            &json!({"path": path, "limit": 2}),
2799        )
2800        .await
2801        .unwrap();
2802        // … an indentation hint is offered for a line it showed …
2803        let error = exec_edit_file(
2804            &substrate,
2805            Some(&ledger),
2806            &json!({"path": path, "old_text": "a = 10\n  b = 2", "new_text": "x"}),
2807        )
2808        .await
2809        .unwrap_err();
2810        assert!(
2811            error.contains(&format!("{HINT_BEGIN}a = 10\n    b = 2{HINT_END}")),
2812            "{error}"
2813        );
2814        // … and neither reaches a line it did not show.
2815        let error = exec_edit_file(
2816            &substrate,
2817            Some(&ledger),
2818            &json!({"path": path, "old_text": "c = 3\n  d = 4", "new_text": "x"}),
2819        )
2820        .await
2821        .unwrap_err();
2822        assert!(!error.contains(HINT_BEGIN), "{error}");
2823        let error = exec_edit_file(
2824            &substrate,
2825            Some(&ledger),
2826            &json!({"path": path, "old_text": "     3\tc = 3", "new_text": "x"}),
2827        )
2828        .await
2829        .unwrap_err();
2830        assert!(error.contains("not found"), "{error}");
2831        std::fs::remove_dir_all(dir).unwrap();
2832    }
2833
2834    #[tokio::test]
2835    async fn a_page_keeps_its_recoveries_across_an_edit() {
2836        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2837        let dir = fresh_dir("paged-after-edit");
2838        let path = dir.join("f.py").to_string_lossy().to_string();
2839        std::fs::write(&path, "a = 1\nb = 2\nc = 3\nd = 4\ne = 5\n").unwrap();
2840        let ledger = ReadLedger::new();
2841        exec_read_file(
2842            &substrate,
2843            Some(&ledger),
2844            &json!({"path": path, "limit": 4}),
2845        )
2846        .await
2847        .unwrap();
2848        // One edit that adds a line on the page …
2849        exec_edit_file(
2850            &substrate,
2851            Some(&ledger),
2852            &json!({"path": path, "old_text": "b = 2", "new_text": "b = 2\nb2 = 2"}),
2853        )
2854        .await
2855        .unwrap();
2856        // … and a numbered paste of a later line on the same page, now line 5,
2857        // is still recovered without a re-read.
2858        exec_edit_file(
2859            &substrate,
2860            Some(&ledger),
2861            &json!({"path": path, "old_text": "     5\td = 4", "new_text": "d = 40"}),
2862        )
2863        .await
2864        .unwrap();
2865        assert_eq!(
2866            std::fs::read_to_string(&path).unwrap(),
2867            "a = 1\nb = 2\nb2 = 2\nc = 3\nd = 40\ne = 5\n"
2868        );
2869        // Line 6 was never on the page.
2870        let err = exec_edit_file(
2871            &substrate,
2872            Some(&ledger),
2873            &json!({"path": path, "old_text": "     6\te = 5", "new_text": "x"}),
2874        )
2875        .await
2876        .unwrap_err();
2877        assert!(err.contains("not found"), "{err}");
2878        std::fs::remove_dir_all(dir).unwrap();
2879    }
2880
2881    #[test]
2882    fn seen_ranges_move_with_an_edit() {
2883        // Lines 0..4 seen; lines 1..2 replaced by three lines.
2884        assert_eq!(
2885            shift_seen_lines(&[(0, 4)], 1, 2, 4),
2886            [(0, 1), (4, 6), (1, 4)]
2887        );
2888        // An edit outside the seen page leaves it and shifts nothing before it.
2889        assert_eq!(shift_seen_lines(&[(0, 2)], 5, 6, 8), [(0, 2)]);
2890        // A page after the edit shifts; the edited lines were unseen, so stay unseen.
2891        assert_eq!(shift_seen_lines(&[(6, 9)], 1, 3, 2), [(5, 8)]);
2892    }
2893
2894    #[tokio::test]
2895    async fn edit_accepts_exact_numbered_display_after_full_fresh_read() {
2896        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2897        let dir = fresh_dir("numbered-edit");
2898        let path = dir.join("f.txt").to_string_lossy().to_string();
2899        for content in ["first\n\nlast\n", "first\r\n\r\nlast\r\n", "first\n\nlast"] {
2900            std::fs::write(&path, content).unwrap();
2901            let ledger = ReadLedger::new();
2902            let read = exec_read_file(&substrate, Some(&ledger), &json!({"path":path}))
2903                .await
2904                .unwrap();
2905            let display = read["content"].as_str().unwrap();
2906            let result = exec_edit_file(
2907                &substrate,
2908                Some(&ledger),
2909                &json!({
2910                    "path":path, "old_text":display, "new_text":"changed"
2911                }),
2912            )
2913            .await
2914            .unwrap();
2915            assert_eq!(result["removed_display_line_numbers"], true);
2916            // Only display prefixes are removed: omitted trailing LF stays
2917            // outside the replaced range, just as with a literal old_text.
2918            assert_eq!(
2919                std::fs::read_to_string(&path).unwrap(),
2920                if content.ends_with('\n') {
2921                    "changed\n"
2922                } else {
2923                    "changed"
2924                }
2925            );
2926        }
2927        std::fs::write(&path, "α\nβ\tdata\nγ\n").unwrap();
2928        let ledger = ReadLedger::new();
2929        exec_read_file(&substrate, Some(&ledger), &json!({"path":path}))
2930            .await
2931            .unwrap();
2932        exec_edit_file(
2933            &substrate,
2934            Some(&ledger),
2935            &json!({
2936                "path":path, "old_text":"     2\tβ\tdata", "new_text":"δ\tdata"
2937            }),
2938        )
2939        .await
2940        .unwrap();
2941        assert_eq!(std::fs::read_to_string(&path).unwrap(), "α\nδ\tdata\nγ\n");
2942        std::fs::remove_dir_all(dir).unwrap();
2943    }
2944
2945    #[tokio::test]
2946    async fn numbered_edit_keeps_location_freshness_and_uniqueness_guards() {
2947        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2948        let dir = fresh_dir("numbered-edit-guards");
2949        let path = dir.join("f.txt").to_string_lossy().to_string();
2950        for (content, old, replace_all) in [
2951            ("first\nlast\n", "     1\tlast", false),
2952            ("first\nlast\n", "     1\tfirst\n     3\tlast", false),
2953            ("first  \nlast\n", "     1\tfirst", false),
2954            ("same\nsame\n", "     2\tsame", false),
2955            ("first\nlast\n", "     1\tfirst", true),
2956        ] {
2957            std::fs::write(&path, content).unwrap();
2958            let ledger = ReadLedger::new();
2959            exec_read_file(&substrate, Some(&ledger), &json!({"path":path}))
2960                .await
2961                .unwrap();
2962            assert!(exec_edit_file(
2963                &substrate,
2964                Some(&ledger),
2965                &json!({
2966                    "path":path, "old_text":old, "new_text":"changed", "replace_all":replace_all
2967                })
2968            )
2969            .await
2970            .is_err());
2971            assert_eq!(std::fs::read_to_string(&path).unwrap(), content);
2972        }
2973        std::fs::write(&path, "first\nlast\n").unwrap();
2974        let ledger = ReadLedger::new();
2975        exec_read_file(&substrate, Some(&ledger), &json!({"path":path, "limit":1}))
2976            .await
2977            .unwrap();
2978        // A page recovers only the lines it showed: line 2 was not on it.
2979        assert!(exec_edit_file(
2980            &substrate,
2981            Some(&ledger),
2982            &json!({
2983                "path":path, "old_text":"     2\tlast", "new_text":"changed"
2984            })
2985        )
2986        .await
2987        .is_err());
2988        exec_read_file(&substrate, Some(&ledger), &json!({"path":path}))
2989            .await
2990            .unwrap();
2991        std::fs::write(&path, "external change").unwrap();
2992        assert!(exec_edit_file(
2993            &substrate,
2994            Some(&ledger),
2995            &json!({
2996                "path":path, "old_text":"     1\tfirst", "new_text":"changed"
2997            })
2998        )
2999        .await
3000        .unwrap_err()
3001        .contains("changed since"));
3002        // Literal numbered source must not be mistaken for display metadata.
3003        std::fs::write(&path, "     1\tliteral").unwrap();
3004        exec_read_file(&substrate, Some(&ledger), &json!({"path":path}))
3005            .await
3006            .unwrap();
3007        let result = exec_edit_file(
3008            &substrate,
3009            Some(&ledger),
3010            &json!({
3011                "path":path, "old_text":"     1\tliteral", "new_text":"changed"
3012            }),
3013        )
3014        .await
3015        .unwrap();
3016        assert_eq!(result["removed_display_line_numbers"], false);
3017        assert_eq!(std::fs::read_to_string(&path).unwrap(), "changed");
3018        std::fs::remove_dir_all(dir).unwrap();
3019    }
3020
3021    #[tokio::test]
3022    async fn a_multi_line_edit_without_carriage_returns_lands_in_a_crlf_file() {
3023        let dir = tempfile::tempdir().unwrap();
3024        let path = dir.path().join("w.py");
3025        std::fs::write(&path, "a = 1\r\nb = 2\r\nc = 3\r\n").unwrap();
3026        let path = path.to_string_lossy().into_owned();
3027        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
3028        let ledger = ReadLedger::new();
3029        execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
3030            .await
3031            .unwrap()
3032            .unwrap();
3033        execute_with_ledger(
3034            &substrate,
3035            &ledger,
3036            "edit_file",
3037            &json!({ "path": path, "old_text": "a = 1\nb = 2", "new_text": "a = 1\nb = 20" }),
3038        )
3039        .await
3040        .unwrap()
3041        .unwrap();
3042        assert_eq!(
3043            std::fs::read_to_string(&path).unwrap(),
3044            "a = 1\r\nb = 20\r\nc = 3\r\n"
3045        );
3046    }
3047
3048    /// (#8) A no-match `old_text` shaped like read_file's line-number prefix gets
3049    /// a targeted hint; an ordinary no-match does not.
3050    #[tokio::test]
3051    async fn edit_no_match_hints_pasted_line_number() {
3052        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
3053        let dir = fresh_dir("linenumhint");
3054        let path = dir.join("f.txt").to_string_lossy().to_string();
3055        exec_write_file(
3056            &substrate,
3057            None,
3058            &json!({ "path": path, "content": "hello world" }),
3059        )
3060        .await
3061        .unwrap();
3062
3063        let err = exec_edit_file(
3064            &substrate,
3065            None,
3066            &json!({ "path": path, "old_text": "     1\thello world", "new_text": "hi" }),
3067        )
3068        .await
3069        .unwrap_err();
3070        assert!(err.contains("line-number prefixes"), "{err}");
3071
3072        let plain = exec_edit_file(
3073            &substrate,
3074            None,
3075            &json!({ "path": path, "old_text": "absent", "new_text": "x" }),
3076        )
3077        .await
3078        .unwrap_err();
3079        assert!(!plain.contains("line-number prefixes"), "{plain}");
3080        assert!(plain.contains("old_text not found"), "{plain}");
3081
3082        std::fs::remove_dir_all(&dir).ok();
3083    }
3084
3085    #[tokio::test]
3086    async fn calculate_is_pure() {
3087        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
3088        let out = execute(
3089            &substrate,
3090            "calculate",
3091            &json!({ "expression": "2 + 3 * 4" }),
3092        )
3093        .await
3094        .unwrap()
3095        .unwrap();
3096        assert_eq!(out["result"], 14.0);
3097    }
3098
3099    #[tokio::test]
3100    async fn unknown_tool_returns_none() {
3101        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
3102        assert!(execute(&substrate, "nope", &json!({})).await.is_none());
3103    }
3104
3105    /// Evaluate via the public `calculate` tool contract and return the
3106    /// `result` float, so the test exercises the same path the runtime does.
3107    fn calc(expr: &str) -> f64 {
3108        exec_calculate(&json!({ "expression": expr }))
3109            .unwrap_or_else(|e| panic!("calculate({expr:?}) failed: {e}"))
3110            .get("result")
3111            .and_then(|v| v.as_f64())
3112            .unwrap_or_else(|| panic!("calculate({expr:?}) returned no numeric result"))
3113    }
3114
3115    /// Pins the math semantics of the `calculate` builtin independently of the
3116    /// backend evaluator. The load-bearing case is `^`: this tool is a
3117    /// calculator, so `^` MUST mean exponentiation (2^3 == 8), not the
3118    /// bitwise-XOR meaning it carries in C-family languages. The tool
3119    /// description documents this contract for the model; this test enforces
3120    /// it for the implementation.
3121    #[test]
3122    fn calculate_contract_semantics() {
3123        assert_eq!(calc("2 + 3 * 4"), 14.0, "operator precedence");
3124        assert_eq!(calc("(1 + 2) * 3"), 9.0, "parentheses override precedence");
3125        assert_eq!(calc("2^3"), 8.0, "^ is exponentiation, not XOR");
3126        assert_eq!(calc("2^10"), 1024.0, "^ is exponentiation");
3127        assert_eq!(calc("10 % 3"), 1.0, "modulo");
3128        assert_eq!(calc("-5 + 2"), -3.0, "unary minus");
3129        // Built-in functions (fasteval-native).
3130        assert_eq!(calc("sin(0)"), 0.0, "native function: sin");
3131        assert_eq!(calc("abs(-3)"), 3.0, "native function: abs");
3132        // Functions/constants filled by our namespace shim.
3133        assert_eq!(calc("sqrt(16)"), 4.0, "shim function: sqrt");
3134        assert!((calc("ln(e)") - 1.0).abs() < 1e-12, "shim: ln + e constant");
3135        assert!(
3136            (calc("pi") - std::f64::consts::PI).abs() < 1e-12,
3137            "shim: pi constant"
3138        );
3139    }
3140
3141    #[test]
3142    fn calculate_rejects_invalid_expression() {
3143        assert!(exec_calculate(&json!({ "expression": "2 +" })).is_err());
3144        assert!(exec_calculate(&json!({})).is_err(), "missing parameter");
3145    }
3146}