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