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, Copy)]
29struct ReadRecord {
30 hash: u64,
31 full_read: bool,
32}
33
34#[derive(Debug, Default)]
47pub struct ReadLedger {
48 seen: Mutex<HashMap<String, ReadRecord>>,
49 mutation_locks: Arc<Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>>,
50}
51
52impl ReadLedger {
53 pub fn new() -> Self {
54 Self::default()
55 }
56
57 fn with_mutation_locks(
58 mutation_locks: Arc<Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>>,
59 ) -> Self {
60 Self {
61 seen: Mutex::new(HashMap::new()),
62 mutation_locks,
63 }
64 }
65
66 fn hash(content: &str) -> u64 {
70 let mut hasher = std::collections::hash_map::DefaultHasher::new();
71 content.hash(&mut hasher);
72 hasher.finish()
73 }
74
75 fn normalize_key(path: &str) -> String {
80 let mut normalized = PathBuf::new();
81 for component in Path::new(path).components() {
82 if !matches!(component, Component::CurDir) {
83 normalized.push(component.as_os_str());
84 }
85 }
86 if normalized.as_os_str().is_empty() {
87 ".".to_string()
88 } else {
89 normalized.to_string_lossy().into_owned()
90 }
91 }
92
93 pub fn record(&self, path: &str, content: &str, full_read: bool) {
99 let record = ReadRecord {
100 hash: Self::hash(content),
101 full_read,
102 };
103 self.seen
104 .lock()
105 .expect("read ledger mutex poisoned")
106 .insert(Self::normalize_key(path), record);
107 }
108
109 pub fn check(&self, path: &str, content: &str) -> ReadState {
111 let guard = self.seen.lock().expect("read ledger mutex poisoned");
112 match guard.get(&Self::normalize_key(path)) {
113 None => ReadState::Unread,
114 Some(recorded) if recorded.hash == Self::hash(content) && recorded.full_read => {
115 ReadState::FreshFull
116 }
117 Some(recorded) if recorded.hash == Self::hash(content) => ReadState::FreshPartial,
118 Some(_) => ReadState::Stale,
119 }
120 }
121
122 pub fn mutation_lock(&self, path: &str) -> Arc<tokio::sync::Mutex<()>> {
126 let key = Self::normalize_key(path);
127 let mut locks = self
128 .mutation_locks
129 .lock()
130 .expect("read ledger mutation-lock mutex poisoned");
131 locks
132 .entry(key)
133 .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
134 .clone()
135 }
136
137 pub fn clear(&self) {
138 self.seen
139 .lock()
140 .expect("read ledger mutex poisoned")
141 .clear();
142 }
143}
144
145#[derive(Debug)]
149pub struct SessionReadLedgers {
150 default: Arc<ReadLedger>,
151 sessions: Mutex<HashMap<String, Arc<ReadLedger>>>,
152 mutation_locks: Arc<Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>>,
153}
154
155impl Default for SessionReadLedgers {
156 fn default() -> Self {
157 Self::new()
158 }
159}
160
161impl SessionReadLedgers {
162 pub fn new() -> Self {
163 let mutation_locks = Arc::new(Mutex::new(HashMap::new()));
164 Self {
165 default: Arc::new(ReadLedger::with_mutation_locks(mutation_locks.clone())),
166 sessions: Mutex::new(HashMap::new()),
167 mutation_locks,
168 }
169 }
170
171 pub fn ledger_for(&self, session_id: Option<&str>) -> Arc<ReadLedger> {
172 let Some(session_id) = session_id else {
173 return self.default.clone();
174 };
175 let mut sessions = self
176 .sessions
177 .lock()
178 .expect("session read-ledger mutex poisoned");
179 sessions
180 .entry(session_id.to_string())
181 .or_insert_with(|| {
182 Arc::new(ReadLedger::with_mutation_locks(self.mutation_locks.clone()))
183 })
184 .clone()
185 }
186
187 pub fn remove(&self, session_id: &str) {
188 self.sessions
189 .lock()
190 .expect("session read-ledger mutex poisoned")
191 .remove(session_id);
192 }
193
194 pub fn clear(&self) {
195 self.default.clear();
196 let ledgers: Vec<Arc<ReadLedger>> = self
197 .sessions
198 .lock()
199 .expect("session read-ledger mutex poisoned")
200 .values()
201 .cloned()
202 .collect();
203 for ledger in ledgers {
204 ledger.clear();
205 }
206 }
207}
208
209fn read_first_error(display_path: &str, verb: &str) -> String {
212 format!("you must read '{display_path}' before {verb} it — call read_file first")
213}
214
215fn stale_error(display_path: &str) -> String {
217 format!("'{display_path}' changed since you last read it — re-read it and retry")
218}
219
220fn full_read_error(display_path: &str, verb: &str) -> String {
221 format!(
222 "you must read the full current content of '{display_path}' before {verb} it — call read_file without offset or limit first"
223 )
224}
225
226fn looks_like_pasted_line_number(old_text: &str) -> bool {
231 let after_spaces = old_text.trim_start_matches(' ');
232 let digits = after_spaces
233 .bytes()
234 .take_while(|b| b.is_ascii_digit())
235 .count();
236 digits > 0 && after_spaces.as_bytes().get(digits) == Some(&b'\t')
237}
238
239pub fn entries() -> Vec<ToolEntry> {
240 vec![
241 ToolEntry::builtin(car_ir::builtins::read_file()).with_category("filesystem"),
242 ToolEntry::builtin(car_ir::builtins::list_dir()).with_category("filesystem"),
243 ToolEntry::builtin(car_ir::builtins::find_files()).with_category("filesystem"),
244 ToolEntry::builtin(car_ir::builtins::grep_files()).with_category("filesystem"),
245 ToolEntry::builtin(car_ir::builtins::calculate()).with_category("utility"),
246 ToolEntry::builtin(car_ir::builtins::write_file())
247 .with_permission(ToolPermission::AskUser)
248 .with_side_effects(true)
249 .with_category("filesystem"),
250 ToolEntry::builtin(car_ir::builtins::edit_file())
251 .with_permission(ToolPermission::AskUser)
252 .with_side_effects(true)
253 .with_category("filesystem"),
254 ]
255}
256
257pub async fn execute(
272 substrate: &Arc<dyn Substrate>,
273 tool: &str,
274 params: &Value,
275) -> Option<Result<Value, String>> {
276 execute_inner(substrate, None, tool, params).await
277}
278
279pub async fn execute_with_ledger(
287 substrate: &Arc<dyn Substrate>,
288 ledger: &ReadLedger,
289 tool: &str,
290 params: &Value,
291) -> Option<Result<Value, String>> {
292 execute_inner(substrate, Some(ledger), tool, params).await
293}
294
295async fn execute_inner(
296 substrate: &Arc<dyn Substrate>,
297 ledger: Option<&ReadLedger>,
298 tool: &str,
299 params: &Value,
300) -> Option<Result<Value, String>> {
301 let result = match tool {
302 "read_file" => exec_read_file(substrate, ledger, params).await,
303 "write_file" => exec_write_file(substrate, ledger, params).await,
304 "edit_file" => exec_edit_file(substrate, ledger, params).await,
305 "list_dir" => exec_list_dir(substrate, params).await,
306 "find_files" => exec_find_files(substrate, params).await,
307 "grep_files" => exec_grep_files(substrate, params).await,
308 "calculate" => exec_calculate(params),
309 _ => return None,
310 };
311 Some(result)
312}
313
314async fn exec_read_file(
315 substrate: &Arc<dyn Substrate>,
316 ledger: Option<&ReadLedger>,
317 params: &Value,
318) -> Result<Value, String> {
319 let path = params
320 .get("path")
321 .and_then(|v| v.as_str())
322 .ok_or("missing 'path' parameter")?;
323 let offset = params.get("offset").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
324 let limit = params
325 .get("limit")
326 .and_then(|v| v.as_u64())
327 .map(|v| v as usize);
328
329 let content = substrate.read_text(path).await?;
330 let size_bytes = content.len();
331 let total_lines = content.lines().count();
332
333 let mut lines: Vec<&str> = content.split('\n').collect();
346 if lines.last() == Some(&"") {
347 lines.pop();
348 }
349 let start = offset.min(lines.len());
350 let end = limit
351 .map(|line_count| (start + line_count).min(lines.len()))
352 .unwrap_or(lines.len());
353 let full_read = start == 0 && end == lines.len();
354
355 if let Some(ledger) = ledger {
360 ledger.record(path, &content, full_read);
361 }
362
363 let returned = if ledger.is_some() {
364 lines[start..end]
365 .iter()
366 .enumerate()
367 .map(|(i, line)| format!("{:>6}\t{}", start + i + 1, line))
368 .collect::<Vec<_>>()
369 .join("\n")
370 } else if offset > 0 || limit.is_some() {
371 lines[start..end].join("\n")
372 } else {
373 content.clone()
374 };
375
376 Ok(json!({
377 "path": substrate.display_path(path),
378 "content": returned,
379 "size_bytes": size_bytes,
380 "total_lines": total_lines,
381 }))
382}
383
384async fn exec_write_file(
385 substrate: &Arc<dyn Substrate>,
386 ledger: Option<&ReadLedger>,
387 params: &Value,
388) -> Result<Value, String> {
389 let path = params
390 .get("path")
391 .and_then(|v| v.as_str())
392 .ok_or("missing 'path' parameter")?;
393 let content = params
394 .get("content")
395 .and_then(|v| v.as_str())
396 .ok_or("missing 'content' parameter")?;
397 let append = params
398 .get("append")
399 .and_then(|v| v.as_bool())
400 .unwrap_or(false);
401
402 let _mutation_guard = match ledger {
403 Some(ledger) => Some(ledger.mutation_lock(path).lock_owned().await),
404 None => None,
405 };
406
407 let existing = if ledger.is_some() {
408 match substrate.read_text(path).await {
409 Ok(content) => Some(content),
410 Err(read_error) => match substrate.path_state(path).await {
411 crate::substrate::PathState::Missing => None,
412 crate::substrate::PathState::Exists => {
413 return Err(format!(
414 "cannot modify existing file '{}' because it cannot be read as UTF-8: {read_error}",
415 substrate.display_path(path)
416 ));
417 }
418 crate::substrate::PathState::Unknown(reason) => {
419 return Err(format!(
420 "cannot determine whether '{}' is safe to create: {reason}",
421 substrate.display_path(path)
422 ));
423 }
424 },
425 }
426 } else {
427 None
428 };
429
430 let combined = if append {
431 let existed = existing.is_some();
435 let existing = match existing {
436 Some(existing) => existing,
437 None if ledger.is_some() => String::new(),
438 None => substrate.read_text(path).await.unwrap_or_default(),
439 };
440 if let Some(ledger) = ledger.filter(|_| existed) {
447 match ledger.check(path, &existing) {
448 ReadState::Unread => {
449 return Err(read_first_error(
450 &substrate.display_path(path),
451 "overwriting",
452 ));
453 }
454 ReadState::Stale => {
455 return Err(stale_error(&substrate.display_path(path)));
456 }
457 ReadState::FreshPartial => {
458 return Err(full_read_error(
459 &substrate.display_path(path),
460 "appending to",
461 ));
462 }
463 ReadState::FreshFull => {}
464 }
465 }
466 let mut combined = existing;
467 combined.push_str(content);
468 substrate.write_text(path, &combined).await?;
469 combined
470 } else {
471 if let Some(ledger) = ledger {
479 if let Some(existing) = existing {
480 match ledger.check(path, &existing) {
481 ReadState::Unread => {
482 return Err(read_first_error(
483 &substrate.display_path(path),
484 "overwriting",
485 ));
486 }
487 ReadState::Stale => {
488 return Err(stale_error(&substrate.display_path(path)));
489 }
490 ReadState::FreshPartial => {
491 return Err(full_read_error(
492 &substrate.display_path(path),
493 "overwriting",
494 ));
495 }
496 ReadState::FreshFull => {}
497 }
498 }
499 }
500 substrate.write_text(path, content).await?;
501 content.to_string()
502 };
503
504 if let Some(ledger) = ledger {
507 ledger.record(path, &combined, true);
508 }
509
510 Ok(json!({
511 "path": substrate.display_path(path),
512 "bytes_written": content.len(),
513 "append": append,
514 }))
515}
516
517async fn exec_edit_file(
518 substrate: &Arc<dyn Substrate>,
519 ledger: Option<&ReadLedger>,
520 params: &Value,
521) -> Result<Value, String> {
522 let path = params
523 .get("path")
524 .and_then(|v| v.as_str())
525 .ok_or("missing 'path' parameter")?;
526 let old_text = params
527 .get("old_text")
528 .and_then(|v| v.as_str())
529 .ok_or("missing 'old_text' parameter")?;
530 let new_text = params
531 .get("new_text")
532 .and_then(|v| v.as_str())
533 .ok_or("missing 'new_text' parameter")?;
534 let replace_all = params
535 .get("replace_all")
536 .and_then(|v| v.as_bool())
537 .unwrap_or(false);
538
539 let _mutation_guard = match ledger {
540 Some(ledger) => Some(ledger.mutation_lock(path).lock_owned().await),
541 None => None,
542 };
543
544 let content = substrate.read_text(path).await?;
545
546 let full_read = if let Some(ledger) = ledger {
550 match ledger.check(path, &content) {
551 ReadState::Unread => {
552 return Err(read_first_error(&substrate.display_path(path), "editing"));
553 }
554 ReadState::Stale => {
555 return Err(stale_error(&substrate.display_path(path)));
556 }
557 ReadState::FreshPartial if replace_all => {
558 return Err(full_read_error(
559 &substrate.display_path(path),
560 "replacing every occurrence in",
561 ));
562 }
563 ReadState::FreshPartial => false,
564 ReadState::FreshFull => true,
565 }
566 } else {
567 false
568 };
569
570 if old_text.is_empty() {
575 return Err(
576 "old_text must be non-empty — pass the exact existing text to replace \
577 (use write_file to replace a whole file)"
578 .to_string(),
579 );
580 }
581
582 let count = content.matches(old_text).count();
583 if count == 0 {
584 let mut msg = format!("old_text not found in '{}'", substrate.display_path(path));
585 if looks_like_pasted_line_number(old_text) {
586 msg.push_str(
587 " — old_text looks like it includes read_file's line-number prefixes; \
588 strip them and retry",
589 );
590 }
591 return Err(msg);
592 }
593 let new_content = if replace_all {
594 content.replace(old_text, new_text)
595 } else {
596 if count > 1 {
597 return Err(format!(
598 "old_text found {count} times in '{}' and must match uniquely — \
599 pass `replace_all: true` to replace every occurrence, or add \
600 surrounding context to old_text so it matches one place",
601 substrate.display_path(path)
602 ));
603 }
604 content.replacen(old_text, new_text, 1)
605 };
606 substrate.write_text(path, &new_content).await?;
607
608 if let Some(ledger) = ledger {
611 ledger.record(path, &new_content, full_read);
612 }
613
614 Ok(json!({
615 "edited": substrate.display_path(path),
616 "diff_summary": format!(
617 "replaced {} lines with {} lines",
618 old_text.lines().count(),
619 new_text.lines().count()
620 ),
621 "replacements": count,
622 }))
623}
624
625async fn exec_list_dir(substrate: &Arc<dyn Substrate>, params: &Value) -> Result<Value, String> {
626 let path = params.get("path").and_then(|v| v.as_str()).unwrap_or(".");
627
628 if !substrate.is_local() {
629 return list_dir_via_command(substrate, path).await;
630 }
631
632 let full_path = local_resolve(path)?;
633 let mut entries = Vec::new();
634
635 let read_dir = std::fs::read_dir(&full_path)
636 .map_err(|e| format!("failed to read dir '{}': {e}", full_path.display()))?;
637 for entry in read_dir {
638 let entry = entry.map_err(|e| format!("failed to read dir entry: {e}"))?;
639 let file_name = entry.file_name().to_string_lossy().to_string();
640 if should_skip_name(&file_name) {
641 continue;
642 }
643 let metadata = entry
644 .metadata()
645 .map_err(|e| format!("failed to read metadata for '{}': {e}", file_name))?;
646 entries.push(json!({
647 "name": file_name,
648 "path": entry.path().display().to_string(),
649 "is_dir": metadata.is_dir(),
650 "size_bytes": if metadata.is_file() { Some(metadata.len()) } else { None::<u64> },
651 }));
652 }
653
654 Ok(json!({
655 "path": full_path.display().to_string(),
656 "entries": entries,
657 }))
658}
659
660async fn exec_find_files(substrate: &Arc<dyn Substrate>, params: &Value) -> Result<Value, String> {
661 let pattern = params
662 .get("pattern")
663 .and_then(|v| v.as_str())
664 .ok_or("missing 'pattern' parameter")?;
665 let root = params.get("path").and_then(|v| v.as_str()).unwrap_or(".");
666 let max_results = params
667 .get("max_results")
668 .and_then(|v| v.as_u64())
669 .unwrap_or(1000) as usize;
670
671 if !substrate.is_local() {
672 return find_files_via_command(substrate, pattern, root, max_results).await;
673 }
674
675 let root_path = local_resolve(root)?;
676 let matcher = glob_to_regex(pattern)?;
677 let pattern_has_sep = pattern.contains('/');
682 let root_str = root_path.to_string_lossy().to_string();
683 let mut files = Vec::new();
684
685 walk_files(&root_path, &mut |path| {
686 if files.len() >= max_results {
687 return;
688 }
689 if let Some(full) = path.to_str() {
690 if matcher.is_match(&glob_haystack(pattern_has_sep, full, &root_str)) {
691 files.push(path.display().to_string());
692 }
693 }
694 })?;
695
696 Ok(json!({
697 "files": files,
698 "count": files.len(),
699 "truncated": files.len() >= max_results,
700 }))
701}
702
703async fn exec_grep_files(substrate: &Arc<dyn Substrate>, params: &Value) -> Result<Value, String> {
704 let pattern = params
705 .get("pattern")
706 .and_then(|v| v.as_str())
707 .ok_or("missing 'pattern' parameter")?;
708 let root = params.get("path").and_then(|v| v.as_str()).unwrap_or(".");
709 let max_results = params
710 .get("max_results")
711 .and_then(|v| v.as_u64())
712 .unwrap_or(50) as usize;
713
714 if !substrate.is_local() {
715 return grep_files_via_command(substrate, pattern, root, max_results).await;
716 }
717
718 let root_path = local_resolve(root)?;
719 let regex = Regex::new(pattern).map_err(|e| format!("invalid regex pattern: {e}"))?;
720 let mut matches = Vec::new();
721
722 walk_files(&root_path, &mut |path| {
723 if matches.len() >= max_results || !is_text_file(path) {
724 return;
725 }
726 let Ok(content) = std::fs::read_to_string(path) else {
727 return;
728 };
729 if content.len() > MAX_FILE_BYTES {
730 return;
731 }
732 for (idx, line) in content.lines().enumerate() {
733 if regex.is_match(line) {
734 matches.push(json!({
735 "path": path.display().to_string(),
736 "line": idx + 1,
737 "text": line,
738 }));
739 if matches.len() >= max_results {
740 break;
741 }
742 }
743 }
744 })?;
745
746 Ok(json!({
747 "matches": matches,
748 "count": matches.len(),
749 "truncated": matches.len() >= max_results,
750 }))
751}
752
753fn exec_calculate(params: &Value) -> Result<Value, String> {
754 let expression = params
755 .get("expression")
756 .and_then(|v| v.as_str())
757 .ok_or("missing 'expression' parameter")?;
758 let mut ns = |name: &str, args: Vec<f64>| -> Option<f64> {
765 match (name, args.as_slice()) {
766 ("sqrt", [x]) => Some(x.sqrt()),
767 ("ln", [x]) => Some(x.ln()),
768 ("pi", []) => Some(std::f64::consts::PI),
769 ("e", []) => Some(std::f64::consts::E),
770 _ => None,
771 }
772 };
773 let result = fasteval::ez_eval(expression, &mut ns)
774 .map_err(|e| format!("failed to evaluate expression: {e}"))?;
775 Ok(json!({ "result": result }))
776}
777
778fn local_resolve(path: &str) -> Result<PathBuf, String> {
785 crate::substrate::LocalSubstrate::resolve_path(path)
786}
787
788fn should_skip_name(name: &str) -> bool {
789 name.starts_with('.') || matches!(name, "node_modules" | "__pycache__" | "target")
790}
791
792fn is_text_file(path: &Path) -> bool {
793 matches!(
794 path.extension().and_then(|v| v.to_str()),
795 Some(
796 "c" | "cc"
797 | "cpp"
798 | "cs"
799 | "css"
800 | "go"
801 | "h"
802 | "html"
803 | "ini"
804 | "java"
805 | "js"
806 | "json"
807 | "jsx"
808 | "kt"
809 | "md"
810 | "py"
811 | "rb"
812 | "rs"
813 | "sh"
814 | "sql"
815 | "toml"
816 | "ts"
817 | "tsx"
818 | "txt"
819 | "xml"
820 | "yaml"
821 | "yml"
822 )
823 )
824}
825
826fn walk_files(root: &Path, visit: &mut dyn FnMut(&Path)) -> Result<(), String> {
827 if root.is_file() {
828 visit(root);
829 return Ok(());
830 }
831
832 let read_dir = std::fs::read_dir(root)
833 .map_err(|e| format!("failed to read dir '{}': {e}", root.display()))?;
834 for entry in read_dir {
835 let entry = entry.map_err(|e| format!("failed to read dir entry: {e}"))?;
836 let path = entry.path();
837 let file_name = entry.file_name().to_string_lossy().to_string();
838 if should_skip_name(&file_name) {
839 continue;
840 }
841 let metadata = entry
842 .metadata()
843 .map_err(|e| format!("failed to read metadata for '{}': {e}", path.display()))?;
844 if metadata.is_dir() {
845 walk_files(&path, visit)?;
846 } else if metadata.is_file() {
847 visit(&path);
848 }
849 }
850 Ok(())
851}
852
853fn glob_to_regex(pattern: &str) -> Result<Regex, String> {
863 let chars: Vec<char> = pattern.chars().collect();
864 let mut re = String::from("^");
865 let mut i = 0;
866 while i < chars.len() {
867 match chars[i] {
868 '*' => {
869 if i + 1 < chars.len() && chars[i + 1] == '*' {
870 i += 1; if i + 1 < chars.len() && chars[i + 1] == '/' {
872 i += 1; re.push_str("(?:.*/)?");
874 } else {
875 re.push_str(".*");
876 }
877 } else {
878 re.push_str("[^/]*");
879 }
880 }
881 '?' => re.push_str("[^/]"),
882 c => re.push_str(®ex::escape(&c.to_string())),
883 }
884 i += 1;
885 }
886 re.push('$');
887 Regex::new(&re).map_err(|e| format!("invalid glob pattern: {e}"))
888}
889
890fn glob_haystack(pattern_has_sep: bool, full: &str, root: &str) -> String {
898 if pattern_has_sep {
899 let full = full.replace('\\', "/");
900 let root = root.replace('\\', "/");
901 full.strip_prefix(&root)
902 .unwrap_or(&full)
903 .trim_start_matches('/')
904 .to_string()
905 } else {
906 full.rsplit(['/', '\\']).next().unwrap_or(full).to_string()
907 }
908}
909
910fn shell_quote(s: &str) -> String {
919 format!("'{}'", s.replace('\'', "'\\''"))
921}
922
923async fn list_dir_via_command(substrate: &Arc<dyn Substrate>, path: &str) -> Result<Value, String> {
924 let cmd = format!("ls -1Ap {}", shell_quote(path));
925 let out = substrate.run_command(&cmd, Some(30.0)).await?;
926 let entries: Vec<Value> = out
927 .stdout
928 .lines()
929 .filter(|l| !l.is_empty())
930 .filter(|name| {
931 let bare = name.trim_end_matches('/');
932 !should_skip_name(bare)
933 })
934 .map(|name| {
935 let is_dir = name.ends_with('/');
936 let bare = name.trim_end_matches('/');
937 json!({
938 "name": bare,
939 "path": format!("{}/{}", path.trim_end_matches('/'), bare),
940 "is_dir": is_dir,
941 "size_bytes": Value::Null,
942 })
943 })
944 .collect();
945 Ok(json!({ "path": path, "entries": entries }))
946}
947
948async fn find_files_via_command(
949 substrate: &Arc<dyn Substrate>,
950 pattern: &str,
951 root: &str,
952 max_results: usize,
953) -> Result<Value, String> {
954 let cmd = format!("find {} -type f", shell_quote(root));
960 let out = substrate.run_command(&cmd, Some(30.0)).await?;
961 let matcher = glob_to_regex(pattern)?;
962 let pattern_has_sep = pattern.contains('/');
963 let mut files: Vec<String> = Vec::new();
964 let mut truncated = false;
965 for line in out.stdout.lines() {
966 if line.is_empty() {
967 continue;
968 }
969 let name = line.rsplit(['/', '\\']).next().unwrap_or(line);
970 if should_skip_name(name) {
971 continue;
972 }
973 if !matcher.is_match(&glob_haystack(pattern_has_sep, line, root)) {
974 continue;
975 }
976 if files.len() >= max_results {
977 truncated = true;
978 break;
979 }
980 files.push(line.to_string());
981 }
982 Ok(json!({
983 "files": files,
984 "count": files.len(),
985 "truncated": truncated,
986 }))
987}
988
989async fn grep_files_via_command(
990 substrate: &Arc<dyn Substrate>,
991 pattern: &str,
992 root: &str,
993 max_results: usize,
994) -> Result<Value, String> {
995 let cmd = format!("grep -rnE {} {}", shell_quote(pattern), shell_quote(root));
996 let out = substrate.run_command(&cmd, Some(30.0)).await?;
997 let mut matches = Vec::new();
998 for line in out.stdout.lines() {
999 if matches.len() >= max_results {
1000 break;
1001 }
1002 let mut parts = line.splitn(3, ':');
1004 let (Some(p), Some(ln), Some(text)) = (parts.next(), parts.next(), parts.next()) else {
1005 continue;
1006 };
1007 let Ok(line_no) = ln.parse::<usize>() else {
1008 continue;
1009 };
1010 matches.push(json!({ "path": p, "line": line_no, "text": text }));
1011 }
1012 let truncated = matches.len() >= max_results;
1013 Ok(json!({
1014 "matches": matches,
1015 "count": matches.len(),
1016 "truncated": truncated,
1017 }))
1018}
1019
1020#[cfg(test)]
1021mod tests {
1022 use super::*;
1023
1024 #[test]
1025 fn glob_patterns_match_file_names() {
1026 let regex = glob_to_regex("*.rs").unwrap();
1027 assert!(regex.is_match("lib.rs"));
1028 assert!(!regex.is_match("lib.ts"));
1029 }
1030
1031 #[test]
1037 fn glob_haystack_supports_path_and_basename_matching() {
1038 assert_eq!(
1040 glob_haystack(true, "/root/src/inner/deep.rs", "/root"),
1041 "src/inner/deep.rs"
1042 );
1043 assert_eq!(
1044 glob_haystack(false, "/root/src/inner/deep.rs", "/root"),
1045 "deep.rs"
1046 );
1047
1048 let m = glob_to_regex("src/**/*.rs").unwrap();
1052 assert!(m.is_match(&glob_haystack(true, "/root/src/top.rs", "/root")));
1053 assert!(m.is_match(&glob_haystack(true, "/root/src/inner/deep.rs", "/root")));
1054 assert!(!m.is_match(&glob_haystack(true, "/root/src/inner/note.txt", "/root")));
1055 }
1056
1057 #[tokio::test]
1061 async fn find_files_supports_recursive_path_globs() {
1062 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1063 let dir = std::env::temp_dir().join(format!(
1064 "car-find-glob-{}",
1065 std::time::SystemTime::now()
1066 .duration_since(std::time::UNIX_EPOCH)
1067 .unwrap()
1068 .as_nanos()
1069 ));
1070 std::fs::create_dir_all(dir.join("src").join("inner")).unwrap();
1071 std::fs::write(dir.join("src").join("top.rs"), "x").unwrap();
1072 std::fs::write(dir.join("src").join("inner").join("deep.rs"), "x").unwrap();
1073 std::fs::write(dir.join("src").join("inner").join("note.txt"), "x").unwrap();
1074 let root = dir.to_string_lossy().to_string();
1075
1076 let r = exec_find_files(
1077 &substrate,
1078 &json!({ "path": root, "pattern": "src/**/*.rs" }),
1079 )
1080 .await
1081 .unwrap();
1082 let joined = r["files"]
1083 .as_array()
1084 .unwrap()
1085 .iter()
1086 .map(|v| v.as_str().unwrap())
1087 .collect::<Vec<_>>()
1088 .join("\n");
1089
1090 assert!(
1091 joined.contains("deep.rs"),
1092 "recursive glob missed nested file:\n{joined}"
1093 );
1094 assert!(
1095 joined.contains("top.rs"),
1096 "recursive glob missed top-level file:\n{joined}"
1097 );
1098 assert!(
1099 !joined.contains("note.txt"),
1100 "glob matched the wrong extension:\n{joined}"
1101 );
1102
1103 let _ = std::fs::remove_dir_all(&dir);
1104 }
1105
1106 #[tokio::test]
1112 async fn find_files_non_local_substrate_applies_path_glob() {
1113 use crate::substrate::CommandOutput;
1114
1115 struct RemoteStub {
1116 stdout: String,
1117 }
1118 #[async_trait::async_trait]
1119 impl Substrate for RemoteStub {
1120 fn name(&self) -> &str {
1121 "test-remote"
1122 }
1123 async fn run_command(
1125 &self,
1126 _cmd: &str,
1127 _timeout_s: Option<f64>,
1128 ) -> Result<CommandOutput, String> {
1129 Ok(CommandOutput {
1130 stdout: self.stdout.clone(),
1131 stderr: String::new(),
1132 exit_code: 0,
1133 })
1134 }
1135 async fn read_text(&self, _path: &str) -> Result<String, String> {
1136 Err("unused".into())
1137 }
1138 async fn write_text(&self, _path: &str, _content: &str) -> Result<(), String> {
1139 Err("unused".into())
1140 }
1141 async fn read_bytes(
1142 &self,
1143 _path: &str,
1144 _offset: Option<u64>,
1145 _len: Option<u64>,
1146 ) -> Result<Vec<u8>, String> {
1147 Err("unused".into())
1148 }
1149 async fn write_bytes(&self, _path: &str, _bytes: &[u8]) -> Result<(), String> {
1150 Err("unused".into())
1151 }
1152 }
1153
1154 let substrate: Arc<dyn Substrate> = Arc::new(RemoteStub {
1156 stdout: [
1157 "/root/src/top.rs",
1158 "/root/src/inner/deep.rs",
1159 "/root/src/inner/note.txt",
1160 "/root/README.md",
1161 ]
1162 .join("\n"),
1163 });
1164
1165 let r = exec_find_files(
1166 &substrate,
1167 &json!({ "path": "/root", "pattern": "src/**/*.rs" }),
1168 )
1169 .await
1170 .unwrap();
1171 let files: Vec<&str> = r["files"]
1172 .as_array()
1173 .unwrap()
1174 .iter()
1175 .map(|v| v.as_str().unwrap())
1176 .collect();
1177
1178 assert_eq!(files, vec!["/root/src/top.rs", "/root/src/inner/deep.rs"]);
1181 assert_eq!(r["count"], 2);
1182 }
1183
1184 fn fresh_dir(tag: &str) -> PathBuf {
1186 let dir = std::env::temp_dir().join(format!(
1187 "car-agent-basics-{tag}-{}",
1188 std::time::SystemTime::now()
1189 .duration_since(std::time::UNIX_EPOCH)
1190 .unwrap()
1191 .as_nanos()
1192 ));
1193 std::fs::create_dir_all(&dir).unwrap();
1194 dir
1195 }
1196
1197 #[tokio::test]
1198 async fn read_write_roundtrip_against_local_substrate() {
1199 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1200 let dir = fresh_dir("roundtrip");
1201 let path = dir.join("note.txt").to_string_lossy().to_string();
1202
1203 let w = exec_write_file(&substrate, None, &json!({ "path": path, "content": "abc" }))
1204 .await
1205 .unwrap();
1206 assert_eq!(w["bytes_written"], 3);
1207
1208 let r = exec_read_file(&substrate, None, &json!({ "path": path }))
1210 .await
1211 .unwrap();
1212 assert_eq!(r["content"], "abc");
1213 assert_eq!(r["size_bytes"], 3);
1214 assert_eq!(r["total_lines"], 1);
1215
1216 exec_write_file(
1218 &substrate,
1219 None,
1220 &json!({ "path": path, "content": "def", "append": true }),
1221 )
1222 .await
1223 .unwrap();
1224 let r2 = exec_read_file(&substrate, None, &json!({ "path": path }))
1225 .await
1226 .unwrap();
1227 assert_eq!(r2["content"], "abcdef");
1228
1229 let e = exec_edit_file(
1231 &substrate,
1232 None,
1233 &json!({ "path": path, "old_text": "abc", "new_text": "XYZ" }),
1234 )
1235 .await
1236 .unwrap();
1237 assert!(e["edited"].is_string());
1238 assert_eq!(e["replacements"], 1);
1239 let r3 = exec_read_file(&substrate, None, &json!({ "path": path }))
1240 .await
1241 .unwrap();
1242 assert_eq!(r3["content"], "XYZdef");
1243
1244 std::fs::remove_dir_all(&dir).ok();
1245 }
1246
1247 #[tokio::test]
1251 async fn read_file_output_is_line_numbered() {
1252 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1253 let ledger = ReadLedger::new();
1254 let dir = fresh_dir("linenum");
1255 let path = dir.join("f.txt").to_string_lossy().to_string();
1256 exec_write_file(
1257 &substrate,
1258 None,
1259 &json!({ "path": path, "content": "alpha\nbeta\ngamma" }),
1260 )
1261 .await
1262 .unwrap();
1263
1264 let r = execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1265 .await
1266 .unwrap()
1267 .unwrap();
1268 assert_eq!(r["content"], " 1\talpha\n 2\tbeta\n 3\tgamma");
1269 assert_eq!(r["total_lines"], 3);
1270
1271 let r2 = execute_with_ledger(
1273 &substrate,
1274 &ledger,
1275 "read_file",
1276 &json!({ "path": path, "offset": 1, "limit": 1 }),
1277 )
1278 .await
1279 .unwrap()
1280 .unwrap();
1281 assert_eq!(r2["content"], " 2\tbeta");
1282 assert_eq!(r2["total_lines"], 3);
1283
1284 std::fs::remove_dir_all(&dir).ok();
1285 }
1286
1287 #[tokio::test]
1288 async fn plain_execute_preserves_legacy_raw_read_output() {
1289 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1290 let dir = fresh_dir("rawread");
1291 let path = dir.join("f.txt").to_string_lossy().to_string();
1292 std::fs::write(&path, "alpha\nbeta\n").unwrap();
1293
1294 let result = execute(&substrate, "read_file", &json!({ "path": path }))
1295 .await
1296 .unwrap()
1297 .unwrap();
1298 assert_eq!(result["content"], "alpha\nbeta\n");
1299
1300 std::fs::remove_dir_all(&dir).ok();
1301 }
1302
1303 #[tokio::test]
1306 async fn edit_requires_prior_read() {
1307 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1308 let ledger = ReadLedger::new();
1309 let dir = fresh_dir("editread");
1310 let path = dir.join("f.txt").to_string_lossy().to_string();
1311 std::fs::write(dir.join("f.txt"), "hello world").unwrap();
1313
1314 let err = execute_with_ledger(
1315 &substrate,
1316 &ledger,
1317 "edit_file",
1318 &json!({ "path": path, "old_text": "hello", "new_text": "hi" }),
1319 )
1320 .await
1321 .unwrap()
1322 .unwrap_err();
1323 assert!(err.contains("before editing it"), "{err}");
1324 assert!(err.contains("read_file"), "{err}");
1325
1326 execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1328 .await
1329 .unwrap()
1330 .unwrap();
1331 let ok = execute_with_ledger(
1332 &substrate,
1333 &ledger,
1334 "edit_file",
1335 &json!({ "path": path, "old_text": "hello", "new_text": "hi" }),
1336 )
1337 .await
1338 .unwrap()
1339 .unwrap();
1340 assert_eq!(ok["replacements"], 1);
1341
1342 std::fs::remove_dir_all(&dir).ok();
1343 }
1344
1345 #[tokio::test]
1348 async fn edit_detects_stale_file() {
1349 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1350 let ledger = ReadLedger::new();
1351 let dir = fresh_dir("stale");
1352 let path = dir.join("f.txt").to_string_lossy().to_string();
1353 std::fs::write(dir.join("f.txt"), "version one").unwrap();
1354
1355 execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1357 .await
1358 .unwrap()
1359 .unwrap();
1360 std::fs::write(dir.join("f.txt"), "version two changed").unwrap();
1361
1362 let err = execute_with_ledger(
1363 &substrate,
1364 &ledger,
1365 "edit_file",
1366 &json!({ "path": path, "old_text": "version", "new_text": "v" }),
1367 )
1368 .await
1369 .unwrap()
1370 .unwrap_err();
1371 assert!(err.contains("changed since you last read it"), "{err}");
1372
1373 execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1375 .await
1376 .unwrap()
1377 .unwrap();
1378 let ok = execute_with_ledger(
1379 &substrate,
1380 &ledger,
1381 "edit_file",
1382 &json!({ "path": path, "old_text": "version", "new_text": "v" }),
1383 )
1384 .await
1385 .unwrap()
1386 .unwrap();
1387 assert!(ok["edited"].is_string());
1388
1389 std::fs::remove_dir_all(&dir).ok();
1390 }
1391
1392 #[tokio::test]
1397 async fn read_file_preserves_crlf_line_endings() {
1398 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1399 let ledger = ReadLedger::new();
1400 let dir = fresh_dir("crlf");
1401 let path = dir.join("f.txt").to_string_lossy().to_string();
1402 std::fs::write(dir.join("f.txt"), "line one\r\nline two\r\n").unwrap();
1403
1404 let out = execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1405 .await
1406 .unwrap()
1407 .unwrap();
1408 let shown = out["content"].as_str().unwrap();
1409 assert_eq!(
1410 shown, " 1\tline one\r\n 2\tline two\r",
1411 "\\r must survive into the numbered display"
1412 );
1413
1414 let ok = execute_with_ledger(
1417 &substrate,
1418 &ledger,
1419 "edit_file",
1420 &json!({ "path": path, "old_text": "line one\r\nline two", "new_text": "merged" }),
1421 )
1422 .await
1423 .unwrap()
1424 .unwrap();
1425 assert_eq!(ok["replacements"], 1);
1426
1427 std::fs::remove_dir_all(&dir).ok();
1428 }
1429
1430 #[tokio::test]
1434 async fn edit_rejects_empty_old_text() {
1435 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1436 let dir = fresh_dir("emptyold");
1437 let path = dir.join("f.txt").to_string_lossy().to_string();
1438 exec_write_file(&substrate, None, &json!({ "path": path, "content": "abc" }))
1439 .await
1440 .unwrap();
1441
1442 for replace_all in [false, true] {
1443 let err = exec_edit_file(
1444 &substrate,
1445 None,
1446 &json!({
1447 "path": path,
1448 "old_text": "",
1449 "new_text": "X",
1450 "replace_all": replace_all
1451 }),
1452 )
1453 .await
1454 .unwrap_err();
1455 assert!(err.contains("old_text must be non-empty"), "{err}");
1456 }
1457 assert_eq!(std::fs::read_to_string(dir.join("f.txt")).unwrap(), "abc");
1459
1460 std::fs::remove_dir_all(&dir).ok();
1461 }
1462
1463 #[tokio::test]
1468 async fn write_existing_rejects_stale_after_external_change() {
1469 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1470 let ledger = ReadLedger::new();
1471 let dir = fresh_dir("stalewrite");
1472 let path = dir.join("f.txt").to_string_lossy().to_string();
1473 std::fs::write(dir.join("f.txt"), "original").unwrap();
1474
1475 execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1476 .await
1477 .unwrap()
1478 .unwrap();
1479 std::fs::write(dir.join("f.txt"), "changed underneath").unwrap();
1481
1482 let err = execute_with_ledger(
1483 &substrate,
1484 &ledger,
1485 "write_file",
1486 &json!({ "path": path, "content": "clobber" }),
1487 )
1488 .await
1489 .unwrap()
1490 .unwrap_err();
1491 assert!(err.contains("changed since you last read it"), "{err}");
1492
1493 let err = execute_with_ledger(
1495 &substrate,
1496 &ledger,
1497 "write_file",
1498 &json!({ "path": path, "content": " + more", "append": true }),
1499 )
1500 .await
1501 .unwrap()
1502 .unwrap_err();
1503 assert!(err.contains("changed since you last read it"), "{err}");
1504
1505 execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1507 .await
1508 .unwrap()
1509 .unwrap();
1510 execute_with_ledger(
1511 &substrate,
1512 &ledger,
1513 "write_file",
1514 &json!({ "path": path, "content": "rewritten" }),
1515 )
1516 .await
1517 .unwrap()
1518 .unwrap();
1519 assert_eq!(
1520 std::fs::read_to_string(dir.join("f.txt")).unwrap(),
1521 "rewritten"
1522 );
1523
1524 std::fs::remove_dir_all(&dir).ok();
1525 }
1526
1527 #[tokio::test]
1530 async fn replace_all_replaces_every_occurrence() {
1531 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1532 let dir = fresh_dir("replaceall");
1533 let path = dir.join("f.txt").to_string_lossy().to_string();
1534 exec_write_file(
1535 &substrate,
1536 None,
1537 &json!({ "path": path, "content": "a x a x a" }),
1538 )
1539 .await
1540 .unwrap();
1541
1542 let err = exec_edit_file(
1544 &substrate,
1545 None,
1546 &json!({ "path": path, "old_text": "a", "new_text": "b" }),
1547 )
1548 .await
1549 .unwrap_err();
1550 assert!(err.contains("replace_all"), "{err}");
1551
1552 let ok = exec_edit_file(
1554 &substrate,
1555 None,
1556 &json!({ "path": path, "old_text": "a", "new_text": "b", "replace_all": true }),
1557 )
1558 .await
1559 .unwrap();
1560 assert_eq!(ok["replacements"], 3);
1561 let r = exec_read_file(&substrate, None, &json!({ "path": path }))
1562 .await
1563 .unwrap();
1564 assert_eq!(r["content"], "b x b x b");
1565
1566 std::fs::remove_dir_all(&dir).ok();
1567 }
1568
1569 #[tokio::test]
1571 async fn write_new_file_allowed() {
1572 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1573 let ledger = ReadLedger::new();
1574 let dir = fresh_dir("writenew");
1575 let path = dir.join("new.txt").to_string_lossy().to_string();
1576
1577 let ok = execute_with_ledger(
1578 &substrate,
1579 &ledger,
1580 "write_file",
1581 &json!({ "path": path, "content": "fresh" }),
1582 )
1583 .await
1584 .unwrap()
1585 .unwrap();
1586 assert_eq!(ok["bytes_written"], 5);
1587
1588 std::fs::remove_dir_all(&dir).ok();
1589 }
1590
1591 #[tokio::test]
1593 async fn write_existing_requires_prior_read() {
1594 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1595 let ledger = ReadLedger::new();
1596 let dir = fresh_dir("writeexisting");
1597 let path = dir.join("f.txt").to_string_lossy().to_string();
1598 std::fs::write(dir.join("f.txt"), "existing content").unwrap();
1599
1600 let err = execute_with_ledger(
1601 &substrate,
1602 &ledger,
1603 "write_file",
1604 &json!({ "path": path, "content": "clobber" }),
1605 )
1606 .await
1607 .unwrap()
1608 .unwrap_err();
1609 assert!(err.contains("before overwriting it"), "{err}");
1610 assert!(err.contains("read_file"), "{err}");
1611
1612 execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1614 .await
1615 .unwrap()
1616 .unwrap();
1617 let ok = execute_with_ledger(
1618 &substrate,
1619 &ledger,
1620 "write_file",
1621 &json!({ "path": path, "content": "clobber" }),
1622 )
1623 .await
1624 .unwrap()
1625 .unwrap();
1626 assert_eq!(ok["bytes_written"], 7);
1627
1628 std::fs::remove_dir_all(&dir).ok();
1629 }
1630
1631 #[tokio::test]
1635 async fn write_self_records_enabling_edit() {
1636 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1637 let ledger = ReadLedger::new();
1638 let dir = fresh_dir("writeedits");
1639 let path = dir.join("f.txt").to_string_lossy().to_string();
1640
1641 execute_with_ledger(
1642 &substrate,
1643 &ledger,
1644 "write_file",
1645 &json!({ "path": path, "content": "one two three" }),
1646 )
1647 .await
1648 .unwrap()
1649 .unwrap();
1650 let ok = execute_with_ledger(
1652 &substrate,
1653 &ledger,
1654 "edit_file",
1655 &json!({ "path": path, "old_text": "two", "new_text": "TWO" }),
1656 )
1657 .await
1658 .unwrap()
1659 .unwrap();
1660 assert_eq!(ok["replacements"], 1);
1661
1662 std::fs::remove_dir_all(&dir).ok();
1663 }
1664
1665 #[tokio::test]
1669 async fn edit_self_records_enabling_second_edit_without_reread() {
1670 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1671 let ledger = ReadLedger::new();
1672 let dir = fresh_dir("editselfrec");
1673 let path = dir.join("f.txt").to_string_lossy().to_string();
1674 std::fs::write(dir.join("f.txt"), "one two three").unwrap();
1675
1676 execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1677 .await
1678 .unwrap()
1679 .unwrap();
1680 execute_with_ledger(
1681 &substrate,
1682 &ledger,
1683 "edit_file",
1684 &json!({ "path": path, "old_text": "one", "new_text": "1" }),
1685 )
1686 .await
1687 .unwrap()
1688 .unwrap();
1689
1690 let ok = execute_with_ledger(
1692 &substrate,
1693 &ledger,
1694 "edit_file",
1695 &json!({ "path": path, "old_text": "two", "new_text": "2" }),
1696 )
1697 .await
1698 .unwrap()
1699 .unwrap();
1700 assert_eq!(ok["replacements"], 1);
1701 assert_eq!(
1702 std::fs::read_to_string(dir.join("f.txt")).unwrap(),
1703 "1 2 three"
1704 );
1705
1706 std::fs::remove_dir_all(&dir).ok();
1707 }
1708
1709 #[tokio::test]
1713 async fn sliced_read_hashes_full_file_licensing_edit() {
1714 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1715 let ledger = ReadLedger::new();
1716 let dir = fresh_dir("slicedread");
1717 let path = dir.join("f.txt").to_string_lossy().to_string();
1718 std::fs::write(dir.join("f.txt"), "l1\nl2\nl3\nl4").unwrap();
1719
1720 let r = execute_with_ledger(
1721 &substrate,
1722 &ledger,
1723 "read_file",
1724 &json!({ "path": path, "offset": 1, "limit": 1 }),
1725 )
1726 .await
1727 .unwrap()
1728 .unwrap();
1729 assert_eq!(r["content"], " 2\tl2"); let ok = execute_with_ledger(
1733 &substrate,
1734 &ledger,
1735 "edit_file",
1736 &json!({ "path": path, "old_text": "l4", "new_text": "L4" }),
1737 )
1738 .await
1739 .unwrap()
1740 .unwrap();
1741 assert_eq!(ok["replacements"], 1);
1742
1743 std::fs::remove_dir_all(&dir).ok();
1744 }
1745
1746 #[tokio::test]
1749 async fn append_to_existing_unread_requires_prior_read() {
1750 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1751 let ledger = ReadLedger::new();
1752 let dir = fresh_dir("appendunread");
1753 let path = dir.join("f.txt").to_string_lossy().to_string();
1754 std::fs::write(dir.join("f.txt"), "existing content").unwrap();
1755
1756 let err = execute_with_ledger(
1757 &substrate,
1758 &ledger,
1759 "write_file",
1760 &json!({ "path": path, "content": " more", "append": true }),
1761 )
1762 .await
1763 .unwrap()
1764 .unwrap_err();
1765 assert!(err.contains("before overwriting it"), "{err}");
1766
1767 execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1768 .await
1769 .unwrap()
1770 .unwrap();
1771 let ok = execute_with_ledger(
1772 &substrate,
1773 &ledger,
1774 "write_file",
1775 &json!({ "path": path, "content": " more", "append": true }),
1776 )
1777 .await
1778 .unwrap()
1779 .unwrap();
1780 assert_eq!(ok["append"], true);
1781 assert_eq!(
1782 std::fs::read_to_string(dir.join("f.txt")).unwrap(),
1783 "existing content more"
1784 );
1785
1786 std::fs::remove_dir_all(&dir).ok();
1787 }
1788
1789 #[tokio::test]
1790 async fn append_to_existing_empty_file_requires_prior_read() {
1791 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1792 let ledger = ReadLedger::new();
1793 let dir = fresh_dir("appendempty");
1794 let path = dir.join("f.txt").to_string_lossy().to_string();
1795 std::fs::write(dir.join("f.txt"), "").unwrap();
1796
1797 let err = execute_with_ledger(
1798 &substrate,
1799 &ledger,
1800 "write_file",
1801 &json!({ "path": path, "content": "new", "append": true }),
1802 )
1803 .await
1804 .unwrap()
1805 .unwrap_err();
1806 assert!(err.contains("before overwriting it"), "{err}");
1807 assert!(std::fs::read_to_string(dir.join("f.txt"))
1808 .unwrap()
1809 .is_empty());
1810
1811 std::fs::remove_dir_all(&dir).ok();
1812 }
1813
1814 #[tokio::test]
1815 async fn partial_read_cannot_authorize_whole_file_mutation() {
1816 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1817 let ledger = ReadLedger::new();
1818 let dir = fresh_dir("partialread");
1819 let path = dir.join("f.txt").to_string_lossy().to_string();
1820 std::fs::write(dir.join("f.txt"), "first\nsecond\nthird").unwrap();
1821
1822 execute_with_ledger(
1823 &substrate,
1824 &ledger,
1825 "read_file",
1826 &json!({ "path": path, "offset": 0, "limit": 1 }),
1827 )
1828 .await
1829 .unwrap()
1830 .unwrap();
1831
1832 let overwrite = execute_with_ledger(
1833 &substrate,
1834 &ledger,
1835 "write_file",
1836 &json!({ "path": path, "content": "replacement" }),
1837 )
1838 .await
1839 .unwrap()
1840 .unwrap_err();
1841 assert!(overwrite.contains("full current content"), "{overwrite}");
1842
1843 let replace_all = execute_with_ledger(
1844 &substrate,
1845 &ledger,
1846 "edit_file",
1847 &json!({ "path": path, "old_text": "i", "new_text": "I", "replace_all": true }),
1848 )
1849 .await
1850 .unwrap()
1851 .unwrap_err();
1852 assert!(
1853 replace_all.contains("full current content"),
1854 "{replace_all}"
1855 );
1856
1857 let edit = execute_with_ledger(
1859 &substrate,
1860 &ledger,
1861 "edit_file",
1862 &json!({ "path": path, "old_text": "first", "new_text": "FIRST" }),
1863 )
1864 .await
1865 .unwrap()
1866 .unwrap();
1867 assert_eq!(edit["replacements"], 1);
1868
1869 std::fs::remove_dir_all(&dir).ok();
1870 }
1871
1872 #[tokio::test]
1873 async fn guarded_write_refuses_existing_non_utf8_file() {
1874 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1875 let ledger = ReadLedger::new();
1876 let dir = fresh_dir("nonutf8write");
1877 let path = dir.join("f.bin").to_string_lossy().to_string();
1878 std::fs::write(dir.join("f.bin"), [0xff, 0x00, 0xfe]).unwrap();
1879
1880 let err = execute_with_ledger(
1881 &substrate,
1882 &ledger,
1883 "write_file",
1884 &json!({ "path": path, "content": "text" }),
1885 )
1886 .await
1887 .unwrap()
1888 .unwrap_err();
1889 assert!(err.contains("cannot modify existing file"), "{err}");
1890 assert_eq!(
1891 std::fs::read(dir.join("f.bin")).unwrap(),
1892 [0xff, 0x00, 0xfe]
1893 );
1894
1895 std::fs::remove_dir_all(&dir).ok();
1896 }
1897
1898 #[tokio::test]
1902 async fn append_detects_stale_after_external_change() {
1903 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1904 let ledger = ReadLedger::new();
1905 let dir = fresh_dir("appendstale");
1906 let path = dir.join("f.txt").to_string_lossy().to_string();
1907 std::fs::write(dir.join("f.txt"), "v1").unwrap();
1908
1909 execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1910 .await
1911 .unwrap()
1912 .unwrap();
1913 std::fs::write(dir.join("f.txt"), "v2 changed").unwrap();
1914
1915 let err = execute_with_ledger(
1916 &substrate,
1917 &ledger,
1918 "write_file",
1919 &json!({ "path": path, "content": " appended", "append": true }),
1920 )
1921 .await
1922 .unwrap()
1923 .unwrap_err();
1924 assert!(err.contains("changed since you last read it"), "{err}");
1925
1926 std::fs::remove_dir_all(&dir).ok();
1927 }
1928
1929 #[tokio::test]
1933 async fn replace_all_edit_is_gated_by_the_ledger() {
1934 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1935 let ledger = ReadLedger::new();
1936 let dir = fresh_dir("replaceallgate");
1937 let path = dir.join("f.txt").to_string_lossy().to_string();
1938 std::fs::write(dir.join("f.txt"), "a a a").unwrap();
1939
1940 execute_with_ledger(&substrate, &ledger, "read_file", &json!({ "path": path }))
1941 .await
1942 .unwrap()
1943 .unwrap();
1944 let ok = execute_with_ledger(
1945 &substrate,
1946 &ledger,
1947 "edit_file",
1948 &json!({ "path": path, "old_text": "a", "new_text": "b", "replace_all": true }),
1949 )
1950 .await
1951 .unwrap()
1952 .unwrap();
1953 assert_eq!(ok["replacements"], 3);
1954
1955 let ok2 = execute_with_ledger(
1957 &substrate,
1958 &ledger,
1959 "edit_file",
1960 &json!({ "path": path, "old_text": "b", "new_text": "c", "replace_all": true }),
1961 )
1962 .await
1963 .unwrap()
1964 .unwrap();
1965 assert_eq!(ok2["replacements"], 3);
1966
1967 std::fs::write(dir.join("f.txt"), "c c c c").unwrap();
1969 let err = execute_with_ledger(
1970 &substrate,
1971 &ledger,
1972 "edit_file",
1973 &json!({ "path": path, "old_text": "c", "new_text": "d", "replace_all": true }),
1974 )
1975 .await
1976 .unwrap()
1977 .unwrap_err();
1978 assert!(err.contains("changed since you last read it"), "{err}");
1979
1980 std::fs::remove_dir_all(&dir).ok();
1981 }
1982
1983 #[tokio::test]
1988 async fn builtin_names_never_return_unknown_tool_prefix() {
1989 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
1990 let ledger = ReadLedger::new();
1991
1992 for name in [
1993 "read_file",
1994 "write_file",
1995 "edit_file",
1996 "list_dir",
1997 "find_files",
1998 "grep_files",
1999 "calculate",
2000 ] {
2001 let result = execute_with_ledger(&substrate, &ledger, name, &json!({})).await;
2002 let inner = result.unwrap_or_else(|| panic!("{name} must be handled, not None"));
2003 if let Err(e) = &inner {
2004 assert!(
2005 !e.starts_with("unknown tool"),
2006 "{name} error must not start with 'unknown tool': {e}"
2007 );
2008 }
2009 }
2010
2011 assert!(
2013 execute_with_ledger(&substrate, &ledger, "no_such_tool", &json!({}))
2014 .await
2015 .is_none()
2016 );
2017
2018 let dir = fresh_dir("unknownprefix");
2020 let path = dir.join("f.txt").to_string_lossy().to_string();
2021 std::fs::write(dir.join("f.txt"), "content").unwrap();
2022 let edit_err = execute_with_ledger(
2023 &substrate,
2024 &ledger,
2025 "edit_file",
2026 &json!({ "path": path, "old_text": "content", "new_text": "x" }),
2027 )
2028 .await
2029 .unwrap()
2030 .unwrap_err();
2031 assert!(!edit_err.starts_with("unknown tool"), "{edit_err}");
2032 assert!(edit_err.contains("before editing it"), "{edit_err}");
2033 let write_err = execute_with_ledger(
2034 &substrate,
2035 &ledger,
2036 "write_file",
2037 &json!({ "path": path, "content": "y" }),
2038 )
2039 .await
2040 .unwrap()
2041 .unwrap_err();
2042 assert!(!write_err.starts_with("unknown tool"), "{write_err}");
2043
2044 std::fs::remove_dir_all(&dir).ok();
2045 }
2046
2047 #[test]
2050 fn ledger_normalizes_dot_components() {
2051 let ledger = ReadLedger::new();
2052 ledger.record("./src/x.rs", "content", true);
2053 assert_eq!(ledger.check("src/x.rs", "content"), ReadState::FreshFull);
2054 assert_eq!(ledger.check("./src/x.rs", "content"), ReadState::FreshFull);
2055
2056 let ledger2 = ReadLedger::new();
2057 ledger2.record("src/x.rs", "content", true);
2058 assert_eq!(ledger2.check("./src/x.rs", "content"), ReadState::FreshFull);
2059
2060 assert_eq!(ledger.check("other.rs", "content"), ReadState::Unread);
2063 let ledger3 = ReadLedger::new();
2064 ledger3.record("/root/a/./b", "c", true);
2065 assert_eq!(ledger3.check("/root/a/b", "c"), ReadState::FreshFull);
2066 }
2067
2068 #[test]
2069 fn session_ledgers_share_mutation_locks_without_sharing_observations() {
2070 let ledgers = SessionReadLedgers::new();
2071 let first = ledgers.ledger_for(Some("first"));
2072 let second = ledgers.ledger_for(Some("second"));
2073 first.record("f.txt", "first view", true);
2074
2075 assert_eq!(
2076 second.check("f.txt", "first view"),
2077 ReadState::Unread,
2078 "one session's read must not authorize another session"
2079 );
2080 assert!(Arc::ptr_eq(
2081 &first.mutation_lock("f.txt"),
2082 &second.mutation_lock("f.txt")
2083 ));
2084 }
2085
2086 #[tokio::test]
2089 async fn edit_no_match_hints_pasted_line_number() {
2090 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2091 let dir = fresh_dir("linenumhint");
2092 let path = dir.join("f.txt").to_string_lossy().to_string();
2093 exec_write_file(
2094 &substrate,
2095 None,
2096 &json!({ "path": path, "content": "hello world" }),
2097 )
2098 .await
2099 .unwrap();
2100
2101 let err = exec_edit_file(
2102 &substrate,
2103 None,
2104 &json!({ "path": path, "old_text": " 1\thello world", "new_text": "hi" }),
2105 )
2106 .await
2107 .unwrap_err();
2108 assert!(err.contains("line-number prefixes"), "{err}");
2109
2110 let plain = exec_edit_file(
2111 &substrate,
2112 None,
2113 &json!({ "path": path, "old_text": "absent", "new_text": "x" }),
2114 )
2115 .await
2116 .unwrap_err();
2117 assert!(!plain.contains("line-number prefixes"), "{plain}");
2118 assert!(plain.contains("old_text not found"), "{plain}");
2119
2120 std::fs::remove_dir_all(&dir).ok();
2121 }
2122
2123 #[tokio::test]
2124 async fn calculate_is_pure() {
2125 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2126 let out = execute(
2127 &substrate,
2128 "calculate",
2129 &json!({ "expression": "2 + 3 * 4" }),
2130 )
2131 .await
2132 .unwrap()
2133 .unwrap();
2134 assert_eq!(out["result"], 14.0);
2135 }
2136
2137 #[tokio::test]
2138 async fn unknown_tool_returns_none() {
2139 let substrate: Arc<dyn Substrate> = Arc::new(crate::substrate::LocalSubstrate::new());
2140 assert!(execute(&substrate, "nope", &json!({})).await.is_none());
2141 }
2142
2143 fn calc(expr: &str) -> f64 {
2146 exec_calculate(&json!({ "expression": expr }))
2147 .unwrap_or_else(|e| panic!("calculate({expr:?}) failed: {e}"))
2148 .get("result")
2149 .and_then(|v| v.as_f64())
2150 .unwrap_or_else(|| panic!("calculate({expr:?}) returned no numeric result"))
2151 }
2152
2153 #[test]
2160 fn calculate_contract_semantics() {
2161 assert_eq!(calc("2 + 3 * 4"), 14.0, "operator precedence");
2162 assert_eq!(calc("(1 + 2) * 3"), 9.0, "parentheses override precedence");
2163 assert_eq!(calc("2^3"), 8.0, "^ is exponentiation, not XOR");
2164 assert_eq!(calc("2^10"), 1024.0, "^ is exponentiation");
2165 assert_eq!(calc("10 % 3"), 1.0, "modulo");
2166 assert_eq!(calc("-5 + 2"), -3.0, "unary minus");
2167 assert_eq!(calc("sin(0)"), 0.0, "native function: sin");
2169 assert_eq!(calc("abs(-3)"), 3.0, "native function: abs");
2170 assert_eq!(calc("sqrt(16)"), 4.0, "shim function: sqrt");
2172 assert!((calc("ln(e)") - 1.0).abs() < 1e-12, "shim: ln + e constant");
2173 assert!(
2174 (calc("pi") - std::f64::consts::PI).abs() < 1e-12,
2175 "shim: pi constant"
2176 );
2177 }
2178
2179 #[test]
2180 fn calculate_rejects_invalid_expression() {
2181 assert!(exec_calculate(&json!({ "expression": "2 +" })).is_err());
2182 assert!(exec_calculate(&json!({})).is_err(), "missing parameter");
2183 }
2184}