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
1246fn local_resolve(path: &str) -> Result<PathBuf, String> {
1253 crate::substrate::LocalSubstrate::resolve_path(path)
1254}
1255
1256fn should_skip_name(name: &str) -> bool {
1257 name.starts_with('.') || matches!(name, "node_modules" | "__pycache__" | "target")
1258}
1259
1260fn is_text_file(path: &Path) -> bool {
1261 matches!(
1262 path.extension().and_then(|v| v.to_str()),
1263 Some(
1264 "c" | "cc"
1265 | "cpp"
1266 | "cs"
1267 | "css"
1268 | "go"
1269 | "h"
1270 | "html"
1271 | "ini"
1272 | "java"
1273 | "js"
1274 | "json"
1275 | "jsx"
1276 | "kt"
1277 | "md"
1278 | "py"
1279 | "rb"
1280 | "rs"
1281 | "sh"
1282 | "sql"
1283 | "toml"
1284 | "ts"
1285 | "tsx"
1286 | "txt"
1287 | "xml"
1288 | "yaml"
1289 | "yml"
1290 )
1291 )
1292}
1293
1294fn walk_files(
1299 root: &Path,
1300 follow: bool,
1301 prune: Option<&LocalPathGuard>,
1302 visit: &mut dyn FnMut(&Path),
1303) -> Result<(), String> {
1304 if !follow && std::fs::symlink_metadata(root).is_ok_and(|meta| meta.file_type().is_symlink()) {
1305 return Ok(());
1306 }
1307 if root.is_file() {
1308 visit(root);
1309 return Ok(());
1310 }
1311
1312 let read_dir = std::fs::read_dir(root)
1313 .map_err(|e| format!("failed to read dir '{}': {e}", root.display()))?;
1314 for entry in read_dir {
1315 let entry = entry.map_err(|e| format!("failed to read dir entry: {e}"))?;
1316 let path = entry.path();
1317 let file_name = entry.file_name().to_string_lossy().to_string();
1318 if should_skip_name(&file_name) {
1319 continue;
1320 }
1321 if !follow && entry.file_type().is_ok_and(|kind| kind.is_symlink()) {
1322 continue;
1323 }
1324 if prune.is_some_and(|prune| {
1325 entry.file_type().is_ok_and(|kind| kind.is_dir()) && prune(&path).is_some()
1326 }) {
1327 continue;
1328 }
1329 let metadata = entry
1330 .metadata()
1331 .map_err(|e| format!("failed to read metadata for '{}': {e}", path.display()))?;
1332 if metadata.is_dir() {
1333 walk_files(&path, follow, prune, visit)?;
1334 } else if metadata.is_file() {
1335 visit(&path);
1336 }
1337 }
1338 Ok(())
1339}
1340
1341fn glob_to_regex(pattern: &str) -> Result<Regex, String> {
1351 let chars: Vec<char> = pattern.chars().collect();
1352 let mut re = String::from("^");
1353 let mut i = 0;
1354 while i < chars.len() {
1355 match chars[i] {
1356 '*' => {
1357 if i + 1 < chars.len() && chars[i + 1] == '*' {
1358 i += 1; if i + 1 < chars.len() && chars[i + 1] == '/' {
1360 i += 1; re.push_str("(?:.*/)?");
1362 } else {
1363 re.push_str(".*");
1364 }
1365 } else {
1366 re.push_str("[^/]*");
1367 }
1368 }
1369 '?' => re.push_str("[^/]"),
1370 c => re.push_str(®ex::escape(&c.to_string())),
1371 }
1372 i += 1;
1373 }
1374 re.push('$');
1375 Regex::new(&re).map_err(|e| format!("invalid glob pattern: {e}"))
1376}
1377
1378fn glob_haystack(pattern_has_sep: bool, full: &str, root: &str) -> String {
1386 if pattern_has_sep {
1387 let full = full.replace('\\', "/");
1388 let root = root.replace('\\', "/");
1389 full.strip_prefix(&root)
1390 .unwrap_or(&full)
1391 .trim_start_matches('/')
1392 .to_string()
1393 } else {
1394 full.rsplit(['/', '\\']).next().unwrap_or(full).to_string()
1395 }
1396}
1397
1398fn shell_quote(s: &str) -> String {
1407 format!("'{}'", s.replace('\'', "'\\''"))
1409}
1410
1411async fn list_dir_via_command(substrate: &Arc<dyn Substrate>, path: &str) -> Result<Value, String> {
1412 let cmd = format!("ls -1Ap {}", shell_quote(path));
1413 let out = substrate.run_command(&cmd, Some(30.0)).await?;
1414 let entries: Vec<Value> = out
1415 .stdout
1416 .lines()
1417 .filter(|l| !l.is_empty())
1418 .filter(|name| {
1419 let bare = name.trim_end_matches('/');
1420 !should_skip_name(bare)
1421 })
1422 .map(|name| {
1423 let is_dir = name.ends_with('/');
1424 let bare = name.trim_end_matches('/');
1425 json!({
1426 "name": bare,
1427 "path": format!("{}/{}", path.trim_end_matches('/'), bare),
1428 "is_dir": is_dir,
1429 "size_bytes": Value::Null,
1430 })
1431 })
1432 .collect();
1433 Ok(json!({ "path": path, "entries": entries }))
1434}
1435
1436async fn find_files_via_command(
1437 substrate: &Arc<dyn Substrate>,
1438 pattern: &str,
1439 root: &str,
1440 max_results: usize,
1441) -> Result<Value, String> {
1442 let cmd = format!("find {} -type f", shell_quote(root));
1448 let out = substrate.run_command(&cmd, Some(30.0)).await?;
1449 let matcher = glob_to_regex(pattern)?;
1450 let pattern_has_sep = pattern.contains('/');
1451 let mut files: Vec<String> = Vec::new();
1452 let mut truncated = false;
1453 for line in out.stdout.lines() {
1454 if line.is_empty() {
1455 continue;
1456 }
1457 let name = line.rsplit(['/', '\\']).next().unwrap_or(line);
1458 if should_skip_name(name) {
1459 continue;
1460 }
1461 if !matcher.is_match(&glob_haystack(pattern_has_sep, line, root)) {
1462 continue;
1463 }
1464 if files.len() >= max_results {
1465 truncated = true;
1466 break;
1467 }
1468 files.push(line.to_string());
1469 }
1470 Ok(json!({
1471 "files": files,
1472 "count": files.len(),
1473 "truncated": truncated,
1474 }))
1475}
1476
1477async fn grep_files_via_command(
1478 substrate: &Arc<dyn Substrate>,
1479 pattern: &str,
1480 root: &str,
1481 max_results: usize,
1482) -> Result<Value, String> {
1483 let cmd = format!("grep -rnE {} {}", shell_quote(pattern), shell_quote(root));
1484 let out = substrate.run_command(&cmd, Some(30.0)).await?;
1485 let mut matches = Vec::new();
1486 for line in out.stdout.lines() {
1487 if matches.len() >= max_results {
1488 break;
1489 }
1490 let mut parts = line.splitn(3, ':');
1492 let (Some(p), Some(ln), Some(text)) = (parts.next(), parts.next(), parts.next()) else {
1493 continue;
1494 };
1495 let Ok(line_no) = ln.parse::<usize>() else {
1496 continue;
1497 };
1498 matches.push(json!({ "path": p, "line": line_no, "text": text }));
1499 }
1500 let truncated = matches.len() >= max_results;
1501 Ok(json!({
1502 "matches": matches,
1503 "count": matches.len(),
1504 "truncated": truncated,
1505 }))
1506}
1507
1508#[cfg(test)]
1509mod tests {
1510 use super::*;
1511
1512 #[cfg(unix)]
1516 #[test]
1517 fn walks_without_following_links_stay_out_of_linked_directories() {
1518 let dir = tempfile::tempdir().unwrap();
1519 let root = dir.path().join("root");
1520 let outside = dir.path().join("outside");
1521 std::fs::create_dir_all(&root).unwrap();
1522 std::fs::create_dir_all(&outside).unwrap();
1523 std::fs::write(outside.join("far.txt"), "x").unwrap();
1524 std::fs::write(root.join("near.txt"), "x").unwrap();
1525 std::os::unix::fs::symlink(&outside, root.join("link")).unwrap();
1526 let mut seen = Vec::new();
1527 walk_files(&root, false, None, &mut |path| {
1528 seen.push(path.to_path_buf())
1529 })
1530 .unwrap();
1531 assert_eq!(seen, vec![root.join("near.txt")]);
1532 let mut through_link = Vec::new();
1533 walk_files(&root.join("link"), false, None, &mut |path| {
1534 through_link.push(path.to_path_buf())
1535 })
1536 .unwrap();
1537 assert!(through_link.is_empty(), "{through_link:?}");
1538 let mut followed = Vec::new();
1539 walk_files(&root.join("link"), true, None, &mut |path| {
1540 followed.push(path.to_path_buf())
1541 })
1542 .unwrap();
1543 assert_eq!(followed, vec![root.join("link").join("far.txt")]);
1544 }
1545
1546 #[test]
1547 fn glob_patterns_match_file_names() {
1548 let regex = glob_to_regex("*.rs").unwrap();
1549 assert!(regex.is_match("lib.rs"));
1550 assert!(!regex.is_match("lib.ts"));
1551 }
1552
1553 #[test]
1559 fn glob_haystack_supports_path_and_basename_matching() {
1560 assert_eq!(
1562 glob_haystack(true, "/root/src/inner/deep.rs", "/root"),
1563 "src/inner/deep.rs"
1564 );
1565 assert_eq!(
1566 glob_haystack(false, "/root/src/inner/deep.rs", "/root"),
1567 "deep.rs"
1568 );
1569
1570 let m = glob_to_regex("src/**/*.rs").unwrap();
1574 assert!(m.is_match(&glob_haystack(true, "/root/src/top.rs", "/root")));
1575 assert!(m.is_match(&glob_haystack(true, "/root/src/inner/deep.rs", "/root")));
1576 assert!(!m.is_match(&glob_haystack(true, "/root/src/inner/note.txt", "/root")));
1577 }
1578
1579 #[tokio::test]
1583 async fn find_files_supports_recursive_path_globs() {
1584 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1585 let dir = std::env::temp_dir().join(format!(
1586 "car-find-glob-{}",
1587 std::time::SystemTime::now()
1588 .duration_since(std::time::UNIX_EPOCH)
1589 .unwrap()
1590 .as_nanos()
1591 ));
1592 std::fs::create_dir_all(dir.join("src").join("inner")).unwrap();
1593 std::fs::write(dir.join("src").join("top.rs"), "x").unwrap();
1594 std::fs::write(dir.join("src").join("inner").join("deep.rs"), "x").unwrap();
1595 std::fs::write(dir.join("src").join("inner").join("note.txt"), "x").unwrap();
1596 let root = dir.to_string_lossy().to_string();
1597
1598 let r = exec_find_files(
1599 &substrate,
1600 &json!({ "path": root, "pattern": "src/**/*.rs" }),
1601 true,
1602 None,
1603 )
1604 .await
1605 .unwrap();
1606 let joined = r["files"]
1607 .as_array()
1608 .unwrap()
1609 .iter()
1610 .map(|v| v.as_str().unwrap())
1611 .collect::<Vec<_>>()
1612 .join("\n");
1613
1614 assert!(
1615 joined.contains("deep.rs"),
1616 "recursive glob missed nested file:\n{joined}"
1617 );
1618 assert!(
1619 joined.contains("top.rs"),
1620 "recursive glob missed top-level file:\n{joined}"
1621 );
1622 assert!(
1623 !joined.contains("note.txt"),
1624 "glob matched the wrong extension:\n{joined}"
1625 );
1626
1627 let _ = std::fs::remove_dir_all(&dir);
1628 }
1629
1630 #[tokio::test]
1636 async fn find_files_non_local_substrate_applies_path_glob() {
1637 use crate::substrate::CommandOutput;
1638
1639 struct RemoteStub {
1640 stdout: String,
1641 }
1642 #[async_trait::async_trait]
1643 impl Substrate for RemoteStub {
1644 fn name(&self) -> &str {
1645 "test-remote"
1646 }
1647 async fn run_command(
1649 &self,
1650 _cmd: &str,
1651 _timeout_s: Option<f64>,
1652 ) -> Result<CommandOutput, String> {
1653 Ok(CommandOutput {
1654 stdout: self.stdout.clone(),
1655 stderr: String::new(),
1656 exit_code: 0,
1657 })
1658 }
1659 async fn read_text(&self, _path: &str) -> Result<String, String> {
1660 Err("unused".into())
1661 }
1662 async fn write_text(&self, _path: &str, _content: &str) -> Result<(), String> {
1663 Err("unused".into())
1664 }
1665 async fn read_bytes(
1666 &self,
1667 _path: &str,
1668 _offset: Option<u64>,
1669 _len: Option<u64>,
1670 ) -> Result<Vec<u8>, String> {
1671 Err("unused".into())
1672 }
1673 async fn write_bytes(&self, _path: &str, _bytes: &[u8]) -> Result<(), String> {
1674 Err("unused".into())
1675 }
1676 }
1677
1678 let substrate: Arc<dyn Substrate> = Arc::new(RemoteStub {
1680 stdout: [
1681 "/root/src/top.rs",
1682 "/root/src/inner/deep.rs",
1683 "/root/src/inner/note.txt",
1684 "/root/README.md",
1685 ]
1686 .join("\n"),
1687 });
1688
1689 let r = exec_find_files(
1690 &substrate,
1691 &json!({ "path": "/root", "pattern": "src/**/*.rs" }),
1692 true,
1693 None,
1694 )
1695 .await
1696 .unwrap();
1697 let files: Vec<&str> = r["files"]
1698 .as_array()
1699 .unwrap()
1700 .iter()
1701 .map(|v| v.as_str().unwrap())
1702 .collect();
1703
1704 assert_eq!(files, vec!["/root/src/top.rs", "/root/src/inner/deep.rs"]);
1707 assert_eq!(r["count"], 2);
1708 }
1709
1710 fn fresh_dir(tag: &str) -> PathBuf {
1712 let dir = std::env::temp_dir().join(format!(
1713 "car-agent-basics-{tag}-{}",
1714 std::time::SystemTime::now()
1715 .duration_since(std::time::UNIX_EPOCH)
1716 .unwrap()
1717 .as_nanos()
1718 ));
1719 std::fs::create_dir_all(&dir).unwrap();
1720 dir
1721 }
1722
1723 #[tokio::test]
1724 async fn read_write_roundtrip_against_local_substrate() {
1725 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1726 let dir = fresh_dir("roundtrip");
1727 let path = dir.join("note.txt").to_string_lossy().to_string();
1728
1729 let w = exec_write_file(&substrate, None, &json!({ "path": path, "content": "abc" }))
1730 .await
1731 .unwrap();
1732 assert_eq!(w["bytes_written"], 3);
1733
1734 let r = exec_read_file(&substrate, None, &json!({ "path": path }))
1736 .await
1737 .unwrap();
1738 assert_eq!(r["content"], "abc");
1739 assert_eq!(r["size_bytes"], 3);
1740 assert_eq!(r["total_lines"], 1);
1741
1742 exec_write_file(
1744 &substrate,
1745 None,
1746 &json!({ "path": path, "content": "def", "append": true }),
1747 )
1748 .await
1749 .unwrap();
1750 let r2 = exec_read_file(&substrate, None, &json!({ "path": path }))
1751 .await
1752 .unwrap();
1753 assert_eq!(r2["content"], "abcdef");
1754
1755 let e = exec_edit_file(
1757 &substrate,
1758 None,
1759 &json!({ "path": path, "old_text": "abc", "new_text": "XYZ" }),
1760 )
1761 .await
1762 .unwrap();
1763 assert!(e["edited"].is_string());
1764 assert_eq!(e["replacements"], 1);
1765 let r3 = exec_read_file(&substrate, None, &json!({ "path": path }))
1766 .await
1767 .unwrap();
1768 assert_eq!(r3["content"], "XYZdef");
1769
1770 std::fs::remove_dir_all(&dir).ok();
1771 }
1772
1773 #[tokio::test]
1777 async fn read_file_output_is_line_numbered() {
1778 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1779 let ledger = ReadLedger::new();
1780 let dir = fresh_dir("linenum");
1781 let path = dir.join("f.txt").to_string_lossy().to_string();
1782 exec_write_file(
1783 &substrate,
1784 None,
1785 &json!({ "path": path, "content": "alpha\nbeta\ngamma" }),
1786 )
1787 .await
1788 .unwrap();
1789
1790 let r = execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1791 .await
1792 .unwrap()
1793 .unwrap();
1794 assert_eq!(r["content"], " 1\talpha\n 2\tbeta\n 3\tgamma");
1795 assert_eq!(r["total_lines"], 3);
1796
1797 let r2 = execute_with_ledger(
1799 &substrate,
1800 &ledger,
1801 "read_file",
1802 &json!({ "path": path, "offset": 1, "limit": 1 }),
1803 )
1804 .await
1805 .unwrap()
1806 .unwrap();
1807 assert_eq!(r2["content"], " 2\tbeta");
1808 assert_eq!(r2["total_lines"], 3);
1809
1810 std::fs::remove_dir_all(&dir).ok();
1811 }
1812
1813 #[tokio::test]
1814 async fn plain_execute_preserves_legacy_raw_read_output() {
1815 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1816 let dir = fresh_dir("rawread");
1817 let path = dir.join("f.txt").to_string_lossy().to_string();
1818 std::fs::write(&path, "alpha\nbeta\n").unwrap();
1819
1820 let result = execute(&substrate, "read_file", &json!({ "path": path }))
1821 .await
1822 .unwrap()
1823 .unwrap();
1824 assert_eq!(result["content"], "alpha\nbeta\n");
1825
1826 std::fs::remove_dir_all(&dir).ok();
1827 }
1828
1829 #[tokio::test]
1832 async fn edit_requires_prior_read() {
1833 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1834 let ledger = ReadLedger::new();
1835 let dir = fresh_dir("editread");
1836 let path = dir.join("f.txt").to_string_lossy().to_string();
1837 std::fs::write(dir.join("f.txt"), "hello world").unwrap();
1839
1840 let err = execute_with_ledger(
1841 &substrate,
1842 &ledger,
1843 "edit_file",
1844 &json!({ "path": path, "old_text": "hello", "new_text": "hi" }),
1845 )
1846 .await
1847 .unwrap()
1848 .unwrap_err();
1849 assert!(err.contains("before editing it"), "{err}");
1850 assert!(err.contains("read_file"), "{err}");
1851
1852 execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1854 .await
1855 .unwrap()
1856 .unwrap();
1857 let ok = execute_with_ledger(
1858 &substrate,
1859 &ledger,
1860 "edit_file",
1861 &json!({ "path": path, "old_text": "hello", "new_text": "hi" }),
1862 )
1863 .await
1864 .unwrap()
1865 .unwrap();
1866 assert_eq!(ok["replacements"], 1);
1867
1868 std::fs::remove_dir_all(&dir).ok();
1869 }
1870
1871 #[tokio::test]
1874 async fn edit_detects_stale_file() {
1875 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1876 let ledger = ReadLedger::new();
1877 let dir = fresh_dir("stale");
1878 let path = dir.join("f.txt").to_string_lossy().to_string();
1879 std::fs::write(dir.join("f.txt"), "version one").unwrap();
1880
1881 execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1883 .await
1884 .unwrap()
1885 .unwrap();
1886 std::fs::write(dir.join("f.txt"), "version two changed").unwrap();
1887
1888 let err = execute_with_ledger(
1889 &substrate,
1890 &ledger,
1891 "edit_file",
1892 &json!({ "path": path, "old_text": "version", "new_text": "v" }),
1893 )
1894 .await
1895 .unwrap()
1896 .unwrap_err();
1897 assert!(err.contains("changed since you last read it"), "{err}");
1898
1899 execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1901 .await
1902 .unwrap()
1903 .unwrap();
1904 let ok = execute_with_ledger(
1905 &substrate,
1906 &ledger,
1907 "edit_file",
1908 &json!({ "path": path, "old_text": "version", "new_text": "v" }),
1909 )
1910 .await
1911 .unwrap()
1912 .unwrap();
1913 assert!(ok["edited"].is_string());
1914
1915 std::fs::remove_dir_all(&dir).ok();
1916 }
1917
1918 #[tokio::test]
1923 async fn read_file_preserves_crlf_line_endings() {
1924 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1925 let ledger = ReadLedger::new();
1926 let dir = fresh_dir("crlf");
1927 let path = dir.join("f.txt").to_string_lossy().to_string();
1928 std::fs::write(dir.join("f.txt"), "line one\r\nline two\r\n").unwrap();
1929
1930 let out = execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1931 .await
1932 .unwrap()
1933 .unwrap();
1934 let shown = out["content"].as_str().unwrap();
1935 assert_eq!(
1936 shown, " 1\tline one\r\n 2\tline two\r",
1937 "\\r must survive into the numbered display"
1938 );
1939
1940 let ok = execute_with_ledger(
1943 &substrate,
1944 &ledger,
1945 "edit_file",
1946 &json!({ "path": path, "old_text": "line one\r\nline two", "new_text": "merged" }),
1947 )
1948 .await
1949 .unwrap()
1950 .unwrap();
1951 assert_eq!(ok["replacements"], 1);
1952
1953 std::fs::remove_dir_all(&dir).ok();
1954 }
1955
1956 #[tokio::test]
1960 async fn edit_rejects_empty_old_text() {
1961 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1962 let dir = fresh_dir("emptyold");
1963 let path = dir.join("f.txt").to_string_lossy().to_string();
1964 exec_write_file(&substrate, None, &json!({ "path": path, "content": "abc" }))
1965 .await
1966 .unwrap();
1967
1968 for replace_all in [false, true] {
1969 let err = exec_edit_file(
1970 &substrate,
1971 None,
1972 &json!({
1973 "path": path,
1974 "old_text": "",
1975 "new_text": "X",
1976 "replace_all": replace_all
1977 }),
1978 )
1979 .await
1980 .unwrap_err();
1981 assert!(err.contains("old_text must be non-empty"), "{err}");
1982 }
1983 assert_eq!(std::fs::read_to_string(dir.join("f.txt")).unwrap(), "abc");
1985
1986 std::fs::remove_dir_all(&dir).ok();
1987 }
1988
1989 #[tokio::test]
1994 async fn write_existing_rejects_stale_after_external_change() {
1995 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1996 let ledger = ReadLedger::new();
1997 let dir = fresh_dir("stalewrite");
1998 let path = dir.join("f.txt").to_string_lossy().to_string();
1999 std::fs::write(dir.join("f.txt"), "original").unwrap();
2000
2001 execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
2002 .await
2003 .unwrap()
2004 .unwrap();
2005 std::fs::write(dir.join("f.txt"), "changed underneath").unwrap();
2007
2008 let err = execute_with_ledger(
2009 &substrate,
2010 &ledger,
2011 "write_file",
2012 &json!({ "path": path, "content": "clobber" }),
2013 )
2014 .await
2015 .unwrap()
2016 .unwrap_err();
2017 assert!(err.contains("changed since you last read it"), "{err}");
2018
2019 let err = execute_with_ledger(
2021 &substrate,
2022 &ledger,
2023 "write_file",
2024 &json!({ "path": path, "content": " + more", "append": true }),
2025 )
2026 .await
2027 .unwrap()
2028 .unwrap_err();
2029 assert!(err.contains("changed since you last read it"), "{err}");
2030
2031 execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
2033 .await
2034 .unwrap()
2035 .unwrap();
2036 execute_with_ledger(
2037 &substrate,
2038 &ledger,
2039 "write_file",
2040 &json!({ "path": path, "content": "rewritten" }),
2041 )
2042 .await
2043 .unwrap()
2044 .unwrap();
2045 assert_eq!(
2046 std::fs::read_to_string(dir.join("f.txt")).unwrap(),
2047 "rewritten"
2048 );
2049
2050 std::fs::remove_dir_all(&dir).ok();
2051 }
2052
2053 #[tokio::test]
2056 async fn replace_all_replaces_every_occurrence() {
2057 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2058 let dir = fresh_dir("replaceall");
2059 let path = dir.join("f.txt").to_string_lossy().to_string();
2060 exec_write_file(
2061 &substrate,
2062 None,
2063 &json!({ "path": path, "content": "a x a x a" }),
2064 )
2065 .await
2066 .unwrap();
2067
2068 let err = exec_edit_file(
2070 &substrate,
2071 None,
2072 &json!({ "path": path, "old_text": "a", "new_text": "b" }),
2073 )
2074 .await
2075 .unwrap_err();
2076 assert!(err.contains("replace_all"), "{err}");
2077
2078 let ok = exec_edit_file(
2080 &substrate,
2081 None,
2082 &json!({ "path": path, "old_text": "a", "new_text": "b", "replace_all": true }),
2083 )
2084 .await
2085 .unwrap();
2086 assert_eq!(ok["replacements"], 3);
2087 let r = exec_read_file(&substrate, None, &json!({ "path": path }))
2088 .await
2089 .unwrap();
2090 assert_eq!(r["content"], "b x b x b");
2091
2092 std::fs::remove_dir_all(&dir).ok();
2093 }
2094
2095 #[tokio::test]
2097 async fn write_new_file_allowed() {
2098 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2099 let ledger = ReadLedger::new();
2100 let dir = fresh_dir("writenew");
2101 let path = dir.join("new.txt").to_string_lossy().to_string();
2102
2103 let ok = execute_with_ledger(
2104 &substrate,
2105 &ledger,
2106 "write_file",
2107 &json!({ "path": path, "content": "fresh" }),
2108 )
2109 .await
2110 .unwrap()
2111 .unwrap();
2112 assert_eq!(ok["bytes_written"], 5);
2113
2114 std::fs::remove_dir_all(&dir).ok();
2115 }
2116
2117 #[tokio::test]
2119 async fn write_existing_requires_prior_read() {
2120 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2121 let ledger = ReadLedger::new();
2122 let dir = fresh_dir("writeexisting");
2123 let path = dir.join("f.txt").to_string_lossy().to_string();
2124 std::fs::write(dir.join("f.txt"), "existing content").unwrap();
2125
2126 let err = execute_with_ledger(
2127 &substrate,
2128 &ledger,
2129 "write_file",
2130 &json!({ "path": path, "content": "clobber" }),
2131 )
2132 .await
2133 .unwrap()
2134 .unwrap_err();
2135 assert!(err.contains("before overwriting it"), "{err}");
2136 assert!(err.contains("read_file"), "{err}");
2137
2138 execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
2140 .await
2141 .unwrap()
2142 .unwrap();
2143 let ok = execute_with_ledger(
2144 &substrate,
2145 &ledger,
2146 "write_file",
2147 &json!({ "path": path, "content": "clobber" }),
2148 )
2149 .await
2150 .unwrap()
2151 .unwrap();
2152 assert_eq!(ok["bytes_written"], 7);
2153
2154 std::fs::remove_dir_all(&dir).ok();
2155 }
2156
2157 #[tokio::test]
2161 async fn write_self_records_enabling_edit() {
2162 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2163 let ledger = ReadLedger::new();
2164 let dir = fresh_dir("writeedits");
2165 let path = dir.join("f.txt").to_string_lossy().to_string();
2166
2167 execute_with_ledger(
2168 &substrate,
2169 &ledger,
2170 "write_file",
2171 &json!({ "path": path, "content": "one two three" }),
2172 )
2173 .await
2174 .unwrap()
2175 .unwrap();
2176 let ok = execute_with_ledger(
2178 &substrate,
2179 &ledger,
2180 "edit_file",
2181 &json!({ "path": path, "old_text": "two", "new_text": "TWO" }),
2182 )
2183 .await
2184 .unwrap()
2185 .unwrap();
2186 assert_eq!(ok["replacements"], 1);
2187
2188 std::fs::remove_dir_all(&dir).ok();
2189 }
2190
2191 #[tokio::test]
2195 async fn edit_self_records_enabling_second_edit_without_reread() {
2196 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2197 let ledger = ReadLedger::new();
2198 let dir = fresh_dir("editselfrec");
2199 let path = dir.join("f.txt").to_string_lossy().to_string();
2200 std::fs::write(dir.join("f.txt"), "one two three").unwrap();
2201
2202 execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
2203 .await
2204 .unwrap()
2205 .unwrap();
2206 execute_with_ledger(
2207 &substrate,
2208 &ledger,
2209 "edit_file",
2210 &json!({ "path": path, "old_text": "one", "new_text": "1" }),
2211 )
2212 .await
2213 .unwrap()
2214 .unwrap();
2215
2216 let ok = execute_with_ledger(
2218 &substrate,
2219 &ledger,
2220 "edit_file",
2221 &json!({ "path": path, "old_text": "two", "new_text": "2" }),
2222 )
2223 .await
2224 .unwrap()
2225 .unwrap();
2226 assert_eq!(ok["replacements"], 1);
2227 assert_eq!(
2228 std::fs::read_to_string(dir.join("f.txt")).unwrap(),
2229 "1 2 three"
2230 );
2231
2232 std::fs::remove_dir_all(&dir).ok();
2233 }
2234
2235 #[tokio::test]
2239 async fn sliced_read_hashes_full_file_licensing_edit() {
2240 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2241 let ledger = ReadLedger::new();
2242 let dir = fresh_dir("slicedread");
2243 let path = dir.join("f.txt").to_string_lossy().to_string();
2244 std::fs::write(dir.join("f.txt"), "l1\nl2\nl3\nl4").unwrap();
2245
2246 let r = execute_with_ledger(
2247 &substrate,
2248 &ledger,
2249 "read_file",
2250 &json!({ "path": path, "offset": 1, "limit": 1 }),
2251 )
2252 .await
2253 .unwrap()
2254 .unwrap();
2255 assert_eq!(r["content"], " 2\tl2"); let ok = execute_with_ledger(
2259 &substrate,
2260 &ledger,
2261 "edit_file",
2262 &json!({ "path": path, "old_text": "l4", "new_text": "L4" }),
2263 )
2264 .await
2265 .unwrap()
2266 .unwrap();
2267 assert_eq!(ok["replacements"], 1);
2268
2269 std::fs::remove_dir_all(&dir).ok();
2270 }
2271
2272 #[tokio::test]
2275 async fn append_to_existing_unread_requires_prior_read() {
2276 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2277 let ledger = ReadLedger::new();
2278 let dir = fresh_dir("appendunread");
2279 let path = dir.join("f.txt").to_string_lossy().to_string();
2280 std::fs::write(dir.join("f.txt"), "existing content").unwrap();
2281
2282 let err = execute_with_ledger(
2283 &substrate,
2284 &ledger,
2285 "write_file",
2286 &json!({ "path": path, "content": " more", "append": true }),
2287 )
2288 .await
2289 .unwrap()
2290 .unwrap_err();
2291 assert!(err.contains("before overwriting it"), "{err}");
2292
2293 execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
2294 .await
2295 .unwrap()
2296 .unwrap();
2297 let ok = execute_with_ledger(
2298 &substrate,
2299 &ledger,
2300 "write_file",
2301 &json!({ "path": path, "content": " more", "append": true }),
2302 )
2303 .await
2304 .unwrap()
2305 .unwrap();
2306 assert_eq!(ok["append"], true);
2307 assert_eq!(
2308 std::fs::read_to_string(dir.join("f.txt")).unwrap(),
2309 "existing content more"
2310 );
2311
2312 std::fs::remove_dir_all(&dir).ok();
2313 }
2314
2315 #[tokio::test]
2316 async fn append_to_existing_empty_file_requires_prior_read() {
2317 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2318 let ledger = ReadLedger::new();
2319 let dir = fresh_dir("appendempty");
2320 let path = dir.join("f.txt").to_string_lossy().to_string();
2321 std::fs::write(dir.join("f.txt"), "").unwrap();
2322
2323 let err = execute_with_ledger(
2324 &substrate,
2325 &ledger,
2326 "write_file",
2327 &json!({ "path": path, "content": "new", "append": true }),
2328 )
2329 .await
2330 .unwrap()
2331 .unwrap_err();
2332 assert!(err.contains("before overwriting it"), "{err}");
2333 assert!(std::fs::read_to_string(dir.join("f.txt"))
2334 .unwrap()
2335 .is_empty());
2336
2337 std::fs::remove_dir_all(&dir).ok();
2338 }
2339
2340 #[tokio::test]
2341 async fn partial_read_cannot_authorize_whole_file_mutation() {
2342 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2343 let ledger = ReadLedger::new();
2344 let dir = fresh_dir("partialread");
2345 let path = dir.join("f.txt").to_string_lossy().to_string();
2346 std::fs::write(dir.join("f.txt"), "first\nsecond\nthird").unwrap();
2347
2348 execute_with_ledger(
2349 &substrate,
2350 &ledger,
2351 "read_file",
2352 &json!({ "path": path, "offset": 0, "limit": 1 }),
2353 )
2354 .await
2355 .unwrap()
2356 .unwrap();
2357
2358 let overwrite = execute_with_ledger(
2359 &substrate,
2360 &ledger,
2361 "write_file",
2362 &json!({ "path": path, "content": "replacement" }),
2363 )
2364 .await
2365 .unwrap()
2366 .unwrap_err();
2367 assert!(overwrite.contains("full current content"), "{overwrite}");
2368
2369 let replace_all = execute_with_ledger(
2370 &substrate,
2371 &ledger,
2372 "edit_file",
2373 &json!({ "path": path, "old_text": "i", "new_text": "I", "replace_all": true }),
2374 )
2375 .await
2376 .unwrap()
2377 .unwrap_err();
2378 assert!(
2379 replace_all.contains("full current content"),
2380 "{replace_all}"
2381 );
2382
2383 let edit = execute_with_ledger(
2385 &substrate,
2386 &ledger,
2387 "edit_file",
2388 &json!({ "path": path, "old_text": "first", "new_text": "FIRST" }),
2389 )
2390 .await
2391 .unwrap()
2392 .unwrap();
2393 assert_eq!(edit["replacements"], 1);
2394
2395 std::fs::remove_dir_all(&dir).ok();
2396 }
2397
2398 #[tokio::test]
2399 async fn guarded_write_refuses_existing_non_utf8_file() {
2400 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2401 let ledger = ReadLedger::new();
2402 let dir = fresh_dir("nonutf8write");
2403 let path = dir.join("f.bin").to_string_lossy().to_string();
2404 std::fs::write(dir.join("f.bin"), [0xff, 0x00, 0xfe]).unwrap();
2405
2406 let err = execute_with_ledger(
2407 &substrate,
2408 &ledger,
2409 "write_file",
2410 &json!({ "path": path, "content": "text" }),
2411 )
2412 .await
2413 .unwrap()
2414 .unwrap_err();
2415 assert!(err.contains("cannot modify existing file"), "{err}");
2416 assert_eq!(
2417 std::fs::read(dir.join("f.bin")).unwrap(),
2418 [0xff, 0x00, 0xfe]
2419 );
2420
2421 std::fs::remove_dir_all(&dir).ok();
2422 }
2423
2424 #[tokio::test]
2428 async fn append_detects_stale_after_external_change() {
2429 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2430 let ledger = ReadLedger::new();
2431 let dir = fresh_dir("appendstale");
2432 let path = dir.join("f.txt").to_string_lossy().to_string();
2433 std::fs::write(dir.join("f.txt"), "v1").unwrap();
2434
2435 execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
2436 .await
2437 .unwrap()
2438 .unwrap();
2439 std::fs::write(dir.join("f.txt"), "v2 changed").unwrap();
2440
2441 let err = execute_with_ledger(
2442 &substrate,
2443 &ledger,
2444 "write_file",
2445 &json!({ "path": path, "content": " appended", "append": true }),
2446 )
2447 .await
2448 .unwrap()
2449 .unwrap_err();
2450 assert!(err.contains("changed since you last read it"), "{err}");
2451
2452 std::fs::remove_dir_all(&dir).ok();
2453 }
2454
2455 #[tokio::test]
2459 async fn replace_all_edit_is_gated_by_the_ledger() {
2460 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2461 let ledger = ReadLedger::new();
2462 let dir = fresh_dir("replaceallgate");
2463 let path = dir.join("f.txt").to_string_lossy().to_string();
2464 std::fs::write(dir.join("f.txt"), "a a a").unwrap();
2465
2466 execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
2467 .await
2468 .unwrap()
2469 .unwrap();
2470 let ok = execute_with_ledger(
2471 &substrate,
2472 &ledger,
2473 "edit_file",
2474 &json!({ "path": path, "old_text": "a", "new_text": "b", "replace_all": true }),
2475 )
2476 .await
2477 .unwrap()
2478 .unwrap();
2479 assert_eq!(ok["replacements"], 3);
2480
2481 let ok2 = execute_with_ledger(
2483 &substrate,
2484 &ledger,
2485 "edit_file",
2486 &json!({ "path": path, "old_text": "b", "new_text": "c", "replace_all": true }),
2487 )
2488 .await
2489 .unwrap()
2490 .unwrap();
2491 assert_eq!(ok2["replacements"], 3);
2492
2493 std::fs::write(dir.join("f.txt"), "c c c c").unwrap();
2495 let err = execute_with_ledger(
2496 &substrate,
2497 &ledger,
2498 "edit_file",
2499 &json!({ "path": path, "old_text": "c", "new_text": "d", "replace_all": true }),
2500 )
2501 .await
2502 .unwrap()
2503 .unwrap_err();
2504 assert!(err.contains("changed since you last read it"), "{err}");
2505
2506 std::fs::remove_dir_all(&dir).ok();
2507 }
2508
2509 #[tokio::test]
2514 async fn builtin_names_never_return_unknown_tool_prefix() {
2515 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2516 let ledger = ReadLedger::new();
2517
2518 for name in [
2519 "read_file",
2520 "write_file",
2521 "edit_file",
2522 "list_dir",
2523 "find_files",
2524 "grep_files",
2525 "calculate",
2526 ] {
2527 let result = execute_with_ledger(&substrate, &ledger, name, &json!({})).await;
2528 let inner = result.unwrap_or_else(|| panic!("{name} must be handled, not None"));
2529 if let Err(e) = &inner {
2530 assert!(
2531 !e.starts_with("unknown tool"),
2532 "{name} error must not start with 'unknown tool': {e}"
2533 );
2534 }
2535 }
2536
2537 assert!(
2539 execute_with_ledger(&substrate, &ledger, "no_such_tool", &json!({}))
2540 .await
2541 .is_none()
2542 );
2543
2544 let dir = fresh_dir("unknownprefix");
2546 let path = dir.join("f.txt").to_string_lossy().to_string();
2547 std::fs::write(dir.join("f.txt"), "content").unwrap();
2548 let edit_err = execute_with_ledger(
2549 &substrate,
2550 &ledger,
2551 "edit_file",
2552 &json!({ "path": path, "old_text": "content", "new_text": "x" }),
2553 )
2554 .await
2555 .unwrap()
2556 .unwrap_err();
2557 assert!(!edit_err.starts_with("unknown tool"), "{edit_err}");
2558 assert!(edit_err.contains("before editing it"), "{edit_err}");
2559 let write_err = execute_with_ledger(
2560 &substrate,
2561 &ledger,
2562 "write_file",
2563 &json!({ "path": path, "content": "y" }),
2564 )
2565 .await
2566 .unwrap()
2567 .unwrap_err();
2568 assert!(!write_err.starts_with("unknown tool"), "{write_err}");
2569
2570 std::fs::remove_dir_all(&dir).ok();
2571 }
2572
2573 #[test]
2576 fn ledger_normalizes_dot_components() {
2577 let ledger = ReadLedger::new();
2578 ledger.record("./src/x.rs", "content", true);
2579 assert_eq!(ledger.check("src/x.rs", "content"), ReadState::FreshFull);
2580 assert_eq!(ledger.check("./src/x.rs", "content"), ReadState::FreshFull);
2581
2582 let ledger2 = ReadLedger::new();
2583 ledger2.record("src/x.rs", "content", true);
2584 assert_eq!(ledger2.check("./src/x.rs", "content"), ReadState::FreshFull);
2585
2586 assert_eq!(ledger.check("other.rs", "content"), ReadState::Unread);
2589 let ledger3 = ReadLedger::new();
2590 ledger3.record("/root/a/./b", "c", true);
2591 assert_eq!(ledger3.check("/root/a/b", "c"), ReadState::FreshFull);
2592 }
2593
2594 #[test]
2595 fn session_ledgers_share_mutation_locks_without_sharing_observations() {
2596 let ledgers = SessionReadLedgers::new();
2597 let first = ledgers.ledger_for(Some("first"));
2598 let second = ledgers.ledger_for(Some("second"));
2599 first.record("f.txt", "first view", true);
2600
2601 assert_eq!(
2602 second.check("f.txt", "first view"),
2603 ReadState::Unread,
2604 "one session's read must not authorize another session"
2605 );
2606 assert!(Arc::ptr_eq(
2607 &first.mutation_lock("f.txt"),
2608 &second.mutation_lock("f.txt")
2609 ));
2610 }
2611
2612 #[tokio::test]
2613 async fn indentation_error_provides_exact_bytes_for_a_literal_retry_without_mutation() {
2614 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2615 let dir = fresh_dir("indentation-retry");
2616 let path = dir.join("test.py").to_string_lossy().to_string();
2617 for (content, old_text, exact) in [
2618 (
2619 "# tests\nif __name__ == '__main__':\n unittest.main()\n",
2620 " if __name__ == '__main__':\n unittest.main()",
2621 "if __name__ == '__main__':\n unittest.main()",
2622 ),
2623 (
2624 "# tests\r\nif __name__ == '__main__':\r\n\tunittest.main()\r\n",
2625 " if __name__ == '__main__':\n unittest.main()",
2626 "if __name__ == '__main__':\r\n\tunittest.main()",
2627 ),
2628 ("α\nβ\n", " β\n", "β\n"),
2629 ] {
2630 std::fs::write(&path, content).unwrap();
2631 let ledger = ReadLedger::new();
2632 exec_read_file(&substrate, Some(&ledger), &json!({"path":path}))
2633 .await
2634 .unwrap();
2635 let error = exec_edit_file(
2636 &substrate,
2637 Some(&ledger),
2638 &json!({
2639 "path":path, "old_text":old_text, "new_text":"WRONG"
2640 }),
2641 )
2642 .await
2643 .unwrap_err();
2644 assert_eq!(std::fs::read_to_string(&path).unwrap(), content);
2645 let suggested = error
2646 .split_once(HINT_BEGIN)
2647 .and_then(|(_, rest)| rest.split_once(HINT_END))
2648 .expect(&error)
2649 .0
2650 .to_string();
2651 assert_eq!(suggested, exact);
2652 exec_edit_file(
2653 &substrate,
2654 Some(&ledger),
2655 &json!({
2656 "path":path, "old_text":suggested, "new_text":"replacement"
2657 }),
2658 )
2659 .await
2660 .unwrap();
2661 assert_eq!(
2662 std::fs::read_to_string(&path).unwrap(),
2663 content.replacen(exact, "replacement", 1)
2664 );
2665 }
2666 std::fs::remove_dir_all(dir).unwrap();
2667 }
2668
2669 #[test]
2670 fn indentation_hints_do_not_guess_among_duplicates_or_unrelated_text() {
2671 assert!(indentation_hint(" call()\n\tcall()\n", " call()").is_none());
2672 assert_eq!(
2673 indentation_hint("a\n call()\n", "call()"),
2674 Some((" call()", 1, 2))
2675 );
2676 assert!(indentation_hint(" call()\n", " different()").is_none());
2677 assert!(indentation_hint(" \n", " \n").is_none());
2678 assert!(indentation_hint(&"line\n".repeat(13), &" line\n".repeat(13)).is_none());
2679 assert!(indentation_hint(&"a".repeat(2048), &format!(" {}", "a".repeat(2048))).is_none());
2680 }
2681
2682 #[tokio::test]
2683 async fn indentation_hints_require_full_fresh_observation_and_a_single_edit() {
2684 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2685 let dir = fresh_dir("indentation-guards");
2686 let path = dir.join("f.txt").to_string_lossy().to_string();
2687 let content = "first\ncall()\n";
2688 for (partial, stale, replace_all) in [
2689 (true, false, false),
2690 (false, true, false),
2691 (false, false, true),
2692 ] {
2693 std::fs::write(&path, content).unwrap();
2694 let ledger = ReadLedger::new();
2695 let mut read = json!({"path":path});
2696 if partial {
2697 read["limit"] = json!(1);
2698 }
2699 exec_read_file(&substrate, Some(&ledger), &read)
2700 .await
2701 .unwrap();
2702 if stale {
2703 std::fs::write(&path, "changed\ncall()\n").unwrap();
2704 }
2705 let before = std::fs::read_to_string(&path).unwrap();
2706 let error = exec_edit_file(&substrate, Some(&ledger), &json!({
2707 "path":path, "old_text":" call()", "new_text":"WRONG", "replace_all":replace_all
2708 })).await.unwrap_err();
2709 assert!(!error.contains(HINT_BEGIN), "{error}");
2710 assert_eq!(std::fs::read_to_string(&path).unwrap(), before);
2711 }
2712 std::fs::remove_dir_all(dir).unwrap();
2713 }
2714
2715 #[tokio::test]
2716 async fn a_paged_read_keeps_recovery_and_hints_for_the_lines_it_showed() {
2717 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2718 let dir = fresh_dir("paged-recovery");
2719 let path = dir.join("big.py").to_string_lossy().to_string();
2720 let content = "a = 1\n b = 2\nc = 3\n d = 4\n";
2721 std::fs::write(&path, content).unwrap();
2722 let ledger = ReadLedger::new();
2723 exec_read_file(
2725 &substrate,
2726 Some(&ledger),
2727 &json!({"path": path, "limit": 2}),
2728 )
2729 .await
2730 .unwrap();
2731 exec_edit_file(
2733 &substrate,
2734 Some(&ledger),
2735 &json!({"path": path, "old_text": " 1\ta = 1", "new_text": "a = 10"}),
2736 )
2737 .await
2738 .unwrap();
2739 exec_read_file(
2740 &substrate,
2741 Some(&ledger),
2742 &json!({"path": path, "limit": 2}),
2743 )
2744 .await
2745 .unwrap();
2746 let error = exec_edit_file(
2748 &substrate,
2749 Some(&ledger),
2750 &json!({"path": path, "old_text": "a = 10\n b = 2", "new_text": "x"}),
2751 )
2752 .await
2753 .unwrap_err();
2754 assert!(
2755 error.contains(&format!("{HINT_BEGIN}a = 10\n b = 2{HINT_END}")),
2756 "{error}"
2757 );
2758 let error = exec_edit_file(
2760 &substrate,
2761 Some(&ledger),
2762 &json!({"path": path, "old_text": "c = 3\n d = 4", "new_text": "x"}),
2763 )
2764 .await
2765 .unwrap_err();
2766 assert!(!error.contains(HINT_BEGIN), "{error}");
2767 let error = exec_edit_file(
2768 &substrate,
2769 Some(&ledger),
2770 &json!({"path": path, "old_text": " 3\tc = 3", "new_text": "x"}),
2771 )
2772 .await
2773 .unwrap_err();
2774 assert!(error.contains("not found"), "{error}");
2775 std::fs::remove_dir_all(dir).unwrap();
2776 }
2777
2778 #[tokio::test]
2779 async fn a_page_keeps_its_recoveries_across_an_edit() {
2780 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2781 let dir = fresh_dir("paged-after-edit");
2782 let path = dir.join("f.py").to_string_lossy().to_string();
2783 std::fs::write(&path, "a = 1\nb = 2\nc = 3\nd = 4\ne = 5\n").unwrap();
2784 let ledger = ReadLedger::new();
2785 exec_read_file(
2786 &substrate,
2787 Some(&ledger),
2788 &json!({"path": path, "limit": 4}),
2789 )
2790 .await
2791 .unwrap();
2792 exec_edit_file(
2794 &substrate,
2795 Some(&ledger),
2796 &json!({"path": path, "old_text": "b = 2", "new_text": "b = 2\nb2 = 2"}),
2797 )
2798 .await
2799 .unwrap();
2800 exec_edit_file(
2803 &substrate,
2804 Some(&ledger),
2805 &json!({"path": path, "old_text": " 5\td = 4", "new_text": "d = 40"}),
2806 )
2807 .await
2808 .unwrap();
2809 assert_eq!(
2810 std::fs::read_to_string(&path).unwrap(),
2811 "a = 1\nb = 2\nb2 = 2\nc = 3\nd = 40\ne = 5\n"
2812 );
2813 let err = exec_edit_file(
2815 &substrate,
2816 Some(&ledger),
2817 &json!({"path": path, "old_text": " 6\te = 5", "new_text": "x"}),
2818 )
2819 .await
2820 .unwrap_err();
2821 assert!(err.contains("not found"), "{err}");
2822 std::fs::remove_dir_all(dir).unwrap();
2823 }
2824
2825 #[test]
2826 fn seen_ranges_move_with_an_edit() {
2827 assert_eq!(
2829 shift_seen_lines(&[(0, 4)], 1, 2, 4),
2830 [(0, 1), (4, 6), (1, 4)]
2831 );
2832 assert_eq!(shift_seen_lines(&[(0, 2)], 5, 6, 8), [(0, 2)]);
2834 assert_eq!(shift_seen_lines(&[(6, 9)], 1, 3, 2), [(5, 8)]);
2836 }
2837
2838 #[tokio::test]
2839 async fn edit_accepts_exact_numbered_display_after_full_fresh_read() {
2840 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2841 let dir = fresh_dir("numbered-edit");
2842 let path = dir.join("f.txt").to_string_lossy().to_string();
2843 for content in ["first\n\nlast\n", "first\r\n\r\nlast\r\n", "first\n\nlast"] {
2844 std::fs::write(&path, content).unwrap();
2845 let ledger = ReadLedger::new();
2846 let read = exec_read_file(&substrate, Some(&ledger), &json!({"path":path}))
2847 .await
2848 .unwrap();
2849 let display = read["content"].as_str().unwrap();
2850 let result = exec_edit_file(
2851 &substrate,
2852 Some(&ledger),
2853 &json!({
2854 "path":path, "old_text":display, "new_text":"changed"
2855 }),
2856 )
2857 .await
2858 .unwrap();
2859 assert_eq!(result["removed_display_line_numbers"], true);
2860 assert_eq!(
2863 std::fs::read_to_string(&path).unwrap(),
2864 if content.ends_with('\n') {
2865 "changed\n"
2866 } else {
2867 "changed"
2868 }
2869 );
2870 }
2871 std::fs::write(&path, "α\nβ\tdata\nγ\n").unwrap();
2872 let ledger = ReadLedger::new();
2873 exec_read_file(&substrate, Some(&ledger), &json!({"path":path}))
2874 .await
2875 .unwrap();
2876 exec_edit_file(
2877 &substrate,
2878 Some(&ledger),
2879 &json!({
2880 "path":path, "old_text":" 2\tβ\tdata", "new_text":"δ\tdata"
2881 }),
2882 )
2883 .await
2884 .unwrap();
2885 assert_eq!(std::fs::read_to_string(&path).unwrap(), "α\nδ\tdata\nγ\n");
2886 std::fs::remove_dir_all(dir).unwrap();
2887 }
2888
2889 #[tokio::test]
2890 async fn numbered_edit_keeps_location_freshness_and_uniqueness_guards() {
2891 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2892 let dir = fresh_dir("numbered-edit-guards");
2893 let path = dir.join("f.txt").to_string_lossy().to_string();
2894 for (content, old, replace_all) in [
2895 ("first\nlast\n", " 1\tlast", false),
2896 ("first\nlast\n", " 1\tfirst\n 3\tlast", false),
2897 ("first \nlast\n", " 1\tfirst", false),
2898 ("same\nsame\n", " 2\tsame", false),
2899 ("first\nlast\n", " 1\tfirst", true),
2900 ] {
2901 std::fs::write(&path, content).unwrap();
2902 let ledger = ReadLedger::new();
2903 exec_read_file(&substrate, Some(&ledger), &json!({"path":path}))
2904 .await
2905 .unwrap();
2906 assert!(exec_edit_file(
2907 &substrate,
2908 Some(&ledger),
2909 &json!({
2910 "path":path, "old_text":old, "new_text":"changed", "replace_all":replace_all
2911 })
2912 )
2913 .await
2914 .is_err());
2915 assert_eq!(std::fs::read_to_string(&path).unwrap(), content);
2916 }
2917 std::fs::write(&path, "first\nlast\n").unwrap();
2918 let ledger = ReadLedger::new();
2919 exec_read_file(&substrate, Some(&ledger), &json!({"path":path, "limit":1}))
2920 .await
2921 .unwrap();
2922 assert!(exec_edit_file(
2924 &substrate,
2925 Some(&ledger),
2926 &json!({
2927 "path":path, "old_text":" 2\tlast", "new_text":"changed"
2928 })
2929 )
2930 .await
2931 .is_err());
2932 exec_read_file(&substrate, Some(&ledger), &json!({"path":path}))
2933 .await
2934 .unwrap();
2935 std::fs::write(&path, "external change").unwrap();
2936 assert!(exec_edit_file(
2937 &substrate,
2938 Some(&ledger),
2939 &json!({
2940 "path":path, "old_text":" 1\tfirst", "new_text":"changed"
2941 })
2942 )
2943 .await
2944 .unwrap_err()
2945 .contains("changed since"));
2946 std::fs::write(&path, " 1\tliteral").unwrap();
2948 exec_read_file(&substrate, Some(&ledger), &json!({"path":path}))
2949 .await
2950 .unwrap();
2951 let result = exec_edit_file(
2952 &substrate,
2953 Some(&ledger),
2954 &json!({
2955 "path":path, "old_text":" 1\tliteral", "new_text":"changed"
2956 }),
2957 )
2958 .await
2959 .unwrap();
2960 assert_eq!(result["removed_display_line_numbers"], false);
2961 assert_eq!(std::fs::read_to_string(&path).unwrap(), "changed");
2962 std::fs::remove_dir_all(dir).unwrap();
2963 }
2964
2965 #[tokio::test]
2966 async fn a_multi_line_edit_without_carriage_returns_lands_in_a_crlf_file() {
2967 let dir = tempfile::tempdir().unwrap();
2968 let path = dir.path().join("w.py");
2969 std::fs::write(&path, "a = 1\r\nb = 2\r\nc = 3\r\n").unwrap();
2970 let path = path.to_string_lossy().into_owned();
2971 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2972 let ledger = ReadLedger::new();
2973 execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
2974 .await
2975 .unwrap()
2976 .unwrap();
2977 execute_with_ledger(
2978 &substrate,
2979 &ledger,
2980 "edit_file",
2981 &json!({ "path": path, "old_text": "a = 1\nb = 2", "new_text": "a = 1\nb = 20" }),
2982 )
2983 .await
2984 .unwrap()
2985 .unwrap();
2986 assert_eq!(
2987 std::fs::read_to_string(&path).unwrap(),
2988 "a = 1\r\nb = 20\r\nc = 3\r\n"
2989 );
2990 }
2991
2992 #[tokio::test]
2995 async fn edit_no_match_hints_pasted_line_number() {
2996 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2997 let dir = fresh_dir("linenumhint");
2998 let path = dir.join("f.txt").to_string_lossy().to_string();
2999 exec_write_file(
3000 &substrate,
3001 None,
3002 &json!({ "path": path, "content": "hello world" }),
3003 )
3004 .await
3005 .unwrap();
3006
3007 let err = exec_edit_file(
3008 &substrate,
3009 None,
3010 &json!({ "path": path, "old_text": " 1\thello world", "new_text": "hi" }),
3011 )
3012 .await
3013 .unwrap_err();
3014 assert!(err.contains("line-number prefixes"), "{err}");
3015
3016 let plain = exec_edit_file(
3017 &substrate,
3018 None,
3019 &json!({ "path": path, "old_text": "absent", "new_text": "x" }),
3020 )
3021 .await
3022 .unwrap_err();
3023 assert!(!plain.contains("line-number prefixes"), "{plain}");
3024 assert!(plain.contains("old_text not found"), "{plain}");
3025
3026 std::fs::remove_dir_all(&dir).ok();
3027 }
3028
3029 #[tokio::test]
3030 async fn calculate_is_pure() {
3031 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
3032 let out = execute(
3033 &substrate,
3034 "calculate",
3035 &json!({ "expression": "2 + 3 * 4" }),
3036 )
3037 .await
3038 .unwrap()
3039 .unwrap();
3040 assert_eq!(out["result"], 14.0);
3041 }
3042
3043 #[tokio::test]
3044 async fn unknown_tool_returns_none() {
3045 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
3046 assert!(execute(&substrate, "nope", &json!({})).await.is_none());
3047 }
3048
3049 fn calc(expr: &str) -> f64 {
3052 exec_calculate(&json!({ "expression": expr }))
3053 .unwrap_or_else(|e| panic!("calculate({expr:?}) failed: {e}"))
3054 .get("result")
3055 .and_then(|v| v.as_f64())
3056 .unwrap_or_else(|| panic!("calculate({expr:?}) returned no numeric result"))
3057 }
3058
3059 #[test]
3066 fn calculate_contract_semantics() {
3067 assert_eq!(calc("2 + 3 * 4"), 14.0, "operator precedence");
3068 assert_eq!(calc("(1 + 2) * 3"), 9.0, "parentheses override precedence");
3069 assert_eq!(calc("2^3"), 8.0, "^ is exponentiation, not XOR");
3070 assert_eq!(calc("2^10"), 1024.0, "^ is exponentiation");
3071 assert_eq!(calc("10 % 3"), 1.0, "modulo");
3072 assert_eq!(calc("-5 + 2"), -3.0, "unary minus");
3073 assert_eq!(calc("sin(0)"), 0.0, "native function: sin");
3075 assert_eq!(calc("abs(-3)"), 3.0, "native function: abs");
3076 assert_eq!(calc("sqrt(16)"), 4.0, "shim function: sqrt");
3078 assert!((calc("ln(e)") - 1.0).abs() < 1e-12, "shim: ln + e constant");
3079 assert!(
3080 (calc("pi") - std::f64::consts::PI).abs() < 1e-12,
3081 "shim: pi constant"
3082 );
3083 }
3084
3085 #[test]
3086 fn calculate_rejects_invalid_expression() {
3087 assert!(exec_calculate(&json!({ "expression": "2 +" })).is_err());
3088 assert!(exec_calculate(&json!({})).is_err(), "missing parameter");
3089 }
3090}