Skip to main content

car_engine/
agent_basics.rs

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