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, FileToolOptions::default()).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(
496 substrate,
497 Some(ledger),
498 tool,
499 params,
500 FileToolOptions::default(),
501 )
502 .await
503}
504
505#[derive(Debug, Clone, Copy, PartialEq, Eq)]
507pub struct FileToolOptions {
508 pub follow_symlinks: bool,
515}
516
517impl Default for FileToolOptions {
518 fn default() -> Self {
519 Self {
520 follow_symlinks: true,
521 }
522 }
523}
524
525pub 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
536pub type LocalPathGuard = Arc<dyn Fn(&Path) -> Option<String> + Send + Sync>;
543
544pub async fn execute_with_ledger_guarded(
551 substrate: &Arc<dyn Substrate>,
552 ledger: &ReadLedger,
553 tool: &str,
554 params: &Value,
555 guard: &LocalPathGuard,
556) -> Option<Result<Value, String>> {
557 let guard = substrate.is_local().then_some(guard);
558 if let Some(refusal) = guard.and_then(|guard| guarded_target_refusal(guard, tool, params)) {
559 return Some(Err(refusal));
560 }
561 execute_inner_guarded(
562 substrate,
563 Some(ledger),
564 tool,
565 params,
566 FileToolOptions::default(),
567 guard,
568 None,
569 )
570 .await
571}
572
573pub async fn execute_with_ledger_guarded_hiding(
580 substrate: &Arc<dyn Substrate>,
581 ledger: &ReadLedger,
582 tool: &str,
583 params: &Value,
584 guard: &LocalPathGuard,
585 hidden: &LocalPathGuard,
586) -> Option<Result<Value, String>> {
587 let local = substrate.is_local();
588 let guard = local.then_some(guard);
589 if let Some(refusal) = guard.and_then(|guard| guarded_target_refusal(guard, tool, params)) {
590 return Some(Err(refusal));
591 }
592 execute_inner_guarded(
593 substrate,
594 Some(ledger),
595 tool,
596 params,
597 FileToolOptions::default(),
598 guard,
599 local.then_some(hidden),
600 )
601 .await
602}
603
604fn guarded_target_refusal(guard: &LocalPathGuard, tool: &str, params: &Value) -> Option<String> {
608 let default = match tool {
609 "read_file" | "write_file" | "edit_file" => None,
610 "list_dir" | "find_files" | "grep_files" => Some("."),
611 _ => return None,
612 };
613 let raw = params.get("path").and_then(Value::as_str).or(default)?;
614 guard(&local_resolve(raw).ok()?)
615}
616
617async fn execute_inner(
618 substrate: &Arc<dyn Substrate>,
619 ledger: Option<&ReadLedger>,
620 tool: &str,
621 params: &Value,
622 options: FileToolOptions,
623) -> Option<Result<Value, String>> {
624 execute_inner_guarded(substrate, ledger, tool, params, options, None, None).await
625}
626
627async fn execute_inner_guarded(
628 substrate: &Arc<dyn Substrate>,
629 ledger: Option<&ReadLedger>,
630 tool: &str,
631 params: &Value,
632 options: FileToolOptions,
633 guard: Option<&LocalPathGuard>,
634 hidden: Option<&LocalPathGuard>,
635) -> Option<Result<Value, String>> {
636 let follow = options.follow_symlinks;
637 let result = match tool {
638 "read_file" => exec_read_file(substrate, ledger, params).await,
639 "write_file" => exec_write_file(substrate, ledger, params).await,
640 "edit_file" => exec_edit_file(substrate, ledger, params).await,
641 "list_dir" => exec_list_dir(substrate, params, follow).await,
642 "find_files" => exec_find_files(substrate, params, follow, hidden).await,
643 "grep_files" => exec_grep_files(substrate, params, follow, guard).await,
644 "calculate" => exec_calculate(params),
645 _ => return None,
646 };
647 Some(result)
648}
649
650async fn exec_read_file(
651 substrate: &Arc<dyn Substrate>,
652 ledger: Option<&ReadLedger>,
653 params: &Value,
654) -> Result<Value, String> {
655 let path = params
656 .get("path")
657 .and_then(|v| v.as_str())
658 .ok_or("missing 'path' parameter")?;
659 let offset = params.get("offset").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
660 let limit = params
661 .get("limit")
662 .and_then(|v| v.as_u64())
663 .map(|v| v as usize);
664 let max_output_bytes = params
668 .get("max_output_bytes")
669 .and_then(|v| v.as_u64())
670 .map(|v| v as usize);
671
672 let content = substrate.read_text(path).await?;
673 let size_bytes = content.len();
674 let total_lines = content.lines().count();
675
676 let mut lines: Vec<&str> = content.split('\n').collect();
689 if lines.last() == Some(&"") {
690 lines.pop();
691 }
692 let start = offset.min(lines.len());
693 let end = limit
694 .map(|line_count| (start + line_count).min(lines.len()))
695 .unwrap_or(lines.len());
696
697 let mut truncated = None;
702 let returned = if let Some(ledger) = ledger {
703 let mut shown = Vec::new();
704 let mut used = 0usize;
705 let mut shown_end = start;
706 for (i, line) in lines[start..end].iter().enumerate() {
707 let rendered = format!("{:>6}\t{}", start + i + 1, line);
708 let cost = rendered.len() + usize::from(!shown.is_empty());
710 match max_output_bytes {
711 Some(max) if used + cost > max => {
712 if shown.is_empty() {
713 let mut cut = max.min(rendered.len());
716 while !rendered.is_char_boundary(cut) {
717 cut -= 1;
718 }
719 shown.push(rendered[..cut].to_string());
720 truncated = Some(format!(
721 "line {} is longer than one read can show and was cut after {cut} \
722 bytes; inspect it with shell (for example `cut -c` or python), \
723 or call read_file with offset={} to continue after it",
724 start + i + 1,
725 start + i + 1
726 ));
727 } else {
728 truncated = Some(format!(
729 "showing lines {}-{shown_end} of {}; call read_file with \
730 offset={shown_end} to read further",
731 start + 1,
732 lines.len()
733 ));
734 }
735 break;
736 }
737 _ => {}
738 }
739 used += cost;
740 shown.push(rendered);
741 shown_end = start + i + 1;
742 }
743 ledger.record_lines(path, &content, start, shown_end, lines.len());
744 shown.join("\n")
745 } else if offset > 0 || limit.is_some() {
746 lines[start..end].join("\n")
747 } else {
748 content.clone()
749 };
750
751 if let Some(note) = truncated {
752 return Ok(json!({
753 "path": substrate.display_path(path),
754 "truncated": note,
755 "content": returned,
756 "size_bytes": size_bytes,
757 "total_lines": total_lines,
758 }));
759 }
760 Ok(json!({
761 "path": substrate.display_path(path),
762 "content": returned,
763 "size_bytes": size_bytes,
764 "total_lines": total_lines,
765 }))
766}
767
768async fn exec_write_file(
769 substrate: &Arc<dyn Substrate>,
770 ledger: Option<&ReadLedger>,
771 params: &Value,
772) -> Result<Value, String> {
773 let path = params
774 .get("path")
775 .and_then(|v| v.as_str())
776 .ok_or("missing 'path' parameter")?;
777 let content = params
778 .get("content")
779 .and_then(|v| v.as_str())
780 .ok_or("missing 'content' parameter")?;
781 let append = params
782 .get("append")
783 .and_then(|v| v.as_bool())
784 .unwrap_or(false);
785
786 let _mutation_guard = match ledger {
787 Some(ledger) => Some(ledger.mutation_lock(path).lock_owned().await),
788 None => None,
789 };
790
791 let existing = if ledger.is_some() {
792 match substrate.read_text(path).await {
793 Ok(content) => Some(content),
794 Err(read_error) => match substrate.path_state(path).await {
795 crate::substrate::PathState::Missing => None,
796 crate::substrate::PathState::Exists => {
797 return Err(format!(
798 "cannot modify existing file '{}' because it cannot be read as UTF-8: {read_error}",
799 substrate.display_path(path)
800 ));
801 }
802 crate::substrate::PathState::Unknown(reason) => {
803 return Err(format!(
804 "cannot determine whether '{}' is safe to create: {reason}",
805 substrate.display_path(path)
806 ));
807 }
808 },
809 }
810 } else {
811 None
812 };
813
814 let combined = if append {
815 let existed = existing.is_some();
819 let existing = match existing {
820 Some(existing) => existing,
821 None if ledger.is_some() => String::new(),
822 None => substrate.read_text(path).await.unwrap_or_default(),
823 };
824 if let Some(ledger) = ledger.filter(|_| existed) {
831 match ledger.check(path, &existing) {
832 ReadState::Unread => {
833 return Err(read_first_error(
834 &substrate.display_path(path),
835 "overwriting",
836 ));
837 }
838 ReadState::Stale => {
839 return Err(stale_error(&substrate.display_path(path)));
840 }
841 ReadState::FreshPartial => {
842 return Err(full_read_error(
843 &substrate.display_path(path),
844 "appending to",
845 ));
846 }
847 ReadState::FreshFull => {}
848 }
849 }
850 let mut combined = existing;
851 combined.push_str(content);
852 substrate.write_text(path, &combined).await?;
853 combined
854 } else {
855 if let Some(ledger) = ledger {
863 if let Some(existing) = existing {
864 match ledger.check(path, &existing) {
865 ReadState::Unread => {
866 return Err(read_first_error(
867 &substrate.display_path(path),
868 "overwriting",
869 ));
870 }
871 ReadState::Stale => {
872 return Err(stale_error(&substrate.display_path(path)));
873 }
874 ReadState::FreshPartial => {
875 return Err(full_read_error(
876 &substrate.display_path(path),
877 "overwriting",
878 ));
879 }
880 ReadState::FreshFull => {}
881 }
882 }
883 }
884 substrate.write_text(path, content).await?;
885 content.to_string()
886 };
887
888 if let Some(ledger) = ledger {
891 ledger.record(path, &combined, true);
892 }
893
894 Ok(json!({
895 "path": substrate.display_path(path),
896 "bytes_written": content.len(),
897 "append": append,
898 }))
899}
900
901async fn exec_edit_file(
902 substrate: &Arc<dyn Substrate>,
903 ledger: Option<&ReadLedger>,
904 params: &Value,
905) -> Result<Value, String> {
906 let path = params
907 .get("path")
908 .and_then(|v| v.as_str())
909 .ok_or("missing 'path' parameter")?;
910 let old_text = params
911 .get("old_text")
912 .and_then(|v| v.as_str())
913 .ok_or("missing 'old_text' parameter")?;
914 let new_text = params
915 .get("new_text")
916 .and_then(|v| v.as_str())
917 .ok_or("missing 'new_text' parameter")?;
918 let replace_all = params
919 .get("replace_all")
920 .and_then(|v| v.as_bool())
921 .unwrap_or(false);
922
923 let _mutation_guard = match ledger {
924 Some(ledger) => Some(ledger.mutation_lock(path).lock_owned().await),
925 None => None,
926 };
927
928 let content = substrate.read_text(path).await?;
929
930 let full_read = if let Some(ledger) = ledger {
934 match ledger.check(path, &content) {
935 ReadState::Unread => {
936 return Err(read_first_error(&substrate.display_path(path), "editing"));
937 }
938 ReadState::Stale => {
939 return Err(stale_error(&substrate.display_path(path)));
940 }
941 ReadState::FreshPartial if replace_all => {
942 return Err(full_read_error(
943 &substrate.display_path(path),
944 "replacing every occurrence in",
945 ));
946 }
947 ReadState::FreshPartial => false,
948 ReadState::FreshFull => true,
949 }
950 } else {
951 false
952 };
953
954 if old_text.is_empty() {
959 return Err(
960 "old_text must be non-empty — pass the exact existing text to replace \
961 (use write_file to replace a whole file)"
962 .to_string(),
963 );
964 }
965
966 let seen = ledger
974 .map(|ledger| ledger.lines_seen(path, &content))
975 .unwrap_or_default();
976 let recovered = if !replace_all && !content.contains(old_text) {
977 unnumbered_read_text(&content, old_text)
978 .filter(|(_, start, end)| seen_covers(&seen, *start, *end))
979 .map(|(plain, _, _)| plain)
980 } else {
981 None
982 };
983 let old_text = recovered.as_deref().unwrap_or(old_text);
984 let crlf = (!content.contains(old_text)
989 && old_text.contains('\n')
990 && !old_text.contains('\r')
991 && content.contains("\r\n"))
992 .then(|| {
993 (
994 old_text.replace('\n', "\r\n"),
995 new_text.replace("\r\n", "\n").replace('\n', "\r\n"),
996 )
997 })
998 .filter(|(old, _)| content.contains(old.as_str()));
1000 let (old_text, new_text) = match &crlf {
1001 Some((old, new)) => (old.as_str(), new.as_str()),
1002 None => (old_text, new_text),
1003 };
1004 let count = content.matches(old_text).count();
1005 if count == 0 {
1006 let mut msg = format!("old_text not found in '{}'", substrate.display_path(path));
1007 if looks_like_pasted_line_number(old_text) {
1008 msg.push_str(
1009 " — old_text looks like it includes read_file's line-number prefixes; \
1010 strip them and retry",
1011 );
1012 }
1013 if !replace_all {
1014 if let Some((candidate, _, _)) = indentation_hint(&content, old_text)
1015 .filter(|(_, start, end)| seen_covers(&seen, *start, *end))
1016 {
1017 msg.push_str("\nOne block matches after ignoring leading indentation or line endings. No edit was applied. Inspect this block and retry with exact old_text, preserving the intended indentation in new_text.\n");
1020 msg.push_str(HINT_BEGIN);
1021 msg.push_str(candidate);
1022 msg.push_str(HINT_END);
1023 }
1024 }
1025 return Err(msg);
1026 }
1027 let new_content = if replace_all {
1028 content.replace(old_text, new_text)
1029 } else {
1030 if count > 1 {
1031 return Err(format!(
1032 "old_text found {count} times in '{}' and must match uniquely — \
1033 pass `replace_all: true` to replace every occurrence, or add \
1034 surrounding context to old_text so it matches one place",
1035 substrate.display_path(path)
1036 ));
1037 }
1038 content.replacen(old_text, new_text, 1)
1039 };
1040 substrate.write_text(path, &new_content).await?;
1041
1042 if let Some(ledger) = ledger {
1049 if full_read || replace_all {
1050 ledger.record(path, &new_content, full_read);
1051 } else {
1052 let offset = content.find(old_text).unwrap_or(0);
1053 let start = content[..offset].matches('\n').count();
1054 let old_end = start + old_text.matches('\n').count() + 1;
1055 let new_end = start + new_text.matches('\n').count() + 1;
1056 let seen = shift_seen_lines(&seen, start, old_end, new_end);
1057 ledger.record_seen(path, &new_content, seen);
1058 }
1059 }
1060
1061 Ok(json!({
1062 "edited": substrate.display_path(path),
1063 "diff_summary": format!(
1064 "replaced {} lines with {} lines",
1065 old_text.lines().count(),
1066 new_text.lines().count()
1067 ),
1068 "replacements": count,
1069 "removed_display_line_numbers": recovered.is_some(),
1070 }))
1071}
1072
1073async fn exec_list_dir(
1074 substrate: &Arc<dyn Substrate>,
1075 params: &Value,
1076 follow: bool,
1077) -> Result<Value, String> {
1078 let path = params.get("path").and_then(|v| v.as_str()).unwrap_or(".");
1079
1080 if !substrate.is_local() {
1081 return list_dir_via_command(substrate, path).await;
1082 }
1083
1084 let full_path = local_resolve(path)?;
1085 let mut entries = Vec::new();
1086
1087 let read_dir = std::fs::read_dir(&full_path)
1088 .map_err(|e| format!("failed to read dir '{}': {e}", full_path.display()))?;
1089 for entry in read_dir {
1090 let entry = entry.map_err(|e| format!("failed to read dir entry: {e}"))?;
1091 let file_name = entry.file_name().to_string_lossy().to_string();
1092 if should_skip_name(&file_name) {
1093 continue;
1094 }
1095 if !follow && entry.file_type().is_ok_and(|kind| kind.is_symlink()) {
1096 continue;
1097 }
1098 let metadata = entry
1099 .metadata()
1100 .map_err(|e| format!("failed to read metadata for '{}': {e}", file_name))?;
1101 entries.push(json!({
1102 "name": file_name,
1103 "path": entry.path().display().to_string(),
1104 "is_dir": metadata.is_dir(),
1105 "size_bytes": if metadata.is_file() { Some(metadata.len()) } else { None::<u64> },
1106 }));
1107 }
1108
1109 Ok(json!({
1110 "path": full_path.display().to_string(),
1111 "entries": entries,
1112 }))
1113}
1114
1115async fn exec_find_files(
1116 substrate: &Arc<dyn Substrate>,
1117 params: &Value,
1118 follow: bool,
1119 hidden: Option<&LocalPathGuard>,
1120) -> Result<Value, String> {
1121 let pattern = params
1122 .get("pattern")
1123 .and_then(|v| v.as_str())
1124 .ok_or("missing 'pattern' parameter")?;
1125 let root = params.get("path").and_then(|v| v.as_str()).unwrap_or(".");
1126 let max_results = params
1127 .get("max_results")
1128 .and_then(|v| v.as_u64())
1129 .unwrap_or(1000) as usize;
1130
1131 if !substrate.is_local() {
1132 return find_files_via_command(substrate, pattern, root, max_results).await;
1133 }
1134
1135 let root_path = local_resolve(root)?;
1136 let matcher = glob_to_regex(pattern)?;
1137 let pattern_has_sep = pattern.contains('/');
1142 let root_str = root_path.to_string_lossy().to_string();
1143 let mut files = Vec::new();
1144
1145 walk_files(&root_path, follow, hidden, &mut |path| {
1146 if files.len() >= max_results {
1147 return;
1148 }
1149 if let Some(full) = path.to_str() {
1150 if matcher.is_match(&glob_haystack(pattern_has_sep, full, &root_str)) {
1151 files.push(path.display().to_string());
1152 }
1153 }
1154 })?;
1155
1156 Ok(json!({
1157 "files": files,
1158 "count": files.len(),
1159 "truncated": files.len() >= max_results,
1160 }))
1161}
1162
1163async fn exec_grep_files(
1164 substrate: &Arc<dyn Substrate>,
1165 params: &Value,
1166 follow: bool,
1167 guard: Option<&LocalPathGuard>,
1168) -> Result<Value, String> {
1169 let pattern = params
1170 .get("pattern")
1171 .and_then(|v| v.as_str())
1172 .ok_or("missing 'pattern' parameter")?;
1173 let root = params.get("path").and_then(|v| v.as_str()).unwrap_or(".");
1174 let max_results = params
1175 .get("max_results")
1176 .and_then(|v| v.as_u64())
1177 .unwrap_or(50) as usize;
1178
1179 if !substrate.is_local() {
1180 return grep_files_via_command(substrate, pattern, root, max_results).await;
1181 }
1182
1183 let root_path = local_resolve(root)?;
1184 let regex = Regex::new(pattern).map_err(|e| format!("invalid regex pattern: {e}"))?;
1185 let mut matches = Vec::new();
1186
1187 walk_files(&root_path, follow, None, &mut |path| {
1188 if matches.len() >= max_results || !is_text_file(path) {
1189 return;
1190 }
1191 if guard.is_some_and(|guard| guard(path).is_some()) {
1192 return;
1193 }
1194 let Ok(content) = std::fs::read_to_string(path) else {
1195 return;
1196 };
1197 if content.len() > MAX_FILE_BYTES {
1198 return;
1199 }
1200 for (idx, line) in content.lines().enumerate() {
1201 if regex.is_match(line) {
1202 matches.push(json!({
1203 "path": path.display().to_string(),
1204 "line": idx + 1,
1205 "text": line,
1206 }));
1207 if matches.len() >= max_results {
1208 break;
1209 }
1210 }
1211 }
1212 })?;
1213
1214 Ok(json!({
1215 "matches": matches,
1216 "count": matches.len(),
1217 "truncated": matches.len() >= max_results,
1218 }))
1219}
1220
1221fn exec_calculate(params: &Value) -> Result<Value, String> {
1222 let expression = params
1223 .get("expression")
1224 .and_then(|v| v.as_str())
1225 .ok_or("missing 'expression' parameter")?;
1226 let mut ns = |name: &str, args: Vec<f64>| -> Option<f64> {
1233 match (name, args.as_slice()) {
1234 ("sqrt", [x]) => Some(x.sqrt()),
1235 ("ln", [x]) => Some(x.ln()),
1236 ("pi", []) => Some(std::f64::consts::PI),
1237 ("e", []) => Some(std::f64::consts::E),
1238 _ => None,
1239 }
1240 };
1241 let result = fasteval::ez_eval(expression, &mut ns)
1242 .map_err(|e| format!("failed to evaluate expression: {e}"))?;
1243 Ok(json!({ "result": result }))
1244}
1245
1246pub fn root_refusal(root: &Path, tool: &str, params: &Value) -> Option<String> {
1253 if !matches!(tool, "write_file" | "edit_file") {
1254 return None;
1255 }
1256 let raw = params.get("path").and_then(Value::as_str)?;
1257 let refused = || {
1258 Some(format!(
1259 "{tool} refused: '{raw}' resolves outside the basics root {}",
1260 root.display()
1261 ))
1262 };
1263 let Ok(root) = car_winenv::canonicalize(root) else {
1264 return refused();
1265 };
1266 let Ok(target) = local_resolve(raw) else {
1267 return refused();
1268 };
1269 let mut existing = target.as_path();
1273 let mut rest: Vec<std::ffi::OsString> = Vec::new();
1274 while std::fs::symlink_metadata(existing).is_err() {
1275 let Some(name) = existing.file_name() else {
1276 return refused();
1277 };
1278 rest.push(name.to_os_string());
1279 let Some(parent) = existing.parent() else {
1280 return refused();
1281 };
1282 existing = parent;
1283 }
1284 let Ok(resolved) = car_winenv::canonicalize(existing) else {
1285 return refused();
1287 };
1288 let mut full = resolved;
1289 for part in rest.iter().rev() {
1290 if part == ".." || part == "." {
1291 return refused();
1292 }
1293 full.push(part);
1294 }
1295 if full.starts_with(&root) {
1296 None
1297 } else {
1298 refused()
1299 }
1300}
1301
1302fn local_resolve(path: &str) -> Result<PathBuf, String> {
1309 crate::substrate::LocalSubstrate::resolve_path(path)
1310}
1311
1312fn should_skip_name(name: &str) -> bool {
1313 name.starts_with('.') || matches!(name, "node_modules" | "__pycache__" | "target")
1314}
1315
1316fn is_text_file(path: &Path) -> bool {
1317 matches!(
1318 path.extension().and_then(|v| v.to_str()),
1319 Some(
1320 "c" | "cc"
1321 | "cpp"
1322 | "cs"
1323 | "css"
1324 | "go"
1325 | "h"
1326 | "html"
1327 | "ini"
1328 | "java"
1329 | "js"
1330 | "json"
1331 | "jsx"
1332 | "kt"
1333 | "md"
1334 | "py"
1335 | "rb"
1336 | "rs"
1337 | "sh"
1338 | "sql"
1339 | "toml"
1340 | "ts"
1341 | "tsx"
1342 | "txt"
1343 | "xml"
1344 | "yaml"
1345 | "yml"
1346 )
1347 )
1348}
1349
1350fn walk_files(
1355 root: &Path,
1356 follow: bool,
1357 prune: Option<&LocalPathGuard>,
1358 visit: &mut dyn FnMut(&Path),
1359) -> Result<(), String> {
1360 if !follow && std::fs::symlink_metadata(root).is_ok_and(|meta| meta.file_type().is_symlink()) {
1361 return Ok(());
1362 }
1363 if root.is_file() {
1364 visit(root);
1365 return Ok(());
1366 }
1367
1368 let read_dir = std::fs::read_dir(root)
1369 .map_err(|e| format!("failed to read dir '{}': {e}", root.display()))?;
1370 for entry in read_dir {
1371 let entry = entry.map_err(|e| format!("failed to read dir entry: {e}"))?;
1372 let path = entry.path();
1373 let file_name = entry.file_name().to_string_lossy().to_string();
1374 if should_skip_name(&file_name) {
1375 continue;
1376 }
1377 if !follow && entry.file_type().is_ok_and(|kind| kind.is_symlink()) {
1378 continue;
1379 }
1380 if prune.is_some_and(|prune| {
1381 entry.file_type().is_ok_and(|kind| kind.is_dir()) && prune(&path).is_some()
1382 }) {
1383 continue;
1384 }
1385 let metadata = entry
1386 .metadata()
1387 .map_err(|e| format!("failed to read metadata for '{}': {e}", path.display()))?;
1388 if metadata.is_dir() {
1389 walk_files(&path, follow, prune, visit)?;
1390 } else if metadata.is_file() {
1391 visit(&path);
1392 }
1393 }
1394 Ok(())
1395}
1396
1397fn glob_to_regex(pattern: &str) -> Result<Regex, String> {
1407 let chars: Vec<char> = pattern.chars().collect();
1408 let mut re = String::from("^");
1409 let mut i = 0;
1410 while i < chars.len() {
1411 match chars[i] {
1412 '*' => {
1413 if i + 1 < chars.len() && chars[i + 1] == '*' {
1414 i += 1; if i + 1 < chars.len() && chars[i + 1] == '/' {
1416 i += 1; re.push_str("(?:.*/)?");
1418 } else {
1419 re.push_str(".*");
1420 }
1421 } else {
1422 re.push_str("[^/]*");
1423 }
1424 }
1425 '?' => re.push_str("[^/]"),
1426 c => re.push_str(®ex::escape(&c.to_string())),
1427 }
1428 i += 1;
1429 }
1430 re.push('$');
1431 Regex::new(&re).map_err(|e| format!("invalid glob pattern: {e}"))
1432}
1433
1434fn glob_haystack(pattern_has_sep: bool, full: &str, root: &str) -> String {
1442 if pattern_has_sep {
1443 let full = full.replace('\\', "/");
1444 let root = root.replace('\\', "/");
1445 full.strip_prefix(&root)
1446 .unwrap_or(&full)
1447 .trim_start_matches('/')
1448 .to_string()
1449 } else {
1450 full.rsplit(['/', '\\']).next().unwrap_or(full).to_string()
1451 }
1452}
1453
1454fn shell_quote(s: &str) -> String {
1463 format!("'{}'", s.replace('\'', "'\\''"))
1465}
1466
1467async fn list_dir_via_command(substrate: &Arc<dyn Substrate>, path: &str) -> Result<Value, String> {
1468 let cmd = format!("ls -1Ap {}", shell_quote(path));
1469 let out = substrate.run_command(&cmd, Some(30.0)).await?;
1470 let entries: Vec<Value> = out
1471 .stdout
1472 .lines()
1473 .filter(|l| !l.is_empty())
1474 .filter(|name| {
1475 let bare = name.trim_end_matches('/');
1476 !should_skip_name(bare)
1477 })
1478 .map(|name| {
1479 let is_dir = name.ends_with('/');
1480 let bare = name.trim_end_matches('/');
1481 json!({
1482 "name": bare,
1483 "path": format!("{}/{}", path.trim_end_matches('/'), bare),
1484 "is_dir": is_dir,
1485 "size_bytes": Value::Null,
1486 })
1487 })
1488 .collect();
1489 Ok(json!({ "path": path, "entries": entries }))
1490}
1491
1492async fn find_files_via_command(
1493 substrate: &Arc<dyn Substrate>,
1494 pattern: &str,
1495 root: &str,
1496 max_results: usize,
1497) -> Result<Value, String> {
1498 let cmd = format!("find {} -type f", shell_quote(root));
1504 let out = substrate.run_command(&cmd, Some(30.0)).await?;
1505 let matcher = glob_to_regex(pattern)?;
1506 let pattern_has_sep = pattern.contains('/');
1507 let mut files: Vec<String> = Vec::new();
1508 let mut truncated = false;
1509 for line in out.stdout.lines() {
1510 if line.is_empty() {
1511 continue;
1512 }
1513 let name = line.rsplit(['/', '\\']).next().unwrap_or(line);
1514 if should_skip_name(name) {
1515 continue;
1516 }
1517 if !matcher.is_match(&glob_haystack(pattern_has_sep, line, root)) {
1518 continue;
1519 }
1520 if files.len() >= max_results {
1521 truncated = true;
1522 break;
1523 }
1524 files.push(line.to_string());
1525 }
1526 Ok(json!({
1527 "files": files,
1528 "count": files.len(),
1529 "truncated": truncated,
1530 }))
1531}
1532
1533async fn grep_files_via_command(
1534 substrate: &Arc<dyn Substrate>,
1535 pattern: &str,
1536 root: &str,
1537 max_results: usize,
1538) -> Result<Value, String> {
1539 let cmd = format!("grep -rnE {} {}", shell_quote(pattern), shell_quote(root));
1540 let out = substrate.run_command(&cmd, Some(30.0)).await?;
1541 let mut matches = Vec::new();
1542 for line in out.stdout.lines() {
1543 if matches.len() >= max_results {
1544 break;
1545 }
1546 let mut parts = line.splitn(3, ':');
1548 let (Some(p), Some(ln), Some(text)) = (parts.next(), parts.next(), parts.next()) else {
1549 continue;
1550 };
1551 let Ok(line_no) = ln.parse::<usize>() else {
1552 continue;
1553 };
1554 matches.push(json!({ "path": p, "line": line_no, "text": text }));
1555 }
1556 let truncated = matches.len() >= max_results;
1557 Ok(json!({
1558 "matches": matches,
1559 "count": matches.len(),
1560 "truncated": truncated,
1561 }))
1562}
1563
1564#[cfg(test)]
1565mod tests {
1566 use super::*;
1567
1568 #[cfg(unix)]
1572 #[test]
1573 fn walks_without_following_links_stay_out_of_linked_directories() {
1574 let dir = tempfile::tempdir().unwrap();
1575 let root = dir.path().join("root");
1576 let outside = dir.path().join("outside");
1577 std::fs::create_dir_all(&root).unwrap();
1578 std::fs::create_dir_all(&outside).unwrap();
1579 std::fs::write(outside.join("far.txt"), "x").unwrap();
1580 std::fs::write(root.join("near.txt"), "x").unwrap();
1581 std::os::unix::fs::symlink(&outside, root.join("link")).unwrap();
1582 let mut seen = Vec::new();
1583 walk_files(&root, false, None, &mut |path| {
1584 seen.push(path.to_path_buf())
1585 })
1586 .unwrap();
1587 assert_eq!(seen, vec![root.join("near.txt")]);
1588 let mut through_link = Vec::new();
1589 walk_files(&root.join("link"), false, None, &mut |path| {
1590 through_link.push(path.to_path_buf())
1591 })
1592 .unwrap();
1593 assert!(through_link.is_empty(), "{through_link:?}");
1594 let mut followed = Vec::new();
1595 walk_files(&root.join("link"), true, None, &mut |path| {
1596 followed.push(path.to_path_buf())
1597 })
1598 .unwrap();
1599 assert_eq!(followed, vec![root.join("link").join("far.txt")]);
1600 }
1601
1602 #[test]
1603 fn glob_patterns_match_file_names() {
1604 let regex = glob_to_regex("*.rs").unwrap();
1605 assert!(regex.is_match("lib.rs"));
1606 assert!(!regex.is_match("lib.ts"));
1607 }
1608
1609 #[test]
1615 fn glob_haystack_supports_path_and_basename_matching() {
1616 assert_eq!(
1618 glob_haystack(true, "/root/src/inner/deep.rs", "/root"),
1619 "src/inner/deep.rs"
1620 );
1621 assert_eq!(
1622 glob_haystack(false, "/root/src/inner/deep.rs", "/root"),
1623 "deep.rs"
1624 );
1625
1626 let m = glob_to_regex("src/**/*.rs").unwrap();
1630 assert!(m.is_match(&glob_haystack(true, "/root/src/top.rs", "/root")));
1631 assert!(m.is_match(&glob_haystack(true, "/root/src/inner/deep.rs", "/root")));
1632 assert!(!m.is_match(&glob_haystack(true, "/root/src/inner/note.txt", "/root")));
1633 }
1634
1635 #[tokio::test]
1639 async fn find_files_supports_recursive_path_globs() {
1640 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1641 let dir = std::env::temp_dir().join(format!(
1642 "car-find-glob-{}",
1643 std::time::SystemTime::now()
1644 .duration_since(std::time::UNIX_EPOCH)
1645 .unwrap()
1646 .as_nanos()
1647 ));
1648 std::fs::create_dir_all(dir.join("src").join("inner")).unwrap();
1649 std::fs::write(dir.join("src").join("top.rs"), "x").unwrap();
1650 std::fs::write(dir.join("src").join("inner").join("deep.rs"), "x").unwrap();
1651 std::fs::write(dir.join("src").join("inner").join("note.txt"), "x").unwrap();
1652 let root = dir.to_string_lossy().to_string();
1653
1654 let r = exec_find_files(
1655 &substrate,
1656 &json!({ "path": root, "pattern": "src/**/*.rs" }),
1657 true,
1658 None,
1659 )
1660 .await
1661 .unwrap();
1662 let joined = r["files"]
1663 .as_array()
1664 .unwrap()
1665 .iter()
1666 .map(|v| v.as_str().unwrap())
1667 .collect::<Vec<_>>()
1668 .join("\n");
1669
1670 assert!(
1671 joined.contains("deep.rs"),
1672 "recursive glob missed nested file:\n{joined}"
1673 );
1674 assert!(
1675 joined.contains("top.rs"),
1676 "recursive glob missed top-level file:\n{joined}"
1677 );
1678 assert!(
1679 !joined.contains("note.txt"),
1680 "glob matched the wrong extension:\n{joined}"
1681 );
1682
1683 let _ = std::fs::remove_dir_all(&dir);
1684 }
1685
1686 #[tokio::test]
1692 async fn find_files_non_local_substrate_applies_path_glob() {
1693 use crate::substrate::CommandOutput;
1694
1695 struct RemoteStub {
1696 stdout: String,
1697 }
1698 #[async_trait::async_trait]
1699 impl Substrate for RemoteStub {
1700 fn name(&self) -> &str {
1701 "test-remote"
1702 }
1703 async fn run_command(
1705 &self,
1706 _cmd: &str,
1707 _timeout_s: Option<f64>,
1708 ) -> Result<CommandOutput, String> {
1709 Ok(CommandOutput {
1710 stdout: self.stdout.clone(),
1711 stderr: String::new(),
1712 exit_code: 0,
1713 })
1714 }
1715 async fn read_text(&self, _path: &str) -> Result<String, String> {
1716 Err("unused".into())
1717 }
1718 async fn write_text(&self, _path: &str, _content: &str) -> Result<(), String> {
1719 Err("unused".into())
1720 }
1721 async fn read_bytes(
1722 &self,
1723 _path: &str,
1724 _offset: Option<u64>,
1725 _len: Option<u64>,
1726 ) -> Result<Vec<u8>, String> {
1727 Err("unused".into())
1728 }
1729 async fn write_bytes(&self, _path: &str, _bytes: &[u8]) -> Result<(), String> {
1730 Err("unused".into())
1731 }
1732 }
1733
1734 let substrate: Arc<dyn Substrate> = Arc::new(RemoteStub {
1736 stdout: [
1737 "/root/src/top.rs",
1738 "/root/src/inner/deep.rs",
1739 "/root/src/inner/note.txt",
1740 "/root/README.md",
1741 ]
1742 .join("\n"),
1743 });
1744
1745 let r = exec_find_files(
1746 &substrate,
1747 &json!({ "path": "/root", "pattern": "src/**/*.rs" }),
1748 true,
1749 None,
1750 )
1751 .await
1752 .unwrap();
1753 let files: Vec<&str> = r["files"]
1754 .as_array()
1755 .unwrap()
1756 .iter()
1757 .map(|v| v.as_str().unwrap())
1758 .collect();
1759
1760 assert_eq!(files, vec!["/root/src/top.rs", "/root/src/inner/deep.rs"]);
1763 assert_eq!(r["count"], 2);
1764 }
1765
1766 fn fresh_dir(tag: &str) -> PathBuf {
1768 let dir = std::env::temp_dir().join(format!(
1769 "car-agent-basics-{tag}-{}",
1770 std::time::SystemTime::now()
1771 .duration_since(std::time::UNIX_EPOCH)
1772 .unwrap()
1773 .as_nanos()
1774 ));
1775 std::fs::create_dir_all(&dir).unwrap();
1776 dir
1777 }
1778
1779 #[tokio::test]
1780 async fn read_write_roundtrip_against_local_substrate() {
1781 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1782 let dir = fresh_dir("roundtrip");
1783 let path = dir.join("note.txt").to_string_lossy().to_string();
1784
1785 let w = exec_write_file(&substrate, None, &json!({ "path": path, "content": "abc" }))
1786 .await
1787 .unwrap();
1788 assert_eq!(w["bytes_written"], 3);
1789
1790 let r = exec_read_file(&substrate, None, &json!({ "path": path }))
1792 .await
1793 .unwrap();
1794 assert_eq!(r["content"], "abc");
1795 assert_eq!(r["size_bytes"], 3);
1796 assert_eq!(r["total_lines"], 1);
1797
1798 exec_write_file(
1800 &substrate,
1801 None,
1802 &json!({ "path": path, "content": "def", "append": true }),
1803 )
1804 .await
1805 .unwrap();
1806 let r2 = exec_read_file(&substrate, None, &json!({ "path": path }))
1807 .await
1808 .unwrap();
1809 assert_eq!(r2["content"], "abcdef");
1810
1811 let e = exec_edit_file(
1813 &substrate,
1814 None,
1815 &json!({ "path": path, "old_text": "abc", "new_text": "XYZ" }),
1816 )
1817 .await
1818 .unwrap();
1819 assert!(e["edited"].is_string());
1820 assert_eq!(e["replacements"], 1);
1821 let r3 = exec_read_file(&substrate, None, &json!({ "path": path }))
1822 .await
1823 .unwrap();
1824 assert_eq!(r3["content"], "XYZdef");
1825
1826 std::fs::remove_dir_all(&dir).ok();
1827 }
1828
1829 #[tokio::test]
1833 async fn read_file_output_is_line_numbered() {
1834 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1835 let ledger = ReadLedger::new();
1836 let dir = fresh_dir("linenum");
1837 let path = dir.join("f.txt").to_string_lossy().to_string();
1838 exec_write_file(
1839 &substrate,
1840 None,
1841 &json!({ "path": path, "content": "alpha\nbeta\ngamma" }),
1842 )
1843 .await
1844 .unwrap();
1845
1846 let r = execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1847 .await
1848 .unwrap()
1849 .unwrap();
1850 assert_eq!(r["content"], " 1\talpha\n 2\tbeta\n 3\tgamma");
1851 assert_eq!(r["total_lines"], 3);
1852
1853 let r2 = execute_with_ledger(
1855 &substrate,
1856 &ledger,
1857 "read_file",
1858 &json!({ "path": path, "offset": 1, "limit": 1 }),
1859 )
1860 .await
1861 .unwrap()
1862 .unwrap();
1863 assert_eq!(r2["content"], " 2\tbeta");
1864 assert_eq!(r2["total_lines"], 3);
1865
1866 std::fs::remove_dir_all(&dir).ok();
1867 }
1868
1869 #[tokio::test]
1870 async fn plain_execute_preserves_legacy_raw_read_output() {
1871 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1872 let dir = fresh_dir("rawread");
1873 let path = dir.join("f.txt").to_string_lossy().to_string();
1874 std::fs::write(&path, "alpha\nbeta\n").unwrap();
1875
1876 let result = execute(&substrate, "read_file", &json!({ "path": path }))
1877 .await
1878 .unwrap()
1879 .unwrap();
1880 assert_eq!(result["content"], "alpha\nbeta\n");
1881
1882 std::fs::remove_dir_all(&dir).ok();
1883 }
1884
1885 #[tokio::test]
1888 async fn edit_requires_prior_read() {
1889 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1890 let ledger = ReadLedger::new();
1891 let dir = fresh_dir("editread");
1892 let path = dir.join("f.txt").to_string_lossy().to_string();
1893 std::fs::write(dir.join("f.txt"), "hello world").unwrap();
1895
1896 let err = execute_with_ledger(
1897 &substrate,
1898 &ledger,
1899 "edit_file",
1900 &json!({ "path": path, "old_text": "hello", "new_text": "hi" }),
1901 )
1902 .await
1903 .unwrap()
1904 .unwrap_err();
1905 assert!(err.contains("before editing it"), "{err}");
1906 assert!(err.contains("read_file"), "{err}");
1907
1908 execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1910 .await
1911 .unwrap()
1912 .unwrap();
1913 let ok = execute_with_ledger(
1914 &substrate,
1915 &ledger,
1916 "edit_file",
1917 &json!({ "path": path, "old_text": "hello", "new_text": "hi" }),
1918 )
1919 .await
1920 .unwrap()
1921 .unwrap();
1922 assert_eq!(ok["replacements"], 1);
1923
1924 std::fs::remove_dir_all(&dir).ok();
1925 }
1926
1927 #[tokio::test]
1930 async fn edit_detects_stale_file() {
1931 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1932 let ledger = ReadLedger::new();
1933 let dir = fresh_dir("stale");
1934 let path = dir.join("f.txt").to_string_lossy().to_string();
1935 std::fs::write(dir.join("f.txt"), "version one").unwrap();
1936
1937 execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1939 .await
1940 .unwrap()
1941 .unwrap();
1942 std::fs::write(dir.join("f.txt"), "version two changed").unwrap();
1943
1944 let err = execute_with_ledger(
1945 &substrate,
1946 &ledger,
1947 "edit_file",
1948 &json!({ "path": path, "old_text": "version", "new_text": "v" }),
1949 )
1950 .await
1951 .unwrap()
1952 .unwrap_err();
1953 assert!(err.contains("changed since you last read it"), "{err}");
1954
1955 execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1957 .await
1958 .unwrap()
1959 .unwrap();
1960 let ok = execute_with_ledger(
1961 &substrate,
1962 &ledger,
1963 "edit_file",
1964 &json!({ "path": path, "old_text": "version", "new_text": "v" }),
1965 )
1966 .await
1967 .unwrap()
1968 .unwrap();
1969 assert!(ok["edited"].is_string());
1970
1971 std::fs::remove_dir_all(&dir).ok();
1972 }
1973
1974 #[tokio::test]
1979 async fn read_file_preserves_crlf_line_endings() {
1980 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1981 let ledger = ReadLedger::new();
1982 let dir = fresh_dir("crlf");
1983 let path = dir.join("f.txt").to_string_lossy().to_string();
1984 std::fs::write(dir.join("f.txt"), "line one\r\nline two\r\n").unwrap();
1985
1986 let out = execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1987 .await
1988 .unwrap()
1989 .unwrap();
1990 let shown = out["content"].as_str().unwrap();
1991 assert_eq!(
1992 shown, " 1\tline one\r\n 2\tline two\r",
1993 "\\r must survive into the numbered display"
1994 );
1995
1996 let ok = execute_with_ledger(
1999 &substrate,
2000 &ledger,
2001 "edit_file",
2002 &json!({ "path": path, "old_text": "line one\r\nline two", "new_text": "merged" }),
2003 )
2004 .await
2005 .unwrap()
2006 .unwrap();
2007 assert_eq!(ok["replacements"], 1);
2008
2009 std::fs::remove_dir_all(&dir).ok();
2010 }
2011
2012 #[tokio::test]
2016 async fn edit_rejects_empty_old_text() {
2017 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2018 let dir = fresh_dir("emptyold");
2019 let path = dir.join("f.txt").to_string_lossy().to_string();
2020 exec_write_file(&substrate, None, &json!({ "path": path, "content": "abc" }))
2021 .await
2022 .unwrap();
2023
2024 for replace_all in [false, true] {
2025 let err = exec_edit_file(
2026 &substrate,
2027 None,
2028 &json!({
2029 "path": path,
2030 "old_text": "",
2031 "new_text": "X",
2032 "replace_all": replace_all
2033 }),
2034 )
2035 .await
2036 .unwrap_err();
2037 assert!(err.contains("old_text must be non-empty"), "{err}");
2038 }
2039 assert_eq!(std::fs::read_to_string(dir.join("f.txt")).unwrap(), "abc");
2041
2042 std::fs::remove_dir_all(&dir).ok();
2043 }
2044
2045 #[tokio::test]
2050 async fn write_existing_rejects_stale_after_external_change() {
2051 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2052 let ledger = ReadLedger::new();
2053 let dir = fresh_dir("stalewrite");
2054 let path = dir.join("f.txt").to_string_lossy().to_string();
2055 std::fs::write(dir.join("f.txt"), "original").unwrap();
2056
2057 execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
2058 .await
2059 .unwrap()
2060 .unwrap();
2061 std::fs::write(dir.join("f.txt"), "changed underneath").unwrap();
2063
2064 let err = execute_with_ledger(
2065 &substrate,
2066 &ledger,
2067 "write_file",
2068 &json!({ "path": path, "content": "clobber" }),
2069 )
2070 .await
2071 .unwrap()
2072 .unwrap_err();
2073 assert!(err.contains("changed since you last read it"), "{err}");
2074
2075 let err = execute_with_ledger(
2077 &substrate,
2078 &ledger,
2079 "write_file",
2080 &json!({ "path": path, "content": " + more", "append": true }),
2081 )
2082 .await
2083 .unwrap()
2084 .unwrap_err();
2085 assert!(err.contains("changed since you last read it"), "{err}");
2086
2087 execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
2089 .await
2090 .unwrap()
2091 .unwrap();
2092 execute_with_ledger(
2093 &substrate,
2094 &ledger,
2095 "write_file",
2096 &json!({ "path": path, "content": "rewritten" }),
2097 )
2098 .await
2099 .unwrap()
2100 .unwrap();
2101 assert_eq!(
2102 std::fs::read_to_string(dir.join("f.txt")).unwrap(),
2103 "rewritten"
2104 );
2105
2106 std::fs::remove_dir_all(&dir).ok();
2107 }
2108
2109 #[tokio::test]
2112 async fn replace_all_replaces_every_occurrence() {
2113 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2114 let dir = fresh_dir("replaceall");
2115 let path = dir.join("f.txt").to_string_lossy().to_string();
2116 exec_write_file(
2117 &substrate,
2118 None,
2119 &json!({ "path": path, "content": "a x a x a" }),
2120 )
2121 .await
2122 .unwrap();
2123
2124 let err = exec_edit_file(
2126 &substrate,
2127 None,
2128 &json!({ "path": path, "old_text": "a", "new_text": "b" }),
2129 )
2130 .await
2131 .unwrap_err();
2132 assert!(err.contains("replace_all"), "{err}");
2133
2134 let ok = exec_edit_file(
2136 &substrate,
2137 None,
2138 &json!({ "path": path, "old_text": "a", "new_text": "b", "replace_all": true }),
2139 )
2140 .await
2141 .unwrap();
2142 assert_eq!(ok["replacements"], 3);
2143 let r = exec_read_file(&substrate, None, &json!({ "path": path }))
2144 .await
2145 .unwrap();
2146 assert_eq!(r["content"], "b x b x b");
2147
2148 std::fs::remove_dir_all(&dir).ok();
2149 }
2150
2151 #[tokio::test]
2153 async fn write_new_file_allowed() {
2154 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2155 let ledger = ReadLedger::new();
2156 let dir = fresh_dir("writenew");
2157 let path = dir.join("new.txt").to_string_lossy().to_string();
2158
2159 let ok = execute_with_ledger(
2160 &substrate,
2161 &ledger,
2162 "write_file",
2163 &json!({ "path": path, "content": "fresh" }),
2164 )
2165 .await
2166 .unwrap()
2167 .unwrap();
2168 assert_eq!(ok["bytes_written"], 5);
2169
2170 std::fs::remove_dir_all(&dir).ok();
2171 }
2172
2173 #[tokio::test]
2175 async fn write_existing_requires_prior_read() {
2176 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2177 let ledger = ReadLedger::new();
2178 let dir = fresh_dir("writeexisting");
2179 let path = dir.join("f.txt").to_string_lossy().to_string();
2180 std::fs::write(dir.join("f.txt"), "existing content").unwrap();
2181
2182 let err = execute_with_ledger(
2183 &substrate,
2184 &ledger,
2185 "write_file",
2186 &json!({ "path": path, "content": "clobber" }),
2187 )
2188 .await
2189 .unwrap()
2190 .unwrap_err();
2191 assert!(err.contains("before overwriting it"), "{err}");
2192 assert!(err.contains("read_file"), "{err}");
2193
2194 execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
2196 .await
2197 .unwrap()
2198 .unwrap();
2199 let ok = execute_with_ledger(
2200 &substrate,
2201 &ledger,
2202 "write_file",
2203 &json!({ "path": path, "content": "clobber" }),
2204 )
2205 .await
2206 .unwrap()
2207 .unwrap();
2208 assert_eq!(ok["bytes_written"], 7);
2209
2210 std::fs::remove_dir_all(&dir).ok();
2211 }
2212
2213 #[tokio::test]
2217 async fn write_self_records_enabling_edit() {
2218 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2219 let ledger = ReadLedger::new();
2220 let dir = fresh_dir("writeedits");
2221 let path = dir.join("f.txt").to_string_lossy().to_string();
2222
2223 execute_with_ledger(
2224 &substrate,
2225 &ledger,
2226 "write_file",
2227 &json!({ "path": path, "content": "one two three" }),
2228 )
2229 .await
2230 .unwrap()
2231 .unwrap();
2232 let ok = execute_with_ledger(
2234 &substrate,
2235 &ledger,
2236 "edit_file",
2237 &json!({ "path": path, "old_text": "two", "new_text": "TWO" }),
2238 )
2239 .await
2240 .unwrap()
2241 .unwrap();
2242 assert_eq!(ok["replacements"], 1);
2243
2244 std::fs::remove_dir_all(&dir).ok();
2245 }
2246
2247 #[tokio::test]
2251 async fn edit_self_records_enabling_second_edit_without_reread() {
2252 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2253 let ledger = ReadLedger::new();
2254 let dir = fresh_dir("editselfrec");
2255 let path = dir.join("f.txt").to_string_lossy().to_string();
2256 std::fs::write(dir.join("f.txt"), "one two three").unwrap();
2257
2258 execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
2259 .await
2260 .unwrap()
2261 .unwrap();
2262 execute_with_ledger(
2263 &substrate,
2264 &ledger,
2265 "edit_file",
2266 &json!({ "path": path, "old_text": "one", "new_text": "1" }),
2267 )
2268 .await
2269 .unwrap()
2270 .unwrap();
2271
2272 let ok = execute_with_ledger(
2274 &substrate,
2275 &ledger,
2276 "edit_file",
2277 &json!({ "path": path, "old_text": "two", "new_text": "2" }),
2278 )
2279 .await
2280 .unwrap()
2281 .unwrap();
2282 assert_eq!(ok["replacements"], 1);
2283 assert_eq!(
2284 std::fs::read_to_string(dir.join("f.txt")).unwrap(),
2285 "1 2 three"
2286 );
2287
2288 std::fs::remove_dir_all(&dir).ok();
2289 }
2290
2291 #[tokio::test]
2295 async fn sliced_read_hashes_full_file_licensing_edit() {
2296 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2297 let ledger = ReadLedger::new();
2298 let dir = fresh_dir("slicedread");
2299 let path = dir.join("f.txt").to_string_lossy().to_string();
2300 std::fs::write(dir.join("f.txt"), "l1\nl2\nl3\nl4").unwrap();
2301
2302 let r = execute_with_ledger(
2303 &substrate,
2304 &ledger,
2305 "read_file",
2306 &json!({ "path": path, "offset": 1, "limit": 1 }),
2307 )
2308 .await
2309 .unwrap()
2310 .unwrap();
2311 assert_eq!(r["content"], " 2\tl2"); let ok = execute_with_ledger(
2315 &substrate,
2316 &ledger,
2317 "edit_file",
2318 &json!({ "path": path, "old_text": "l4", "new_text": "L4" }),
2319 )
2320 .await
2321 .unwrap()
2322 .unwrap();
2323 assert_eq!(ok["replacements"], 1);
2324
2325 std::fs::remove_dir_all(&dir).ok();
2326 }
2327
2328 #[tokio::test]
2331 async fn append_to_existing_unread_requires_prior_read() {
2332 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2333 let ledger = ReadLedger::new();
2334 let dir = fresh_dir("appendunread");
2335 let path = dir.join("f.txt").to_string_lossy().to_string();
2336 std::fs::write(dir.join("f.txt"), "existing content").unwrap();
2337
2338 let err = execute_with_ledger(
2339 &substrate,
2340 &ledger,
2341 "write_file",
2342 &json!({ "path": path, "content": " more", "append": true }),
2343 )
2344 .await
2345 .unwrap()
2346 .unwrap_err();
2347 assert!(err.contains("before overwriting it"), "{err}");
2348
2349 execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
2350 .await
2351 .unwrap()
2352 .unwrap();
2353 let ok = execute_with_ledger(
2354 &substrate,
2355 &ledger,
2356 "write_file",
2357 &json!({ "path": path, "content": " more", "append": true }),
2358 )
2359 .await
2360 .unwrap()
2361 .unwrap();
2362 assert_eq!(ok["append"], true);
2363 assert_eq!(
2364 std::fs::read_to_string(dir.join("f.txt")).unwrap(),
2365 "existing content more"
2366 );
2367
2368 std::fs::remove_dir_all(&dir).ok();
2369 }
2370
2371 #[tokio::test]
2372 async fn append_to_existing_empty_file_requires_prior_read() {
2373 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2374 let ledger = ReadLedger::new();
2375 let dir = fresh_dir("appendempty");
2376 let path = dir.join("f.txt").to_string_lossy().to_string();
2377 std::fs::write(dir.join("f.txt"), "").unwrap();
2378
2379 let err = execute_with_ledger(
2380 &substrate,
2381 &ledger,
2382 "write_file",
2383 &json!({ "path": path, "content": "new", "append": true }),
2384 )
2385 .await
2386 .unwrap()
2387 .unwrap_err();
2388 assert!(err.contains("before overwriting it"), "{err}");
2389 assert!(std::fs::read_to_string(dir.join("f.txt"))
2390 .unwrap()
2391 .is_empty());
2392
2393 std::fs::remove_dir_all(&dir).ok();
2394 }
2395
2396 #[tokio::test]
2397 async fn partial_read_cannot_authorize_whole_file_mutation() {
2398 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2399 let ledger = ReadLedger::new();
2400 let dir = fresh_dir("partialread");
2401 let path = dir.join("f.txt").to_string_lossy().to_string();
2402 std::fs::write(dir.join("f.txt"), "first\nsecond\nthird").unwrap();
2403
2404 execute_with_ledger(
2405 &substrate,
2406 &ledger,
2407 "read_file",
2408 &json!({ "path": path, "offset": 0, "limit": 1 }),
2409 )
2410 .await
2411 .unwrap()
2412 .unwrap();
2413
2414 let overwrite = execute_with_ledger(
2415 &substrate,
2416 &ledger,
2417 "write_file",
2418 &json!({ "path": path, "content": "replacement" }),
2419 )
2420 .await
2421 .unwrap()
2422 .unwrap_err();
2423 assert!(overwrite.contains("full current content"), "{overwrite}");
2424
2425 let replace_all = execute_with_ledger(
2426 &substrate,
2427 &ledger,
2428 "edit_file",
2429 &json!({ "path": path, "old_text": "i", "new_text": "I", "replace_all": true }),
2430 )
2431 .await
2432 .unwrap()
2433 .unwrap_err();
2434 assert!(
2435 replace_all.contains("full current content"),
2436 "{replace_all}"
2437 );
2438
2439 let edit = execute_with_ledger(
2441 &substrate,
2442 &ledger,
2443 "edit_file",
2444 &json!({ "path": path, "old_text": "first", "new_text": "FIRST" }),
2445 )
2446 .await
2447 .unwrap()
2448 .unwrap();
2449 assert_eq!(edit["replacements"], 1);
2450
2451 std::fs::remove_dir_all(&dir).ok();
2452 }
2453
2454 #[tokio::test]
2455 async fn guarded_write_refuses_existing_non_utf8_file() {
2456 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2457 let ledger = ReadLedger::new();
2458 let dir = fresh_dir("nonutf8write");
2459 let path = dir.join("f.bin").to_string_lossy().to_string();
2460 std::fs::write(dir.join("f.bin"), [0xff, 0x00, 0xfe]).unwrap();
2461
2462 let err = execute_with_ledger(
2463 &substrate,
2464 &ledger,
2465 "write_file",
2466 &json!({ "path": path, "content": "text" }),
2467 )
2468 .await
2469 .unwrap()
2470 .unwrap_err();
2471 assert!(err.contains("cannot modify existing file"), "{err}");
2472 assert_eq!(
2473 std::fs::read(dir.join("f.bin")).unwrap(),
2474 [0xff, 0x00, 0xfe]
2475 );
2476
2477 std::fs::remove_dir_all(&dir).ok();
2478 }
2479
2480 #[tokio::test]
2484 async fn append_detects_stale_after_external_change() {
2485 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2486 let ledger = ReadLedger::new();
2487 let dir = fresh_dir("appendstale");
2488 let path = dir.join("f.txt").to_string_lossy().to_string();
2489 std::fs::write(dir.join("f.txt"), "v1").unwrap();
2490
2491 execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
2492 .await
2493 .unwrap()
2494 .unwrap();
2495 std::fs::write(dir.join("f.txt"), "v2 changed").unwrap();
2496
2497 let err = execute_with_ledger(
2498 &substrate,
2499 &ledger,
2500 "write_file",
2501 &json!({ "path": path, "content": " appended", "append": true }),
2502 )
2503 .await
2504 .unwrap()
2505 .unwrap_err();
2506 assert!(err.contains("changed since you last read it"), "{err}");
2507
2508 std::fs::remove_dir_all(&dir).ok();
2509 }
2510
2511 #[tokio::test]
2515 async fn replace_all_edit_is_gated_by_the_ledger() {
2516 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2517 let ledger = ReadLedger::new();
2518 let dir = fresh_dir("replaceallgate");
2519 let path = dir.join("f.txt").to_string_lossy().to_string();
2520 std::fs::write(dir.join("f.txt"), "a a a").unwrap();
2521
2522 execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
2523 .await
2524 .unwrap()
2525 .unwrap();
2526 let ok = execute_with_ledger(
2527 &substrate,
2528 &ledger,
2529 "edit_file",
2530 &json!({ "path": path, "old_text": "a", "new_text": "b", "replace_all": true }),
2531 )
2532 .await
2533 .unwrap()
2534 .unwrap();
2535 assert_eq!(ok["replacements"], 3);
2536
2537 let ok2 = execute_with_ledger(
2539 &substrate,
2540 &ledger,
2541 "edit_file",
2542 &json!({ "path": path, "old_text": "b", "new_text": "c", "replace_all": true }),
2543 )
2544 .await
2545 .unwrap()
2546 .unwrap();
2547 assert_eq!(ok2["replacements"], 3);
2548
2549 std::fs::write(dir.join("f.txt"), "c c c c").unwrap();
2551 let err = execute_with_ledger(
2552 &substrate,
2553 &ledger,
2554 "edit_file",
2555 &json!({ "path": path, "old_text": "c", "new_text": "d", "replace_all": true }),
2556 )
2557 .await
2558 .unwrap()
2559 .unwrap_err();
2560 assert!(err.contains("changed since you last read it"), "{err}");
2561
2562 std::fs::remove_dir_all(&dir).ok();
2563 }
2564
2565 #[tokio::test]
2570 async fn builtin_names_never_return_unknown_tool_prefix() {
2571 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2572 let ledger = ReadLedger::new();
2573
2574 for name in [
2575 "read_file",
2576 "write_file",
2577 "edit_file",
2578 "list_dir",
2579 "find_files",
2580 "grep_files",
2581 "calculate",
2582 ] {
2583 let result = execute_with_ledger(&substrate, &ledger, name, &json!({})).await;
2584 let inner = result.unwrap_or_else(|| panic!("{name} must be handled, not None"));
2585 if let Err(e) = &inner {
2586 assert!(
2587 !e.starts_with("unknown tool"),
2588 "{name} error must not start with 'unknown tool': {e}"
2589 );
2590 }
2591 }
2592
2593 assert!(
2595 execute_with_ledger(&substrate, &ledger, "no_such_tool", &json!({}))
2596 .await
2597 .is_none()
2598 );
2599
2600 let dir = fresh_dir("unknownprefix");
2602 let path = dir.join("f.txt").to_string_lossy().to_string();
2603 std::fs::write(dir.join("f.txt"), "content").unwrap();
2604 let edit_err = execute_with_ledger(
2605 &substrate,
2606 &ledger,
2607 "edit_file",
2608 &json!({ "path": path, "old_text": "content", "new_text": "x" }),
2609 )
2610 .await
2611 .unwrap()
2612 .unwrap_err();
2613 assert!(!edit_err.starts_with("unknown tool"), "{edit_err}");
2614 assert!(edit_err.contains("before editing it"), "{edit_err}");
2615 let write_err = execute_with_ledger(
2616 &substrate,
2617 &ledger,
2618 "write_file",
2619 &json!({ "path": path, "content": "y" }),
2620 )
2621 .await
2622 .unwrap()
2623 .unwrap_err();
2624 assert!(!write_err.starts_with("unknown tool"), "{write_err}");
2625
2626 std::fs::remove_dir_all(&dir).ok();
2627 }
2628
2629 #[test]
2632 fn ledger_normalizes_dot_components() {
2633 let ledger = ReadLedger::new();
2634 ledger.record("./src/x.rs", "content", true);
2635 assert_eq!(ledger.check("src/x.rs", "content"), ReadState::FreshFull);
2636 assert_eq!(ledger.check("./src/x.rs", "content"), ReadState::FreshFull);
2637
2638 let ledger2 = ReadLedger::new();
2639 ledger2.record("src/x.rs", "content", true);
2640 assert_eq!(ledger2.check("./src/x.rs", "content"), ReadState::FreshFull);
2641
2642 assert_eq!(ledger.check("other.rs", "content"), ReadState::Unread);
2645 let ledger3 = ReadLedger::new();
2646 ledger3.record("/root/a/./b", "c", true);
2647 assert_eq!(ledger3.check("/root/a/b", "c"), ReadState::FreshFull);
2648 }
2649
2650 #[test]
2651 fn session_ledgers_share_mutation_locks_without_sharing_observations() {
2652 let ledgers = SessionReadLedgers::new();
2653 let first = ledgers.ledger_for(Some("first"));
2654 let second = ledgers.ledger_for(Some("second"));
2655 first.record("f.txt", "first view", true);
2656
2657 assert_eq!(
2658 second.check("f.txt", "first view"),
2659 ReadState::Unread,
2660 "one session's read must not authorize another session"
2661 );
2662 assert!(Arc::ptr_eq(
2663 &first.mutation_lock("f.txt"),
2664 &second.mutation_lock("f.txt")
2665 ));
2666 }
2667
2668 #[tokio::test]
2669 async fn indentation_error_provides_exact_bytes_for_a_literal_retry_without_mutation() {
2670 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2671 let dir = fresh_dir("indentation-retry");
2672 let path = dir.join("test.py").to_string_lossy().to_string();
2673 for (content, old_text, exact) in [
2674 (
2675 "# tests\nif __name__ == '__main__':\n unittest.main()\n",
2676 " if __name__ == '__main__':\n unittest.main()",
2677 "if __name__ == '__main__':\n unittest.main()",
2678 ),
2679 (
2680 "# tests\r\nif __name__ == '__main__':\r\n\tunittest.main()\r\n",
2681 " if __name__ == '__main__':\n unittest.main()",
2682 "if __name__ == '__main__':\r\n\tunittest.main()",
2683 ),
2684 ("α\nβ\n", " β\n", "β\n"),
2685 ] {
2686 std::fs::write(&path, content).unwrap();
2687 let ledger = ReadLedger::new();
2688 exec_read_file(&substrate, Some(&ledger), &json!({"path":path}))
2689 .await
2690 .unwrap();
2691 let error = exec_edit_file(
2692 &substrate,
2693 Some(&ledger),
2694 &json!({
2695 "path":path, "old_text":old_text, "new_text":"WRONG"
2696 }),
2697 )
2698 .await
2699 .unwrap_err();
2700 assert_eq!(std::fs::read_to_string(&path).unwrap(), content);
2701 let suggested = error
2702 .split_once(HINT_BEGIN)
2703 .and_then(|(_, rest)| rest.split_once(HINT_END))
2704 .expect(&error)
2705 .0
2706 .to_string();
2707 assert_eq!(suggested, exact);
2708 exec_edit_file(
2709 &substrate,
2710 Some(&ledger),
2711 &json!({
2712 "path":path, "old_text":suggested, "new_text":"replacement"
2713 }),
2714 )
2715 .await
2716 .unwrap();
2717 assert_eq!(
2718 std::fs::read_to_string(&path).unwrap(),
2719 content.replacen(exact, "replacement", 1)
2720 );
2721 }
2722 std::fs::remove_dir_all(dir).unwrap();
2723 }
2724
2725 #[test]
2726 fn indentation_hints_do_not_guess_among_duplicates_or_unrelated_text() {
2727 assert!(indentation_hint(" call()\n\tcall()\n", " call()").is_none());
2728 assert_eq!(
2729 indentation_hint("a\n call()\n", "call()"),
2730 Some((" call()", 1, 2))
2731 );
2732 assert!(indentation_hint(" call()\n", " different()").is_none());
2733 assert!(indentation_hint(" \n", " \n").is_none());
2734 assert!(indentation_hint(&"line\n".repeat(13), &" line\n".repeat(13)).is_none());
2735 assert!(indentation_hint(&"a".repeat(2048), &format!(" {}", "a".repeat(2048))).is_none());
2736 }
2737
2738 #[tokio::test]
2739 async fn indentation_hints_require_full_fresh_observation_and_a_single_edit() {
2740 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2741 let dir = fresh_dir("indentation-guards");
2742 let path = dir.join("f.txt").to_string_lossy().to_string();
2743 let content = "first\ncall()\n";
2744 for (partial, stale, replace_all) in [
2745 (true, false, false),
2746 (false, true, false),
2747 (false, false, true),
2748 ] {
2749 std::fs::write(&path, content).unwrap();
2750 let ledger = ReadLedger::new();
2751 let mut read = json!({"path":path});
2752 if partial {
2753 read["limit"] = json!(1);
2754 }
2755 exec_read_file(&substrate, Some(&ledger), &read)
2756 .await
2757 .unwrap();
2758 if stale {
2759 std::fs::write(&path, "changed\ncall()\n").unwrap();
2760 }
2761 let before = std::fs::read_to_string(&path).unwrap();
2762 let error = exec_edit_file(&substrate, Some(&ledger), &json!({
2763 "path":path, "old_text":" call()", "new_text":"WRONG", "replace_all":replace_all
2764 })).await.unwrap_err();
2765 assert!(!error.contains(HINT_BEGIN), "{error}");
2766 assert_eq!(std::fs::read_to_string(&path).unwrap(), before);
2767 }
2768 std::fs::remove_dir_all(dir).unwrap();
2769 }
2770
2771 #[tokio::test]
2772 async fn a_paged_read_keeps_recovery_and_hints_for_the_lines_it_showed() {
2773 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2774 let dir = fresh_dir("paged-recovery");
2775 let path = dir.join("big.py").to_string_lossy().to_string();
2776 let content = "a = 1\n b = 2\nc = 3\n d = 4\n";
2777 std::fs::write(&path, content).unwrap();
2778 let ledger = ReadLedger::new();
2779 exec_read_file(
2781 &substrate,
2782 Some(&ledger),
2783 &json!({"path": path, "limit": 2}),
2784 )
2785 .await
2786 .unwrap();
2787 exec_edit_file(
2789 &substrate,
2790 Some(&ledger),
2791 &json!({"path": path, "old_text": " 1\ta = 1", "new_text": "a = 10"}),
2792 )
2793 .await
2794 .unwrap();
2795 exec_read_file(
2796 &substrate,
2797 Some(&ledger),
2798 &json!({"path": path, "limit": 2}),
2799 )
2800 .await
2801 .unwrap();
2802 let error = exec_edit_file(
2804 &substrate,
2805 Some(&ledger),
2806 &json!({"path": path, "old_text": "a = 10\n b = 2", "new_text": "x"}),
2807 )
2808 .await
2809 .unwrap_err();
2810 assert!(
2811 error.contains(&format!("{HINT_BEGIN}a = 10\n b = 2{HINT_END}")),
2812 "{error}"
2813 );
2814 let error = exec_edit_file(
2816 &substrate,
2817 Some(&ledger),
2818 &json!({"path": path, "old_text": "c = 3\n d = 4", "new_text": "x"}),
2819 )
2820 .await
2821 .unwrap_err();
2822 assert!(!error.contains(HINT_BEGIN), "{error}");
2823 let error = exec_edit_file(
2824 &substrate,
2825 Some(&ledger),
2826 &json!({"path": path, "old_text": " 3\tc = 3", "new_text": "x"}),
2827 )
2828 .await
2829 .unwrap_err();
2830 assert!(error.contains("not found"), "{error}");
2831 std::fs::remove_dir_all(dir).unwrap();
2832 }
2833
2834 #[tokio::test]
2835 async fn a_page_keeps_its_recoveries_across_an_edit() {
2836 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2837 let dir = fresh_dir("paged-after-edit");
2838 let path = dir.join("f.py").to_string_lossy().to_string();
2839 std::fs::write(&path, "a = 1\nb = 2\nc = 3\nd = 4\ne = 5\n").unwrap();
2840 let ledger = ReadLedger::new();
2841 exec_read_file(
2842 &substrate,
2843 Some(&ledger),
2844 &json!({"path": path, "limit": 4}),
2845 )
2846 .await
2847 .unwrap();
2848 exec_edit_file(
2850 &substrate,
2851 Some(&ledger),
2852 &json!({"path": path, "old_text": "b = 2", "new_text": "b = 2\nb2 = 2"}),
2853 )
2854 .await
2855 .unwrap();
2856 exec_edit_file(
2859 &substrate,
2860 Some(&ledger),
2861 &json!({"path": path, "old_text": " 5\td = 4", "new_text": "d = 40"}),
2862 )
2863 .await
2864 .unwrap();
2865 assert_eq!(
2866 std::fs::read_to_string(&path).unwrap(),
2867 "a = 1\nb = 2\nb2 = 2\nc = 3\nd = 40\ne = 5\n"
2868 );
2869 let err = exec_edit_file(
2871 &substrate,
2872 Some(&ledger),
2873 &json!({"path": path, "old_text": " 6\te = 5", "new_text": "x"}),
2874 )
2875 .await
2876 .unwrap_err();
2877 assert!(err.contains("not found"), "{err}");
2878 std::fs::remove_dir_all(dir).unwrap();
2879 }
2880
2881 #[test]
2882 fn seen_ranges_move_with_an_edit() {
2883 assert_eq!(
2885 shift_seen_lines(&[(0, 4)], 1, 2, 4),
2886 [(0, 1), (4, 6), (1, 4)]
2887 );
2888 assert_eq!(shift_seen_lines(&[(0, 2)], 5, 6, 8), [(0, 2)]);
2890 assert_eq!(shift_seen_lines(&[(6, 9)], 1, 3, 2), [(5, 8)]);
2892 }
2893
2894 #[tokio::test]
2895 async fn edit_accepts_exact_numbered_display_after_full_fresh_read() {
2896 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2897 let dir = fresh_dir("numbered-edit");
2898 let path = dir.join("f.txt").to_string_lossy().to_string();
2899 for content in ["first\n\nlast\n", "first\r\n\r\nlast\r\n", "first\n\nlast"] {
2900 std::fs::write(&path, content).unwrap();
2901 let ledger = ReadLedger::new();
2902 let read = exec_read_file(&substrate, Some(&ledger), &json!({"path":path}))
2903 .await
2904 .unwrap();
2905 let display = read["content"].as_str().unwrap();
2906 let result = exec_edit_file(
2907 &substrate,
2908 Some(&ledger),
2909 &json!({
2910 "path":path, "old_text":display, "new_text":"changed"
2911 }),
2912 )
2913 .await
2914 .unwrap();
2915 assert_eq!(result["removed_display_line_numbers"], true);
2916 assert_eq!(
2919 std::fs::read_to_string(&path).unwrap(),
2920 if content.ends_with('\n') {
2921 "changed\n"
2922 } else {
2923 "changed"
2924 }
2925 );
2926 }
2927 std::fs::write(&path, "α\nβ\tdata\nγ\n").unwrap();
2928 let ledger = ReadLedger::new();
2929 exec_read_file(&substrate, Some(&ledger), &json!({"path":path}))
2930 .await
2931 .unwrap();
2932 exec_edit_file(
2933 &substrate,
2934 Some(&ledger),
2935 &json!({
2936 "path":path, "old_text":" 2\tβ\tdata", "new_text":"δ\tdata"
2937 }),
2938 )
2939 .await
2940 .unwrap();
2941 assert_eq!(std::fs::read_to_string(&path).unwrap(), "α\nδ\tdata\nγ\n");
2942 std::fs::remove_dir_all(dir).unwrap();
2943 }
2944
2945 #[tokio::test]
2946 async fn numbered_edit_keeps_location_freshness_and_uniqueness_guards() {
2947 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2948 let dir = fresh_dir("numbered-edit-guards");
2949 let path = dir.join("f.txt").to_string_lossy().to_string();
2950 for (content, old, replace_all) in [
2951 ("first\nlast\n", " 1\tlast", false),
2952 ("first\nlast\n", " 1\tfirst\n 3\tlast", false),
2953 ("first \nlast\n", " 1\tfirst", false),
2954 ("same\nsame\n", " 2\tsame", false),
2955 ("first\nlast\n", " 1\tfirst", true),
2956 ] {
2957 std::fs::write(&path, content).unwrap();
2958 let ledger = ReadLedger::new();
2959 exec_read_file(&substrate, Some(&ledger), &json!({"path":path}))
2960 .await
2961 .unwrap();
2962 assert!(exec_edit_file(
2963 &substrate,
2964 Some(&ledger),
2965 &json!({
2966 "path":path, "old_text":old, "new_text":"changed", "replace_all":replace_all
2967 })
2968 )
2969 .await
2970 .is_err());
2971 assert_eq!(std::fs::read_to_string(&path).unwrap(), content);
2972 }
2973 std::fs::write(&path, "first\nlast\n").unwrap();
2974 let ledger = ReadLedger::new();
2975 exec_read_file(&substrate, Some(&ledger), &json!({"path":path, "limit":1}))
2976 .await
2977 .unwrap();
2978 assert!(exec_edit_file(
2980 &substrate,
2981 Some(&ledger),
2982 &json!({
2983 "path":path, "old_text":" 2\tlast", "new_text":"changed"
2984 })
2985 )
2986 .await
2987 .is_err());
2988 exec_read_file(&substrate, Some(&ledger), &json!({"path":path}))
2989 .await
2990 .unwrap();
2991 std::fs::write(&path, "external change").unwrap();
2992 assert!(exec_edit_file(
2993 &substrate,
2994 Some(&ledger),
2995 &json!({
2996 "path":path, "old_text":" 1\tfirst", "new_text":"changed"
2997 })
2998 )
2999 .await
3000 .unwrap_err()
3001 .contains("changed since"));
3002 std::fs::write(&path, " 1\tliteral").unwrap();
3004 exec_read_file(&substrate, Some(&ledger), &json!({"path":path}))
3005 .await
3006 .unwrap();
3007 let result = exec_edit_file(
3008 &substrate,
3009 Some(&ledger),
3010 &json!({
3011 "path":path, "old_text":" 1\tliteral", "new_text":"changed"
3012 }),
3013 )
3014 .await
3015 .unwrap();
3016 assert_eq!(result["removed_display_line_numbers"], false);
3017 assert_eq!(std::fs::read_to_string(&path).unwrap(), "changed");
3018 std::fs::remove_dir_all(dir).unwrap();
3019 }
3020
3021 #[tokio::test]
3022 async fn a_multi_line_edit_without_carriage_returns_lands_in_a_crlf_file() {
3023 let dir = tempfile::tempdir().unwrap();
3024 let path = dir.path().join("w.py");
3025 std::fs::write(&path, "a = 1\r\nb = 2\r\nc = 3\r\n").unwrap();
3026 let path = path.to_string_lossy().into_owned();
3027 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
3028 let ledger = ReadLedger::new();
3029 execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
3030 .await
3031 .unwrap()
3032 .unwrap();
3033 execute_with_ledger(
3034 &substrate,
3035 &ledger,
3036 "edit_file",
3037 &json!({ "path": path, "old_text": "a = 1\nb = 2", "new_text": "a = 1\nb = 20" }),
3038 )
3039 .await
3040 .unwrap()
3041 .unwrap();
3042 assert_eq!(
3043 std::fs::read_to_string(&path).unwrap(),
3044 "a = 1\r\nb = 20\r\nc = 3\r\n"
3045 );
3046 }
3047
3048 #[tokio::test]
3051 async fn edit_no_match_hints_pasted_line_number() {
3052 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
3053 let dir = fresh_dir("linenumhint");
3054 let path = dir.join("f.txt").to_string_lossy().to_string();
3055 exec_write_file(
3056 &substrate,
3057 None,
3058 &json!({ "path": path, "content": "hello world" }),
3059 )
3060 .await
3061 .unwrap();
3062
3063 let err = exec_edit_file(
3064 &substrate,
3065 None,
3066 &json!({ "path": path, "old_text": " 1\thello world", "new_text": "hi" }),
3067 )
3068 .await
3069 .unwrap_err();
3070 assert!(err.contains("line-number prefixes"), "{err}");
3071
3072 let plain = exec_edit_file(
3073 &substrate,
3074 None,
3075 &json!({ "path": path, "old_text": "absent", "new_text": "x" }),
3076 )
3077 .await
3078 .unwrap_err();
3079 assert!(!plain.contains("line-number prefixes"), "{plain}");
3080 assert!(plain.contains("old_text not found"), "{plain}");
3081
3082 std::fs::remove_dir_all(&dir).ok();
3083 }
3084
3085 #[tokio::test]
3086 async fn calculate_is_pure() {
3087 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
3088 let out = execute(
3089 &substrate,
3090 "calculate",
3091 &json!({ "expression": "2 + 3 * 4" }),
3092 )
3093 .await
3094 .unwrap()
3095 .unwrap();
3096 assert_eq!(out["result"], 14.0);
3097 }
3098
3099 #[tokio::test]
3100 async fn unknown_tool_returns_none() {
3101 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
3102 assert!(execute(&substrate, "nope", &json!({})).await.is_none());
3103 }
3104
3105 fn calc(expr: &str) -> f64 {
3108 exec_calculate(&json!({ "expression": expr }))
3109 .unwrap_or_else(|e| panic!("calculate({expr:?}) failed: {e}"))
3110 .get("result")
3111 .and_then(|v| v.as_f64())
3112 .unwrap_or_else(|| panic!("calculate({expr:?}) returned no numeric result"))
3113 }
3114
3115 #[test]
3122 fn calculate_contract_semantics() {
3123 assert_eq!(calc("2 + 3 * 4"), 14.0, "operator precedence");
3124 assert_eq!(calc("(1 + 2) * 3"), 9.0, "parentheses override precedence");
3125 assert_eq!(calc("2^3"), 8.0, "^ is exponentiation, not XOR");
3126 assert_eq!(calc("2^10"), 1024.0, "^ is exponentiation");
3127 assert_eq!(calc("10 % 3"), 1.0, "modulo");
3128 assert_eq!(calc("-5 + 2"), -3.0, "unary minus");
3129 assert_eq!(calc("sin(0)"), 0.0, "native function: sin");
3131 assert_eq!(calc("abs(-3)"), 3.0, "native function: abs");
3132 assert_eq!(calc("sqrt(16)"), 4.0, "shim function: sqrt");
3134 assert!((calc("ln(e)") - 1.0).abs() < 1e-12, "shim: ln + e constant");
3135 assert!(
3136 (calc("pi") - std::f64::consts::PI).abs() < 1e-12,
3137 "shim: pi constant"
3138 );
3139 }
3140
3141 #[test]
3142 fn calculate_rejects_invalid_expression() {
3143 assert!(exec_calculate(&json!({ "expression": "2 +" })).is_err());
3144 assert!(exec_calculate(&json!({})).is_err(), "missing parameter");
3145 }
3146}