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).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(substrate, Some(ledger), tool, params).await
496}
497
498async fn execute_inner(
499    substrate: &Arc<dyn Substrate>,
500    ledger: Option<&ReadLedger>,
501    tool: &str,
502    params: &Value,
503) -> Option<Result<Value, String>> {
504    let result = match tool {
505        "read_file" => exec_read_file(substrate, ledger, params).await,
506        "write_file" => exec_write_file(substrate, ledger, params).await,
507        "edit_file" => exec_edit_file(substrate, ledger, params).await,
508        "list_dir" => exec_list_dir(substrate, params).await,
509        "find_files" => exec_find_files(substrate, params).await,
510        "grep_files" => exec_grep_files(substrate, params).await,
511        "calculate" => exec_calculate(params),
512        _ => return None,
513    };
514    Some(result)
515}
516
517async fn exec_read_file(
518    substrate: &Arc<dyn Substrate>,
519    ledger: Option<&ReadLedger>,
520    params: &Value,
521) -> Result<Value, String> {
522    let path = params
523        .get("path")
524        .and_then(|v| v.as_str())
525        .ok_or("missing 'path' parameter")?;
526    let offset = params.get("offset").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
527    let limit = params
528        .get("limit")
529        .and_then(|v| v.as_u64())
530        .map(|v| v as usize);
531    // Not advertised to models: a caller that shows the result to a model
532    // through a fixed budget passes it, so the read stops where the model's
533    // view would, and the ledger records what was actually shown.
534    let max_output_bytes = params
535        .get("max_output_bytes")
536        .and_then(|v| v.as_u64())
537        .map(|v| v as usize);
538
539    let content = substrate.read_text(path).await?;
540    let size_bytes = content.len();
541    let total_lines = content.lines().count();
542
543    // Number each returned line `cat -n` style so the model can cite exact line
544    // numbers (matching grep_files' `line`). Numbering is 1-based and starts at
545    // `offset + 1` when a slice is requested. The prefixes are display-only —
546    // the tool descriptions warn the model to strip them before reusing text.
547    //
548    // Split on '\n' ONLY — never `str::lines()`, which strips a trailing `\r`.
549    // On a CRLF file that would render an LF-only view, so any multi-line
550    // `old_text` the model builds from read output could never match the
551    // on-disk bytes and every multi-line edit would fail as "not found".
552    // Keeping `\r` in the displayed line (like real `cat -n`) means text
553    // copied across lines round-trips byte-exact. A single trailing empty
554    // segment (file ending in '\n') is dropped, matching `cat -n`.
555    let mut lines: Vec<&str> = content.split('\n').collect();
556    if lines.last() == Some(&"") {
557        lines.pop();
558    }
559    let start = offset.min(lines.len());
560    let end = limit
561        .map(|line_count| (start + line_count).min(lines.len()))
562        .unwrap_or(lines.len());
563
564    // Record a hash of the full current file for staleness detection, but keep
565    // whether the agent actually saw every line. A slice can license a narrow,
566    // unique edit; it cannot license a whole-file overwrite, append, or
567    // replace-all that would change unseen content.
568    let mut truncated = None;
569    let returned = if let Some(ledger) = ledger {
570        let mut shown = Vec::new();
571        let mut used = 0usize;
572        let mut shown_end = start;
573        for (i, line) in lines[start..end].iter().enumerate() {
574            let rendered = format!("{:>6}\t{}", start + i + 1, line);
575            // The joining newline only precedes a line that has a predecessor.
576            let cost = rendered.len() + usize::from(!shown.is_empty());
577            match max_output_bytes {
578                Some(max) if used + cost > max => {
579                    if shown.is_empty() {
580                        // One line longer than the whole budget: show its head,
581                        // and do not count the line as seen.
582                        let mut cut = max.min(rendered.len());
583                        while !rendered.is_char_boundary(cut) {
584                            cut -= 1;
585                        }
586                        shown.push(rendered[..cut].to_string());
587                        truncated = Some(format!(
588                            "line {} is longer than one read can show and was cut after {cut} \
589                             bytes; inspect it with shell (for example `cut -c` or python), \
590                             or call read_file with offset={} to continue after it",
591                            start + i + 1,
592                            start + i + 1
593                        ));
594                    } else {
595                        truncated = Some(format!(
596                            "showing lines {}-{shown_end} of {}; call read_file with \
597                             offset={shown_end} to read further",
598                            start + 1,
599                            lines.len()
600                        ));
601                    }
602                    break;
603                }
604                _ => {}
605            }
606            used += cost;
607            shown.push(rendered);
608            shown_end = start + i + 1;
609        }
610        ledger.record_lines(path, &content, start, shown_end, lines.len());
611        shown.join("\n")
612    } else if offset > 0 || limit.is_some() {
613        lines[start..end].join("\n")
614    } else {
615        content.clone()
616    };
617
618    if let Some(note) = truncated {
619        return Ok(json!({
620            "path": substrate.display_path(path),
621            "truncated": note,
622            "content": returned,
623            "size_bytes": size_bytes,
624            "total_lines": total_lines,
625        }));
626    }
627    Ok(json!({
628        "path": substrate.display_path(path),
629        "content": returned,
630        "size_bytes": size_bytes,
631        "total_lines": total_lines,
632    }))
633}
634
635async fn exec_write_file(
636    substrate: &Arc<dyn Substrate>,
637    ledger: Option<&ReadLedger>,
638    params: &Value,
639) -> Result<Value, String> {
640    let path = params
641        .get("path")
642        .and_then(|v| v.as_str())
643        .ok_or("missing 'path' parameter")?;
644    let content = params
645        .get("content")
646        .and_then(|v| v.as_str())
647        .ok_or("missing 'content' parameter")?;
648    let append = params
649        .get("append")
650        .and_then(|v| v.as_bool())
651        .unwrap_or(false);
652
653    let _mutation_guard = match ledger {
654        Some(ledger) => Some(ledger.mutation_lock(path).lock_owned().await),
655        None => None,
656    };
657
658    let existing = if ledger.is_some() {
659        match substrate.read_text(path).await {
660            Ok(content) => Some(content),
661            Err(read_error) => match substrate.path_state(path).await {
662                crate::substrate::PathState::Missing => None,
663                crate::substrate::PathState::Exists => {
664                    return Err(format!(
665                        "cannot modify existing file '{}' because it cannot be read as UTF-8: {read_error}",
666                        substrate.display_path(path)
667                    ));
668                }
669                crate::substrate::PathState::Unknown(reason) => {
670                    return Err(format!(
671                        "cannot determine whether '{}' is safe to create: {reason}",
672                        substrate.display_path(path)
673                    ));
674                }
675            },
676        }
677    } else {
678        None
679    };
680
681    let combined = if append {
682        // Compose append on top of the substrate's read/write primitives so
683        // behavior is environment-agnostic. A missing file starts empty,
684        // matching the historic OpenOptions(create=true, append=true).
685        let existed = existing.is_some();
686        let existing = match existing {
687            Some(existing) => existing,
688            None if ledger.is_some() => String::new(),
689            None => substrate.read_text(path).await.unwrap_or_default(),
690        };
691        // Read-before-edit + staleness guard: an append that lands on an
692        // existing file the session never read is refused, same as a plain
693        // overwrite — and an append onto content that changed since the last
694        // read is refused as stale (the new bytes would land after content the
695        // session has never seen). A missing file (empty existing) is a
696        // creation and stays ungated.
697        if let Some(ledger) = ledger.filter(|_| existed) {
698            match ledger.check(path, &existing) {
699                ReadState::Unread => {
700                    return Err(read_first_error(
701                        &substrate.display_path(path),
702                        "overwriting",
703                    ));
704                }
705                ReadState::Stale => {
706                    return Err(stale_error(&substrate.display_path(path)));
707                }
708                ReadState::FreshPartial => {
709                    return Err(full_read_error(
710                        &substrate.display_path(path),
711                        "appending to",
712                    ));
713                }
714                ReadState::FreshFull => {}
715            }
716        }
717        let mut combined = existing;
718        combined.push_str(content);
719        substrate.write_text(path, &combined).await?;
720        combined
721    } else {
722        // Read-before-edit + staleness guard: overwriting a file that already
723        // exists but was never read this session is refused (a blind clobber),
724        // and overwriting one that CHANGED since the last read is refused as
725        // stale — the session would silently destroy content it has never
726        // observed (e.g. a change made by its own shell command or an external
727        // process). Re-reading shows the current bytes and re-licenses the
728        // write. Creating a NEW file is always allowed.
729        if let Some(ledger) = ledger {
730            if let Some(existing) = existing {
731                match ledger.check(path, &existing) {
732                    ReadState::Unread => {
733                        return Err(read_first_error(
734                            &substrate.display_path(path),
735                            "overwriting",
736                        ));
737                    }
738                    ReadState::Stale => {
739                        return Err(stale_error(&substrate.display_path(path)));
740                    }
741                    ReadState::FreshPartial => {
742                        return Err(full_read_error(
743                            &substrate.display_path(path),
744                            "overwriting",
745                        ));
746                    }
747                    ReadState::FreshFull => {}
748                }
749            }
750        }
751        substrate.write_text(path, content).await?;
752        content.to_string()
753    };
754
755    // Self-record the new content so a subsequent edit_file is licensed and the
756    // session's snapshot of this path stays current.
757    if let Some(ledger) = ledger {
758        ledger.record(path, &combined, true);
759    }
760
761    Ok(json!({
762        "path": substrate.display_path(path),
763        "bytes_written": content.len(),
764        "append": append,
765    }))
766}
767
768async fn exec_edit_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 old_text = params
778        .get("old_text")
779        .and_then(|v| v.as_str())
780        .ok_or("missing 'old_text' parameter")?;
781    let new_text = params
782        .get("new_text")
783        .and_then(|v| v.as_str())
784        .ok_or("missing 'new_text' parameter")?;
785    let replace_all = params
786        .get("replace_all")
787        .and_then(|v| v.as_bool())
788        .unwrap_or(false);
789
790    let _mutation_guard = match ledger {
791        Some(ledger) => Some(ledger.mutation_lock(path).lock_owned().await),
792        None => None,
793    };
794
795    let content = substrate.read_text(path).await?;
796
797    // Read-before-edit / staleness guard: the session must have read this exact
798    // path, and its content must still match what was read (a write self-records,
799    // so a write→edit chain is fine). (H1/F4-remainder, audit 2026-07-06.)
800    let full_read = if let Some(ledger) = ledger {
801        match ledger.check(path, &content) {
802            ReadState::Unread => {
803                return Err(read_first_error(&substrate.display_path(path), "editing"));
804            }
805            ReadState::Stale => {
806                return Err(stale_error(&substrate.display_path(path)));
807            }
808            ReadState::FreshPartial if replace_all => {
809                return Err(full_read_error(
810                    &substrate.display_path(path),
811                    "replacing every occurrence in",
812                ));
813            }
814            ReadState::FreshPartial => false,
815            ReadState::FreshFull => true,
816        }
817    } else {
818        false
819    };
820
821    // An empty old_text is never a real edit request: `str::matches("")`
822    // yields char_count+1 hits, and with replace_all that would interleave
823    // new_text at every char boundary — silent whole-file corruption reported
824    // as success. Reject up front. (H1/F4-remainder review, audit 2026-07-06.)
825    if old_text.is_empty() {
826        return Err(
827            "old_text must be non-empty — pass the exact existing text to replace \
828             (use write_file to replace a whole file)"
829                .to_string(),
830        );
831    }
832
833    // Literal file bytes always take precedence. Numbered-display recovery is
834    // limited to lines this session read of the current content and to a
835    // single unique edit; existing guards still reject stale reads, ambiguous
836    // matches and replace-all expansion.
837    // Recovery and hints act only on lines this session has seen: every line
838    // after a full read, and the pages it read otherwise — so a large file,
839    // which is read in pages, keeps them.
840    let seen = ledger
841        .map(|ledger| ledger.lines_seen(path, &content))
842        .unwrap_or_default();
843    let recovered = if !replace_all && !content.contains(old_text) {
844        unnumbered_read_text(&content, old_text)
845            .filter(|(_, start, end)| seen_covers(&seen, *start, *end))
846            .map(|(plain, _, _)| plain)
847    } else {
848        None
849    };
850    let old_text = recovered.as_deref().unwrap_or(old_text);
851    // A CRLF file read through a renderer that shows raw text hides the `\r`,
852    // so a model's multi-line old_text arrives with bare `\n`. The only
853    // difference allowed here is the line ending: new_text gets the file's
854    // endings too, and the match must still be unique.
855    let crlf = (!content.contains(old_text)
856        && old_text.contains('\n')
857        && !old_text.contains('\r')
858        && content.contains("\r\n"))
859    .then(|| {
860        (
861            old_text.replace('\n', "\r\n"),
862            new_text.replace("\r\n", "\n").replace('\n', "\r\n"),
863        )
864    })
865    // Uniqueness (or replace_all) is then checked below like any other edit.
866    .filter(|(old, _)| content.contains(old.as_str()));
867    let (old_text, new_text) = match &crlf {
868        Some((old, new)) => (old.as_str(), new.as_str()),
869        None => (old_text, new_text),
870    };
871    let count = content.matches(old_text).count();
872    if count == 0 {
873        let mut msg = format!("old_text not found in '{}'", substrate.display_path(path));
874        if looks_like_pasted_line_number(old_text) {
875            msg.push_str(
876                " — old_text looks like it includes read_file's line-number prefixes; \
877                 strip them and retry",
878            );
879        }
880        if !replace_all {
881            if let Some((candidate, _, _)) = indentation_hint(&content, old_text)
882                .filter(|(_, start, end)| seen_covers(&seen, *start, *end))
883            {
884                // Raw, between marker lines: a model copies what it is shown, and
885                // an escaped (JSON) block is copied with its escapes.
886                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");
887                msg.push_str(HINT_BEGIN);
888                msg.push_str(candidate);
889                msg.push_str(HINT_END);
890            }
891        }
892        return Err(msg);
893    }
894    let new_content = if replace_all {
895        content.replace(old_text, new_text)
896    } else {
897        if count > 1 {
898            return Err(format!(
899                "old_text found {count} times in '{}' and must match uniquely — \
900                 pass `replace_all: true` to replace every occurrence, or add \
901                 surrounding context to old_text so it matches one place",
902                substrate.display_path(path)
903            ));
904        }
905        content.replacen(old_text, new_text, 1)
906    };
907    substrate.write_text(path, &new_content).await?;
908
909    // Self-record the post-edit content so the session's snapshot stays current
910    // and a follow-up edit doesn't spuriously read as stale. After a paged
911    // read, the pages move with the edit: lines before it stay, lines after it
912    // shift by the change in line count, and the edited lines count as seen
913    // when the lines they replaced were — so a second edit on the same page
914    // keeps its recoveries without a re-read.
915    if let Some(ledger) = ledger {
916        if full_read || replace_all {
917            ledger.record(path, &new_content, full_read);
918        } else {
919            let offset = content.find(old_text).unwrap_or(0);
920            let start = content[..offset].matches('\n').count();
921            let old_end = start + old_text.matches('\n').count() + 1;
922            let new_end = start + new_text.matches('\n').count() + 1;
923            let seen = shift_seen_lines(&seen, start, old_end, new_end);
924            ledger.record_seen(path, &new_content, seen);
925        }
926    }
927
928    Ok(json!({
929        "edited": substrate.display_path(path),
930        "diff_summary": format!(
931            "replaced {} lines with {} lines",
932            old_text.lines().count(),
933            new_text.lines().count()
934        ),
935        "replacements": count,
936        "removed_display_line_numbers": recovered.is_some(),
937    }))
938}
939
940async fn exec_list_dir(substrate: &Arc<dyn Substrate>, params: &Value) -> Result<Value, String> {
941    let path = params.get("path").and_then(|v| v.as_str()).unwrap_or(".");
942
943    if !substrate.is_local() {
944        return list_dir_via_command(substrate, path).await;
945    }
946
947    let full_path = local_resolve(path)?;
948    let mut entries = Vec::new();
949
950    let read_dir = std::fs::read_dir(&full_path)
951        .map_err(|e| format!("failed to read dir '{}': {e}", full_path.display()))?;
952    for entry in read_dir {
953        let entry = entry.map_err(|e| format!("failed to read dir entry: {e}"))?;
954        let file_name = entry.file_name().to_string_lossy().to_string();
955        if should_skip_name(&file_name) {
956            continue;
957        }
958        let metadata = entry
959            .metadata()
960            .map_err(|e| format!("failed to read metadata for '{}': {e}", file_name))?;
961        entries.push(json!({
962            "name": file_name,
963            "path": entry.path().display().to_string(),
964            "is_dir": metadata.is_dir(),
965            "size_bytes": if metadata.is_file() { Some(metadata.len()) } else { None::<u64> },
966        }));
967    }
968
969    Ok(json!({
970        "path": full_path.display().to_string(),
971        "entries": entries,
972    }))
973}
974
975async fn exec_find_files(substrate: &Arc<dyn Substrate>, params: &Value) -> Result<Value, String> {
976    let pattern = params
977        .get("pattern")
978        .and_then(|v| v.as_str())
979        .ok_or("missing 'pattern' parameter")?;
980    let root = params.get("path").and_then(|v| v.as_str()).unwrap_or(".");
981    let max_results = params
982        .get("max_results")
983        .and_then(|v| v.as_u64())
984        .unwrap_or(1000) as usize;
985
986    if !substrate.is_local() {
987        return find_files_via_command(substrate, pattern, root, max_results).await;
988    }
989
990    let root_path = local_resolve(root)?;
991    let matcher = glob_to_regex(pattern)?;
992    // A pattern with a path separator is matched against each file's path
993    // relative to the search root (so `src/**/*.rs` scopes to a subtree); a
994    // bare pattern is matched against the basename (so `*.rs` finds every `.rs`
995    // at any depth). Shared with the non-local path via `glob_haystack`. (F4.)
996    let pattern_has_sep = pattern.contains('/');
997    let root_str = root_path.to_string_lossy().to_string();
998    let mut files = Vec::new();
999
1000    walk_files(&root_path, &mut |path| {
1001        if files.len() >= max_results {
1002            return;
1003        }
1004        if let Some(full) = path.to_str() {
1005            if matcher.is_match(&glob_haystack(pattern_has_sep, full, &root_str)) {
1006                files.push(path.display().to_string());
1007            }
1008        }
1009    })?;
1010
1011    Ok(json!({
1012        "files": files,
1013        "count": files.len(),
1014        "truncated": files.len() >= max_results,
1015    }))
1016}
1017
1018async fn exec_grep_files(substrate: &Arc<dyn Substrate>, params: &Value) -> Result<Value, String> {
1019    let pattern = params
1020        .get("pattern")
1021        .and_then(|v| v.as_str())
1022        .ok_or("missing 'pattern' parameter")?;
1023    let root = params.get("path").and_then(|v| v.as_str()).unwrap_or(".");
1024    let max_results = params
1025        .get("max_results")
1026        .and_then(|v| v.as_u64())
1027        .unwrap_or(50) as usize;
1028
1029    if !substrate.is_local() {
1030        return grep_files_via_command(substrate, pattern, root, max_results).await;
1031    }
1032
1033    let root_path = local_resolve(root)?;
1034    let regex = Regex::new(pattern).map_err(|e| format!("invalid regex pattern: {e}"))?;
1035    let mut matches = Vec::new();
1036
1037    walk_files(&root_path, &mut |path| {
1038        if matches.len() >= max_results || !is_text_file(path) {
1039            return;
1040        }
1041        let Ok(content) = std::fs::read_to_string(path) else {
1042            return;
1043        };
1044        if content.len() > MAX_FILE_BYTES {
1045            return;
1046        }
1047        for (idx, line) in content.lines().enumerate() {
1048            if regex.is_match(line) {
1049                matches.push(json!({
1050                    "path": path.display().to_string(),
1051                    "line": idx + 1,
1052                    "text": line,
1053                }));
1054                if matches.len() >= max_results {
1055                    break;
1056                }
1057            }
1058        }
1059    })?;
1060
1061    Ok(json!({
1062        "matches": matches,
1063        "count": matches.len(),
1064        "truncated": matches.len() >= max_results,
1065    }))
1066}
1067
1068fn exec_calculate(params: &Value) -> Result<Value, String> {
1069    let expression = params
1070        .get("expression")
1071        .and_then(|v| v.as_str())
1072        .ok_or("missing 'expression' parameter")?;
1073    // fasteval ships `sin/cos/abs/log/min/max/pi()/e()` and `^` (exponentiation)
1074    // as built-ins, but not `sqrt`, `ln`, or the bare `pi`/`e` constants.
1075    // fasteval consults this namespace only for names it doesn't resolve
1076    // itself, so the shim fills those gaps without shadowing any built-in —
1077    // making the `calculate` tool's supported surface explicit here rather
1078    // than inherited from the dependency.
1079    let mut ns = |name: &str, args: Vec<f64>| -> Option<f64> {
1080        match (name, args.as_slice()) {
1081            ("sqrt", [x]) => Some(x.sqrt()),
1082            ("ln", [x]) => Some(x.ln()),
1083            ("pi", []) => Some(std::f64::consts::PI),
1084            ("e", []) => Some(std::f64::consts::E),
1085            _ => None,
1086        }
1087    };
1088    let result = fasteval::ez_eval(expression, &mut ns)
1089        .map_err(|e| format!("failed to evaluate expression: {e}"))?;
1090    Ok(json!({ "result": result }))
1091}
1092
1093// ─── Local-path resolution (host CWD), used only on the local fast path ───
1094//
1095// Mirrors `LocalSubstrate::resolve_path` exactly: absolute paths pass through,
1096// relative paths join the host process current_dir(). The directory-walking
1097// convenience tools (list/find/grep) use it directly when the bound substrate
1098// is the host; non-local substrates compose on `run_command` instead.
1099fn local_resolve(path: &str) -> Result<PathBuf, String> {
1100    crate::substrate::LocalSubstrate::resolve_path(path)
1101}
1102
1103fn should_skip_name(name: &str) -> bool {
1104    name.starts_with('.') || matches!(name, "node_modules" | "__pycache__" | "target")
1105}
1106
1107fn is_text_file(path: &Path) -> bool {
1108    matches!(
1109        path.extension().and_then(|v| v.to_str()),
1110        Some(
1111            "c" | "cc"
1112                | "cpp"
1113                | "cs"
1114                | "css"
1115                | "go"
1116                | "h"
1117                | "html"
1118                | "ini"
1119                | "java"
1120                | "js"
1121                | "json"
1122                | "jsx"
1123                | "kt"
1124                | "md"
1125                | "py"
1126                | "rb"
1127                | "rs"
1128                | "sh"
1129                | "sql"
1130                | "toml"
1131                | "ts"
1132                | "tsx"
1133                | "txt"
1134                | "xml"
1135                | "yaml"
1136                | "yml"
1137        )
1138    )
1139}
1140
1141fn walk_files(root: &Path, visit: &mut dyn FnMut(&Path)) -> Result<(), String> {
1142    if root.is_file() {
1143        visit(root);
1144        return Ok(());
1145    }
1146
1147    let read_dir = std::fs::read_dir(root)
1148        .map_err(|e| format!("failed to read dir '{}': {e}", root.display()))?;
1149    for entry in read_dir {
1150        let entry = entry.map_err(|e| format!("failed to read dir entry: {e}"))?;
1151        let path = entry.path();
1152        let file_name = entry.file_name().to_string_lossy().to_string();
1153        if should_skip_name(&file_name) {
1154            continue;
1155        }
1156        let metadata = entry
1157            .metadata()
1158            .map_err(|e| format!("failed to read metadata for '{}': {e}", path.display()))?;
1159        if metadata.is_dir() {
1160            walk_files(&path, visit)?;
1161        } else if metadata.is_file() {
1162            visit(&path);
1163        }
1164    }
1165    Ok(())
1166}
1167
1168/// Translate a glob to an anchored regex with path-segment awareness so
1169/// recursive path patterns work, not just basenames (F4, audit 2026-07-06):
1170/// - `*`   matches within one path segment (`[^/]*`)
1171/// - `**/` matches any number of leading segments, including none (`(?:.*/)?`)
1172/// - `**`  matches across segments (`.*`)
1173/// - `?`   matches a single non-separator char (`[^/]`)
1174/// A pattern with no `/` is matched against the basename by the caller, so
1175/// `*.rs` still finds `lib.rs` at any depth; a pattern with `/` (e.g.
1176/// `src/**/*.rs`) is matched against the path relative to the search root.
1177fn glob_to_regex(pattern: &str) -> Result<Regex, String> {
1178    let chars: Vec<char> = pattern.chars().collect();
1179    let mut re = String::from("^");
1180    let mut i = 0;
1181    while i < chars.len() {
1182        match chars[i] {
1183            '*' => {
1184                if i + 1 < chars.len() && chars[i + 1] == '*' {
1185                    i += 1; // consume the second '*'
1186                    if i + 1 < chars.len() && chars[i + 1] == '/' {
1187                        i += 1; // consume the '/': `**/` → optional leading segments
1188                        re.push_str("(?:.*/)?");
1189                    } else {
1190                        re.push_str(".*");
1191                    }
1192                } else {
1193                    re.push_str("[^/]*");
1194                }
1195            }
1196            '?' => re.push_str("[^/]"),
1197            c => re.push_str(&regex::escape(&c.to_string())),
1198        }
1199        i += 1;
1200    }
1201    re.push('$');
1202    Regex::new(&re).map_err(|e| format!("invalid glob pattern: {e}"))
1203}
1204
1205/// Pick the string a candidate file is matched against for a glob search: the
1206/// path relative to the search `root` when the pattern contains a separator
1207/// (so `src/**/*.rs` scopes to a subtree), else the basename (so `*.rs` finds
1208/// every match at any depth). Shared by the local directory walk AND the
1209/// non-local `find` path so both substrates honor the same path-glob semantics
1210/// the tool schema advertises — otherwise the sandbox/remote path silently
1211/// returns nothing for a `/`-bearing pattern. (F4, audit 2026-07-06.)
1212fn glob_haystack(pattern_has_sep: bool, full: &str, root: &str) -> String {
1213    if pattern_has_sep {
1214        let full = full.replace('\\', "/");
1215        let root = root.replace('\\', "/");
1216        full.strip_prefix(&root)
1217            .unwrap_or(&full)
1218            .trim_start_matches('/')
1219            .to_string()
1220    } else {
1221        full.rsplit(['/', '\\']).next().unwrap_or(full).to_string()
1222    }
1223}
1224
1225// ─── Non-local composition on run_command ─────────────────────────────────
1226//
1227// For non-host substrates (e.g. a VM bridge) the directory-walking convenience
1228// tools have no host fs to walk, so they compose on the substrate's
1229// `run_command`, mirroring the bridge's "no ls/find/grep — reduce to a shell
1230// command" design. These paths are NOT exercised by existing consumers (which
1231// all default to LocalSubstrate), so they introduce no behavior change there.
1232
1233fn shell_quote(s: &str) -> String {
1234    // POSIX single-quote escaping: ' -> '\''
1235    format!("'{}'", s.replace('\'', "'\\''"))
1236}
1237
1238async fn list_dir_via_command(substrate: &Arc<dyn Substrate>, path: &str) -> Result<Value, String> {
1239    let cmd = format!("ls -1Ap {}", shell_quote(path));
1240    let out = substrate.run_command(&cmd, Some(30.0)).await?;
1241    let entries: Vec<Value> = out
1242        .stdout
1243        .lines()
1244        .filter(|l| !l.is_empty())
1245        .filter(|name| {
1246            let bare = name.trim_end_matches('/');
1247            !should_skip_name(bare)
1248        })
1249        .map(|name| {
1250            let is_dir = name.ends_with('/');
1251            let bare = name.trim_end_matches('/');
1252            json!({
1253                "name": bare,
1254                "path": format!("{}/{}", path.trim_end_matches('/'), bare),
1255                "is_dir": is_dir,
1256                "size_bytes": Value::Null,
1257            })
1258        })
1259        .collect();
1260    Ok(json!({ "path": path, "entries": entries }))
1261}
1262
1263async fn find_files_via_command(
1264    substrate: &Arc<dyn Substrate>,
1265    pattern: &str,
1266    root: &str,
1267    max_results: usize,
1268) -> Result<Value, String> {
1269    // List every file, then apply the SAME matcher the local walk uses, so a
1270    // path glob (`src/**/*.rs`) works here too. `find -name <pattern>` matched
1271    // the basename only and never matched a `/`-bearing pattern — the tool
1272    // schema advertises path globs on every substrate, so the non-local path
1273    // must honor them or silently return nothing. (F4, audit 2026-07-06.)
1274    let cmd = format!("find {} -type f", shell_quote(root));
1275    let out = substrate.run_command(&cmd, Some(30.0)).await?;
1276    let matcher = glob_to_regex(pattern)?;
1277    let pattern_has_sep = pattern.contains('/');
1278    let mut files: Vec<String> = Vec::new();
1279    let mut truncated = false;
1280    for line in out.stdout.lines() {
1281        if line.is_empty() {
1282            continue;
1283        }
1284        let name = line.rsplit(['/', '\\']).next().unwrap_or(line);
1285        if should_skip_name(name) {
1286            continue;
1287        }
1288        if !matcher.is_match(&glob_haystack(pattern_has_sep, line, root)) {
1289            continue;
1290        }
1291        if files.len() >= max_results {
1292            truncated = true;
1293            break;
1294        }
1295        files.push(line.to_string());
1296    }
1297    Ok(json!({
1298        "files": files,
1299        "count": files.len(),
1300        "truncated": truncated,
1301    }))
1302}
1303
1304async fn grep_files_via_command(
1305    substrate: &Arc<dyn Substrate>,
1306    pattern: &str,
1307    root: &str,
1308    max_results: usize,
1309) -> Result<Value, String> {
1310    let cmd = format!("grep -rnE {} {}", shell_quote(pattern), shell_quote(root));
1311    let out = substrate.run_command(&cmd, Some(30.0)).await?;
1312    let mut matches = Vec::new();
1313    for line in out.stdout.lines() {
1314        if matches.len() >= max_results {
1315            break;
1316        }
1317        // grep -rn format: path:line:text
1318        let mut parts = line.splitn(3, ':');
1319        let (Some(p), Some(ln), Some(text)) = (parts.next(), parts.next(), parts.next()) else {
1320            continue;
1321        };
1322        let Ok(line_no) = ln.parse::<usize>() else {
1323            continue;
1324        };
1325        matches.push(json!({ "path": p, "line": line_no, "text": text }));
1326    }
1327    let truncated = matches.len() >= max_results;
1328    Ok(json!({
1329        "matches": matches,
1330        "count": matches.len(),
1331        "truncated": truncated,
1332    }))
1333}
1334
1335#[cfg(test)]
1336mod tests {
1337    use super::*;
1338
1339    #[test]
1340    fn glob_patterns_match_file_names() {
1341        let regex = glob_to_regex("*.rs").unwrap();
1342        assert!(regex.is_match("lib.rs"));
1343        assert!(!regex.is_match("lib.ts"));
1344    }
1345
1346    /// F4 (audit 2026-07-06): the local walk and the non-local `find` path
1347    /// share `glob_haystack`, so a `/`-bearing pattern matches the path
1348    /// relative to the search root on EVERY substrate (the non-local path used
1349    /// to `find -name` and silently returned nothing for a path glob). This
1350    /// exercises the shared matching logic both branches now depend on.
1351    #[test]
1352    fn glob_haystack_supports_path_and_basename_matching() {
1353        // path pattern → relative-to-root (normalized to '/'); bare → basename
1354        assert_eq!(
1355            glob_haystack(true, "/root/src/inner/deep.rs", "/root"),
1356            "src/inner/deep.rs"
1357        );
1358        assert_eq!(
1359            glob_haystack(false, "/root/src/inner/deep.rs", "/root"),
1360            "deep.rs"
1361        );
1362
1363        // Combined with the translator, a recursive path glob scopes to the
1364        // subtree and rejects the wrong extension — the behavior the non-local
1365        // substrate previously could not produce.
1366        let m = glob_to_regex("src/**/*.rs").unwrap();
1367        assert!(m.is_match(&glob_haystack(true, "/root/src/top.rs", "/root")));
1368        assert!(m.is_match(&glob_haystack(true, "/root/src/inner/deep.rs", "/root")));
1369        assert!(!m.is_match(&glob_haystack(true, "/root/src/inner/note.txt", "/root")));
1370    }
1371
1372    /// F4 (audit 2026-07-06): the file search must support recursive path globs
1373    /// (`src/**/*.rs`), not just basename matching — otherwise the agent cannot
1374    /// scope a search to a subtree and resorts to guessing.
1375    #[tokio::test]
1376    async fn find_files_supports_recursive_path_globs() {
1377        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1378        let dir = std::env::temp_dir().join(format!(
1379            "car-find-glob-{}",
1380            std::time::SystemTime::now()
1381                .duration_since(std::time::UNIX_EPOCH)
1382                .unwrap()
1383                .as_nanos()
1384        ));
1385        std::fs::create_dir_all(dir.join("src").join("inner")).unwrap();
1386        std::fs::write(dir.join("src").join("top.rs"), "x").unwrap();
1387        std::fs::write(dir.join("src").join("inner").join("deep.rs"), "x").unwrap();
1388        std::fs::write(dir.join("src").join("inner").join("note.txt"), "x").unwrap();
1389        let root = dir.to_string_lossy().to_string();
1390
1391        let r = exec_find_files(
1392            &substrate,
1393            &json!({ "path": root, "pattern": "src/**/*.rs" }),
1394        )
1395        .await
1396        .unwrap();
1397        let joined = r["files"]
1398            .as_array()
1399            .unwrap()
1400            .iter()
1401            .map(|v| v.as_str().unwrap())
1402            .collect::<Vec<_>>()
1403            .join("\n");
1404
1405        assert!(
1406            joined.contains("deep.rs"),
1407            "recursive glob missed nested file:\n{joined}"
1408        );
1409        assert!(
1410            joined.contains("top.rs"),
1411            "recursive glob missed top-level file:\n{joined}"
1412        );
1413        assert!(
1414            !joined.contains("note.txt"),
1415            "glob matched the wrong extension:\n{joined}"
1416        );
1417
1418        let _ = std::fs::remove_dir_all(&dir);
1419    }
1420
1421    /// F4 (audit 2026-07-06): the NON-LOCAL substrate path (`find_files_via_command`)
1422    /// must honor recursive path globs too — it lists every file via `run_command`
1423    /// then applies the SAME matcher as the local walk. Previously untested; a mock
1424    /// substrate (`is_local()` defaults to false) with scripted `find` stdout pins
1425    /// it, so a regression that reverts to basename-only `-name` matching is caught.
1426    #[tokio::test]
1427    async fn find_files_non_local_substrate_applies_path_glob() {
1428        use crate::substrate::CommandOutput;
1429
1430        struct RemoteStub {
1431            stdout: String,
1432        }
1433        #[async_trait::async_trait]
1434        impl Substrate for RemoteStub {
1435            fn name(&self) -> &str {
1436                "test-remote"
1437            }
1438            // is_local() defaults to false -> exercises the non-local path.
1439            async fn run_command(
1440                &self,
1441                _cmd: &str,
1442                _timeout_s: Option<f64>,
1443            ) -> Result<CommandOutput, String> {
1444                Ok(CommandOutput {
1445                    stdout: self.stdout.clone(),
1446                    stderr: String::new(),
1447                    exit_code: 0,
1448                })
1449            }
1450            async fn read_text(&self, _path: &str) -> Result<String, String> {
1451                Err("unused".into())
1452            }
1453            async fn write_text(&self, _path: &str, _content: &str) -> Result<(), String> {
1454                Err("unused".into())
1455            }
1456            async fn read_bytes(
1457                &self,
1458                _path: &str,
1459                _offset: Option<u64>,
1460                _len: Option<u64>,
1461            ) -> Result<Vec<u8>, String> {
1462                Err("unused".into())
1463            }
1464            async fn write_bytes(&self, _path: &str, _bytes: &[u8]) -> Result<(), String> {
1465                Err("unused".into())
1466            }
1467        }
1468
1469        // Scripted `find <root> -type f` output — matching + non-matching paths.
1470        let substrate: Arc<dyn Substrate> = Arc::new(RemoteStub {
1471            stdout: [
1472                "/root/src/top.rs",
1473                "/root/src/inner/deep.rs",
1474                "/root/src/inner/note.txt",
1475                "/root/README.md",
1476            ]
1477            .join("\n"),
1478        });
1479
1480        let r = exec_find_files(
1481            &substrate,
1482            &json!({ "path": "/root", "pattern": "src/**/*.rs" }),
1483        )
1484        .await
1485        .unwrap();
1486        let files: Vec<&str> = r["files"]
1487            .as_array()
1488            .unwrap()
1489            .iter()
1490            .map(|v| v.as_str().unwrap())
1491            .collect();
1492
1493        // The recursive path glob scopes to src/ and rejects the wrong extension —
1494        // the exact behavior the non-local path silently lacked before F4.
1495        assert_eq!(files, vec!["/root/src/top.rs", "/root/src/inner/deep.rs"]);
1496        assert_eq!(r["count"], 2);
1497    }
1498
1499    /// Unique nanos-named temp dir for a test, created and returned.
1500    fn fresh_dir(tag: &str) -> PathBuf {
1501        let dir = std::env::temp_dir().join(format!(
1502            "car-agent-basics-{tag}-{}",
1503            std::time::SystemTime::now()
1504                .duration_since(std::time::UNIX_EPOCH)
1505                .unwrap()
1506                .as_nanos()
1507        ));
1508        std::fs::create_dir_all(&dir).unwrap();
1509        dir
1510    }
1511
1512    #[tokio::test]
1513    async fn read_write_roundtrip_against_local_substrate() {
1514        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1515        let dir = fresh_dir("roundtrip");
1516        let path = dir.join("note.txt").to_string_lossy().to_string();
1517
1518        let w = exec_write_file(&substrate, None, &json!({ "path": path, "content": "abc" }))
1519            .await
1520            .unwrap();
1521        assert_eq!(w["bytes_written"], 3);
1522
1523        // The published, ungated execute path retains its historic raw content.
1524        let r = exec_read_file(&substrate, None, &json!({ "path": path }))
1525            .await
1526            .unwrap();
1527        assert_eq!(r["content"], "abc");
1528        assert_eq!(r["size_bytes"], 3);
1529        assert_eq!(r["total_lines"], 1);
1530
1531        // append composes correctly through the substrate
1532        exec_write_file(
1533            &substrate,
1534            None,
1535            &json!({ "path": path, "content": "def", "append": true }),
1536        )
1537        .await
1538        .unwrap();
1539        let r2 = exec_read_file(&substrate, None, &json!({ "path": path }))
1540            .await
1541            .unwrap();
1542        assert_eq!(r2["content"], "abcdef");
1543
1544        // edit unique-match
1545        let e = exec_edit_file(
1546            &substrate,
1547            None,
1548            &json!({ "path": path, "old_text": "abc", "new_text": "XYZ" }),
1549        )
1550        .await
1551        .unwrap();
1552        assert!(e["edited"].is_string());
1553        assert_eq!(e["replacements"], 1);
1554        let r3 = exec_read_file(&substrate, None, &json!({ "path": path }))
1555            .await
1556            .unwrap();
1557        assert_eq!(r3["content"], "XYZdef");
1558
1559        std::fs::remove_dir_all(&dir).ok();
1560    }
1561
1562    /// (a) read output is line-numbered `cat -n` style, 1-based, and when an
1563    /// `offset` is given the numbering starts at `offset + 1` while
1564    /// `size_bytes`/`total_lines` still describe the FULL file.
1565    #[tokio::test]
1566    async fn read_file_output_is_line_numbered() {
1567        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1568        let ledger = ReadLedger::new();
1569        let dir = fresh_dir("linenum");
1570        let path = dir.join("f.txt").to_string_lossy().to_string();
1571        exec_write_file(
1572            &substrate,
1573            None,
1574            &json!({ "path": path, "content": "alpha\nbeta\ngamma" }),
1575        )
1576        .await
1577        .unwrap();
1578
1579        let r = execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1580            .await
1581            .unwrap()
1582            .unwrap();
1583        assert_eq!(r["content"], "     1\talpha\n     2\tbeta\n     3\tgamma");
1584        assert_eq!(r["total_lines"], 3);
1585
1586        // Offset slice: numbering continues from offset + 1, full-file metadata.
1587        let r2 = execute_with_ledger(
1588            &substrate,
1589            &ledger,
1590            "read_file",
1591            &json!({ "path": path, "offset": 1, "limit": 1 }),
1592        )
1593        .await
1594        .unwrap()
1595        .unwrap();
1596        assert_eq!(r2["content"], "     2\tbeta");
1597        assert_eq!(r2["total_lines"], 3);
1598
1599        std::fs::remove_dir_all(&dir).ok();
1600    }
1601
1602    #[tokio::test]
1603    async fn plain_execute_preserves_legacy_raw_read_output() {
1604        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1605        let dir = fresh_dir("rawread");
1606        let path = dir.join("f.txt").to_string_lossy().to_string();
1607        std::fs::write(&path, "alpha\nbeta\n").unwrap();
1608
1609        let result = execute(&substrate, "read_file", &json!({ "path": path }))
1610            .await
1611            .unwrap()
1612            .unwrap();
1613        assert_eq!(result["content"], "alpha\nbeta\n");
1614
1615        std::fs::remove_dir_all(&dir).ok();
1616    }
1617
1618    /// (b) `edit_file` refuses a path the session never read, then succeeds once
1619    /// `read_file` has run — the read-before-edit guard.
1620    #[tokio::test]
1621    async fn edit_requires_prior_read() {
1622        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1623        let ledger = ReadLedger::new();
1624        let dir = fresh_dir("editread");
1625        let path = dir.join("f.txt").to_string_lossy().to_string();
1626        // Create on disk directly — the ledger has no record of this path.
1627        std::fs::write(dir.join("f.txt"), "hello world").unwrap();
1628
1629        let err = execute_with_ledger(
1630            &substrate,
1631            &ledger,
1632            "edit_file",
1633            &json!({ "path": path, "old_text": "hello", "new_text": "hi" }),
1634        )
1635        .await
1636        .unwrap()
1637        .unwrap_err();
1638        assert!(err.contains("before editing it"), "{err}");
1639        assert!(err.contains("read_file"), "{err}");
1640
1641        // Reading licenses the edit.
1642        execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1643            .await
1644            .unwrap()
1645            .unwrap();
1646        let ok = execute_with_ledger(
1647            &substrate,
1648            &ledger,
1649            "edit_file",
1650            &json!({ "path": path, "old_text": "hello", "new_text": "hi" }),
1651        )
1652        .await
1653        .unwrap()
1654        .unwrap();
1655        assert_eq!(ok["replacements"], 1);
1656
1657        std::fs::remove_dir_all(&dir).ok();
1658    }
1659
1660    /// (b) `edit_file` refuses a stale file — one that changed on disk after the
1661    /// session read it — until it is re-read.
1662    #[tokio::test]
1663    async fn edit_detects_stale_file() {
1664        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1665        let ledger = ReadLedger::new();
1666        let dir = fresh_dir("stale");
1667        let path = dir.join("f.txt").to_string_lossy().to_string();
1668        std::fs::write(dir.join("f.txt"), "version one").unwrap();
1669
1670        // Read records the current content; then the file changes underneath.
1671        execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1672            .await
1673            .unwrap()
1674            .unwrap();
1675        std::fs::write(dir.join("f.txt"), "version two changed").unwrap();
1676
1677        let err = execute_with_ledger(
1678            &substrate,
1679            &ledger,
1680            "edit_file",
1681            &json!({ "path": path, "old_text": "version", "new_text": "v" }),
1682        )
1683        .await
1684        .unwrap()
1685        .unwrap_err();
1686        assert!(err.contains("changed since you last read it"), "{err}");
1687
1688        // Re-reading clears the staleness.
1689        execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1690            .await
1691            .unwrap()
1692            .unwrap();
1693        let ok = execute_with_ledger(
1694            &substrate,
1695            &ledger,
1696            "edit_file",
1697            &json!({ "path": path, "old_text": "version", "new_text": "v" }),
1698        )
1699        .await
1700        .unwrap()
1701        .unwrap();
1702        assert!(ok["edited"].is_string());
1703
1704        std::fs::remove_dir_all(&dir).ok();
1705    }
1706
1707    /// (review) CRLF files keep their `\r` in the numbered display so multi-line
1708    /// `old_text` copied from read output matches the on-disk bytes exactly.
1709    /// `str::lines()` would strip the `\r` and make every multi-line edit on a
1710    /// CRLF file unmatchable.
1711    #[tokio::test]
1712    async fn read_file_preserves_crlf_line_endings() {
1713        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1714        let ledger = ReadLedger::new();
1715        let dir = fresh_dir("crlf");
1716        let path = dir.join("f.txt").to_string_lossy().to_string();
1717        std::fs::write(dir.join("f.txt"), "line one\r\nline two\r\n").unwrap();
1718
1719        let out = execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1720            .await
1721            .unwrap()
1722            .unwrap();
1723        let shown = out["content"].as_str().unwrap();
1724        assert_eq!(
1725            shown, "     1\tline one\r\n     2\tline two\r",
1726            "\\r must survive into the numbered display"
1727        );
1728
1729        // The round-trip proof: multi-line old_text reconstructed from the
1730        // display (prefixes stripped, \r kept) matches the file and edits it.
1731        let ok = execute_with_ledger(
1732            &substrate,
1733            &ledger,
1734            "edit_file",
1735            &json!({ "path": path, "old_text": "line one\r\nline two", "new_text": "merged" }),
1736        )
1737        .await
1738        .unwrap()
1739        .unwrap();
1740        assert_eq!(ok["replacements"], 1);
1741
1742        std::fs::remove_dir_all(&dir).ok();
1743    }
1744
1745    /// (review) An empty `old_text` is rejected up front — `matches("")` is
1746    /// char_count+1, so with `replace_all` it would interleave `new_text` at
1747    /// every char boundary (silent whole-file corruption reported as success).
1748    #[tokio::test]
1749    async fn edit_rejects_empty_old_text() {
1750        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1751        let dir = fresh_dir("emptyold");
1752        let path = dir.join("f.txt").to_string_lossy().to_string();
1753        exec_write_file(&substrate, None, &json!({ "path": path, "content": "abc" }))
1754            .await
1755            .unwrap();
1756
1757        for replace_all in [false, true] {
1758            let err = exec_edit_file(
1759                &substrate,
1760                None,
1761                &json!({
1762                    "path": path,
1763                    "old_text": "",
1764                    "new_text": "X",
1765                    "replace_all": replace_all
1766                }),
1767            )
1768            .await
1769            .unwrap_err();
1770            assert!(err.contains("old_text must be non-empty"), "{err}");
1771        }
1772        // The file is untouched.
1773        assert_eq!(std::fs::read_to_string(dir.join("f.txt")).unwrap(), "abc");
1774
1775        std::fs::remove_dir_all(&dir).ok();
1776    }
1777
1778    /// (review) `write_file` over an existing file refuses when the file changed
1779    /// since the session's last read — a blind overwrite would destroy content
1780    /// the session never observed. Re-reading re-licenses the write. This is the
1781    /// staleness half the docs promise alongside the read-first gate.
1782    #[tokio::test]
1783    async fn write_existing_rejects_stale_after_external_change() {
1784        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1785        let ledger = ReadLedger::new();
1786        let dir = fresh_dir("stalewrite");
1787        let path = dir.join("f.txt").to_string_lossy().to_string();
1788        std::fs::write(dir.join("f.txt"), "original").unwrap();
1789
1790        execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1791            .await
1792            .unwrap()
1793            .unwrap();
1794        // The file changes underneath (external process / shell command).
1795        std::fs::write(dir.join("f.txt"), "changed underneath").unwrap();
1796
1797        let err = execute_with_ledger(
1798            &substrate,
1799            &ledger,
1800            "write_file",
1801            &json!({ "path": path, "content": "clobber" }),
1802        )
1803        .await
1804        .unwrap()
1805        .unwrap_err();
1806        assert!(err.contains("changed since you last read it"), "{err}");
1807
1808        // Append over stale content is refused the same way.
1809        let err = execute_with_ledger(
1810            &substrate,
1811            &ledger,
1812            "write_file",
1813            &json!({ "path": path, "content": " + more", "append": true }),
1814        )
1815        .await
1816        .unwrap()
1817        .unwrap_err();
1818        assert!(err.contains("changed since you last read it"), "{err}");
1819
1820        // Re-reading shows the current bytes and re-licenses the write.
1821        execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1822            .await
1823            .unwrap()
1824            .unwrap();
1825        execute_with_ledger(
1826            &substrate,
1827            &ledger,
1828            "write_file",
1829            &json!({ "path": path, "content": "rewritten" }),
1830        )
1831        .await
1832        .unwrap()
1833        .unwrap();
1834        assert_eq!(
1835            std::fs::read_to_string(dir.join("f.txt")).unwrap(),
1836            "rewritten"
1837        );
1838
1839        std::fs::remove_dir_all(&dir).ok();
1840    }
1841
1842    /// (c) `replace_all: true` replaces every occurrence and reports the count;
1843    /// the default path still refuses a non-unique match and points at the flag.
1844    #[tokio::test]
1845    async fn replace_all_replaces_every_occurrence() {
1846        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1847        let dir = fresh_dir("replaceall");
1848        let path = dir.join("f.txt").to_string_lossy().to_string();
1849        exec_write_file(
1850            &substrate,
1851            None,
1852            &json!({ "path": path, "content": "a x a x a" }),
1853        )
1854        .await
1855        .unwrap();
1856
1857        // Default (unique) edit refuses the 3-way match and names the remedy.
1858        let err = exec_edit_file(
1859            &substrate,
1860            None,
1861            &json!({ "path": path, "old_text": "a", "new_text": "b" }),
1862        )
1863        .await
1864        .unwrap_err();
1865        assert!(err.contains("replace_all"), "{err}");
1866
1867        // replace_all replaces every occurrence.
1868        let ok = exec_edit_file(
1869            &substrate,
1870            None,
1871            &json!({ "path": path, "old_text": "a", "new_text": "b", "replace_all": true }),
1872        )
1873        .await
1874        .unwrap();
1875        assert_eq!(ok["replacements"], 3);
1876        let r = exec_read_file(&substrate, None, &json!({ "path": path }))
1877            .await
1878            .unwrap();
1879        assert_eq!(r["content"], "b x b x b");
1880
1881        std::fs::remove_dir_all(&dir).ok();
1882    }
1883
1884    /// (d) Creating a brand-new file needs no prior read.
1885    #[tokio::test]
1886    async fn write_new_file_allowed() {
1887        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1888        let ledger = ReadLedger::new();
1889        let dir = fresh_dir("writenew");
1890        let path = dir.join("new.txt").to_string_lossy().to_string();
1891
1892        let ok = execute_with_ledger(
1893            &substrate,
1894            &ledger,
1895            "write_file",
1896            &json!({ "path": path, "content": "fresh" }),
1897        )
1898        .await
1899        .unwrap()
1900        .unwrap();
1901        assert_eq!(ok["bytes_written"], 5);
1902
1903        std::fs::remove_dir_all(&dir).ok();
1904    }
1905
1906    /// (d) Overwriting an EXISTING file the session never read is refused.
1907    #[tokio::test]
1908    async fn write_existing_requires_prior_read() {
1909        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1910        let ledger = ReadLedger::new();
1911        let dir = fresh_dir("writeexisting");
1912        let path = dir.join("f.txt").to_string_lossy().to_string();
1913        std::fs::write(dir.join("f.txt"), "existing content").unwrap();
1914
1915        let err = execute_with_ledger(
1916            &substrate,
1917            &ledger,
1918            "write_file",
1919            &json!({ "path": path, "content": "clobber" }),
1920        )
1921        .await
1922        .unwrap()
1923        .unwrap_err();
1924        assert!(err.contains("before overwriting it"), "{err}");
1925        assert!(err.contains("read_file"), "{err}");
1926
1927        // Reading licenses the overwrite.
1928        execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1929            .await
1930            .unwrap()
1931            .unwrap();
1932        let ok = execute_with_ledger(
1933            &substrate,
1934            &ledger,
1935            "write_file",
1936            &json!({ "path": path, "content": "clobber" }),
1937        )
1938        .await
1939        .unwrap()
1940        .unwrap();
1941        assert_eq!(ok["bytes_written"], 7);
1942
1943        std::fs::remove_dir_all(&dir).ok();
1944    }
1945
1946    /// (d) A successful write self-records the new content, so a follow-up
1947    /// `edit_file` on the same path is licensed WITHOUT an intervening read —
1948    /// the "session knows the current content" contract the loops rely on.
1949    #[tokio::test]
1950    async fn write_self_records_enabling_edit() {
1951        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1952        let ledger = ReadLedger::new();
1953        let dir = fresh_dir("writeedits");
1954        let path = dir.join("f.txt").to_string_lossy().to_string();
1955
1956        execute_with_ledger(
1957            &substrate,
1958            &ledger,
1959            "write_file",
1960            &json!({ "path": path, "content": "one two three" }),
1961        )
1962        .await
1963        .unwrap()
1964        .unwrap();
1965        // No read_file in between — the write recorded the content.
1966        let ok = execute_with_ledger(
1967            &substrate,
1968            &ledger,
1969            "edit_file",
1970            &json!({ "path": path, "old_text": "two", "new_text": "TWO" }),
1971        )
1972        .await
1973        .unwrap()
1974        .unwrap();
1975        assert_eq!(ok["replacements"], 1);
1976
1977        std::fs::remove_dir_all(&dir).ok();
1978    }
1979
1980    /// (#2) A successful edit self-records the new content, so a SECOND edit with
1981    /// no intervening read is licensed. Deleting the edit self-record makes the
1982    /// ledger stale here and fails this test.
1983    #[tokio::test]
1984    async fn edit_self_records_enabling_second_edit_without_reread() {
1985        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1986        let ledger = ReadLedger::new();
1987        let dir = fresh_dir("editselfrec");
1988        let path = dir.join("f.txt").to_string_lossy().to_string();
1989        std::fs::write(dir.join("f.txt"), "one two three").unwrap();
1990
1991        execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1992            .await
1993            .unwrap()
1994            .unwrap();
1995        execute_with_ledger(
1996            &substrate,
1997            &ledger,
1998            "edit_file",
1999            &json!({ "path": path, "old_text": "one", "new_text": "1" }),
2000        )
2001        .await
2002        .unwrap()
2003        .unwrap();
2004
2005        // No read between the two edits — the first edit recorded the new content.
2006        let ok = execute_with_ledger(
2007            &substrate,
2008            &ledger,
2009            "edit_file",
2010            &json!({ "path": path, "old_text": "two", "new_text": "2" }),
2011        )
2012        .await
2013        .unwrap()
2014        .unwrap();
2015        assert_eq!(ok["replacements"], 1);
2016        assert_eq!(
2017            std::fs::read_to_string(dir.join("f.txt")).unwrap(),
2018            "1 2 three"
2019        );
2020
2021        std::fs::remove_dir_all(&dir).ok();
2022    }
2023
2024    /// (#3) A sliced read (offset/limit) records the FULL file's hash, not the
2025    /// returned slice — so editing a line OUTSIDE the slice is licensed AND
2026    /// Fresh. If the slice were hashed, `check()` would return Stale.
2027    #[tokio::test]
2028    async fn sliced_read_hashes_full_file_licensing_edit() {
2029        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2030        let ledger = ReadLedger::new();
2031        let dir = fresh_dir("slicedread");
2032        let path = dir.join("f.txt").to_string_lossy().to_string();
2033        std::fs::write(dir.join("f.txt"), "l1\nl2\nl3\nl4").unwrap();
2034
2035        let r = execute_with_ledger(
2036            &substrate,
2037            &ledger,
2038            "read_file",
2039            &json!({ "path": path, "offset": 1, "limit": 1 }),
2040        )
2041        .await
2042        .unwrap()
2043        .unwrap();
2044        assert_eq!(r["content"], "     2\tl2"); // only the slice is returned
2045
2046        // l4 is outside the returned slice; the edit is still licensed + Fresh.
2047        let ok = execute_with_ledger(
2048            &substrate,
2049            &ledger,
2050            "edit_file",
2051            &json!({ "path": path, "old_text": "l4", "new_text": "L4" }),
2052        )
2053        .await
2054        .unwrap()
2055        .unwrap();
2056        assert_eq!(ok["replacements"], 1);
2057
2058        std::fs::remove_dir_all(&dir).ok();
2059    }
2060
2061    /// (#4) Appending onto an EXISTING file the session never read is refused
2062    /// (same read-first guard as an overwrite); reading it licenses the append.
2063    #[tokio::test]
2064    async fn append_to_existing_unread_requires_prior_read() {
2065        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2066        let ledger = ReadLedger::new();
2067        let dir = fresh_dir("appendunread");
2068        let path = dir.join("f.txt").to_string_lossy().to_string();
2069        std::fs::write(dir.join("f.txt"), "existing content").unwrap();
2070
2071        let err = execute_with_ledger(
2072            &substrate,
2073            &ledger,
2074            "write_file",
2075            &json!({ "path": path, "content": " more", "append": true }),
2076        )
2077        .await
2078        .unwrap()
2079        .unwrap_err();
2080        assert!(err.contains("before overwriting it"), "{err}");
2081
2082        execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
2083            .await
2084            .unwrap()
2085            .unwrap();
2086        let ok = execute_with_ledger(
2087            &substrate,
2088            &ledger,
2089            "write_file",
2090            &json!({ "path": path, "content": " more", "append": true }),
2091        )
2092        .await
2093        .unwrap()
2094        .unwrap();
2095        assert_eq!(ok["append"], true);
2096        assert_eq!(
2097            std::fs::read_to_string(dir.join("f.txt")).unwrap(),
2098            "existing content more"
2099        );
2100
2101        std::fs::remove_dir_all(&dir).ok();
2102    }
2103
2104    #[tokio::test]
2105    async fn append_to_existing_empty_file_requires_prior_read() {
2106        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2107        let ledger = ReadLedger::new();
2108        let dir = fresh_dir("appendempty");
2109        let path = dir.join("f.txt").to_string_lossy().to_string();
2110        std::fs::write(dir.join("f.txt"), "").unwrap();
2111
2112        let err = execute_with_ledger(
2113            &substrate,
2114            &ledger,
2115            "write_file",
2116            &json!({ "path": path, "content": "new", "append": true }),
2117        )
2118        .await
2119        .unwrap()
2120        .unwrap_err();
2121        assert!(err.contains("before overwriting it"), "{err}");
2122        assert!(std::fs::read_to_string(dir.join("f.txt"))
2123            .unwrap()
2124            .is_empty());
2125
2126        std::fs::remove_dir_all(&dir).ok();
2127    }
2128
2129    #[tokio::test]
2130    async fn partial_read_cannot_authorize_whole_file_mutation() {
2131        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2132        let ledger = ReadLedger::new();
2133        let dir = fresh_dir("partialread");
2134        let path = dir.join("f.txt").to_string_lossy().to_string();
2135        std::fs::write(dir.join("f.txt"), "first\nsecond\nthird").unwrap();
2136
2137        execute_with_ledger(
2138            &substrate,
2139            &ledger,
2140            "read_file",
2141            &json!({ "path": path, "offset": 0, "limit": 1 }),
2142        )
2143        .await
2144        .unwrap()
2145        .unwrap();
2146
2147        let overwrite = execute_with_ledger(
2148            &substrate,
2149            &ledger,
2150            "write_file",
2151            &json!({ "path": path, "content": "replacement" }),
2152        )
2153        .await
2154        .unwrap()
2155        .unwrap_err();
2156        assert!(overwrite.contains("full current content"), "{overwrite}");
2157
2158        let replace_all = execute_with_ledger(
2159            &substrate,
2160            &ledger,
2161            "edit_file",
2162            &json!({ "path": path, "old_text": "i", "new_text": "I", "replace_all": true }),
2163        )
2164        .await
2165        .unwrap()
2166        .unwrap_err();
2167        assert!(
2168            replace_all.contains("full current content"),
2169            "{replace_all}"
2170        );
2171
2172        // A normal unique edit remains usable after the focused read.
2173        let edit = execute_with_ledger(
2174            &substrate,
2175            &ledger,
2176            "edit_file",
2177            &json!({ "path": path, "old_text": "first", "new_text": "FIRST" }),
2178        )
2179        .await
2180        .unwrap()
2181        .unwrap();
2182        assert_eq!(edit["replacements"], 1);
2183
2184        std::fs::remove_dir_all(&dir).ok();
2185    }
2186
2187    #[tokio::test]
2188    async fn guarded_write_refuses_existing_non_utf8_file() {
2189        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2190        let ledger = ReadLedger::new();
2191        let dir = fresh_dir("nonutf8write");
2192        let path = dir.join("f.bin").to_string_lossy().to_string();
2193        std::fs::write(dir.join("f.bin"), [0xff, 0x00, 0xfe]).unwrap();
2194
2195        let err = execute_with_ledger(
2196            &substrate,
2197            &ledger,
2198            "write_file",
2199            &json!({ "path": path, "content": "text" }),
2200        )
2201        .await
2202        .unwrap()
2203        .unwrap_err();
2204        assert!(err.contains("cannot modify existing file"), "{err}");
2205        assert_eq!(
2206            std::fs::read(dir.join("f.bin")).unwrap(),
2207            [0xff, 0x00, 0xfe]
2208        );
2209
2210        std::fs::remove_dir_all(&dir).ok();
2211    }
2212
2213    /// (#4) Appending onto a STALE record (file changed on disk after the read)
2214    /// is refused with the stale error until re-read — the append arm gates
2215    /// staleness, not just presence.
2216    #[tokio::test]
2217    async fn append_detects_stale_after_external_change() {
2218        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2219        let ledger = ReadLedger::new();
2220        let dir = fresh_dir("appendstale");
2221        let path = dir.join("f.txt").to_string_lossy().to_string();
2222        std::fs::write(dir.join("f.txt"), "v1").unwrap();
2223
2224        execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
2225            .await
2226            .unwrap()
2227            .unwrap();
2228        std::fs::write(dir.join("f.txt"), "v2 changed").unwrap();
2229
2230        let err = execute_with_ledger(
2231            &substrate,
2232            &ledger,
2233            "write_file",
2234            &json!({ "path": path, "content": " appended", "append": true }),
2235        )
2236        .await
2237        .unwrap()
2238        .unwrap_err();
2239        assert!(err.contains("changed since you last read it"), "{err}");
2240
2241        std::fs::remove_dir_all(&dir).ok();
2242    }
2243
2244    /// (#5) The read-before-edit gate sits above BOTH edit arms: a `replace_all`
2245    /// edit is licensed by a read, self-records, and is refused as stale after an
2246    /// external change — exactly like the unique-match arm.
2247    #[tokio::test]
2248    async fn replace_all_edit_is_gated_by_the_ledger() {
2249        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2250        let ledger = ReadLedger::new();
2251        let dir = fresh_dir("replaceallgate");
2252        let path = dir.join("f.txt").to_string_lossy().to_string();
2253        std::fs::write(dir.join("f.txt"), "a a a").unwrap();
2254
2255        execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
2256            .await
2257            .unwrap()
2258            .unwrap();
2259        let ok = execute_with_ledger(
2260            &substrate,
2261            &ledger,
2262            "edit_file",
2263            &json!({ "path": path, "old_text": "a", "new_text": "b", "replace_all": true }),
2264        )
2265        .await
2266        .unwrap()
2267        .unwrap();
2268        assert_eq!(ok["replacements"], 3);
2269
2270        // Self-recorded: a second replace_all with no read succeeds.
2271        let ok2 = execute_with_ledger(
2272            &substrate,
2273            &ledger,
2274            "edit_file",
2275            &json!({ "path": path, "old_text": "b", "new_text": "c", "replace_all": true }),
2276        )
2277        .await
2278        .unwrap()
2279        .unwrap();
2280        assert_eq!(ok2["replacements"], 3);
2281
2282        // External change → the replace_all edit is refused as stale.
2283        std::fs::write(dir.join("f.txt"), "c c c c").unwrap();
2284        let err = execute_with_ledger(
2285            &substrate,
2286            &ledger,
2287            "edit_file",
2288            &json!({ "path": path, "old_text": "c", "new_text": "d", "replace_all": true }),
2289        )
2290        .await
2291        .unwrap()
2292        .unwrap_err();
2293        assert!(err.contains("changed since you last read it"), "{err}");
2294
2295        std::fs::remove_dir_all(&dir).ok();
2296    }
2297
2298    /// (#6a) Every builtin name is HANDLED (returns `Some`), and no error it
2299    /// produces — including the deliberate gate errors — starts with "unknown
2300    /// tool". That prefix is the ONLY fall-through trigger at every call site, so
2301    /// this pins that a builtin can never be re-dispatched to a second ledger.
2302    #[tokio::test]
2303    async fn builtin_names_never_return_unknown_tool_prefix() {
2304        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2305        let ledger = ReadLedger::new();
2306
2307        for name in [
2308            "read_file",
2309            "write_file",
2310            "edit_file",
2311            "list_dir",
2312            "find_files",
2313            "grep_files",
2314            "calculate",
2315        ] {
2316            let result = execute_with_ledger(&substrate, &ledger, name, &json!({})).await;
2317            let inner = result.unwrap_or_else(|| panic!("{name} must be handled, not None"));
2318            if let Err(e) = &inner {
2319                assert!(
2320                    !e.starts_with("unknown tool"),
2321                    "{name} error must not start with 'unknown tool': {e}"
2322                );
2323            }
2324        }
2325
2326        // Only a genuinely unknown name is None (→ "unknown tool" at the caller).
2327        assert!(
2328            execute_with_ledger(&substrate, &ledger, "no_such_tool", &json!({}))
2329                .await
2330                .is_none()
2331        );
2332
2333        // The deliberate gate errors also never masquerade as "unknown tool".
2334        let dir = fresh_dir("unknownprefix");
2335        let path = dir.join("f.txt").to_string_lossy().to_string();
2336        std::fs::write(dir.join("f.txt"), "content").unwrap();
2337        let edit_err = execute_with_ledger(
2338            &substrate,
2339            &ledger,
2340            "edit_file",
2341            &json!({ "path": path, "old_text": "content", "new_text": "x" }),
2342        )
2343        .await
2344        .unwrap()
2345        .unwrap_err();
2346        assert!(!edit_err.starts_with("unknown tool"), "{edit_err}");
2347        assert!(edit_err.contains("before editing it"), "{edit_err}");
2348        let write_err = execute_with_ledger(
2349            &substrate,
2350            &ledger,
2351            "write_file",
2352            &json!({ "path": path, "content": "y" }),
2353        )
2354        .await
2355        .unwrap()
2356        .unwrap_err();
2357        assert!(!write_err.starts_with("unknown tool"), "{write_err}");
2358
2359        std::fs::remove_dir_all(&dir).ok();
2360    }
2361
2362    /// (#7) Lexical `.` components are normalized in the ledger key, so rooted
2363    /// `./x` and `x` paths do not alias into a spurious Unread.
2364    #[test]
2365    fn ledger_normalizes_dot_components() {
2366        let ledger = ReadLedger::new();
2367        ledger.record("./src/x.rs", "content", true);
2368        assert_eq!(ledger.check("src/x.rs", "content"), ReadState::FreshFull);
2369        assert_eq!(ledger.check("./src/x.rs", "content"), ReadState::FreshFull);
2370
2371        let ledger2 = ReadLedger::new();
2372        ledger2.record("src/x.rs", "content", true);
2373        assert_eq!(ledger2.check("./src/x.rs", "content"), ReadState::FreshFull);
2374
2375        // Unrelated paths are still Unread, while adapter-rooted interior dots
2376        // normalize to the same ledger key.
2377        assert_eq!(ledger.check("other.rs", "content"), ReadState::Unread);
2378        let ledger3 = ReadLedger::new();
2379        ledger3.record("/root/a/./b", "c", true);
2380        assert_eq!(ledger3.check("/root/a/b", "c"), ReadState::FreshFull);
2381    }
2382
2383    #[test]
2384    fn session_ledgers_share_mutation_locks_without_sharing_observations() {
2385        let ledgers = SessionReadLedgers::new();
2386        let first = ledgers.ledger_for(Some("first"));
2387        let second = ledgers.ledger_for(Some("second"));
2388        first.record("f.txt", "first view", true);
2389
2390        assert_eq!(
2391            second.check("f.txt", "first view"),
2392            ReadState::Unread,
2393            "one session's read must not authorize another session"
2394        );
2395        assert!(Arc::ptr_eq(
2396            &first.mutation_lock("f.txt"),
2397            &second.mutation_lock("f.txt")
2398        ));
2399    }
2400
2401    #[tokio::test]
2402    async fn indentation_error_provides_exact_bytes_for_a_literal_retry_without_mutation() {
2403        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2404        let dir = fresh_dir("indentation-retry");
2405        let path = dir.join("test.py").to_string_lossy().to_string();
2406        for (content, old_text, exact) in [
2407            (
2408                "# tests\nif __name__ == '__main__':\n    unittest.main()\n",
2409                "    if __name__ == '__main__':\n        unittest.main()",
2410                "if __name__ == '__main__':\n    unittest.main()",
2411            ),
2412            (
2413                "# tests\r\nif __name__ == '__main__':\r\n\tunittest.main()\r\n",
2414                "    if __name__ == '__main__':\n    unittest.main()",
2415                "if __name__ == '__main__':\r\n\tunittest.main()",
2416            ),
2417            ("α\nβ\n", "  β\n", "β\n"),
2418        ] {
2419            std::fs::write(&path, content).unwrap();
2420            let ledger = ReadLedger::new();
2421            exec_read_file(&substrate, Some(&ledger), &json!({"path":path}))
2422                .await
2423                .unwrap();
2424            let error = exec_edit_file(
2425                &substrate,
2426                Some(&ledger),
2427                &json!({
2428                    "path":path, "old_text":old_text, "new_text":"WRONG"
2429                }),
2430            )
2431            .await
2432            .unwrap_err();
2433            assert_eq!(std::fs::read_to_string(&path).unwrap(), content);
2434            let suggested = error
2435                .split_once(HINT_BEGIN)
2436                .and_then(|(_, rest)| rest.split_once(HINT_END))
2437                .expect(&error)
2438                .0
2439                .to_string();
2440            assert_eq!(suggested, exact);
2441            exec_edit_file(
2442                &substrate,
2443                Some(&ledger),
2444                &json!({
2445                    "path":path, "old_text":suggested, "new_text":"replacement"
2446                }),
2447            )
2448            .await
2449            .unwrap();
2450            assert_eq!(
2451                std::fs::read_to_string(&path).unwrap(),
2452                content.replacen(exact, "replacement", 1)
2453            );
2454        }
2455        std::fs::remove_dir_all(dir).unwrap();
2456    }
2457
2458    #[test]
2459    fn indentation_hints_do_not_guess_among_duplicates_or_unrelated_text() {
2460        assert!(indentation_hint("  call()\n\tcall()\n", "    call()").is_none());
2461        assert_eq!(
2462            indentation_hint("a\n  call()\n", "call()"),
2463            Some(("  call()", 1, 2))
2464        );
2465        assert!(indentation_hint("    call()\n", "  different()").is_none());
2466        assert!(indentation_hint("  \n", " \n").is_none());
2467        assert!(indentation_hint(&"line\n".repeat(13), &"  line\n".repeat(13)).is_none());
2468        assert!(indentation_hint(&"a".repeat(2048), &format!(" {}", "a".repeat(2048))).is_none());
2469    }
2470
2471    #[tokio::test]
2472    async fn indentation_hints_require_full_fresh_observation_and_a_single_edit() {
2473        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2474        let dir = fresh_dir("indentation-guards");
2475        let path = dir.join("f.txt").to_string_lossy().to_string();
2476        let content = "first\ncall()\n";
2477        for (partial, stale, replace_all) in [
2478            (true, false, false),
2479            (false, true, false),
2480            (false, false, true),
2481        ] {
2482            std::fs::write(&path, content).unwrap();
2483            let ledger = ReadLedger::new();
2484            let mut read = json!({"path":path});
2485            if partial {
2486                read["limit"] = json!(1);
2487            }
2488            exec_read_file(&substrate, Some(&ledger), &read)
2489                .await
2490                .unwrap();
2491            if stale {
2492                std::fs::write(&path, "changed\ncall()\n").unwrap();
2493            }
2494            let before = std::fs::read_to_string(&path).unwrap();
2495            let error = exec_edit_file(&substrate, Some(&ledger), &json!({
2496                "path":path, "old_text":"    call()", "new_text":"WRONG", "replace_all":replace_all
2497            })).await.unwrap_err();
2498            assert!(!error.contains(HINT_BEGIN), "{error}");
2499            assert_eq!(std::fs::read_to_string(&path).unwrap(), before);
2500        }
2501        std::fs::remove_dir_all(dir).unwrap();
2502    }
2503
2504    #[tokio::test]
2505    async fn a_paged_read_keeps_recovery_and_hints_for_the_lines_it_showed() {
2506        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2507        let dir = fresh_dir("paged-recovery");
2508        let path = dir.join("big.py").to_string_lossy().to_string();
2509        let content = "a = 1\n    b = 2\nc = 3\n    d = 4\n";
2510        std::fs::write(&path, content).unwrap();
2511        let ledger = ReadLedger::new();
2512        // A page of lines 1-2 only.
2513        exec_read_file(
2514            &substrate,
2515            Some(&ledger),
2516            &json!({"path": path, "limit": 2}),
2517        )
2518        .await
2519        .unwrap();
2520        // A numbered paste of a line the page showed is recovered …
2521        exec_edit_file(
2522            &substrate,
2523            Some(&ledger),
2524            &json!({"path": path, "old_text": "     1\ta = 1", "new_text": "a = 10"}),
2525        )
2526        .await
2527        .unwrap();
2528        exec_read_file(
2529            &substrate,
2530            Some(&ledger),
2531            &json!({"path": path, "limit": 2}),
2532        )
2533        .await
2534        .unwrap();
2535        // … an indentation hint is offered for a line it showed …
2536        let error = exec_edit_file(
2537            &substrate,
2538            Some(&ledger),
2539            &json!({"path": path, "old_text": "a = 10\n  b = 2", "new_text": "x"}),
2540        )
2541        .await
2542        .unwrap_err();
2543        assert!(
2544            error.contains(&format!("{HINT_BEGIN}a = 10\n    b = 2{HINT_END}")),
2545            "{error}"
2546        );
2547        // … and neither reaches a line it did not show.
2548        let error = exec_edit_file(
2549            &substrate,
2550            Some(&ledger),
2551            &json!({"path": path, "old_text": "c = 3\n  d = 4", "new_text": "x"}),
2552        )
2553        .await
2554        .unwrap_err();
2555        assert!(!error.contains(HINT_BEGIN), "{error}");
2556        let error = exec_edit_file(
2557            &substrate,
2558            Some(&ledger),
2559            &json!({"path": path, "old_text": "     3\tc = 3", "new_text": "x"}),
2560        )
2561        .await
2562        .unwrap_err();
2563        assert!(error.contains("not found"), "{error}");
2564        std::fs::remove_dir_all(dir).unwrap();
2565    }
2566
2567    #[tokio::test]
2568    async fn a_page_keeps_its_recoveries_across_an_edit() {
2569        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2570        let dir = fresh_dir("paged-after-edit");
2571        let path = dir.join("f.py").to_string_lossy().to_string();
2572        std::fs::write(&path, "a = 1\nb = 2\nc = 3\nd = 4\ne = 5\n").unwrap();
2573        let ledger = ReadLedger::new();
2574        exec_read_file(
2575            &substrate,
2576            Some(&ledger),
2577            &json!({"path": path, "limit": 4}),
2578        )
2579        .await
2580        .unwrap();
2581        // One edit that adds a line on the page …
2582        exec_edit_file(
2583            &substrate,
2584            Some(&ledger),
2585            &json!({"path": path, "old_text": "b = 2", "new_text": "b = 2\nb2 = 2"}),
2586        )
2587        .await
2588        .unwrap();
2589        // … and a numbered paste of a later line on the same page, now line 5,
2590        // is still recovered without a re-read.
2591        exec_edit_file(
2592            &substrate,
2593            Some(&ledger),
2594            &json!({"path": path, "old_text": "     5\td = 4", "new_text": "d = 40"}),
2595        )
2596        .await
2597        .unwrap();
2598        assert_eq!(
2599            std::fs::read_to_string(&path).unwrap(),
2600            "a = 1\nb = 2\nb2 = 2\nc = 3\nd = 40\ne = 5\n"
2601        );
2602        // Line 6 was never on the page.
2603        let err = exec_edit_file(
2604            &substrate,
2605            Some(&ledger),
2606            &json!({"path": path, "old_text": "     6\te = 5", "new_text": "x"}),
2607        )
2608        .await
2609        .unwrap_err();
2610        assert!(err.contains("not found"), "{err}");
2611        std::fs::remove_dir_all(dir).unwrap();
2612    }
2613
2614    #[test]
2615    fn seen_ranges_move_with_an_edit() {
2616        // Lines 0..4 seen; lines 1..2 replaced by three lines.
2617        assert_eq!(
2618            shift_seen_lines(&[(0, 4)], 1, 2, 4),
2619            [(0, 1), (4, 6), (1, 4)]
2620        );
2621        // An edit outside the seen page leaves it and shifts nothing before it.
2622        assert_eq!(shift_seen_lines(&[(0, 2)], 5, 6, 8), [(0, 2)]);
2623        // A page after the edit shifts; the edited lines were unseen, so stay unseen.
2624        assert_eq!(shift_seen_lines(&[(6, 9)], 1, 3, 2), [(5, 8)]);
2625    }
2626
2627    #[tokio::test]
2628    async fn edit_accepts_exact_numbered_display_after_full_fresh_read() {
2629        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2630        let dir = fresh_dir("numbered-edit");
2631        let path = dir.join("f.txt").to_string_lossy().to_string();
2632        for content in ["first\n\nlast\n", "first\r\n\r\nlast\r\n", "first\n\nlast"] {
2633            std::fs::write(&path, content).unwrap();
2634            let ledger = ReadLedger::new();
2635            let read = exec_read_file(&substrate, Some(&ledger), &json!({"path":path}))
2636                .await
2637                .unwrap();
2638            let display = read["content"].as_str().unwrap();
2639            let result = exec_edit_file(
2640                &substrate,
2641                Some(&ledger),
2642                &json!({
2643                    "path":path, "old_text":display, "new_text":"changed"
2644                }),
2645            )
2646            .await
2647            .unwrap();
2648            assert_eq!(result["removed_display_line_numbers"], true);
2649            // Only display prefixes are removed: omitted trailing LF stays
2650            // outside the replaced range, just as with a literal old_text.
2651            assert_eq!(
2652                std::fs::read_to_string(&path).unwrap(),
2653                if content.ends_with('\n') {
2654                    "changed\n"
2655                } else {
2656                    "changed"
2657                }
2658            );
2659        }
2660        std::fs::write(&path, "α\nβ\tdata\nγ\n").unwrap();
2661        let ledger = ReadLedger::new();
2662        exec_read_file(&substrate, Some(&ledger), &json!({"path":path}))
2663            .await
2664            .unwrap();
2665        exec_edit_file(
2666            &substrate,
2667            Some(&ledger),
2668            &json!({
2669                "path":path, "old_text":"     2\tβ\tdata", "new_text":"δ\tdata"
2670            }),
2671        )
2672        .await
2673        .unwrap();
2674        assert_eq!(std::fs::read_to_string(&path).unwrap(), "α\nδ\tdata\nγ\n");
2675        std::fs::remove_dir_all(dir).unwrap();
2676    }
2677
2678    #[tokio::test]
2679    async fn numbered_edit_keeps_location_freshness_and_uniqueness_guards() {
2680        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2681        let dir = fresh_dir("numbered-edit-guards");
2682        let path = dir.join("f.txt").to_string_lossy().to_string();
2683        for (content, old, replace_all) in [
2684            ("first\nlast\n", "     1\tlast", false),
2685            ("first\nlast\n", "     1\tfirst\n     3\tlast", false),
2686            ("first  \nlast\n", "     1\tfirst", false),
2687            ("same\nsame\n", "     2\tsame", false),
2688            ("first\nlast\n", "     1\tfirst", true),
2689        ] {
2690            std::fs::write(&path, content).unwrap();
2691            let ledger = ReadLedger::new();
2692            exec_read_file(&substrate, Some(&ledger), &json!({"path":path}))
2693                .await
2694                .unwrap();
2695            assert!(exec_edit_file(
2696                &substrate,
2697                Some(&ledger),
2698                &json!({
2699                    "path":path, "old_text":old, "new_text":"changed", "replace_all":replace_all
2700                })
2701            )
2702            .await
2703            .is_err());
2704            assert_eq!(std::fs::read_to_string(&path).unwrap(), content);
2705        }
2706        std::fs::write(&path, "first\nlast\n").unwrap();
2707        let ledger = ReadLedger::new();
2708        exec_read_file(&substrate, Some(&ledger), &json!({"path":path, "limit":1}))
2709            .await
2710            .unwrap();
2711        // A page recovers only the lines it showed: line 2 was not on it.
2712        assert!(exec_edit_file(
2713            &substrate,
2714            Some(&ledger),
2715            &json!({
2716                "path":path, "old_text":"     2\tlast", "new_text":"changed"
2717            })
2718        )
2719        .await
2720        .is_err());
2721        exec_read_file(&substrate, Some(&ledger), &json!({"path":path}))
2722            .await
2723            .unwrap();
2724        std::fs::write(&path, "external change").unwrap();
2725        assert!(exec_edit_file(
2726            &substrate,
2727            Some(&ledger),
2728            &json!({
2729                "path":path, "old_text":"     1\tfirst", "new_text":"changed"
2730            })
2731        )
2732        .await
2733        .unwrap_err()
2734        .contains("changed since"));
2735        // Literal numbered source must not be mistaken for display metadata.
2736        std::fs::write(&path, "     1\tliteral").unwrap();
2737        exec_read_file(&substrate, Some(&ledger), &json!({"path":path}))
2738            .await
2739            .unwrap();
2740        let result = exec_edit_file(
2741            &substrate,
2742            Some(&ledger),
2743            &json!({
2744                "path":path, "old_text":"     1\tliteral", "new_text":"changed"
2745            }),
2746        )
2747        .await
2748        .unwrap();
2749        assert_eq!(result["removed_display_line_numbers"], false);
2750        assert_eq!(std::fs::read_to_string(&path).unwrap(), "changed");
2751        std::fs::remove_dir_all(dir).unwrap();
2752    }
2753
2754    #[tokio::test]
2755    async fn a_multi_line_edit_without_carriage_returns_lands_in_a_crlf_file() {
2756        let dir = tempfile::tempdir().unwrap();
2757        let path = dir.path().join("w.py");
2758        std::fs::write(&path, "a = 1\r\nb = 2\r\nc = 3\r\n").unwrap();
2759        let path = path.to_string_lossy().into_owned();
2760        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2761        let ledger = ReadLedger::new();
2762        execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
2763            .await
2764            .unwrap()
2765            .unwrap();
2766        execute_with_ledger(
2767            &substrate,
2768            &ledger,
2769            "edit_file",
2770            &json!({ "path": path, "old_text": "a = 1\nb = 2", "new_text": "a = 1\nb = 20" }),
2771        )
2772        .await
2773        .unwrap()
2774        .unwrap();
2775        assert_eq!(
2776            std::fs::read_to_string(&path).unwrap(),
2777            "a = 1\r\nb = 20\r\nc = 3\r\n"
2778        );
2779    }
2780
2781    /// (#8) A no-match `old_text` shaped like read_file's line-number prefix gets
2782    /// a targeted hint; an ordinary no-match does not.
2783    #[tokio::test]
2784    async fn edit_no_match_hints_pasted_line_number() {
2785        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2786        let dir = fresh_dir("linenumhint");
2787        let path = dir.join("f.txt").to_string_lossy().to_string();
2788        exec_write_file(
2789            &substrate,
2790            None,
2791            &json!({ "path": path, "content": "hello world" }),
2792        )
2793        .await
2794        .unwrap();
2795
2796        let err = exec_edit_file(
2797            &substrate,
2798            None,
2799            &json!({ "path": path, "old_text": "     1\thello world", "new_text": "hi" }),
2800        )
2801        .await
2802        .unwrap_err();
2803        assert!(err.contains("line-number prefixes"), "{err}");
2804
2805        let plain = exec_edit_file(
2806            &substrate,
2807            None,
2808            &json!({ "path": path, "old_text": "absent", "new_text": "x" }),
2809        )
2810        .await
2811        .unwrap_err();
2812        assert!(!plain.contains("line-number prefixes"), "{plain}");
2813        assert!(plain.contains("old_text not found"), "{plain}");
2814
2815        std::fs::remove_dir_all(&dir).ok();
2816    }
2817
2818    #[tokio::test]
2819    async fn calculate_is_pure() {
2820        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2821        let out = execute(
2822            &substrate,
2823            "calculate",
2824            &json!({ "expression": "2 + 3 * 4" }),
2825        )
2826        .await
2827        .unwrap()
2828        .unwrap();
2829        assert_eq!(out["result"], 14.0);
2830    }
2831
2832    #[tokio::test]
2833    async fn unknown_tool_returns_none() {
2834        let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2835        assert!(execute(&substrate, "nope", &json!({})).await.is_none());
2836    }
2837
2838    /// Evaluate via the public `calculate` tool contract and return the
2839    /// `result` float, so the test exercises the same path the runtime does.
2840    fn calc(expr: &str) -> f64 {
2841        exec_calculate(&json!({ "expression": expr }))
2842            .unwrap_or_else(|e| panic!("calculate({expr:?}) failed: {e}"))
2843            .get("result")
2844            .and_then(|v| v.as_f64())
2845            .unwrap_or_else(|| panic!("calculate({expr:?}) returned no numeric result"))
2846    }
2847
2848    /// Pins the math semantics of the `calculate` builtin independently of the
2849    /// backend evaluator. The load-bearing case is `^`: this tool is a
2850    /// calculator, so `^` MUST mean exponentiation (2^3 == 8), not the
2851    /// bitwise-XOR meaning it carries in C-family languages. The tool
2852    /// description documents this contract for the model; this test enforces
2853    /// it for the implementation.
2854    #[test]
2855    fn calculate_contract_semantics() {
2856        assert_eq!(calc("2 + 3 * 4"), 14.0, "operator precedence");
2857        assert_eq!(calc("(1 + 2) * 3"), 9.0, "parentheses override precedence");
2858        assert_eq!(calc("2^3"), 8.0, "^ is exponentiation, not XOR");
2859        assert_eq!(calc("2^10"), 1024.0, "^ is exponentiation");
2860        assert_eq!(calc("10 % 3"), 1.0, "modulo");
2861        assert_eq!(calc("-5 + 2"), -3.0, "unary minus");
2862        // Built-in functions (fasteval-native).
2863        assert_eq!(calc("sin(0)"), 0.0, "native function: sin");
2864        assert_eq!(calc("abs(-3)"), 3.0, "native function: abs");
2865        // Functions/constants filled by our namespace shim.
2866        assert_eq!(calc("sqrt(16)"), 4.0, "shim function: sqrt");
2867        assert!((calc("ln(e)") - 1.0).abs() < 1e-12, "shim: ln + e constant");
2868        assert!(
2869            (calc("pi") - std::f64::consts::PI).abs() < 1e-12,
2870            "shim: pi constant"
2871        );
2872    }
2873
2874    #[test]
2875    fn calculate_rejects_invalid_expression() {
2876        assert!(exec_calculate(&json!({ "expression": "2 +" })).is_err());
2877        assert!(exec_calculate(&json!({})).is_err(), "missing parameter");
2878    }
2879}