1use super::*;
2
3const READ_DEFAULT_LIMIT: usize = 300;
5const READ_MAX_LIMIT: usize = 2000;
8const READ_MAX_LINE_CHARS: usize = 2000;
10const READ_MAX_LINE_BYTES: usize = 4 * READ_MAX_LINE_CHARS + 4;
13const GREP_MAX_LINES: usize = 200;
15const GREP_MAX_CONTEXT: usize = 10;
17
18const LIST_DEFAULT_LIMIT: usize = 100;
20const LIST_MAX_LIMIT: usize = 1000;
21const LIST_MAX_ENTRIES: usize = 10_000;
22const GLOB_MAX_MATCHES: usize = 10_000;
23
24fn parse_window(call: &ToolCall) -> Result<(usize, usize), String> {
26 let offset = match call.arguments.get("offset") {
27 None => 0usize,
28 Some(raw) => raw
29 .trim()
30 .parse::<usize>()
31 .map_err(|_| format!("'offset' must be a non-negative integer, got {raw:?}"))?,
32 };
33 let limit = match call.arguments.get("limit") {
34 None => LIST_DEFAULT_LIMIT,
35 Some(raw) => match raw.trim().parse::<usize>() {
36 Ok(0) | Err(_) => return Err(format!("'limit' must be at least 1, got {raw:?}")),
37 Ok(v) => v.min(LIST_MAX_LIMIT),
38 },
39 };
40 Ok((offset, limit))
41}
42
43fn unsafe_git_read_arg(argument: &str) -> bool {
46 [
47 "--output",
48 "--ext-diff",
49 "--textconv",
50 "--no-index",
51 "--pathspec-from-file",
52 ]
53 .iter()
54 .any(|forbidden| argument == *forbidden || argument.starts_with(&format!("{forbidden}=")))
55}
56
57fn paged_listing(label: &str, entries: &[String], (offset, limit): (usize, usize)) -> String {
60 let total = entries.len();
61 if total == 0 {
62 return format!("{label}: 0 entries");
63 }
64 if offset >= total {
65 return format!("{label}: {total} entries total; offset {offset} is past the end");
66 }
67 let end = total.min(offset + limit);
68 let mut output = format!("{label}: entries {offset}-{} of {total}\n", end - 1);
69 output.push_str(&entries[offset..end].join("\n"));
70 if end < total {
71 output.push_str(&format!(
72 "\n[{} more entries; continue with offset={end}]",
73 total - end
74 ));
75 }
76 output
77}
78
79fn capped_search_output(stdout: &[u8]) -> String {
81 let text = String::from_utf8_lossy(stdout);
82 let lines: Vec<&str> = text.lines().collect();
83 let mut kept: Vec<String> = lines
84 .iter()
85 .take(GREP_MAX_LINES)
86 .map(|l| l.to_string())
87 .collect();
88 if lines.len() > GREP_MAX_LINES {
89 kept.push(format!(
90 "[{} more lines omitted; narrow the query, pass a file path, or lower context]",
91 lines.len() - GREP_MAX_LINES
92 ));
93 }
94 kept.join("\n")
95}
96
97const GREP_MAX_STDOUT_BYTES: usize = 2 * 1024 * 1024;
101const GREP_MAX_STDERR_BYTES: usize = 64 * 1024;
102const GREP_PROCESS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
103
104#[derive(Debug)]
106struct SearchOutput {
107 success: bool,
110 code: Option<i32>,
111 stdout: Vec<u8>,
112 stderr: Vec<u8>,
113 truncated: bool,
114}
115
116fn drain_bounded(
120 mut pipe: impl std::io::Read,
121 cap: usize,
122 cap_signal: Option<std::sync::mpsc::Sender<()>>,
123) -> std::io::Result<(Vec<u8>, bool)> {
124 let mut retained = Vec::new();
125 let mut truncated = false;
126 let mut buf = [0u8; 64 * 1024];
127 loop {
128 let n = pipe.read(&mut buf)?;
129 if n == 0 {
130 break;
131 }
132 let room = cap.saturating_sub(retained.len());
133 retained.extend_from_slice(&buf[..n.min(room)]);
134 if n > room && !truncated {
135 truncated = true;
136 if let Some(sender) = &cap_signal {
137 let _ = sender.send(());
138 }
139 }
140 }
141 Ok((retained, truncated))
142}
143
144fn bounded_command_output(mut command: Command) -> std::io::Result<SearchOutput> {
148 bounded_command_output_with_timeout(&mut command, GREP_PROCESS_TIMEOUT)
149}
150
151fn bounded_command_output_with_timeout(
152 command: &mut Command,
153 timeout: std::time::Duration,
154) -> std::io::Result<SearchOutput> {
155 let mut child = command
156 .stdout(std::process::Stdio::piped())
157 .stderr(std::process::Stdio::piped())
158 .spawn()?;
159 let stdout_pipe = child.stdout.take().expect("piped stdout");
160 let stderr_pipe = child.stderr.take().expect("piped stderr");
161 let (cap_tx, cap_rx) = std::sync::mpsc::channel();
162 let stdout_thread =
163 std::thread::spawn(move || drain_bounded(stdout_pipe, GREP_MAX_STDOUT_BYTES, Some(cap_tx)));
164 let stderr_thread =
165 std::thread::spawn(move || drain_bounded(stderr_pipe, GREP_MAX_STDERR_BYTES, None));
166
167 let started = std::time::Instant::now();
168 let mut hit_cap = false;
169 let mut timed_out = false;
170 let status = loop {
171 if cap_rx.try_recv().is_ok() {
172 hit_cap = true;
173 let _ = child.kill();
174 break child.wait()?;
175 }
176 if let Some(status) = child.try_wait()? {
177 break status;
178 }
179 if started.elapsed() >= timeout {
180 timed_out = true;
181 let _ = child.kill();
182 break child.wait()?;
183 }
184 std::thread::sleep(std::time::Duration::from_millis(5));
185 };
186 let (stdout, stdout_truncated) = stdout_thread
187 .join()
188 .map_err(|_| std::io::Error::other("search stdout reader panicked"))??;
189 let (stderr, _stderr_truncated) = stderr_thread
190 .join()
191 .map_err(|_| std::io::Error::other("search stderr reader panicked"))??;
192 if timed_out {
193 return Err(std::io::Error::new(
194 std::io::ErrorKind::TimedOut,
195 "search process exceeded 30 second deadline",
196 ));
197 }
198 let truncated = hit_cap || stdout_truncated;
199 Ok(SearchOutput {
200 success: status.success() || truncated,
201 code: status.code(),
202 stdout,
203 stderr,
204 truncated,
205 })
206}
207
208#[cfg(all(test, unix))]
209mod bounded_process_tests {
210 use super::*;
211
212 #[test]
213 fn drains_large_stderr_without_deadlocking() {
214 let mut command = Command::new("sh");
215 let script = concat!(
216 "i=0; while [ $i -lt 100000 ]; do ",
217 "printf 'diagnostic diagnostic diagnostic\\n' 1>&2; ",
218 "i=$((i + 1)); done"
219 );
220 command.args(["-c", script]);
221 let output =
222 bounded_command_output_with_timeout(&mut command, std::time::Duration::from_secs(5))
223 .unwrap();
224 assert!(output.success);
225 assert_eq!(output.stderr.len(), GREP_MAX_STDERR_BYTES);
226 }
227
228 #[test]
229 fn kills_a_search_that_never_finishes() {
230 let mut command = Command::new("sleep");
231 command.arg("5");
232 let error =
233 bounded_command_output_with_timeout(&mut command, std::time::Duration::from_millis(50))
234 .unwrap_err();
235 assert_eq!(error.kind(), std::io::ErrorKind::TimedOut);
236 }
237}
238
239struct ReadWindow {
241 lines: Vec<String>,
242 total: usize,
243 truncated: bool,
244}
245
246fn collect_read_window(
250 path: &std::path::Path,
251 raw_path: &str,
252 offset: usize,
253 limit: usize,
254) -> Result<ReadWindow, String> {
255 let file = fs::File::open(path).map_err(|e| format!("Failed to read {:?}: {}", path, e))?;
256 let mut reader = std::io::BufReader::new(file);
257 let mut window = ReadWindow {
258 lines: Vec::new(),
259 total: 0,
260 truncated: false,
261 };
262 loop {
263 let wanted = window.total + 1 >= offset && window.lines.len() < limit;
264 let cap = if wanted { READ_MAX_LINE_BYTES } else { 0 };
265 let Some((bytes, skipped, saw_nul)) = read_bounded_line(&mut reader, cap)
266 .map_err(|e| format!("Failed to read {:?}: {}", path, e))?
267 else {
268 return Ok(window);
269 };
270 window.total += 1;
271 if saw_nul {
274 return Err(format!("{raw_path} appears to be a binary file"));
275 }
276 if !wanted {
277 continue;
278 }
279 let text = String::from_utf8_lossy(&bytes);
280 let rendered = match char_cap(&text, READ_MAX_LINE_CHARS) {
281 Some(cut) => {
282 window.truncated = true;
283 let more = skipped + (text.len() - cut.len());
284 format!("{cut}…[+{more} bytes on this line]")
285 }
286 None if skipped > 0 => {
287 window.truncated = true;
288 format!("{text}…[+{skipped} bytes on this line]")
289 }
290 None => text.into_owned(),
291 };
292 window
293 .lines
294 .push(format!("{:>6}\t{rendered}", window.total));
295 }
296}
297
298fn read_bounded_line(
305 reader: &mut impl std::io::BufRead,
306 cap: usize,
307) -> std::io::Result<Option<(Vec<u8>, usize, bool)>> {
308 let mut kept = Vec::new();
309 let mut skipped = 0usize;
310 let mut saw_any = false;
311 let mut saw_nul = false;
312 loop {
313 let buf = reader.fill_buf()?;
314 if buf.is_empty() {
315 return Ok(saw_any.then_some((kept, skipped, saw_nul)));
316 }
317 saw_any = true;
318 let newline = buf.iter().position(|&byte| byte == b'\n');
319 let take = newline.unwrap_or(buf.len());
320 saw_nul = saw_nul || buf[..take].contains(&0);
321 let keep = take.min(cap.saturating_sub(kept.len()));
322 kept.extend_from_slice(&buf[..keep]);
323 skipped += take - keep;
324 reader.consume(take + usize::from(newline.is_some()));
325 if newline.is_some() {
326 if skipped == 0 && kept.last() == Some(&b'\r') {
327 kept.pop();
328 }
329 return Ok(Some((kept, skipped, saw_nul)));
330 }
331 }
332}
333
334fn char_cap(text: &str, cap: usize) -> Option<&str> {
336 let mut indices = text.char_indices();
337 match indices.nth(cap) {
338 Some((byte_index, _)) => Some(&text[..byte_index]),
339 None => None,
340 }
341}
342
343fn parse_line_argument(call: &ToolCall, name: &str, default: usize) -> Result<usize, String> {
345 match call.arguments.get(name) {
346 None => Ok(default),
347 Some(raw) => match raw.trim().parse::<usize>() {
348 Ok(0) => Err(format!("'{name}' must be at least 1")),
349 Ok(v) => Ok(v),
350 Err(_) => Err(format!("'{name}' must be a positive integer, got {raw:?}")),
351 },
352 }
353}
354
355#[derive(Debug, Clone)]
357pub struct ToolResult {
358 pub tool_name: String,
359 pub success: bool,
360 pub output: String,
361 pub error: Option<String>,
362}
363
364impl ToolResult {
365 pub fn success(tool_name: &str, output: String) -> Self {
366 Self {
367 tool_name: tool_name.to_string(),
368 success: true,
369 output,
370 error: None,
371 }
372 }
373
374 pub fn failure(tool_name: &str, error: String) -> Self {
375 Self {
376 tool_name: tool_name.to_string(),
377 success: false,
378 output: String::new(),
379 error: Some(error),
380 }
381 }
382}
383
384#[derive(Debug, Clone)]
386pub struct ToolCall {
387 pub name: String,
388 pub arguments: HashMap<String, String>,
389}
390
391pub struct AgentTools {
393 working_dir: PathBuf,
395}
396
397impl AgentTools {
398 pub fn new(working_dir: PathBuf) -> Self {
400 Self { working_dir }
401 }
402 pub(crate) fn read_file(&self, call: &ToolCall) -> ToolResult {
408 let Some(raw_path) = call.arguments.get("path") else {
409 return ToolResult::failure("read_file", "Missing 'path' argument".to_string());
410 };
411 let path = self.resolve_path(raw_path);
412 let offset = match parse_line_argument(call, "offset", 1) {
413 Ok(v) => v,
414 Err(e) => return ToolResult::failure("read_file", e),
415 };
416 let limit = match parse_line_argument(call, "limit", READ_DEFAULT_LIMIT) {
417 Ok(v) => v,
418 Err(e) => return ToolResult::failure("read_file", e),
419 };
420
421 let limit = limit.min(READ_MAX_LIMIT);
422 let ReadWindow {
423 lines: window,
424 total,
425 truncated: any_truncated,
426 } = match collect_read_window(&path, raw_path, offset, limit) {
427 Ok(window) => window,
428 Err(message) => return ToolResult::failure("read_file", message),
429 };
430 if offset > total {
431 return ToolResult::success(
432 "read_file",
433 format!("file {raw_path}: {total} lines total; offset {offset} is past the end"),
434 );
435 }
436 let end = offset - 1 + window.len();
437 let mut output = format!("file {raw_path}: lines {offset}-{end} of {total}\n");
438 output.push_str(&window.join("\n"));
439 output.push('\n');
440 if any_truncated {
441 output.push_str("[overlong lines truncated; grep returns full lines]\n");
442 }
443 if end < total {
444 output.push_str(&format!(
445 "[{} more lines; continue with offset={}]",
446 total - end,
447 end + 1
448 ));
449 }
450 ToolResult::success("read_file", output)
451 }
452
453 pub(crate) fn search_code(&self, call: &ToolCall) -> ToolResult {
459 let query = match call.arguments.get("query") {
460 Some(q) => q,
461 None => {
462 return ToolResult::failure("search_code", "Missing 'query' argument".to_string())
463 }
464 };
465 let target = call
466 .arguments
467 .get("path")
468 .cloned()
469 .unwrap_or_else(|| ".".to_string());
470 if !self.resolve_path(&target).exists() {
471 return ToolResult::failure(
472 "search_code",
473 format!("Search target does not exist: {target}"),
474 );
475 }
476 let context = match call.arguments.get("context") {
477 None => 0usize,
478 Some(raw) => match raw.trim().parse::<usize>() {
479 Ok(n) => n.min(GREP_MAX_CONTEXT),
480 Err(_) => {
481 return ToolResult::failure(
482 "search_code",
483 format!("'context' must be a non-negative integer, got {raw:?}"),
484 )
485 }
486 },
487 };
488 match self.spawn_search(query, &target, context) {
489 Ok(out) if out.code == Some(1) && out.stdout.is_empty() => ToolResult::success(
490 "search_code",
491 format!("no matches for {query:?} in {target}"),
492 ),
493 Ok(out) if out.success || out.code == Some(1) => {
494 let mut rendered = capped_search_output(&out.stdout);
495 if out.truncated {
496 rendered.push_str("\n[search output truncated; narrow the query]");
497 }
498 ToolResult::success("search_code", rendered)
499 }
500 Ok(out) => ToolResult::failure(
501 "search_code",
502 format!("Search failed: {}", String::from_utf8_lossy(&out.stderr)),
503 ),
504 Err(e) => ToolResult::failure("search_code", format!("Search failed: {}", e)),
505 }
506 }
507
508 fn spawn_search(
513 &self,
514 query: &str,
515 target: &str,
516 context: usize,
517 ) -> std::io::Result<SearchOutput> {
518 let context_arg = format!("-C{context}");
519 let mut rg_args = vec!["-n", "--no-heading", "--color", "never", "-H"];
520 if context > 0 {
521 rg_args.push(&context_arg);
522 }
523 rg_args.extend(["--max-count", "50", "--", query, target]);
524 let mut rg = Command::new("rg");
525 rg.args(&rg_args).current_dir(&self.working_dir);
526 bounded_command_output(rg).or_else(|error| {
530 if error.kind() != std::io::ErrorKind::NotFound {
531 return Err(error);
532 }
533 let mut grep_args = vec!["-Hrn"];
537 if context > 0 {
538 grep_args.push(&context_arg);
539 }
540 grep_args.extend(["--", query, target]);
541 let mut grep = Command::new("grep");
542 grep.args(&grep_args).current_dir(&self.working_dir);
543 bounded_command_output(grep)
544 })
545 }
546 pub(crate) fn apply_diff(&self, call: &ToolCall) -> ToolResult {
548 let path = match call.arguments.get("path") {
549 Some(p) => self.resolve_path(p),
550 None => {
551 return ToolResult::failure("apply_diff", "Missing 'path' argument".to_string())
552 }
553 };
554
555 let diff_content = match call.arguments.get("diff") {
556 Some(c) => c,
557 None => {
558 return ToolResult::failure("apply_diff", "Missing 'diff' argument".to_string())
559 }
560 };
561
562 let original = match fs::read_to_string(&path) {
564 Ok(c) => c,
565 Err(e) => {
566 return ToolResult::failure(
569 "apply_diff",
570 format!("Failed to read base file {:?}: {}", path, e),
571 );
572 }
573 };
574
575 let patch = match Patch::from_str(diff_content) {
577 Ok(p) => p,
578 Err(e) => {
579 return ToolResult::failure("apply_diff", format!("Failed to parse diff: {}", e));
580 }
581 };
582
583 match apply(&original, &patch) {
585 Ok(patched) => match fs::write(&path, patched) {
586 Ok(_) => {
587 ToolResult::success("apply_diff", format!("Successfully patched {:?}", path))
588 }
589 Err(e) => ToolResult::failure(
590 "apply_diff",
591 format!("Failed to write patched file: {}", e),
592 ),
593 },
594 Err(e) => ToolResult::failure("apply_diff", format!("Failed to apply patch: {}", e)),
595 }
596 }
597
598 pub(crate) fn edit_file(&self, call: &ToolCall) -> ToolResult {
604 let (path, old, new) = match (
605 call.arguments.get("path"),
606 call.arguments.get("old_string"),
607 call.arguments.get("new_string"),
608 ) {
609 (Some(p), Some(o), Some(n)) => (self.resolve_path(p), o, n),
610 _ => {
611 return ToolResult::failure(
612 "edit_file",
613 "Missing required arguments: path, old_string, new_string".to_string(),
614 )
615 }
616 };
617 let content = match fs::read_to_string(&path) {
618 Ok(c) => c,
619 Err(e) => {
620 return ToolResult::failure("edit_file", format!("Failed to read {path:?}: {e}"))
621 }
622 };
623 match content.matches(old.as_str()).count() {
624 0 => ToolResult::failure(
625 "edit_file",
626 "old_string not found; re-read the file — it may have changed".to_string(),
627 ),
628 1 => {
629 let updated = content.replacen(old.as_str(), new, 1);
630 match fs::write(&path, updated) {
631 Ok(_) => ToolResult::success("edit_file", format!("Edited {path:?}")),
632 Err(e) => {
633 ToolResult::failure("edit_file", format!("Failed to write {path:?}: {e}"))
634 }
635 }
636 }
637 n => ToolResult::failure(
638 "edit_file",
639 format!("old_string matches {n} locations; provide a unique anchor"),
640 ),
641 }
642 }
643
644 pub(crate) fn glob(&self, call: &ToolCall) -> ToolResult {
646 let Some(pattern) = call.arguments.get("pattern") else {
647 return ToolResult::failure("glob", "Missing 'pattern' argument".to_string());
648 };
649 let window = match parse_window(call) {
650 Ok(w) => w,
651 Err(e) => return ToolResult::failure("glob", e),
652 };
653 let (mut matches, total) = collect_glob_matches(&self.working_dir, pattern);
654 matches.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| left.1.cmp(&right.1)));
655 let retained = matches.len();
656 let listing: Vec<String> = matches.into_iter().map(|(_, p)| p).collect();
657 let label = if total > retained {
658 format!("glob {pattern} (newest {retained} of {total} matches)")
659 } else {
660 format!("glob {pattern}")
661 };
662 ToolResult::success("glob", paged_listing(&label, &listing, window))
663 }
664
665 pub(crate) fn git_read(&self, call: &ToolCall) -> ToolResult {
667 let Some(subcommand) = call.arguments.get("subcommand") else {
668 return ToolResult::failure("git_read", "Missing 'subcommand' argument".to_string());
669 };
670 if !matches!(subcommand.as_str(), "status" | "diff" | "log" | "show") {
671 return ToolResult::failure(
672 "git_read",
673 format!(
674 "Subcommand {subcommand:?} is not read-only; allowed: status, diff, log, show"
675 ),
676 );
677 }
678 let mut args = vec![subcommand.clone()];
679 if let Some(extra) = call.arguments.get("args") {
680 args.extend(extra.split_whitespace().map(str::to_string));
681 }
682 if let Some(argument) = args.iter().find(|argument| unsafe_git_read_arg(argument)) {
683 return ToolResult::failure(
684 "git_read",
685 format!("Git argument {argument:?} is not permitted for read-only inspection"),
686 );
687 }
688 if subcommand != "status" {
689 args.splice(1..1, ["--no-ext-diff".into(), "--no-textconv".into()]);
690 }
691 let mut command = Command::new("git");
692 command
693 .args(&args)
694 .current_dir(&self.working_dir)
695 .env("GIT_PAGER", "cat")
696 .env("PAGER", "cat")
697 .env_remove("GIT_EXTERNAL_DIFF");
698 match bounded_command_output(command) {
699 Ok(out) if out.success => {
700 let mut rendered = String::from_utf8_lossy(&out.stdout).to_string();
701 if out.truncated {
702 rendered.push_str("\n[git output truncated; narrow the revision or path]");
703 }
704 ToolResult::success("git_read", rendered)
705 }
706 Ok(out) => {
707 ToolResult::failure("git_read", String::from_utf8_lossy(&out.stderr).to_string())
708 }
709 Err(e) if e.kind() == std::io::ErrorKind::TimedOut => {
710 ToolResult::failure("git_read", format!("git timed out: {e}"))
711 }
712 Err(e) => ToolResult::failure("git_read", format!("git failed to start: {e}")),
713 }
714 }
715 pub(crate) fn list_files(&self, call: &ToolCall) -> ToolResult {
717 let label = call
718 .arguments
719 .get("path")
720 .cloned()
721 .unwrap_or_else(|| ".".to_string());
722 let path = call
723 .arguments
724 .get("path")
725 .map(|p| self.resolve_path(p))
726 .unwrap_or_else(|| self.working_dir.clone());
727 let window = match parse_window(call) {
728 Ok(w) => w,
729 Err(e) => return ToolResult::failure("list_files", e),
730 };
731 if !path.is_dir() {
732 return ToolResult::failure(
733 "list_files",
734 format!("Failed to list {:?}: not a directory", path),
735 );
736 }
737
738 let mut files = std::collections::BTreeSet::new();
739 let mut total = 0usize;
740 for name in ignore::WalkBuilder::new(&path)
741 .max_depth(Some(1))
742 .standard_filters(true)
743 .require_git(false)
744 .build()
745 .filter_map(|e| e.ok())
746 .filter(|e| e.depth() > 0)
747 .map(|e| {
748 let name = e.file_name().to_string_lossy().to_string();
749 if e.file_type().map(|t| t.is_dir()).unwrap_or(false) {
750 format!("{}/", name)
751 } else {
752 name
753 }
754 })
755 {
756 total = total.saturating_add(1);
757 files.insert(name);
758 if files.len() > LIST_MAX_ENTRIES {
759 files.pop_last();
760 }
761 }
762 let retained = files.len();
763 let files: Vec<String> = files.into_iter().collect();
764 let label = if total > retained {
765 format!("list_files {label} (first {retained} of {total} entries)")
766 } else {
767 format!("list_files {label}")
768 };
769 ToolResult::success("list_files", paged_listing(&label, &files, window))
770 }
771
772 pub(crate) fn write_file(&self, call: &ToolCall) -> ToolResult {
774 let path = match call.arguments.get("path") {
775 Some(p) => self.resolve_path(p),
776 None => {
777 return ToolResult::failure("write_file", "Missing 'path' argument".to_string())
778 }
779 };
780 let content = match call.arguments.get("content") {
781 Some(c) => c,
782 None => {
783 return ToolResult::failure("write_file", "Missing 'content' argument".to_string())
784 }
785 };
786 if let Some(parent) = path.parent() {
787 if let Err(e) = fs::create_dir_all(parent) {
788 return ToolResult::failure(
789 "write_file",
790 format!("Failed to create directories: {}", e),
791 );
792 }
793 }
794 match fs::write(&path, content) {
795 Ok(_) => ToolResult::success("write_file", format!("Successfully wrote {:?}", path)),
796 Err(e) => {
797 ToolResult::failure("write_file", format!("Failed to write {:?}: {}", path, e))
798 }
799 }
800 }
801
802 pub(crate) fn delete_file(&self, call: &ToolCall) -> ToolResult {
804 let path = match call.arguments.get("path") {
805 Some(p) => self.resolve_path(p),
806 None => {
807 return ToolResult::failure("delete_file", "Missing 'path' argument".to_string())
808 }
809 };
810
811 if !path.exists() {
812 return ToolResult::success(
813 "delete_file",
814 format!("Path does not exist, nothing to delete: {:?}", path),
815 );
816 }
817
818 if path.is_dir() {
819 return ToolResult::failure(
820 "delete_file",
821 format!(
822 "Cannot delete directory {:?}; only files are supported",
823 path
824 ),
825 );
826 }
827
828 match std::fs::remove_file(&path) {
829 Ok(()) => ToolResult::success("delete_file", format!("Deleted {:?}", path)),
830 Err(e) => {
831 ToolResult::failure("delete_file", format!("Failed to delete {:?}: {}", path, e))
832 }
833 }
834 }
835
836 pub(crate) fn move_file(&self, call: &ToolCall) -> ToolResult {
838 let from = match call
839 .arguments
840 .get("path")
841 .or_else(|| call.arguments.get("from"))
842 {
843 Some(p) => self.resolve_path(p),
844 None => return ToolResult::failure("move_file", "Missing 'path' argument".to_string()),
845 };
846 let to = match call.arguments.get("to") {
847 Some(p) => self.resolve_path(p),
848 None => return ToolResult::failure("move_file", "Missing 'to' argument".to_string()),
849 };
850
851 if !from.exists() {
852 return ToolResult::failure(
853 "move_file",
854 format!("Source path does not exist: {:?}", from),
855 );
856 }
857
858 if let Some(parent) = to.parent() {
860 if !parent.exists() {
861 if let Err(e) = std::fs::create_dir_all(parent) {
862 return ToolResult::failure(
863 "move_file",
864 format!("Failed to create destination directory {:?}: {}", parent, e),
865 );
866 }
867 }
868 }
869
870 match std::fs::rename(&from, &to) {
871 Ok(()) => ToolResult::success("move_file", format!("Moved {:?} -> {:?}", from, to)),
872 Err(e) => ToolResult::failure(
873 "move_file",
874 format!("Failed to move {:?} -> {:?}: {}", from, to, e),
875 ),
876 }
877 }
878
879 fn resolve_path(&self, path: &str) -> PathBuf {
881 let p = Path::new(path);
882 if p.is_absolute() {
883 p.to_path_buf()
884 } else {
885 self.working_dir.join(p)
886 }
887 }
888
889 }
893fn collect_glob_matches(
897 root: &Path,
898 pattern: &str,
899) -> (Vec<(std::time::SystemTime, String)>, usize) {
900 let mut newest: std::collections::BinaryHeap<
901 std::cmp::Reverse<(std::time::SystemTime, String)>,
902 > = std::collections::BinaryHeap::new();
903 let mut total = 0usize;
904 for entry in ignore::WalkBuilder::new(root)
905 .standard_filters(true)
906 .require_git(false)
907 .build()
908 .flatten()
909 .filter(|entry| entry.file_type().is_some_and(|kind| kind.is_file()))
910 {
911 let Ok(relative) = entry.path().strip_prefix(root) else {
912 continue;
913 };
914 let relative = relative.to_string_lossy().replace('\\', "/");
915 if !perspt_core::types::glob_matches(pattern, &relative) {
916 continue;
917 }
918 total = total.saturating_add(1);
919 let modified = entry
920 .metadata()
921 .ok()
922 .and_then(|metadata| metadata.modified().ok())
923 .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
924 newest.push(std::cmp::Reverse((modified, relative)));
925 if newest.len() > GLOB_MAX_MATCHES {
926 newest.pop();
927 }
928 }
929 (newest.into_iter().map(|entry| entry.0).collect(), total)
930}