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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum ReadState {
17 Unread,
19 Stale,
22 FreshPartial,
24 FreshFull,
26}
27
28#[derive(Debug, Clone)]
29struct ReadRecord {
30 hash: u64,
31 full_read: bool,
32 lines_seen: Vec<(usize, usize)>,
36}
37
38#[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 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 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 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 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 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 pub fn record_seen(&self, path: &str, content: &str, ranges: Vec<(usize, usize)>) {
173 self.record(path, content, false);
174 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 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 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#[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
285fn 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
291fn 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
305fn 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
318fn 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
357fn shift_seen_lines(
361 seen: &[(usize, usize)],
362 start: usize,
363 old_end: usize,
364 new_end: usize,
365) -> Vec<(usize, usize)> {
366 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
389fn seen_covers(seen: &[(usize, usize)], start: usize, end: usize) -> bool {
391 seen.iter().any(|&(a, b)| a <= start && end <= b)
392}
393
394fn 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 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
460pub async fn execute(
475 substrate: &Arc<dyn Substrate>,
476 tool: &str,
477 params: &Value,
478) -> Option<Result<Value, String>> {
479 execute_inner(substrate, None, tool, params).await
480}
481
482pub async fn execute_with_ledger(
490 substrate: &Arc<dyn Substrate>,
491 ledger: &ReadLedger,
492 tool: &str,
493 params: &Value,
494) -> Option<Result<Value, String>> {
495 execute_inner(substrate, Some(ledger), tool, params).await
496}
497
498async fn execute_inner(
499 substrate: &Arc<dyn Substrate>,
500 ledger: Option<&ReadLedger>,
501 tool: &str,
502 params: &Value,
503) -> Option<Result<Value, String>> {
504 let result = match tool {
505 "read_file" => exec_read_file(substrate, ledger, params).await,
506 "write_file" => exec_write_file(substrate, ledger, params).await,
507 "edit_file" => exec_edit_file(substrate, ledger, params).await,
508 "list_dir" => exec_list_dir(substrate, params).await,
509 "find_files" => exec_find_files(substrate, params).await,
510 "grep_files" => exec_grep_files(substrate, params).await,
511 "calculate" => exec_calculate(params),
512 _ => return None,
513 };
514 Some(result)
515}
516
517async fn exec_read_file(
518 substrate: &Arc<dyn Substrate>,
519 ledger: Option<&ReadLedger>,
520 params: &Value,
521) -> Result<Value, String> {
522 let path = params
523 .get("path")
524 .and_then(|v| v.as_str())
525 .ok_or("missing 'path' parameter")?;
526 let offset = params.get("offset").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
527 let limit = params
528 .get("limit")
529 .and_then(|v| v.as_u64())
530 .map(|v| v as usize);
531 let max_output_bytes = params
535 .get("max_output_bytes")
536 .and_then(|v| v.as_u64())
537 .map(|v| v as usize);
538
539 let content = substrate.read_text(path).await?;
540 let size_bytes = content.len();
541 let total_lines = content.lines().count();
542
543 let mut lines: Vec<&str> = content.split('\n').collect();
556 if lines.last() == Some(&"") {
557 lines.pop();
558 }
559 let start = offset.min(lines.len());
560 let end = limit
561 .map(|line_count| (start + line_count).min(lines.len()))
562 .unwrap_or(lines.len());
563
564 let mut truncated = None;
569 let returned = if let Some(ledger) = ledger {
570 let mut shown = Vec::new();
571 let mut used = 0usize;
572 let mut shown_end = start;
573 for (i, line) in lines[start..end].iter().enumerate() {
574 let rendered = format!("{:>6}\t{}", start + i + 1, line);
575 let cost = rendered.len() + usize::from(!shown.is_empty());
577 match max_output_bytes {
578 Some(max) if used + cost > max => {
579 if shown.is_empty() {
580 let mut cut = max.min(rendered.len());
583 while !rendered.is_char_boundary(cut) {
584 cut -= 1;
585 }
586 shown.push(rendered[..cut].to_string());
587 truncated = Some(format!(
588 "line {} is longer than one read can show and was cut after {cut} \
589 bytes; inspect it with shell (for example `cut -c` or python), \
590 or call read_file with offset={} to continue after it",
591 start + i + 1,
592 start + i + 1
593 ));
594 } else {
595 truncated = Some(format!(
596 "showing lines {}-{shown_end} of {}; call read_file with \
597 offset={shown_end} to read further",
598 start + 1,
599 lines.len()
600 ));
601 }
602 break;
603 }
604 _ => {}
605 }
606 used += cost;
607 shown.push(rendered);
608 shown_end = start + i + 1;
609 }
610 ledger.record_lines(path, &content, start, shown_end, lines.len());
611 shown.join("\n")
612 } else if offset > 0 || limit.is_some() {
613 lines[start..end].join("\n")
614 } else {
615 content.clone()
616 };
617
618 if let Some(note) = truncated {
619 return Ok(json!({
620 "path": substrate.display_path(path),
621 "truncated": note,
622 "content": returned,
623 "size_bytes": size_bytes,
624 "total_lines": total_lines,
625 }));
626 }
627 Ok(json!({
628 "path": substrate.display_path(path),
629 "content": returned,
630 "size_bytes": size_bytes,
631 "total_lines": total_lines,
632 }))
633}
634
635async fn exec_write_file(
636 substrate: &Arc<dyn Substrate>,
637 ledger: Option<&ReadLedger>,
638 params: &Value,
639) -> Result<Value, String> {
640 let path = params
641 .get("path")
642 .and_then(|v| v.as_str())
643 .ok_or("missing 'path' parameter")?;
644 let content = params
645 .get("content")
646 .and_then(|v| v.as_str())
647 .ok_or("missing 'content' parameter")?;
648 let append = params
649 .get("append")
650 .and_then(|v| v.as_bool())
651 .unwrap_or(false);
652
653 let _mutation_guard = match ledger {
654 Some(ledger) => Some(ledger.mutation_lock(path).lock_owned().await),
655 None => None,
656 };
657
658 let existing = if ledger.is_some() {
659 match substrate.read_text(path).await {
660 Ok(content) => Some(content),
661 Err(read_error) => match substrate.path_state(path).await {
662 crate::substrate::PathState::Missing => None,
663 crate::substrate::PathState::Exists => {
664 return Err(format!(
665 "cannot modify existing file '{}' because it cannot be read as UTF-8: {read_error}",
666 substrate.display_path(path)
667 ));
668 }
669 crate::substrate::PathState::Unknown(reason) => {
670 return Err(format!(
671 "cannot determine whether '{}' is safe to create: {reason}",
672 substrate.display_path(path)
673 ));
674 }
675 },
676 }
677 } else {
678 None
679 };
680
681 let combined = if append {
682 let existed = existing.is_some();
686 let existing = match existing {
687 Some(existing) => existing,
688 None if ledger.is_some() => String::new(),
689 None => substrate.read_text(path).await.unwrap_or_default(),
690 };
691 if let Some(ledger) = ledger.filter(|_| existed) {
698 match ledger.check(path, &existing) {
699 ReadState::Unread => {
700 return Err(read_first_error(
701 &substrate.display_path(path),
702 "overwriting",
703 ));
704 }
705 ReadState::Stale => {
706 return Err(stale_error(&substrate.display_path(path)));
707 }
708 ReadState::FreshPartial => {
709 return Err(full_read_error(
710 &substrate.display_path(path),
711 "appending to",
712 ));
713 }
714 ReadState::FreshFull => {}
715 }
716 }
717 let mut combined = existing;
718 combined.push_str(content);
719 substrate.write_text(path, &combined).await?;
720 combined
721 } else {
722 if let Some(ledger) = ledger {
730 if let Some(existing) = existing {
731 match ledger.check(path, &existing) {
732 ReadState::Unread => {
733 return Err(read_first_error(
734 &substrate.display_path(path),
735 "overwriting",
736 ));
737 }
738 ReadState::Stale => {
739 return Err(stale_error(&substrate.display_path(path)));
740 }
741 ReadState::FreshPartial => {
742 return Err(full_read_error(
743 &substrate.display_path(path),
744 "overwriting",
745 ));
746 }
747 ReadState::FreshFull => {}
748 }
749 }
750 }
751 substrate.write_text(path, content).await?;
752 content.to_string()
753 };
754
755 if let Some(ledger) = ledger {
758 ledger.record(path, &combined, true);
759 }
760
761 Ok(json!({
762 "path": substrate.display_path(path),
763 "bytes_written": content.len(),
764 "append": append,
765 }))
766}
767
768async fn exec_edit_file(
769 substrate: &Arc<dyn Substrate>,
770 ledger: Option<&ReadLedger>,
771 params: &Value,
772) -> Result<Value, String> {
773 let path = params
774 .get("path")
775 .and_then(|v| v.as_str())
776 .ok_or("missing 'path' parameter")?;
777 let old_text = params
778 .get("old_text")
779 .and_then(|v| v.as_str())
780 .ok_or("missing 'old_text' parameter")?;
781 let new_text = params
782 .get("new_text")
783 .and_then(|v| v.as_str())
784 .ok_or("missing 'new_text' parameter")?;
785 let replace_all = params
786 .get("replace_all")
787 .and_then(|v| v.as_bool())
788 .unwrap_or(false);
789
790 let _mutation_guard = match ledger {
791 Some(ledger) => Some(ledger.mutation_lock(path).lock_owned().await),
792 None => None,
793 };
794
795 let content = substrate.read_text(path).await?;
796
797 let full_read = if let Some(ledger) = ledger {
801 match ledger.check(path, &content) {
802 ReadState::Unread => {
803 return Err(read_first_error(&substrate.display_path(path), "editing"));
804 }
805 ReadState::Stale => {
806 return Err(stale_error(&substrate.display_path(path)));
807 }
808 ReadState::FreshPartial if replace_all => {
809 return Err(full_read_error(
810 &substrate.display_path(path),
811 "replacing every occurrence in",
812 ));
813 }
814 ReadState::FreshPartial => false,
815 ReadState::FreshFull => true,
816 }
817 } else {
818 false
819 };
820
821 if old_text.is_empty() {
826 return Err(
827 "old_text must be non-empty — pass the exact existing text to replace \
828 (use write_file to replace a whole file)"
829 .to_string(),
830 );
831 }
832
833 let seen = ledger
841 .map(|ledger| ledger.lines_seen(path, &content))
842 .unwrap_or_default();
843 let recovered = if !replace_all && !content.contains(old_text) {
844 unnumbered_read_text(&content, old_text)
845 .filter(|(_, start, end)| seen_covers(&seen, *start, *end))
846 .map(|(plain, _, _)| plain)
847 } else {
848 None
849 };
850 let old_text = recovered.as_deref().unwrap_or(old_text);
851 let crlf = (!content.contains(old_text)
856 && old_text.contains('\n')
857 && !old_text.contains('\r')
858 && content.contains("\r\n"))
859 .then(|| {
860 (
861 old_text.replace('\n', "\r\n"),
862 new_text.replace("\r\n", "\n").replace('\n', "\r\n"),
863 )
864 })
865 .filter(|(old, _)| content.contains(old.as_str()));
867 let (old_text, new_text) = match &crlf {
868 Some((old, new)) => (old.as_str(), new.as_str()),
869 None => (old_text, new_text),
870 };
871 let count = content.matches(old_text).count();
872 if count == 0 {
873 let mut msg = format!("old_text not found in '{}'", substrate.display_path(path));
874 if looks_like_pasted_line_number(old_text) {
875 msg.push_str(
876 " — old_text looks like it includes read_file's line-number prefixes; \
877 strip them and retry",
878 );
879 }
880 if !replace_all {
881 if let Some((candidate, _, _)) = indentation_hint(&content, old_text)
882 .filter(|(_, start, end)| seen_covers(&seen, *start, *end))
883 {
884 msg.push_str("\nOne block matches after ignoring leading indentation or line endings. No edit was applied. Inspect this block and retry with exact old_text, preserving the intended indentation in new_text.\n");
887 msg.push_str(HINT_BEGIN);
888 msg.push_str(candidate);
889 msg.push_str(HINT_END);
890 }
891 }
892 return Err(msg);
893 }
894 let new_content = if replace_all {
895 content.replace(old_text, new_text)
896 } else {
897 if count > 1 {
898 return Err(format!(
899 "old_text found {count} times in '{}' and must match uniquely — \
900 pass `replace_all: true` to replace every occurrence, or add \
901 surrounding context to old_text so it matches one place",
902 substrate.display_path(path)
903 ));
904 }
905 content.replacen(old_text, new_text, 1)
906 };
907 substrate.write_text(path, &new_content).await?;
908
909 if let Some(ledger) = ledger {
916 if full_read || replace_all {
917 ledger.record(path, &new_content, full_read);
918 } else {
919 let offset = content.find(old_text).unwrap_or(0);
920 let start = content[..offset].matches('\n').count();
921 let old_end = start + old_text.matches('\n').count() + 1;
922 let new_end = start + new_text.matches('\n').count() + 1;
923 let seen = shift_seen_lines(&seen, start, old_end, new_end);
924 ledger.record_seen(path, &new_content, seen);
925 }
926 }
927
928 Ok(json!({
929 "edited": substrate.display_path(path),
930 "diff_summary": format!(
931 "replaced {} lines with {} lines",
932 old_text.lines().count(),
933 new_text.lines().count()
934 ),
935 "replacements": count,
936 "removed_display_line_numbers": recovered.is_some(),
937 }))
938}
939
940async fn exec_list_dir(substrate: &Arc<dyn Substrate>, params: &Value) -> Result<Value, String> {
941 let path = params.get("path").and_then(|v| v.as_str()).unwrap_or(".");
942
943 if !substrate.is_local() {
944 return list_dir_via_command(substrate, path).await;
945 }
946
947 let full_path = local_resolve(path)?;
948 let mut entries = Vec::new();
949
950 let read_dir = std::fs::read_dir(&full_path)
951 .map_err(|e| format!("failed to read dir '{}': {e}", full_path.display()))?;
952 for entry in read_dir {
953 let entry = entry.map_err(|e| format!("failed to read dir entry: {e}"))?;
954 let file_name = entry.file_name().to_string_lossy().to_string();
955 if should_skip_name(&file_name) {
956 continue;
957 }
958 let metadata = entry
959 .metadata()
960 .map_err(|e| format!("failed to read metadata for '{}': {e}", file_name))?;
961 entries.push(json!({
962 "name": file_name,
963 "path": entry.path().display().to_string(),
964 "is_dir": metadata.is_dir(),
965 "size_bytes": if metadata.is_file() { Some(metadata.len()) } else { None::<u64> },
966 }));
967 }
968
969 Ok(json!({
970 "path": full_path.display().to_string(),
971 "entries": entries,
972 }))
973}
974
975async fn exec_find_files(substrate: &Arc<dyn Substrate>, params: &Value) -> Result<Value, String> {
976 let pattern = params
977 .get("pattern")
978 .and_then(|v| v.as_str())
979 .ok_or("missing 'pattern' parameter")?;
980 let root = params.get("path").and_then(|v| v.as_str()).unwrap_or(".");
981 let max_results = params
982 .get("max_results")
983 .and_then(|v| v.as_u64())
984 .unwrap_or(1000) as usize;
985
986 if !substrate.is_local() {
987 return find_files_via_command(substrate, pattern, root, max_results).await;
988 }
989
990 let root_path = local_resolve(root)?;
991 let matcher = glob_to_regex(pattern)?;
992 let pattern_has_sep = pattern.contains('/');
997 let root_str = root_path.to_string_lossy().to_string();
998 let mut files = Vec::new();
999
1000 walk_files(&root_path, &mut |path| {
1001 if files.len() >= max_results {
1002 return;
1003 }
1004 if let Some(full) = path.to_str() {
1005 if matcher.is_match(&glob_haystack(pattern_has_sep, full, &root_str)) {
1006 files.push(path.display().to_string());
1007 }
1008 }
1009 })?;
1010
1011 Ok(json!({
1012 "files": files,
1013 "count": files.len(),
1014 "truncated": files.len() >= max_results,
1015 }))
1016}
1017
1018async fn exec_grep_files(substrate: &Arc<dyn Substrate>, params: &Value) -> Result<Value, String> {
1019 let pattern = params
1020 .get("pattern")
1021 .and_then(|v| v.as_str())
1022 .ok_or("missing 'pattern' parameter")?;
1023 let root = params.get("path").and_then(|v| v.as_str()).unwrap_or(".");
1024 let max_results = params
1025 .get("max_results")
1026 .and_then(|v| v.as_u64())
1027 .unwrap_or(50) as usize;
1028
1029 if !substrate.is_local() {
1030 return grep_files_via_command(substrate, pattern, root, max_results).await;
1031 }
1032
1033 let root_path = local_resolve(root)?;
1034 let regex = Regex::new(pattern).map_err(|e| format!("invalid regex pattern: {e}"))?;
1035 let mut matches = Vec::new();
1036
1037 walk_files(&root_path, &mut |path| {
1038 if matches.len() >= max_results || !is_text_file(path) {
1039 return;
1040 }
1041 let Ok(content) = std::fs::read_to_string(path) else {
1042 return;
1043 };
1044 if content.len() > MAX_FILE_BYTES {
1045 return;
1046 }
1047 for (idx, line) in content.lines().enumerate() {
1048 if regex.is_match(line) {
1049 matches.push(json!({
1050 "path": path.display().to_string(),
1051 "line": idx + 1,
1052 "text": line,
1053 }));
1054 if matches.len() >= max_results {
1055 break;
1056 }
1057 }
1058 }
1059 })?;
1060
1061 Ok(json!({
1062 "matches": matches,
1063 "count": matches.len(),
1064 "truncated": matches.len() >= max_results,
1065 }))
1066}
1067
1068fn exec_calculate(params: &Value) -> Result<Value, String> {
1069 let expression = params
1070 .get("expression")
1071 .and_then(|v| v.as_str())
1072 .ok_or("missing 'expression' parameter")?;
1073 let mut ns = |name: &str, args: Vec<f64>| -> Option<f64> {
1080 match (name, args.as_slice()) {
1081 ("sqrt", [x]) => Some(x.sqrt()),
1082 ("ln", [x]) => Some(x.ln()),
1083 ("pi", []) => Some(std::f64::consts::PI),
1084 ("e", []) => Some(std::f64::consts::E),
1085 _ => None,
1086 }
1087 };
1088 let result = fasteval::ez_eval(expression, &mut ns)
1089 .map_err(|e| format!("failed to evaluate expression: {e}"))?;
1090 Ok(json!({ "result": result }))
1091}
1092
1093fn local_resolve(path: &str) -> Result<PathBuf, String> {
1100 crate::substrate::LocalSubstrate::resolve_path(path)
1101}
1102
1103fn should_skip_name(name: &str) -> bool {
1104 name.starts_with('.') || matches!(name, "node_modules" | "__pycache__" | "target")
1105}
1106
1107fn is_text_file(path: &Path) -> bool {
1108 matches!(
1109 path.extension().and_then(|v| v.to_str()),
1110 Some(
1111 "c" | "cc"
1112 | "cpp"
1113 | "cs"
1114 | "css"
1115 | "go"
1116 | "h"
1117 | "html"
1118 | "ini"
1119 | "java"
1120 | "js"
1121 | "json"
1122 | "jsx"
1123 | "kt"
1124 | "md"
1125 | "py"
1126 | "rb"
1127 | "rs"
1128 | "sh"
1129 | "sql"
1130 | "toml"
1131 | "ts"
1132 | "tsx"
1133 | "txt"
1134 | "xml"
1135 | "yaml"
1136 | "yml"
1137 )
1138 )
1139}
1140
1141fn walk_files(root: &Path, visit: &mut dyn FnMut(&Path)) -> Result<(), String> {
1142 if root.is_file() {
1143 visit(root);
1144 return Ok(());
1145 }
1146
1147 let read_dir = std::fs::read_dir(root)
1148 .map_err(|e| format!("failed to read dir '{}': {e}", root.display()))?;
1149 for entry in read_dir {
1150 let entry = entry.map_err(|e| format!("failed to read dir entry: {e}"))?;
1151 let path = entry.path();
1152 let file_name = entry.file_name().to_string_lossy().to_string();
1153 if should_skip_name(&file_name) {
1154 continue;
1155 }
1156 let metadata = entry
1157 .metadata()
1158 .map_err(|e| format!("failed to read metadata for '{}': {e}", path.display()))?;
1159 if metadata.is_dir() {
1160 walk_files(&path, visit)?;
1161 } else if metadata.is_file() {
1162 visit(&path);
1163 }
1164 }
1165 Ok(())
1166}
1167
1168fn glob_to_regex(pattern: &str) -> Result<Regex, String> {
1178 let chars: Vec<char> = pattern.chars().collect();
1179 let mut re = String::from("^");
1180 let mut i = 0;
1181 while i < chars.len() {
1182 match chars[i] {
1183 '*' => {
1184 if i + 1 < chars.len() && chars[i + 1] == '*' {
1185 i += 1; if i + 1 < chars.len() && chars[i + 1] == '/' {
1187 i += 1; re.push_str("(?:.*/)?");
1189 } else {
1190 re.push_str(".*");
1191 }
1192 } else {
1193 re.push_str("[^/]*");
1194 }
1195 }
1196 '?' => re.push_str("[^/]"),
1197 c => re.push_str(®ex::escape(&c.to_string())),
1198 }
1199 i += 1;
1200 }
1201 re.push('$');
1202 Regex::new(&re).map_err(|e| format!("invalid glob pattern: {e}"))
1203}
1204
1205fn glob_haystack(pattern_has_sep: bool, full: &str, root: &str) -> String {
1213 if pattern_has_sep {
1214 let full = full.replace('\\', "/");
1215 let root = root.replace('\\', "/");
1216 full.strip_prefix(&root)
1217 .unwrap_or(&full)
1218 .trim_start_matches('/')
1219 .to_string()
1220 } else {
1221 full.rsplit(['/', '\\']).next().unwrap_or(full).to_string()
1222 }
1223}
1224
1225fn shell_quote(s: &str) -> String {
1234 format!("'{}'", s.replace('\'', "'\\''"))
1236}
1237
1238async fn list_dir_via_command(substrate: &Arc<dyn Substrate>, path: &str) -> Result<Value, String> {
1239 let cmd = format!("ls -1Ap {}", shell_quote(path));
1240 let out = substrate.run_command(&cmd, Some(30.0)).await?;
1241 let entries: Vec<Value> = out
1242 .stdout
1243 .lines()
1244 .filter(|l| !l.is_empty())
1245 .filter(|name| {
1246 let bare = name.trim_end_matches('/');
1247 !should_skip_name(bare)
1248 })
1249 .map(|name| {
1250 let is_dir = name.ends_with('/');
1251 let bare = name.trim_end_matches('/');
1252 json!({
1253 "name": bare,
1254 "path": format!("{}/{}", path.trim_end_matches('/'), bare),
1255 "is_dir": is_dir,
1256 "size_bytes": Value::Null,
1257 })
1258 })
1259 .collect();
1260 Ok(json!({ "path": path, "entries": entries }))
1261}
1262
1263async fn find_files_via_command(
1264 substrate: &Arc<dyn Substrate>,
1265 pattern: &str,
1266 root: &str,
1267 max_results: usize,
1268) -> Result<Value, String> {
1269 let cmd = format!("find {} -type f", shell_quote(root));
1275 let out = substrate.run_command(&cmd, Some(30.0)).await?;
1276 let matcher = glob_to_regex(pattern)?;
1277 let pattern_has_sep = pattern.contains('/');
1278 let mut files: Vec<String> = Vec::new();
1279 let mut truncated = false;
1280 for line in out.stdout.lines() {
1281 if line.is_empty() {
1282 continue;
1283 }
1284 let name = line.rsplit(['/', '\\']).next().unwrap_or(line);
1285 if should_skip_name(name) {
1286 continue;
1287 }
1288 if !matcher.is_match(&glob_haystack(pattern_has_sep, line, root)) {
1289 continue;
1290 }
1291 if files.len() >= max_results {
1292 truncated = true;
1293 break;
1294 }
1295 files.push(line.to_string());
1296 }
1297 Ok(json!({
1298 "files": files,
1299 "count": files.len(),
1300 "truncated": truncated,
1301 }))
1302}
1303
1304async fn grep_files_via_command(
1305 substrate: &Arc<dyn Substrate>,
1306 pattern: &str,
1307 root: &str,
1308 max_results: usize,
1309) -> Result<Value, String> {
1310 let cmd = format!("grep -rnE {} {}", shell_quote(pattern), shell_quote(root));
1311 let out = substrate.run_command(&cmd, Some(30.0)).await?;
1312 let mut matches = Vec::new();
1313 for line in out.stdout.lines() {
1314 if matches.len() >= max_results {
1315 break;
1316 }
1317 let mut parts = line.splitn(3, ':');
1319 let (Some(p), Some(ln), Some(text)) = (parts.next(), parts.next(), parts.next()) else {
1320 continue;
1321 };
1322 let Ok(line_no) = ln.parse::<usize>() else {
1323 continue;
1324 };
1325 matches.push(json!({ "path": p, "line": line_no, "text": text }));
1326 }
1327 let truncated = matches.len() >= max_results;
1328 Ok(json!({
1329 "matches": matches,
1330 "count": matches.len(),
1331 "truncated": truncated,
1332 }))
1333}
1334
1335#[cfg(test)]
1336mod tests {
1337 use super::*;
1338
1339 #[test]
1340 fn glob_patterns_match_file_names() {
1341 let regex = glob_to_regex("*.rs").unwrap();
1342 assert!(regex.is_match("lib.rs"));
1343 assert!(!regex.is_match("lib.ts"));
1344 }
1345
1346 #[test]
1352 fn glob_haystack_supports_path_and_basename_matching() {
1353 assert_eq!(
1355 glob_haystack(true, "/root/src/inner/deep.rs", "/root"),
1356 "src/inner/deep.rs"
1357 );
1358 assert_eq!(
1359 glob_haystack(false, "/root/src/inner/deep.rs", "/root"),
1360 "deep.rs"
1361 );
1362
1363 let m = glob_to_regex("src/**/*.rs").unwrap();
1367 assert!(m.is_match(&glob_haystack(true, "/root/src/top.rs", "/root")));
1368 assert!(m.is_match(&glob_haystack(true, "/root/src/inner/deep.rs", "/root")));
1369 assert!(!m.is_match(&glob_haystack(true, "/root/src/inner/note.txt", "/root")));
1370 }
1371
1372 #[tokio::test]
1376 async fn find_files_supports_recursive_path_globs() {
1377 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1378 let dir = std::env::temp_dir().join(format!(
1379 "car-find-glob-{}",
1380 std::time::SystemTime::now()
1381 .duration_since(std::time::UNIX_EPOCH)
1382 .unwrap()
1383 .as_nanos()
1384 ));
1385 std::fs::create_dir_all(dir.join("src").join("inner")).unwrap();
1386 std::fs::write(dir.join("src").join("top.rs"), "x").unwrap();
1387 std::fs::write(dir.join("src").join("inner").join("deep.rs"), "x").unwrap();
1388 std::fs::write(dir.join("src").join("inner").join("note.txt"), "x").unwrap();
1389 let root = dir.to_string_lossy().to_string();
1390
1391 let r = exec_find_files(
1392 &substrate,
1393 &json!({ "path": root, "pattern": "src/**/*.rs" }),
1394 )
1395 .await
1396 .unwrap();
1397 let joined = r["files"]
1398 .as_array()
1399 .unwrap()
1400 .iter()
1401 .map(|v| v.as_str().unwrap())
1402 .collect::<Vec<_>>()
1403 .join("\n");
1404
1405 assert!(
1406 joined.contains("deep.rs"),
1407 "recursive glob missed nested file:\n{joined}"
1408 );
1409 assert!(
1410 joined.contains("top.rs"),
1411 "recursive glob missed top-level file:\n{joined}"
1412 );
1413 assert!(
1414 !joined.contains("note.txt"),
1415 "glob matched the wrong extension:\n{joined}"
1416 );
1417
1418 let _ = std::fs::remove_dir_all(&dir);
1419 }
1420
1421 #[tokio::test]
1427 async fn find_files_non_local_substrate_applies_path_glob() {
1428 use crate::substrate::CommandOutput;
1429
1430 struct RemoteStub {
1431 stdout: String,
1432 }
1433 #[async_trait::async_trait]
1434 impl Substrate for RemoteStub {
1435 fn name(&self) -> &str {
1436 "test-remote"
1437 }
1438 async fn run_command(
1440 &self,
1441 _cmd: &str,
1442 _timeout_s: Option<f64>,
1443 ) -> Result<CommandOutput, String> {
1444 Ok(CommandOutput {
1445 stdout: self.stdout.clone(),
1446 stderr: String::new(),
1447 exit_code: 0,
1448 })
1449 }
1450 async fn read_text(&self, _path: &str) -> Result<String, String> {
1451 Err("unused".into())
1452 }
1453 async fn write_text(&self, _path: &str, _content: &str) -> Result<(), String> {
1454 Err("unused".into())
1455 }
1456 async fn read_bytes(
1457 &self,
1458 _path: &str,
1459 _offset: Option<u64>,
1460 _len: Option<u64>,
1461 ) -> Result<Vec<u8>, String> {
1462 Err("unused".into())
1463 }
1464 async fn write_bytes(&self, _path: &str, _bytes: &[u8]) -> Result<(), String> {
1465 Err("unused".into())
1466 }
1467 }
1468
1469 let substrate: Arc<dyn Substrate> = Arc::new(RemoteStub {
1471 stdout: [
1472 "/root/src/top.rs",
1473 "/root/src/inner/deep.rs",
1474 "/root/src/inner/note.txt",
1475 "/root/README.md",
1476 ]
1477 .join("\n"),
1478 });
1479
1480 let r = exec_find_files(
1481 &substrate,
1482 &json!({ "path": "/root", "pattern": "src/**/*.rs" }),
1483 )
1484 .await
1485 .unwrap();
1486 let files: Vec<&str> = r["files"]
1487 .as_array()
1488 .unwrap()
1489 .iter()
1490 .map(|v| v.as_str().unwrap())
1491 .collect();
1492
1493 assert_eq!(files, vec!["/root/src/top.rs", "/root/src/inner/deep.rs"]);
1496 assert_eq!(r["count"], 2);
1497 }
1498
1499 fn fresh_dir(tag: &str) -> PathBuf {
1501 let dir = std::env::temp_dir().join(format!(
1502 "car-agent-basics-{tag}-{}",
1503 std::time::SystemTime::now()
1504 .duration_since(std::time::UNIX_EPOCH)
1505 .unwrap()
1506 .as_nanos()
1507 ));
1508 std::fs::create_dir_all(&dir).unwrap();
1509 dir
1510 }
1511
1512 #[tokio::test]
1513 async fn read_write_roundtrip_against_local_substrate() {
1514 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1515 let dir = fresh_dir("roundtrip");
1516 let path = dir.join("note.txt").to_string_lossy().to_string();
1517
1518 let w = exec_write_file(&substrate, None, &json!({ "path": path, "content": "abc" }))
1519 .await
1520 .unwrap();
1521 assert_eq!(w["bytes_written"], 3);
1522
1523 let r = exec_read_file(&substrate, None, &json!({ "path": path }))
1525 .await
1526 .unwrap();
1527 assert_eq!(r["content"], "abc");
1528 assert_eq!(r["size_bytes"], 3);
1529 assert_eq!(r["total_lines"], 1);
1530
1531 exec_write_file(
1533 &substrate,
1534 None,
1535 &json!({ "path": path, "content": "def", "append": true }),
1536 )
1537 .await
1538 .unwrap();
1539 let r2 = exec_read_file(&substrate, None, &json!({ "path": path }))
1540 .await
1541 .unwrap();
1542 assert_eq!(r2["content"], "abcdef");
1543
1544 let e = exec_edit_file(
1546 &substrate,
1547 None,
1548 &json!({ "path": path, "old_text": "abc", "new_text": "XYZ" }),
1549 )
1550 .await
1551 .unwrap();
1552 assert!(e["edited"].is_string());
1553 assert_eq!(e["replacements"], 1);
1554 let r3 = exec_read_file(&substrate, None, &json!({ "path": path }))
1555 .await
1556 .unwrap();
1557 assert_eq!(r3["content"], "XYZdef");
1558
1559 std::fs::remove_dir_all(&dir).ok();
1560 }
1561
1562 #[tokio::test]
1566 async fn read_file_output_is_line_numbered() {
1567 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1568 let ledger = ReadLedger::new();
1569 let dir = fresh_dir("linenum");
1570 let path = dir.join("f.txt").to_string_lossy().to_string();
1571 exec_write_file(
1572 &substrate,
1573 None,
1574 &json!({ "path": path, "content": "alpha\nbeta\ngamma" }),
1575 )
1576 .await
1577 .unwrap();
1578
1579 let r = execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1580 .await
1581 .unwrap()
1582 .unwrap();
1583 assert_eq!(r["content"], " 1\talpha\n 2\tbeta\n 3\tgamma");
1584 assert_eq!(r["total_lines"], 3);
1585
1586 let r2 = execute_with_ledger(
1588 &substrate,
1589 &ledger,
1590 "read_file",
1591 &json!({ "path": path, "offset": 1, "limit": 1 }),
1592 )
1593 .await
1594 .unwrap()
1595 .unwrap();
1596 assert_eq!(r2["content"], " 2\tbeta");
1597 assert_eq!(r2["total_lines"], 3);
1598
1599 std::fs::remove_dir_all(&dir).ok();
1600 }
1601
1602 #[tokio::test]
1603 async fn plain_execute_preserves_legacy_raw_read_output() {
1604 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1605 let dir = fresh_dir("rawread");
1606 let path = dir.join("f.txt").to_string_lossy().to_string();
1607 std::fs::write(&path, "alpha\nbeta\n").unwrap();
1608
1609 let result = execute(&substrate, "read_file", &json!({ "path": path }))
1610 .await
1611 .unwrap()
1612 .unwrap();
1613 assert_eq!(result["content"], "alpha\nbeta\n");
1614
1615 std::fs::remove_dir_all(&dir).ok();
1616 }
1617
1618 #[tokio::test]
1621 async fn edit_requires_prior_read() {
1622 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1623 let ledger = ReadLedger::new();
1624 let dir = fresh_dir("editread");
1625 let path = dir.join("f.txt").to_string_lossy().to_string();
1626 std::fs::write(dir.join("f.txt"), "hello world").unwrap();
1628
1629 let err = execute_with_ledger(
1630 &substrate,
1631 &ledger,
1632 "edit_file",
1633 &json!({ "path": path, "old_text": "hello", "new_text": "hi" }),
1634 )
1635 .await
1636 .unwrap()
1637 .unwrap_err();
1638 assert!(err.contains("before editing it"), "{err}");
1639 assert!(err.contains("read_file"), "{err}");
1640
1641 execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1643 .await
1644 .unwrap()
1645 .unwrap();
1646 let ok = execute_with_ledger(
1647 &substrate,
1648 &ledger,
1649 "edit_file",
1650 &json!({ "path": path, "old_text": "hello", "new_text": "hi" }),
1651 )
1652 .await
1653 .unwrap()
1654 .unwrap();
1655 assert_eq!(ok["replacements"], 1);
1656
1657 std::fs::remove_dir_all(&dir).ok();
1658 }
1659
1660 #[tokio::test]
1663 async fn edit_detects_stale_file() {
1664 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1665 let ledger = ReadLedger::new();
1666 let dir = fresh_dir("stale");
1667 let path = dir.join("f.txt").to_string_lossy().to_string();
1668 std::fs::write(dir.join("f.txt"), "version one").unwrap();
1669
1670 execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1672 .await
1673 .unwrap()
1674 .unwrap();
1675 std::fs::write(dir.join("f.txt"), "version two changed").unwrap();
1676
1677 let err = execute_with_ledger(
1678 &substrate,
1679 &ledger,
1680 "edit_file",
1681 &json!({ "path": path, "old_text": "version", "new_text": "v" }),
1682 )
1683 .await
1684 .unwrap()
1685 .unwrap_err();
1686 assert!(err.contains("changed since you last read it"), "{err}");
1687
1688 execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1690 .await
1691 .unwrap()
1692 .unwrap();
1693 let ok = execute_with_ledger(
1694 &substrate,
1695 &ledger,
1696 "edit_file",
1697 &json!({ "path": path, "old_text": "version", "new_text": "v" }),
1698 )
1699 .await
1700 .unwrap()
1701 .unwrap();
1702 assert!(ok["edited"].is_string());
1703
1704 std::fs::remove_dir_all(&dir).ok();
1705 }
1706
1707 #[tokio::test]
1712 async fn read_file_preserves_crlf_line_endings() {
1713 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1714 let ledger = ReadLedger::new();
1715 let dir = fresh_dir("crlf");
1716 let path = dir.join("f.txt").to_string_lossy().to_string();
1717 std::fs::write(dir.join("f.txt"), "line one\r\nline two\r\n").unwrap();
1718
1719 let out = execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1720 .await
1721 .unwrap()
1722 .unwrap();
1723 let shown = out["content"].as_str().unwrap();
1724 assert_eq!(
1725 shown, " 1\tline one\r\n 2\tline two\r",
1726 "\\r must survive into the numbered display"
1727 );
1728
1729 let ok = execute_with_ledger(
1732 &substrate,
1733 &ledger,
1734 "edit_file",
1735 &json!({ "path": path, "old_text": "line one\r\nline two", "new_text": "merged" }),
1736 )
1737 .await
1738 .unwrap()
1739 .unwrap();
1740 assert_eq!(ok["replacements"], 1);
1741
1742 std::fs::remove_dir_all(&dir).ok();
1743 }
1744
1745 #[tokio::test]
1749 async fn edit_rejects_empty_old_text() {
1750 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1751 let dir = fresh_dir("emptyold");
1752 let path = dir.join("f.txt").to_string_lossy().to_string();
1753 exec_write_file(&substrate, None, &json!({ "path": path, "content": "abc" }))
1754 .await
1755 .unwrap();
1756
1757 for replace_all in [false, true] {
1758 let err = exec_edit_file(
1759 &substrate,
1760 None,
1761 &json!({
1762 "path": path,
1763 "old_text": "",
1764 "new_text": "X",
1765 "replace_all": replace_all
1766 }),
1767 )
1768 .await
1769 .unwrap_err();
1770 assert!(err.contains("old_text must be non-empty"), "{err}");
1771 }
1772 assert_eq!(std::fs::read_to_string(dir.join("f.txt")).unwrap(), "abc");
1774
1775 std::fs::remove_dir_all(&dir).ok();
1776 }
1777
1778 #[tokio::test]
1783 async fn write_existing_rejects_stale_after_external_change() {
1784 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1785 let ledger = ReadLedger::new();
1786 let dir = fresh_dir("stalewrite");
1787 let path = dir.join("f.txt").to_string_lossy().to_string();
1788 std::fs::write(dir.join("f.txt"), "original").unwrap();
1789
1790 execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1791 .await
1792 .unwrap()
1793 .unwrap();
1794 std::fs::write(dir.join("f.txt"), "changed underneath").unwrap();
1796
1797 let err = execute_with_ledger(
1798 &substrate,
1799 &ledger,
1800 "write_file",
1801 &json!({ "path": path, "content": "clobber" }),
1802 )
1803 .await
1804 .unwrap()
1805 .unwrap_err();
1806 assert!(err.contains("changed since you last read it"), "{err}");
1807
1808 let err = execute_with_ledger(
1810 &substrate,
1811 &ledger,
1812 "write_file",
1813 &json!({ "path": path, "content": " + more", "append": true }),
1814 )
1815 .await
1816 .unwrap()
1817 .unwrap_err();
1818 assert!(err.contains("changed since you last read it"), "{err}");
1819
1820 execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1822 .await
1823 .unwrap()
1824 .unwrap();
1825 execute_with_ledger(
1826 &substrate,
1827 &ledger,
1828 "write_file",
1829 &json!({ "path": path, "content": "rewritten" }),
1830 )
1831 .await
1832 .unwrap()
1833 .unwrap();
1834 assert_eq!(
1835 std::fs::read_to_string(dir.join("f.txt")).unwrap(),
1836 "rewritten"
1837 );
1838
1839 std::fs::remove_dir_all(&dir).ok();
1840 }
1841
1842 #[tokio::test]
1845 async fn replace_all_replaces_every_occurrence() {
1846 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1847 let dir = fresh_dir("replaceall");
1848 let path = dir.join("f.txt").to_string_lossy().to_string();
1849 exec_write_file(
1850 &substrate,
1851 None,
1852 &json!({ "path": path, "content": "a x a x a" }),
1853 )
1854 .await
1855 .unwrap();
1856
1857 let err = exec_edit_file(
1859 &substrate,
1860 None,
1861 &json!({ "path": path, "old_text": "a", "new_text": "b" }),
1862 )
1863 .await
1864 .unwrap_err();
1865 assert!(err.contains("replace_all"), "{err}");
1866
1867 let ok = exec_edit_file(
1869 &substrate,
1870 None,
1871 &json!({ "path": path, "old_text": "a", "new_text": "b", "replace_all": true }),
1872 )
1873 .await
1874 .unwrap();
1875 assert_eq!(ok["replacements"], 3);
1876 let r = exec_read_file(&substrate, None, &json!({ "path": path }))
1877 .await
1878 .unwrap();
1879 assert_eq!(r["content"], "b x b x b");
1880
1881 std::fs::remove_dir_all(&dir).ok();
1882 }
1883
1884 #[tokio::test]
1886 async fn write_new_file_allowed() {
1887 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1888 let ledger = ReadLedger::new();
1889 let dir = fresh_dir("writenew");
1890 let path = dir.join("new.txt").to_string_lossy().to_string();
1891
1892 let ok = execute_with_ledger(
1893 &substrate,
1894 &ledger,
1895 "write_file",
1896 &json!({ "path": path, "content": "fresh" }),
1897 )
1898 .await
1899 .unwrap()
1900 .unwrap();
1901 assert_eq!(ok["bytes_written"], 5);
1902
1903 std::fs::remove_dir_all(&dir).ok();
1904 }
1905
1906 #[tokio::test]
1908 async fn write_existing_requires_prior_read() {
1909 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1910 let ledger = ReadLedger::new();
1911 let dir = fresh_dir("writeexisting");
1912 let path = dir.join("f.txt").to_string_lossy().to_string();
1913 std::fs::write(dir.join("f.txt"), "existing content").unwrap();
1914
1915 let err = execute_with_ledger(
1916 &substrate,
1917 &ledger,
1918 "write_file",
1919 &json!({ "path": path, "content": "clobber" }),
1920 )
1921 .await
1922 .unwrap()
1923 .unwrap_err();
1924 assert!(err.contains("before overwriting it"), "{err}");
1925 assert!(err.contains("read_file"), "{err}");
1926
1927 execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1929 .await
1930 .unwrap()
1931 .unwrap();
1932 let ok = execute_with_ledger(
1933 &substrate,
1934 &ledger,
1935 "write_file",
1936 &json!({ "path": path, "content": "clobber" }),
1937 )
1938 .await
1939 .unwrap()
1940 .unwrap();
1941 assert_eq!(ok["bytes_written"], 7);
1942
1943 std::fs::remove_dir_all(&dir).ok();
1944 }
1945
1946 #[tokio::test]
1950 async fn write_self_records_enabling_edit() {
1951 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1952 let ledger = ReadLedger::new();
1953 let dir = fresh_dir("writeedits");
1954 let path = dir.join("f.txt").to_string_lossy().to_string();
1955
1956 execute_with_ledger(
1957 &substrate,
1958 &ledger,
1959 "write_file",
1960 &json!({ "path": path, "content": "one two three" }),
1961 )
1962 .await
1963 .unwrap()
1964 .unwrap();
1965 let ok = execute_with_ledger(
1967 &substrate,
1968 &ledger,
1969 "edit_file",
1970 &json!({ "path": path, "old_text": "two", "new_text": "TWO" }),
1971 )
1972 .await
1973 .unwrap()
1974 .unwrap();
1975 assert_eq!(ok["replacements"], 1);
1976
1977 std::fs::remove_dir_all(&dir).ok();
1978 }
1979
1980 #[tokio::test]
1984 async fn edit_self_records_enabling_second_edit_without_reread() {
1985 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1986 let ledger = ReadLedger::new();
1987 let dir = fresh_dir("editselfrec");
1988 let path = dir.join("f.txt").to_string_lossy().to_string();
1989 std::fs::write(dir.join("f.txt"), "one two three").unwrap();
1990
1991 execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1992 .await
1993 .unwrap()
1994 .unwrap();
1995 execute_with_ledger(
1996 &substrate,
1997 &ledger,
1998 "edit_file",
1999 &json!({ "path": path, "old_text": "one", "new_text": "1" }),
2000 )
2001 .await
2002 .unwrap()
2003 .unwrap();
2004
2005 let ok = execute_with_ledger(
2007 &substrate,
2008 &ledger,
2009 "edit_file",
2010 &json!({ "path": path, "old_text": "two", "new_text": "2" }),
2011 )
2012 .await
2013 .unwrap()
2014 .unwrap();
2015 assert_eq!(ok["replacements"], 1);
2016 assert_eq!(
2017 std::fs::read_to_string(dir.join("f.txt")).unwrap(),
2018 "1 2 three"
2019 );
2020
2021 std::fs::remove_dir_all(&dir).ok();
2022 }
2023
2024 #[tokio::test]
2028 async fn sliced_read_hashes_full_file_licensing_edit() {
2029 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2030 let ledger = ReadLedger::new();
2031 let dir = fresh_dir("slicedread");
2032 let path = dir.join("f.txt").to_string_lossy().to_string();
2033 std::fs::write(dir.join("f.txt"), "l1\nl2\nl3\nl4").unwrap();
2034
2035 let r = execute_with_ledger(
2036 &substrate,
2037 &ledger,
2038 "read_file",
2039 &json!({ "path": path, "offset": 1, "limit": 1 }),
2040 )
2041 .await
2042 .unwrap()
2043 .unwrap();
2044 assert_eq!(r["content"], " 2\tl2"); let ok = execute_with_ledger(
2048 &substrate,
2049 &ledger,
2050 "edit_file",
2051 &json!({ "path": path, "old_text": "l4", "new_text": "L4" }),
2052 )
2053 .await
2054 .unwrap()
2055 .unwrap();
2056 assert_eq!(ok["replacements"], 1);
2057
2058 std::fs::remove_dir_all(&dir).ok();
2059 }
2060
2061 #[tokio::test]
2064 async fn append_to_existing_unread_requires_prior_read() {
2065 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2066 let ledger = ReadLedger::new();
2067 let dir = fresh_dir("appendunread");
2068 let path = dir.join("f.txt").to_string_lossy().to_string();
2069 std::fs::write(dir.join("f.txt"), "existing content").unwrap();
2070
2071 let err = execute_with_ledger(
2072 &substrate,
2073 &ledger,
2074 "write_file",
2075 &json!({ "path": path, "content": " more", "append": true }),
2076 )
2077 .await
2078 .unwrap()
2079 .unwrap_err();
2080 assert!(err.contains("before overwriting it"), "{err}");
2081
2082 execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
2083 .await
2084 .unwrap()
2085 .unwrap();
2086 let ok = execute_with_ledger(
2087 &substrate,
2088 &ledger,
2089 "write_file",
2090 &json!({ "path": path, "content": " more", "append": true }),
2091 )
2092 .await
2093 .unwrap()
2094 .unwrap();
2095 assert_eq!(ok["append"], true);
2096 assert_eq!(
2097 std::fs::read_to_string(dir.join("f.txt")).unwrap(),
2098 "existing content more"
2099 );
2100
2101 std::fs::remove_dir_all(&dir).ok();
2102 }
2103
2104 #[tokio::test]
2105 async fn append_to_existing_empty_file_requires_prior_read() {
2106 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2107 let ledger = ReadLedger::new();
2108 let dir = fresh_dir("appendempty");
2109 let path = dir.join("f.txt").to_string_lossy().to_string();
2110 std::fs::write(dir.join("f.txt"), "").unwrap();
2111
2112 let err = execute_with_ledger(
2113 &substrate,
2114 &ledger,
2115 "write_file",
2116 &json!({ "path": path, "content": "new", "append": true }),
2117 )
2118 .await
2119 .unwrap()
2120 .unwrap_err();
2121 assert!(err.contains("before overwriting it"), "{err}");
2122 assert!(std::fs::read_to_string(dir.join("f.txt"))
2123 .unwrap()
2124 .is_empty());
2125
2126 std::fs::remove_dir_all(&dir).ok();
2127 }
2128
2129 #[tokio::test]
2130 async fn partial_read_cannot_authorize_whole_file_mutation() {
2131 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2132 let ledger = ReadLedger::new();
2133 let dir = fresh_dir("partialread");
2134 let path = dir.join("f.txt").to_string_lossy().to_string();
2135 std::fs::write(dir.join("f.txt"), "first\nsecond\nthird").unwrap();
2136
2137 execute_with_ledger(
2138 &substrate,
2139 &ledger,
2140 "read_file",
2141 &json!({ "path": path, "offset": 0, "limit": 1 }),
2142 )
2143 .await
2144 .unwrap()
2145 .unwrap();
2146
2147 let overwrite = execute_with_ledger(
2148 &substrate,
2149 &ledger,
2150 "write_file",
2151 &json!({ "path": path, "content": "replacement" }),
2152 )
2153 .await
2154 .unwrap()
2155 .unwrap_err();
2156 assert!(overwrite.contains("full current content"), "{overwrite}");
2157
2158 let replace_all = execute_with_ledger(
2159 &substrate,
2160 &ledger,
2161 "edit_file",
2162 &json!({ "path": path, "old_text": "i", "new_text": "I", "replace_all": true }),
2163 )
2164 .await
2165 .unwrap()
2166 .unwrap_err();
2167 assert!(
2168 replace_all.contains("full current content"),
2169 "{replace_all}"
2170 );
2171
2172 let edit = execute_with_ledger(
2174 &substrate,
2175 &ledger,
2176 "edit_file",
2177 &json!({ "path": path, "old_text": "first", "new_text": "FIRST" }),
2178 )
2179 .await
2180 .unwrap()
2181 .unwrap();
2182 assert_eq!(edit["replacements"], 1);
2183
2184 std::fs::remove_dir_all(&dir).ok();
2185 }
2186
2187 #[tokio::test]
2188 async fn guarded_write_refuses_existing_non_utf8_file() {
2189 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2190 let ledger = ReadLedger::new();
2191 let dir = fresh_dir("nonutf8write");
2192 let path = dir.join("f.bin").to_string_lossy().to_string();
2193 std::fs::write(dir.join("f.bin"), [0xff, 0x00, 0xfe]).unwrap();
2194
2195 let err = execute_with_ledger(
2196 &substrate,
2197 &ledger,
2198 "write_file",
2199 &json!({ "path": path, "content": "text" }),
2200 )
2201 .await
2202 .unwrap()
2203 .unwrap_err();
2204 assert!(err.contains("cannot modify existing file"), "{err}");
2205 assert_eq!(
2206 std::fs::read(dir.join("f.bin")).unwrap(),
2207 [0xff, 0x00, 0xfe]
2208 );
2209
2210 std::fs::remove_dir_all(&dir).ok();
2211 }
2212
2213 #[tokio::test]
2217 async fn append_detects_stale_after_external_change() {
2218 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2219 let ledger = ReadLedger::new();
2220 let dir = fresh_dir("appendstale");
2221 let path = dir.join("f.txt").to_string_lossy().to_string();
2222 std::fs::write(dir.join("f.txt"), "v1").unwrap();
2223
2224 execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
2225 .await
2226 .unwrap()
2227 .unwrap();
2228 std::fs::write(dir.join("f.txt"), "v2 changed").unwrap();
2229
2230 let err = execute_with_ledger(
2231 &substrate,
2232 &ledger,
2233 "write_file",
2234 &json!({ "path": path, "content": " appended", "append": true }),
2235 )
2236 .await
2237 .unwrap()
2238 .unwrap_err();
2239 assert!(err.contains("changed since you last read it"), "{err}");
2240
2241 std::fs::remove_dir_all(&dir).ok();
2242 }
2243
2244 #[tokio::test]
2248 async fn replace_all_edit_is_gated_by_the_ledger() {
2249 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2250 let ledger = ReadLedger::new();
2251 let dir = fresh_dir("replaceallgate");
2252 let path = dir.join("f.txt").to_string_lossy().to_string();
2253 std::fs::write(dir.join("f.txt"), "a a a").unwrap();
2254
2255 execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
2256 .await
2257 .unwrap()
2258 .unwrap();
2259 let ok = execute_with_ledger(
2260 &substrate,
2261 &ledger,
2262 "edit_file",
2263 &json!({ "path": path, "old_text": "a", "new_text": "b", "replace_all": true }),
2264 )
2265 .await
2266 .unwrap()
2267 .unwrap();
2268 assert_eq!(ok["replacements"], 3);
2269
2270 let ok2 = execute_with_ledger(
2272 &substrate,
2273 &ledger,
2274 "edit_file",
2275 &json!({ "path": path, "old_text": "b", "new_text": "c", "replace_all": true }),
2276 )
2277 .await
2278 .unwrap()
2279 .unwrap();
2280 assert_eq!(ok2["replacements"], 3);
2281
2282 std::fs::write(dir.join("f.txt"), "c c c c").unwrap();
2284 let err = execute_with_ledger(
2285 &substrate,
2286 &ledger,
2287 "edit_file",
2288 &json!({ "path": path, "old_text": "c", "new_text": "d", "replace_all": true }),
2289 )
2290 .await
2291 .unwrap()
2292 .unwrap_err();
2293 assert!(err.contains("changed since you last read it"), "{err}");
2294
2295 std::fs::remove_dir_all(&dir).ok();
2296 }
2297
2298 #[tokio::test]
2303 async fn builtin_names_never_return_unknown_tool_prefix() {
2304 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2305 let ledger = ReadLedger::new();
2306
2307 for name in [
2308 "read_file",
2309 "write_file",
2310 "edit_file",
2311 "list_dir",
2312 "find_files",
2313 "grep_files",
2314 "calculate",
2315 ] {
2316 let result = execute_with_ledger(&substrate, &ledger, name, &json!({})).await;
2317 let inner = result.unwrap_or_else(|| panic!("{name} must be handled, not None"));
2318 if let Err(e) = &inner {
2319 assert!(
2320 !e.starts_with("unknown tool"),
2321 "{name} error must not start with 'unknown tool': {e}"
2322 );
2323 }
2324 }
2325
2326 assert!(
2328 execute_with_ledger(&substrate, &ledger, "no_such_tool", &json!({}))
2329 .await
2330 .is_none()
2331 );
2332
2333 let dir = fresh_dir("unknownprefix");
2335 let path = dir.join("f.txt").to_string_lossy().to_string();
2336 std::fs::write(dir.join("f.txt"), "content").unwrap();
2337 let edit_err = execute_with_ledger(
2338 &substrate,
2339 &ledger,
2340 "edit_file",
2341 &json!({ "path": path, "old_text": "content", "new_text": "x" }),
2342 )
2343 .await
2344 .unwrap()
2345 .unwrap_err();
2346 assert!(!edit_err.starts_with("unknown tool"), "{edit_err}");
2347 assert!(edit_err.contains("before editing it"), "{edit_err}");
2348 let write_err = execute_with_ledger(
2349 &substrate,
2350 &ledger,
2351 "write_file",
2352 &json!({ "path": path, "content": "y" }),
2353 )
2354 .await
2355 .unwrap()
2356 .unwrap_err();
2357 assert!(!write_err.starts_with("unknown tool"), "{write_err}");
2358
2359 std::fs::remove_dir_all(&dir).ok();
2360 }
2361
2362 #[test]
2365 fn ledger_normalizes_dot_components() {
2366 let ledger = ReadLedger::new();
2367 ledger.record("./src/x.rs", "content", true);
2368 assert_eq!(ledger.check("src/x.rs", "content"), ReadState::FreshFull);
2369 assert_eq!(ledger.check("./src/x.rs", "content"), ReadState::FreshFull);
2370
2371 let ledger2 = ReadLedger::new();
2372 ledger2.record("src/x.rs", "content", true);
2373 assert_eq!(ledger2.check("./src/x.rs", "content"), ReadState::FreshFull);
2374
2375 assert_eq!(ledger.check("other.rs", "content"), ReadState::Unread);
2378 let ledger3 = ReadLedger::new();
2379 ledger3.record("/root/a/./b", "c", true);
2380 assert_eq!(ledger3.check("/root/a/b", "c"), ReadState::FreshFull);
2381 }
2382
2383 #[test]
2384 fn session_ledgers_share_mutation_locks_without_sharing_observations() {
2385 let ledgers = SessionReadLedgers::new();
2386 let first = ledgers.ledger_for(Some("first"));
2387 let second = ledgers.ledger_for(Some("second"));
2388 first.record("f.txt", "first view", true);
2389
2390 assert_eq!(
2391 second.check("f.txt", "first view"),
2392 ReadState::Unread,
2393 "one session's read must not authorize another session"
2394 );
2395 assert!(Arc::ptr_eq(
2396 &first.mutation_lock("f.txt"),
2397 &second.mutation_lock("f.txt")
2398 ));
2399 }
2400
2401 #[tokio::test]
2402 async fn indentation_error_provides_exact_bytes_for_a_literal_retry_without_mutation() {
2403 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2404 let dir = fresh_dir("indentation-retry");
2405 let path = dir.join("test.py").to_string_lossy().to_string();
2406 for (content, old_text, exact) in [
2407 (
2408 "# tests\nif __name__ == '__main__':\n unittest.main()\n",
2409 " if __name__ == '__main__':\n unittest.main()",
2410 "if __name__ == '__main__':\n unittest.main()",
2411 ),
2412 (
2413 "# tests\r\nif __name__ == '__main__':\r\n\tunittest.main()\r\n",
2414 " if __name__ == '__main__':\n unittest.main()",
2415 "if __name__ == '__main__':\r\n\tunittest.main()",
2416 ),
2417 ("α\nβ\n", " β\n", "β\n"),
2418 ] {
2419 std::fs::write(&path, content).unwrap();
2420 let ledger = ReadLedger::new();
2421 exec_read_file(&substrate, Some(&ledger), &json!({"path":path}))
2422 .await
2423 .unwrap();
2424 let error = exec_edit_file(
2425 &substrate,
2426 Some(&ledger),
2427 &json!({
2428 "path":path, "old_text":old_text, "new_text":"WRONG"
2429 }),
2430 )
2431 .await
2432 .unwrap_err();
2433 assert_eq!(std::fs::read_to_string(&path).unwrap(), content);
2434 let suggested = error
2435 .split_once(HINT_BEGIN)
2436 .and_then(|(_, rest)| rest.split_once(HINT_END))
2437 .expect(&error)
2438 .0
2439 .to_string();
2440 assert_eq!(suggested, exact);
2441 exec_edit_file(
2442 &substrate,
2443 Some(&ledger),
2444 &json!({
2445 "path":path, "old_text":suggested, "new_text":"replacement"
2446 }),
2447 )
2448 .await
2449 .unwrap();
2450 assert_eq!(
2451 std::fs::read_to_string(&path).unwrap(),
2452 content.replacen(exact, "replacement", 1)
2453 );
2454 }
2455 std::fs::remove_dir_all(dir).unwrap();
2456 }
2457
2458 #[test]
2459 fn indentation_hints_do_not_guess_among_duplicates_or_unrelated_text() {
2460 assert!(indentation_hint(" call()\n\tcall()\n", " call()").is_none());
2461 assert_eq!(
2462 indentation_hint("a\n call()\n", "call()"),
2463 Some((" call()", 1, 2))
2464 );
2465 assert!(indentation_hint(" call()\n", " different()").is_none());
2466 assert!(indentation_hint(" \n", " \n").is_none());
2467 assert!(indentation_hint(&"line\n".repeat(13), &" line\n".repeat(13)).is_none());
2468 assert!(indentation_hint(&"a".repeat(2048), &format!(" {}", "a".repeat(2048))).is_none());
2469 }
2470
2471 #[tokio::test]
2472 async fn indentation_hints_require_full_fresh_observation_and_a_single_edit() {
2473 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2474 let dir = fresh_dir("indentation-guards");
2475 let path = dir.join("f.txt").to_string_lossy().to_string();
2476 let content = "first\ncall()\n";
2477 for (partial, stale, replace_all) in [
2478 (true, false, false),
2479 (false, true, false),
2480 (false, false, true),
2481 ] {
2482 std::fs::write(&path, content).unwrap();
2483 let ledger = ReadLedger::new();
2484 let mut read = json!({"path":path});
2485 if partial {
2486 read["limit"] = json!(1);
2487 }
2488 exec_read_file(&substrate, Some(&ledger), &read)
2489 .await
2490 .unwrap();
2491 if stale {
2492 std::fs::write(&path, "changed\ncall()\n").unwrap();
2493 }
2494 let before = std::fs::read_to_string(&path).unwrap();
2495 let error = exec_edit_file(&substrate, Some(&ledger), &json!({
2496 "path":path, "old_text":" call()", "new_text":"WRONG", "replace_all":replace_all
2497 })).await.unwrap_err();
2498 assert!(!error.contains(HINT_BEGIN), "{error}");
2499 assert_eq!(std::fs::read_to_string(&path).unwrap(), before);
2500 }
2501 std::fs::remove_dir_all(dir).unwrap();
2502 }
2503
2504 #[tokio::test]
2505 async fn a_paged_read_keeps_recovery_and_hints_for_the_lines_it_showed() {
2506 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2507 let dir = fresh_dir("paged-recovery");
2508 let path = dir.join("big.py").to_string_lossy().to_string();
2509 let content = "a = 1\n b = 2\nc = 3\n d = 4\n";
2510 std::fs::write(&path, content).unwrap();
2511 let ledger = ReadLedger::new();
2512 exec_read_file(
2514 &substrate,
2515 Some(&ledger),
2516 &json!({"path": path, "limit": 2}),
2517 )
2518 .await
2519 .unwrap();
2520 exec_edit_file(
2522 &substrate,
2523 Some(&ledger),
2524 &json!({"path": path, "old_text": " 1\ta = 1", "new_text": "a = 10"}),
2525 )
2526 .await
2527 .unwrap();
2528 exec_read_file(
2529 &substrate,
2530 Some(&ledger),
2531 &json!({"path": path, "limit": 2}),
2532 )
2533 .await
2534 .unwrap();
2535 let error = exec_edit_file(
2537 &substrate,
2538 Some(&ledger),
2539 &json!({"path": path, "old_text": "a = 10\n b = 2", "new_text": "x"}),
2540 )
2541 .await
2542 .unwrap_err();
2543 assert!(
2544 error.contains(&format!("{HINT_BEGIN}a = 10\n b = 2{HINT_END}")),
2545 "{error}"
2546 );
2547 let error = exec_edit_file(
2549 &substrate,
2550 Some(&ledger),
2551 &json!({"path": path, "old_text": "c = 3\n d = 4", "new_text": "x"}),
2552 )
2553 .await
2554 .unwrap_err();
2555 assert!(!error.contains(HINT_BEGIN), "{error}");
2556 let error = exec_edit_file(
2557 &substrate,
2558 Some(&ledger),
2559 &json!({"path": path, "old_text": " 3\tc = 3", "new_text": "x"}),
2560 )
2561 .await
2562 .unwrap_err();
2563 assert!(error.contains("not found"), "{error}");
2564 std::fs::remove_dir_all(dir).unwrap();
2565 }
2566
2567 #[tokio::test]
2568 async fn a_page_keeps_its_recoveries_across_an_edit() {
2569 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2570 let dir = fresh_dir("paged-after-edit");
2571 let path = dir.join("f.py").to_string_lossy().to_string();
2572 std::fs::write(&path, "a = 1\nb = 2\nc = 3\nd = 4\ne = 5\n").unwrap();
2573 let ledger = ReadLedger::new();
2574 exec_read_file(
2575 &substrate,
2576 Some(&ledger),
2577 &json!({"path": path, "limit": 4}),
2578 )
2579 .await
2580 .unwrap();
2581 exec_edit_file(
2583 &substrate,
2584 Some(&ledger),
2585 &json!({"path": path, "old_text": "b = 2", "new_text": "b = 2\nb2 = 2"}),
2586 )
2587 .await
2588 .unwrap();
2589 exec_edit_file(
2592 &substrate,
2593 Some(&ledger),
2594 &json!({"path": path, "old_text": " 5\td = 4", "new_text": "d = 40"}),
2595 )
2596 .await
2597 .unwrap();
2598 assert_eq!(
2599 std::fs::read_to_string(&path).unwrap(),
2600 "a = 1\nb = 2\nb2 = 2\nc = 3\nd = 40\ne = 5\n"
2601 );
2602 let err = exec_edit_file(
2604 &substrate,
2605 Some(&ledger),
2606 &json!({"path": path, "old_text": " 6\te = 5", "new_text": "x"}),
2607 )
2608 .await
2609 .unwrap_err();
2610 assert!(err.contains("not found"), "{err}");
2611 std::fs::remove_dir_all(dir).unwrap();
2612 }
2613
2614 #[test]
2615 fn seen_ranges_move_with_an_edit() {
2616 assert_eq!(
2618 shift_seen_lines(&[(0, 4)], 1, 2, 4),
2619 [(0, 1), (4, 6), (1, 4)]
2620 );
2621 assert_eq!(shift_seen_lines(&[(0, 2)], 5, 6, 8), [(0, 2)]);
2623 assert_eq!(shift_seen_lines(&[(6, 9)], 1, 3, 2), [(5, 8)]);
2625 }
2626
2627 #[tokio::test]
2628 async fn edit_accepts_exact_numbered_display_after_full_fresh_read() {
2629 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2630 let dir = fresh_dir("numbered-edit");
2631 let path = dir.join("f.txt").to_string_lossy().to_string();
2632 for content in ["first\n\nlast\n", "first\r\n\r\nlast\r\n", "first\n\nlast"] {
2633 std::fs::write(&path, content).unwrap();
2634 let ledger = ReadLedger::new();
2635 let read = exec_read_file(&substrate, Some(&ledger), &json!({"path":path}))
2636 .await
2637 .unwrap();
2638 let display = read["content"].as_str().unwrap();
2639 let result = exec_edit_file(
2640 &substrate,
2641 Some(&ledger),
2642 &json!({
2643 "path":path, "old_text":display, "new_text":"changed"
2644 }),
2645 )
2646 .await
2647 .unwrap();
2648 assert_eq!(result["removed_display_line_numbers"], true);
2649 assert_eq!(
2652 std::fs::read_to_string(&path).unwrap(),
2653 if content.ends_with('\n') {
2654 "changed\n"
2655 } else {
2656 "changed"
2657 }
2658 );
2659 }
2660 std::fs::write(&path, "α\nβ\tdata\nγ\n").unwrap();
2661 let ledger = ReadLedger::new();
2662 exec_read_file(&substrate, Some(&ledger), &json!({"path":path}))
2663 .await
2664 .unwrap();
2665 exec_edit_file(
2666 &substrate,
2667 Some(&ledger),
2668 &json!({
2669 "path":path, "old_text":" 2\tβ\tdata", "new_text":"δ\tdata"
2670 }),
2671 )
2672 .await
2673 .unwrap();
2674 assert_eq!(std::fs::read_to_string(&path).unwrap(), "α\nδ\tdata\nγ\n");
2675 std::fs::remove_dir_all(dir).unwrap();
2676 }
2677
2678 #[tokio::test]
2679 async fn numbered_edit_keeps_location_freshness_and_uniqueness_guards() {
2680 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2681 let dir = fresh_dir("numbered-edit-guards");
2682 let path = dir.join("f.txt").to_string_lossy().to_string();
2683 for (content, old, replace_all) in [
2684 ("first\nlast\n", " 1\tlast", false),
2685 ("first\nlast\n", " 1\tfirst\n 3\tlast", false),
2686 ("first \nlast\n", " 1\tfirst", false),
2687 ("same\nsame\n", " 2\tsame", false),
2688 ("first\nlast\n", " 1\tfirst", true),
2689 ] {
2690 std::fs::write(&path, content).unwrap();
2691 let ledger = ReadLedger::new();
2692 exec_read_file(&substrate, Some(&ledger), &json!({"path":path}))
2693 .await
2694 .unwrap();
2695 assert!(exec_edit_file(
2696 &substrate,
2697 Some(&ledger),
2698 &json!({
2699 "path":path, "old_text":old, "new_text":"changed", "replace_all":replace_all
2700 })
2701 )
2702 .await
2703 .is_err());
2704 assert_eq!(std::fs::read_to_string(&path).unwrap(), content);
2705 }
2706 std::fs::write(&path, "first\nlast\n").unwrap();
2707 let ledger = ReadLedger::new();
2708 exec_read_file(&substrate, Some(&ledger), &json!({"path":path, "limit":1}))
2709 .await
2710 .unwrap();
2711 assert!(exec_edit_file(
2713 &substrate,
2714 Some(&ledger),
2715 &json!({
2716 "path":path, "old_text":" 2\tlast", "new_text":"changed"
2717 })
2718 )
2719 .await
2720 .is_err());
2721 exec_read_file(&substrate, Some(&ledger), &json!({"path":path}))
2722 .await
2723 .unwrap();
2724 std::fs::write(&path, "external change").unwrap();
2725 assert!(exec_edit_file(
2726 &substrate,
2727 Some(&ledger),
2728 &json!({
2729 "path":path, "old_text":" 1\tfirst", "new_text":"changed"
2730 })
2731 )
2732 .await
2733 .unwrap_err()
2734 .contains("changed since"));
2735 std::fs::write(&path, " 1\tliteral").unwrap();
2737 exec_read_file(&substrate, Some(&ledger), &json!({"path":path}))
2738 .await
2739 .unwrap();
2740 let result = exec_edit_file(
2741 &substrate,
2742 Some(&ledger),
2743 &json!({
2744 "path":path, "old_text":" 1\tliteral", "new_text":"changed"
2745 }),
2746 )
2747 .await
2748 .unwrap();
2749 assert_eq!(result["removed_display_line_numbers"], false);
2750 assert_eq!(std::fs::read_to_string(&path).unwrap(), "changed");
2751 std::fs::remove_dir_all(dir).unwrap();
2752 }
2753
2754 #[tokio::test]
2755 async fn a_multi_line_edit_without_carriage_returns_lands_in_a_crlf_file() {
2756 let dir = tempfile::tempdir().unwrap();
2757 let path = dir.path().join("w.py");
2758 std::fs::write(&path, "a = 1\r\nb = 2\r\nc = 3\r\n").unwrap();
2759 let path = path.to_string_lossy().into_owned();
2760 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2761 let ledger = ReadLedger::new();
2762 execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
2763 .await
2764 .unwrap()
2765 .unwrap();
2766 execute_with_ledger(
2767 &substrate,
2768 &ledger,
2769 "edit_file",
2770 &json!({ "path": path, "old_text": "a = 1\nb = 2", "new_text": "a = 1\nb = 20" }),
2771 )
2772 .await
2773 .unwrap()
2774 .unwrap();
2775 assert_eq!(
2776 std::fs::read_to_string(&path).unwrap(),
2777 "a = 1\r\nb = 20\r\nc = 3\r\n"
2778 );
2779 }
2780
2781 #[tokio::test]
2784 async fn edit_no_match_hints_pasted_line_number() {
2785 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2786 let dir = fresh_dir("linenumhint");
2787 let path = dir.join("f.txt").to_string_lossy().to_string();
2788 exec_write_file(
2789 &substrate,
2790 None,
2791 &json!({ "path": path, "content": "hello world" }),
2792 )
2793 .await
2794 .unwrap();
2795
2796 let err = exec_edit_file(
2797 &substrate,
2798 None,
2799 &json!({ "path": path, "old_text": " 1\thello world", "new_text": "hi" }),
2800 )
2801 .await
2802 .unwrap_err();
2803 assert!(err.contains("line-number prefixes"), "{err}");
2804
2805 let plain = exec_edit_file(
2806 &substrate,
2807 None,
2808 &json!({ "path": path, "old_text": "absent", "new_text": "x" }),
2809 )
2810 .await
2811 .unwrap_err();
2812 assert!(!plain.contains("line-number prefixes"), "{plain}");
2813 assert!(plain.contains("old_text not found"), "{plain}");
2814
2815 std::fs::remove_dir_all(&dir).ok();
2816 }
2817
2818 #[tokio::test]
2819 async fn calculate_is_pure() {
2820 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2821 let out = execute(
2822 &substrate,
2823 "calculate",
2824 &json!({ "expression": "2 + 3 * 4" }),
2825 )
2826 .await
2827 .unwrap()
2828 .unwrap();
2829 assert_eq!(out["result"], 14.0);
2830 }
2831
2832 #[tokio::test]
2833 async fn unknown_tool_returns_none() {
2834 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2835 assert!(execute(&substrate, "nope", &json!({})).await.is_none());
2836 }
2837
2838 fn calc(expr: &str) -> f64 {
2841 exec_calculate(&json!({ "expression": expr }))
2842 .unwrap_or_else(|e| panic!("calculate({expr:?}) failed: {e}"))
2843 .get("result")
2844 .and_then(|v| v.as_f64())
2845 .unwrap_or_else(|| panic!("calculate({expr:?}) returned no numeric result"))
2846 }
2847
2848 #[test]
2855 fn calculate_contract_semantics() {
2856 assert_eq!(calc("2 + 3 * 4"), 14.0, "operator precedence");
2857 assert_eq!(calc("(1 + 2) * 3"), 9.0, "parentheses override precedence");
2858 assert_eq!(calc("2^3"), 8.0, "^ is exponentiation, not XOR");
2859 assert_eq!(calc("2^10"), 1024.0, "^ is exponentiation");
2860 assert_eq!(calc("10 % 3"), 1.0, "modulo");
2861 assert_eq!(calc("-5 + 2"), -3.0, "unary minus");
2862 assert_eq!(calc("sin(0)"), 0.0, "native function: sin");
2864 assert_eq!(calc("abs(-3)"), 3.0, "native function: abs");
2865 assert_eq!(calc("sqrt(16)"), 4.0, "shim function: sqrt");
2867 assert!((calc("ln(e)") - 1.0).abs() < 1e-12, "shim: ln + e constant");
2868 assert!(
2869 (calc("pi") - std::f64::consts::PI).abs() < 1e-12,
2870 "shim: pi constant"
2871 );
2872 }
2873
2874 #[test]
2875 fn calculate_rejects_invalid_expression() {
2876 assert!(exec_calculate(&json!({ "expression": "2 +" })).is_err());
2877 assert!(exec_calculate(&json!({})).is_err(), "missing parameter");
2878 }
2879}