1 //! Agent tools: schema definitions + execution for siGit Code.
2
3 use regex::Regex;
4 use serde_json::{Value, json};
5 use std::fs;
6 use std::path::{Path, PathBuf};
7 use std::process::Command;
8
9 const WEBSITE_READ_CHAR_LIMIT: usize = 20_000;
10 const WEBSITE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
11 const WEBSITE_USER_AGENT: &str =
12 "siGit/0.1 (+https://github.com/getsigit/sigit; website-reading tool)";
13
14 const READ_FILE_CHAR_LIMIT: usize = 10_000;
15 const SEARCH_FILES_MATCH_LIMIT: usize = 50;
16 /// Upper bound on `max_results` for `search_files` and the number of paths
17 /// returned by `glob`, so a broad pattern can't flood the context window.
18 const SEARCH_RESULTS_HARD_CAP: usize = 1_000;
19
20 // ── Tool schemas ─────────────────────────────────────────────────────────────
21
22 pub struct AgentTool {
23 pub name: &'static str,
24 pub description: &'static str,
25 pub parameters_schema: Value,
26 }
27
28 pub fn all_tools() -> Vec<AgentTool> {
29 vec![
30 AgentTool {
31 name: "read_file",
32 description: "Read the contents of a file at the given path. \
33 Prefer an absolute path when possible. Use start_line and \
34 end_line to read a specific range instead of the whole file — \
35 strongly prefer this when you already know which lines matter. \
36 Output is truncated to 10 000 characters.",
37 parameters_schema: json!({
38 "type": "object",
39 "properties": {
40 "path": {
41 "type": "string",
42 "description": "Absolute or relative path to the file to read."
43 },
44 "start_line": {
45 "type": "integer",
46 "description": "First line to read (1-based, inclusive). Omit to start from the beginning."
47 },
48 "end_line": {
49 "type": "integer",
50 "description": "Last line to read (1-based, inclusive). Omit to read to the end."
51 }
52 },
53 "required": ["path"],
54 "additionalProperties": false
55 }),
56 },
57 AgentTool {
58 name: "create_directory",
59 description: "Create a directory at the given path. \
60 Prefer an absolute path when possible. Missing parent \
61 directories are created automatically. Use this before \
62 create_file when the parent path does not exist. Succeeds \
63 if the directory already exists.",
64 parameters_schema: json!({
65 "type": "object",
66 "properties": {
67 "path": {
68 "type": "string",
69 "description": "Absolute or relative path to the directory to create."
70 }
71 },
72 "required": ["path"],
73 "additionalProperties": false
74 }),
75 },
76 AgentTool {
77 name: "list_directory",
78 description: "List files and directories at the given path. \
79 Prefer an absolute path when possible. Each entry is \
80 prefixed with [DIR] or [FILE]. Directories are listed \
81 first, sorted alphabetically.",
82 parameters_schema: json!({
83 "type": "object",
84 "properties": {
85 "path": {
86 "type": "string",
87 "description": "Absolute or relative path to the directory to list."
88 }
89 },
90 "required": ["path"],
91 "additionalProperties": false
92 }),
93 },
94 AgentTool {
95 name: "search_files",
96 description: "Search for a regex pattern across files in a directory tree. \
97 Prefer an absolute root path when possible. Returns matching \
98 lines in `file:line_number: content` format. Skips binary \
99 files and hidden directories. Pass `file_glob` to restrict the \
100 search to files whose name matches a glob (e.g. \"*.rs\"), and \
101 `max_results` to raise or lower the default cap of 50 matches.",
102 parameters_schema: json!({
103 "type": "object",
104 "properties": {
105 "pattern": {
106 "type": "string",
107 "description": "Regular expression pattern to search for."
108 },
109 "path": {
110 "type": "string",
111 "description": "Root directory to search in. Defaults to \".\" (current directory)."
112 },
113 "file_glob": {
114 "type": "string",
115 "description": "Optional glob on the file name (not the full path), e.g. \"*.rs\" or \"*.{ts,tsx}\". Only matching files are searched."
116 },
117 "max_results": {
118 "type": "integer",
119 "description": "Maximum number of matching lines to return (default 50, capped at 1000)."
120 }
121 },
122 "required": ["pattern"],
123 "additionalProperties": false
124 }),
125 },
126 AgentTool {
127 name: "read_website",
128 description: "Fetch a web page and return readable text content. \
129 Use this when the user gives you a URL and asks you to read, \
130 summarize, inspect, or extract information from the page. \
131 Supports normal http and https URLs. Output is truncated if the \
132 page is very large.",
133 parameters_schema: json!({
134 "type": "object",
135 "properties": {
136 "url": {
137 "type": "string",
138 "description": "Absolute http or https URL to fetch."
139 }
140 },
141 "required": ["url"],
142 "additionalProperties": false
143 }),
144 },
145 AgentTool {
146 name: "create_file",
147 description: "Create a new file at the given path with the provided content. \
148 Prefer an absolute path when possible. Parent directories are \
149 created automatically if they do not exist. Fails if the file \
150 already exists — use edit_file to modify existing files.",
151 parameters_schema: json!({
152 "type": "object",
153 "properties": {
154 "path": {
155 "type": "string",
156 "description": "Absolute or relative path for the new file."
157 },
158 "content": {
159 "type": "string",
160 "description": "The full text content to write into the new file."
161 }
162 },
163 "required": ["path", "content"],
164 "additionalProperties": false
165 }),
166 },
167 AgentTool {
168 name: "edit_file",
169 description: "Edit an existing file by replacing an exact substring (old_text) with \
170 new text (new_text). Prefer an absolute path when possible. By \
171 default old_text must appear exactly once; set replace_all to true \
172 to replace every occurrence (useful for renaming a symbol). Use \
173 read_file first to see the current content and identify the exact \
174 text to replace. To append to a file, match the last few lines as \
175 old_text and include them plus the new content as new_text.",
176 parameters_schema: json!({
177 "type": "object",
178 "properties": {
179 "path": {
180 "type": "string",
181 "description": "Path to the existing file to edit."
182 },
183 "old_text": {
184 "type": "string",
185 "description": "The exact text span to find and replace. Must match exactly once unless replace_all is true."
186 },
187 "new_text": {
188 "type": "string",
189 "description": "The replacement text that will take the place of old_text."
190 },
191 "replace_all": {
192 "type": "boolean",
193 "description": "Replace every occurrence of old_text instead of requiring a unique match. Defaults to false."
194 }
195 },
196 "required": ["path", "old_text", "new_text"],
197 "additionalProperties": false
198 }),
199 },
200 AgentTool {
201 name: "delete_file",
202 description: "Delete a file or empty directory at the given path. \
203 Prefer an absolute path when possible. Refuses to delete \
204 non-empty directories to prevent accidental data loss. \
205 Use read_file or list_directory first to confirm the target.",
206 parameters_schema: json!({
207 "type": "object",
208 "properties": {
209 "path": {
210 "type": "string",
211 "description": "Absolute or relative path to the file or empty directory to delete."
212 }
213 },
214 "required": ["path"],
215 "additionalProperties": false
216 }),
217 },
218 AgentTool {
219 name: "run_command",
220 description: "Run a shell command and return its combined stdout and stderr output. \
221 The command runs in the given working directory (defaults to the \
222 user's home directory). Always use an absolute working directory \
223 path. Use this for build tools (cargo, npm, make), package managers, \
224 linters, test runners, and git commands, including git init, \
225 porcelain commands like status/add/commit/checkout, and plumbing \
226 commands like rev-parse, hash-object, update-ref, and cat-file. \
227 For `git clone`, always specify the full absolute destination path \
228 as the last argument (e.g. `git clone <url> /absolute/path/to/dir`) \
229 and set cwd to the parent directory. Never run `git clone` without \
230 an explicit destination. If the user asks for a new repo or scaffold, \
231 use this for `git clone`, `git init`, and normal repo setup steps. \
232 In smbCloud repos, prefer existing workspace commands, Rails \
233 conventions, and deploy flows over inventing new command sequences. \
234 Commands that run indefinitely (servers, watchers) will be killed \
235 after 120 seconds.",
236 parameters_schema: json!({
237 "type": "object",
238 "properties": {
239 "command": {
240 "type": "string",
241 "description": "The shell command to execute (e.g. \"cargo update\", \"git status\", \"git rev-parse HEAD\")."
242 },
243 "cwd": {
244 "type": "string",
245 "description": "Working directory for the command. Defaults to \".\" (current directory)."
246 }
247 },
248 "required": ["command"],
249 "additionalProperties": false
250 }),
251 },
252 AgentTool {
253 name: "multi_edit",
254 description: "Apply several exact-substring edits to a single file in one call. \
255 Edits are applied in order, each to the result of the previous one, \
256 and the whole batch is atomic — if any edit fails to match, the file \
257 is left untouched and an error explains which edit failed. Prefer \
258 this over multiple edit_file calls when changing several spots in the \
259 same file. Each edit has old_text (must match exactly once, or every \
260 time when replace_all is true) and new_text.",
261 parameters_schema: json!({
262 "type": "object",
263 "properties": {
264 "path": {
265 "type": "string",
266 "description": "Path to the existing file to edit."
267 },
268 "edits": {
269 "type": "array",
270 "description": "Ordered list of edits to apply to the file.",
271 "items": {
272 "type": "object",
273 "properties": {
274 "old_text": {
275 "type": "string",
276 "description": "The exact text span to find and replace."
277 },
278 "new_text": {
279 "type": "string",
280 "description": "The replacement text."
281 },
282 "replace_all": {
283 "type": "boolean",
284 "description": "Replace every occurrence instead of requiring a unique match. Defaults to false."
285 }
286 },
287 "required": ["old_text", "new_text"],
288 "additionalProperties": false
289 }
290 }
291 },
292 "required": ["path", "edits"],
293 "additionalProperties": false
294 }),
295 },
296 AgentTool {
297 name: "glob",
298 description: "Find files by name using a glob pattern (e.g. \"**/*.rs\", \
299 \"src/**/*.{ts,tsx}\", \"Cargo.toml\"). Returns matching file paths, \
300 most-recently-modified first. Supports `*` (any run of non-separator \
301 characters), `**` (any number of directories), `?` (one character), \
302 and `{a,b}` alternation. Use this to locate files by name; use \
303 search_files to search file contents.",
304 parameters_schema: json!({
305 "type": "object",
306 "properties": {
307 "pattern": {
308 "type": "string",
309 "description": "Glob pattern matched against paths relative to the search root."
310 },
311 "path": {
312 "type": "string",
313 "description": "Root directory to search in. Defaults to \".\" (current directory)."
314 }
315 },
316 "required": ["pattern"],
317 "additionalProperties": false
318 }),
319 },
320 AgentTool {
321 name: "write_todos",
322 description: "Record or update a checklist of the steps for the current task. \
323 Use this for any multi-step task to plan the work and show progress: \
324 call it once up front with all the steps as `pending`, then call it \
325 again whenever a step's status changes. Mark exactly one step \
326 `in_progress` at a time and `completed` as soon as it is done. \
327 Keep the list short and outcome-focused.",
328 parameters_schema: json!({
329 "type": "object",
330 "properties": {
331 "todos": {
332 "type": "array",
333 "description": "The full, current checklist (replaces any previous list).",
334 "items": {
335 "type": "object",
336 "properties": {
337 "content": {
338 "type": "string",
339 "description": "Short imperative description of the step."
340 },
341 "status": {
342 "type": "string",
343 "enum": ["pending", "in_progress", "completed"],
344 "description": "Current status of the step."
345 }
346 },
347 "required": ["content", "status"],
348 "additionalProperties": false
349 }
350 }
351 },
352 "required": ["todos"],
353 "additionalProperties": false
354 }),
355 },
356 AgentTool {
357 name: "remember",
358 description: "Persist a durable note, preference, or convention by appending it to \
359 this project's instruction file (AGENTS.md / CLAUDE.md). Use this \
360 when the user asks you to remember something for next time, or states \
361 a lasting preference about how to work in this project. The note is \
362 written to the nearest existing instruction file, or a new CLAUDE.md \
363 at the repository root if none exists yet.",
364 parameters_schema: json!({
365 "type": "object",
366 "properties": {
367 "note": {
368 "type": "string",
369 "description": "The fact or preference to remember, phrased as a standalone instruction."
370 }
371 },
372 "required": ["note"],
373 "additionalProperties": false
374 }),
375 },
376 ]
377 }
378
379 // ── Tool execution ───────────────────────────────────────────────────────────
380
381 pub async fn execute_tool(name: &str, arguments: &str) -> String {
382 match name {
383 "read_file" => exec_read_file(arguments),
384 "list_directory" => exec_list_directory(arguments),
385 "search_files" => exec_search_files(arguments),
386 "read_website" => {
387 // reqwest::blocking panics inside a tokio runtime, so run on the blocking pool.
388 let args = arguments.to_owned();
389 tokio::task::spawn_blocking(move || exec_read_website(&args))
390 .await
391 .unwrap_or_else(|err| format!("Error: read_website task failed: {err}"))
392 }
393 "create_directory" => exec_create_directory(arguments),
394 "create_file" => exec_create_file(arguments),
395 "edit_file" => exec_edit_file(arguments),
396 "multi_edit" => exec_multi_edit(arguments),
397 "glob" => exec_glob(arguments),
398 "write_todos" => exec_write_todos(arguments),
399 "remember" => exec_remember(arguments),
400 "delete_file" => exec_delete_file(arguments),
401 "run_command" => exec_run_command(arguments),
402 "skill" => crate::skills::activate_skill(arguments),
403 _ => format!("Unknown tool: {name}"),
404 }
405 }
406
407 fn absolute_path(path: &Path) -> PathBuf {
408 if path.is_absolute() {
409 path.to_path_buf()
410 } else {
411 std::env::current_dir()
412 .unwrap_or_else(|_| PathBuf::from("."))
413 .join(path)
414 }
415 }
416
417 fn absolute_path_string(path: &Path) -> String {
418 absolute_path(path).display().to_string()
419 }
420
421 // ── read_file ────────────────────────────────────────────────────────────────
422
423 fn exec_read_file(arguments: &str) -> String {
424 let args: Value = match serde_json::from_str(arguments) {
425 Ok(v) => v,
426 Err(err) => return format!("Error: failed to parse arguments: {err}"),
427 };
428
429 let path_str = match args.get("path").and_then(Value::as_str) {
430 Some(p) => p,
431 None => return "Error: missing required parameter \"path\"".to_string(),
432 };
433
434 let start_line = args
435 .get("start_line")
436 .and_then(Value::as_u64)
437 .map(|n| n as usize);
438 let end_line = args
439 .get("end_line")
440 .and_then(Value::as_u64)
441 .map(|n| n as usize);
442
443 let path = Path::new(path_str);
444 let absolute_path = absolute_path(path);
445 let absolute_path_str = absolute_path.display().to_string();
446
447 if !absolute_path.exists() {
448 return format!("Error: path does not exist: {absolute_path_str}");
449 }
450
451 if !absolute_path.is_file() {
452 return format!("Error: path is not a file: {absolute_path_str}");
453 }
454
455 match fs::read_to_string(&absolute_path) {
456 Ok(contents) => {
457 if start_line.is_some() || end_line.is_some() {
458 let lines: Vec<&str> = contents.lines().collect();
459 let total = lines.len();
460 let start = start_line.unwrap_or(1).max(1);
461 let end = end_line.unwrap_or(total).min(total);
462
463 if start > total {
464 return format!(
465 "Error: start_line {start} is beyond end of file ({total} lines)"
466 );
467 }
468
469 let selected: Vec<&str> = lines[(start - 1)..end].to_vec();
470 let range_text = selected.join("\n");
471 format!("Lines {start}-{end} of {total} in {absolute_path_str}:\n{range_text}")
472 } else if contents.len() > READ_FILE_CHAR_LIMIT {
473 let truncated: String = contents.chars().take(READ_FILE_CHAR_LIMIT).collect();
474 format!(
475 "{truncated}\n\n--- truncated (showing {READ_FILE_CHAR_LIMIT} of {} characters) ---",
476 contents.len()
477 )
478 } else {
479 contents
480 }
481 }
482 Err(err) => format!("Error: could not read file: {err}"),
483 }
484 }
485
486 // ── list_directory ───────────────────────────────────────────────────────────
487
488 fn exec_list_directory(arguments: &str) -> String {
489 let args: Value = match serde_json::from_str(arguments) {
490 Ok(v) => v,
491 Err(err) => return format!("Error: failed to parse arguments: {err}"),
492 };
493
494 let path_str = match args.get("path").and_then(Value::as_str) {
495 Some(p) => p,
496 None => return "Error: missing required parameter \"path\"".to_string(),
497 };
498
499 let path = Path::new(path_str);
500 let absolute_path = absolute_path(path);
501 let absolute_path_str = absolute_path.display().to_string();
502
503 if !absolute_path.exists() {
504 return format!("Error: path does not exist: {absolute_path_str}");
505 }
506
507 if !absolute_path.is_dir() {
508 return format!("Error: path is not a directory: {absolute_path_str}");
509 }
510
511 let entries = match fs::read_dir(&absolute_path) {
512 Ok(rd) => rd,
513 Err(err) => return format!("Error: could not read directory: {err}"),
514 };
515
516 let mut dirs: Vec<String> = Vec::new();
517 let mut files: Vec<String> = Vec::new();
518
519 for entry in entries {
520 let entry = match entry {
521 Ok(e) => e,
522 Err(err) => {
523 files.push(format!("[ERR] {err}"));
524 continue;
525 }
526 };
527
528 let name = entry.file_name().to_string_lossy().to_string();
529
530 let is_dir = match entry.file_type() {
531 Ok(ft) => ft.is_dir(),
532 Err(_) => false,
533 };
534
535 if is_dir {
536 dirs.push(format!("[DIR] {name}"));
537 } else {
538 files.push(format!("[FILE] {name}"));
539 }
540 }
541
542 dirs.sort();
543 files.sort();
544
545 dirs.extend(files);
546
547 if dirs.is_empty() {
548 return format!("(empty directory: {absolute_path_str})");
549 }
550
551 dirs.join("\n")
552 }
553
554 // ── search_files ─────────────────────────────────────────────────────────────
555
556 fn exec_search_files(arguments: &str) -> String {
557 let args: Value = match serde_json::from_str(arguments) {
558 Ok(v) => v,
559 Err(err) => return format!("Error: failed to parse arguments: {err}"),
560 };
561
562 let pattern_str = match args.get("pattern").and_then(Value::as_str) {
563 Some(p) => p,
564 None => return "Error: missing required parameter \"pattern\"".to_string(),
565 };
566
567 let root_str = args.get("path").and_then(Value::as_str).unwrap_or(".");
568
569 let re = match Regex::new(pattern_str) {
570 Ok(r) => r,
571 Err(err) => return format!("Error: invalid regex pattern: {err}"),
572 };
573
574 // Optional file-name filter compiled from a glob (e.g. "*.rs").
575 let name_filter = match args.get("file_glob").and_then(Value::as_str) {
576 Some(glob) => match Regex::new(&glob_to_regex(glob)) {
577 Ok(r) => Some(r),
578 Err(err) => return format!("Error: invalid file_glob: {err}"),
579 },
580 None => None,
581 };
582
583 let limit = args
584 .get("max_results")
585 .and_then(Value::as_u64)
586 .map(|n| (n as usize).clamp(1, SEARCH_RESULTS_HARD_CAP))
587 .unwrap_or(SEARCH_FILES_MATCH_LIMIT);
588
589 let root = Path::new(root_str);
590 let absolute_root = absolute_path(root);
591 let absolute_root_str = absolute_root.display().to_string();
592
593 if !absolute_root.exists() {
594 return format!("Error: path does not exist: {absolute_root_str}");
595 }
596
597 if !absolute_root.is_dir() {
598 return format!("Error: path is not a directory: {absolute_root_str}");
599 }
600
601 let mut matches: Vec<String> = Vec::new();
602 walk_and_search(
603 &absolute_root,
604 &re,
605 name_filter.as_ref(),
606 limit,
607 &mut matches,
608 );
609
610 if matches.is_empty() {
611 return format!("No matches found for pattern: {pattern_str}");
612 }
613
614 let total = matches.len();
615 if total > limit {
616 matches.truncate(limit);
617 matches.push(format!(
618 "\n--- truncated (showing {limit} of {total}+ matches; raise max_results to see more) ---"
619 ));
620 }
621
622 matches.join("\n")
623 }
624
625 /// Collects up to `limit + 1` matches (the extra signals truncation) so a broad
626 /// pattern can't walk an entire tree once enough hits are found.
627 fn walk_and_search(
628 dir: &Path,
629 re: &Regex,
630 name_filter: Option<&Regex>,
631 limit: usize,
632 matches: &mut Vec<String>,
633 ) {
634 let entries = match fs::read_dir(dir) {
635 Ok(rd) => rd,
636 Err(_) => return,
637 };
638
639 let mut sorted: Vec<fs::DirEntry> = entries.filter_map(Result::ok).collect();
640 sorted.sort_by_key(|e| e.file_name());
641
642 for entry in sorted {
643 if matches.len() > limit {
644 return;
645 }
646
647 let path = entry.path();
648 let name = entry.file_name();
649 let name_str = name.to_string_lossy();
650
651 if name_str.starts_with('.') {
652 continue;
653 }
654
655 if path.is_dir() {
656 walk_and_search(&path, re, name_filter, limit, matches);
657 } else if path.is_file() {
658 if let Some(filter) = name_filter
659 && !filter.is_match(&name_str)
660 {
661 continue;
662 }
663 search_file(&path, re, matches);
664 }
665 }
666 }
667
668 /// skips non-UTF-8 files (probably binary).
669 fn search_file(path: &Path, re: &Regex, matches: &mut Vec<String>) {
670 let contents = match fs::read_to_string(path) {
671 Ok(c) => c,
672 Err(_) => return,
673 };
674
675 let display_path = absolute_path_string(path);
676
677 for (line_idx, line) in contents.lines().enumerate() {
678 if re.is_match(line) {
679 let line_number = line_idx + 1;
680 matches.push(format!("{display_path}:{line_number}: {line}"));
681 }
682 }
683 }
684
685 // ── read_website ─────────────────────────────────────────────────────────────
686
687 fn exec_read_website(arguments: &str) -> String {
688 let args: Value = match serde_json::from_str(arguments) {
689 Ok(v) => v,
690 Err(err) => return format!("Error: failed to parse arguments: {err}"),
691 };
692
693 let url = match args.get("url").and_then(Value::as_str) {
694 Some(u) => u,
695 None => return "Error: missing required parameter \"url\"".to_string(),
696 };
697
698 if !(url.starts_with("http://") || url.starts_with("https://")) {
699 return format!("Error: url must start with http:// or https://: {url}");
700 }
701
702 let client = match reqwest::blocking::Client::builder()
703 .timeout(WEBSITE_READ_TIMEOUT)
704 .user_agent(WEBSITE_USER_AGENT)
705 .build()
706 {
707 Ok(client) => client,
708 Err(err) => return format!("Error: failed to build website client: {err}"),
709 };
710
711 let response = match client.get(url).send() {
712 Ok(r) => r,
713 Err(err) => return format!("Error: failed to fetch website: {err}"),
714 };
715
716 let final_url = response.url().to_string();
717 let status = response.status();
718 if !status.is_success() {
719 return format!("Error: website returned HTTP {status} for {final_url}");
720 }
721
722 let body = match response.text() {
723 Ok(text) => text,
724 Err(err) => return format!("Error: failed to read website body: {err}"),
725 };
726
727 let title = Regex::new(r"(?is)<title[^>]*>(.*?)</title>")
728 .unwrap()
729 .captures(&body)
730 .and_then(|captures| captures.get(1))
731 .map(|m| {
732 Regex::new(r"\s+")
733 .unwrap()
734 .replace_all(m.as_str(), " ")
735 .trim()
736 .to_string()
737 })
738 .filter(|title| !title.is_empty());
739
740 let with_block_breaks = Regex::new(
741 r"(?is)</?(?:p|div|section|article|main|aside|header|footer|nav|li|ul|ol|h1|h2|h3|h4|h5|h6|br|tr|td|th)[^>]*>",
742 )
743 .unwrap()
744 .replace_all(&body, "\n");
745 let without_scripts = Regex::new(r"(?is)<script[^>]*>.*?</script>")
746 .unwrap()
747 .replace_all(&with_block_breaks, " ");
748 let without_styles = Regex::new(r"(?is)<style[^>]*>.*?</style>")
749 .unwrap()
750 .replace_all(&without_scripts, " ");
751 let without_tags = Regex::new(r"(?is)<[^>]+>")
752 .unwrap()
753 .replace_all(&without_styles, " ");
754 let normalized_newlines = without_tags
755 .replace("&nbsp;", " ")
756 .replace("&amp;", "&")
757 .replace("&lt;", "<")
758 .replace("&gt;", ">")
759 .replace("&quot;", "\"")
760 .replace("&#39;", "'");
761 let collapsed_lines = Regex::new(r"[ \t]+")
762 .unwrap()
763 .replace_all(&normalized_newlines, " ");
764 let collapsed_breaks = Regex::new(r"\n\s*\n+")
765 .unwrap()
766 .replace_all(&collapsed_lines, "\n\n");
767 let cleaned = collapsed_breaks
768 .lines()
769 .map(str::trim)
770 .filter(|line| !line.is_empty())
771 .collect::<Vec<_>>()
772 .join("\n");
773
774 if cleaned.is_empty() {
775 return format!("Fetched {url}, but no readable text content was found.");
776 }
777
778 let mut metadata = vec![format!("URL: {final_url}")];
779 if let Some(title) = &title {
780 metadata.push(format!("Title: {title}"));
781 }
782
783 let body_text = match title {
784 Some(title) if !cleaned.starts_with(&title) => cleaned,
785 _ => cleaned,
786 };
787
788 let output = format!("{}\n\n{}", metadata.join("\n"), body_text);
789
790 if output.len() > WEBSITE_READ_CHAR_LIMIT {
791 let truncated: String = output.chars().take(WEBSITE_READ_CHAR_LIMIT).collect();
792 return format!(
793 "{truncated}\n\n--- truncated (showing {WEBSITE_READ_CHAR_LIMIT} of {} characters) ---",
794 output.len()
795 );
796 }
797
798 output
799 }
800
801 // ── create_directory ─────────────────────────────────────────────────────────
802
803 fn exec_create_directory(arguments: &str) -> String {
804 let args: Value = match serde_json::from_str(arguments) {
805 Ok(v) => v,
806 Err(err) => return format!("Error: failed to parse arguments: {err}"),
807 };
808
809 let path_str = match args.get("path").and_then(Value::as_str) {
810 Some(p) => p,
811 None => return "Error: missing required parameter \"path\"".to_string(),
812 };
813
814 let path = Path::new(path_str);
815 let absolute_path = absolute_path(path);
816 let absolute_path_str = absolute_path.display().to_string();
817
818 if absolute_path.exists() {
819 if absolute_path.is_dir() {
820 return format!("Directory already exists: {absolute_path_str}");
821 }
822 return format!("Error: path exists and is not a directory: {absolute_path_str}");
823 }
824
825 match fs::create_dir_all(&absolute_path) {
826 Ok(()) => format!("Created directory: {absolute_path_str}"),
827 Err(err) => format!("Error: could not create directory: {err}"),
828 }
829 }
830
831 /// fails if file exists so the LLM is forced to use `edit_file` for modifications.
832 fn exec_create_file(arguments: &str) -> String {
833 let args: Value = match serde_json::from_str(arguments) {
834 Ok(v) => v,
835 Err(err) => return format!("Error: failed to parse arguments: {err}"),
836 };
837
838 let path_str = match args.get("path").and_then(Value::as_str) {
839 Some(p) => p,
840 None => return "Error: missing required parameter \"path\"".to_string(),
841 };
842
843 let content = match args.get("content").and_then(Value::as_str) {
844 Some(c) => c,
845 None => return "Error: missing required parameter \"content\"".to_string(),
846 };
847
848 let path = Path::new(path_str);
849 let absolute_path = absolute_path(path);
850 let absolute_path_str = absolute_path.display().to_string();
851
852 if absolute_path.exists() {
853 return format!(
854 "Error: file already exists: {absolute_path_str} — use edit_file to modify existing files"
855 );
856 }
857
858 if let Some(parent) = absolute_path.parent()
859 && !parent.as_os_str().is_empty()
860 && !parent.exists()
861 && let Err(err) = fs::create_dir_all(parent)
862 {
863 return format!("Error: could not create parent directories: {err}");
864 }
865
866 match fs::write(&absolute_path, content) {
867 Ok(()) => format!(
868 "Created file: {absolute_path_str} ({} bytes)",
869 content.len()
870 ),
871 Err(err) => format!("Error: could not write file: {err}"),
872 }
873 }
874
875 // ── edit_file / multi_edit ─────────────────────────────────────────────────
876
877 /// Apply one exact-substring replacement to `contents`. Returns the updated
878 /// string, or a human-readable explanation of why the match failed so the model
879 /// can correct itself in a single follow-up instead of guessing blindly.
880 fn apply_edit(
881 contents: &str,
882 old_text: &str,
883 new_text: &str,
884 replace_all: bool,
885 ) -> Result<String, String> {
886 if old_text.is_empty() {
887 return Err("old_text is empty; nothing to match".to_string());
888 }
889 if old_text == new_text {
890 return Err("old_text and new_text are identical; no change to make".to_string());
891 }
892
893 let occurrences = contents.matches(old_text).count();
894
895 if occurrences == 0 {
896 return Err(format!(
897 "old_text not found. Use read_file to copy the exact text \
898 (including whitespace and indentation) to replace.{}",
899 nearest_line_hint(contents, old_text)
900 ));
901 }
902
903 if occurrences > 1 && !replace_all {
904 return Err(format!(
905 "old_text appears {occurrences} times; include more surrounding context so it \
906 matches exactly once, or set replace_all to true to change every occurrence."
907 ));
908 }
909
910 if replace_all {
911 Ok(contents.replace(old_text, new_text))
912 } else {
913 Ok(contents.replacen(old_text, new_text, 1))
914 }
915 }
916
917 /// When `old_text` doesn't match verbatim, point at the line whose trimmed text
918 /// equals the first trimmed line of `old_text` — the usual culprit is a
919 /// whitespace/indentation mismatch, and naming the line lets the model fix it.
920 fn nearest_line_hint(contents: &str, old_text: &str) -> String {
921 let first = old_text.lines().find(|l| !l.trim().is_empty());
922 let Some(first) = first.map(str::trim) else {
923 return String::new();
924 };
925 for (idx, line) in contents.lines().enumerate() {
926 if line.trim() == first {
927 return format!(
928 " (the first line of old_text appears at line {}, so the difference is likely \
929 whitespace or indentation)",
930 idx + 1
931 );
932 }
933 }
934 String::new()
935 }
936
937 fn exec_edit_file(arguments: &str) -> String {
938 let args: Value = match serde_json::from_str(arguments) {
939 Ok(v) => v,
940 Err(err) => return format!("Error: failed to parse arguments: {err}"),
941 };
942
943 let path_str = match args.get("path").and_then(Value::as_str) {
944 Some(p) => p,
945 None => return "Error: missing required parameter \"path\"".to_string(),
946 };
947
948 let old_text = match args.get("old_text").and_then(Value::as_str) {
949 Some(t) => t,
950 None => return "Error: missing required parameter \"old_text\"".to_string(),
951 };
952
953 let new_text = match args.get("new_text").and_then(Value::as_str) {
954 Some(t) => t,
955 None => return "Error: missing required parameter \"new_text\"".to_string(),
956 };
957
958 let replace_all = args
959 .get("replace_all")
960 .and_then(Value::as_bool)
961 .unwrap_or(false);
962
963 let path = Path::new(path_str);
964 let absolute_path = absolute_path(path);
965 let absolute_path_str = absolute_path.display().to_string();
966
967 if !absolute_path.exists() {
968 return format!(
969 "Error: file does not exist: {absolute_path_str} — use create_file for new files"
970 );
971 }
972
973 if !absolute_path.is_file() {
974 return format!("Error: path is not a file: {absolute_path_str}");
975 }
976
977 let contents = match fs::read_to_string(&absolute_path) {
978 Ok(c) => c,
979 Err(err) => return format!("Error: could not read file: {err}"),
980 };
981
982 let updated = match apply_edit(&contents, old_text, new_text, replace_all) {
983 Ok(updated) => updated,
984 Err(why) => return format!("Error: {why} (in {absolute_path_str})"),
985 };
986
987 match fs::write(&absolute_path, &updated) {
988 Ok(()) => format!(
989 "Edited file: {absolute_path_str} ({} bytes written)",
990 updated.len()
991 ),
992 Err(err) => format!("Error: could not write file: {err}"),
993 }
994 }
995
996 /// Apply a batch of edits to one file atomically: each edit is applied to the
997 /// result of the previous one, and the file is only written if *every* edit
998 /// matches. A failure leaves the file untouched.
999 fn exec_multi_edit(arguments: &str) -> String {
1000 let args: Value = match serde_json::from_str(arguments) {
1001 Ok(v) => v,
1002 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1003 };
1004
1005 let path_str = match args.get("path").and_then(Value::as_str) {
1006 Some(p) => p,
1007 None => return "Error: missing required parameter \"path\"".to_string(),
1008 };
1009
1010 let edits = match args.get("edits").and_then(Value::as_array) {
1011 Some(e) if !e.is_empty() => e,
1012 Some(_) => return "Error: \"edits\" must contain at least one edit".to_string(),
1013 None => return "Error: missing required parameter \"edits\"".to_string(),
1014 };
1015
1016 let path = Path::new(path_str);
1017 let absolute_path = absolute_path(path);
1018 let absolute_path_str = absolute_path.display().to_string();
1019
1020 if !absolute_path.exists() {
1021 return format!(
1022 "Error: file does not exist: {absolute_path_str} — use create_file for new files"
1023 );
1024 }
1025
1026 if !absolute_path.is_file() {
1027 return format!("Error: path is not a file: {absolute_path_str}");
1028 }
1029
1030 let mut working = match fs::read_to_string(&absolute_path) {
1031 Ok(c) => c,
1032 Err(err) => return format!("Error: could not read file: {err}"),
1033 };
1034
1035 for (idx, edit) in edits.iter().enumerate() {
1036 let old_text = match edit.get("old_text").and_then(Value::as_str) {
1037 Some(t) => t,
1038 None => return format!("Error: edit #{} is missing \"old_text\"", idx + 1),
1039 };
1040 let new_text = match edit.get("new_text").and_then(Value::as_str) {
1041 Some(t) => t,
1042 None => return format!("Error: edit #{} is missing \"new_text\"", idx + 1),
1043 };
1044 let replace_all = edit
1045 .get("replace_all")
1046 .and_then(Value::as_bool)
1047 .unwrap_or(false);
1048
1049 match apply_edit(&working, old_text, new_text, replace_all) {
1050 Ok(updated) => working = updated,
1051 Err(why) => {
1052 return format!(
1053 "Error: edit #{} failed: {why}. No changes were written to {absolute_path_str}.",
1054 idx + 1
1055 );
1056 }
1057 }
1058 }
1059
1060 match fs::write(&absolute_path, &working) {
1061 Ok(()) => format!(
1062 "Applied {} edits to {absolute_path_str} ({} bytes written)",
1063 edits.len(),
1064 working.len()
1065 ),
1066 Err(err) => format!("Error: could not write file: {err}"),
1067 }
1068 }
1069
1070 // ── glob ─────────────────────────────────────────────────────────────────────
1071
1072 /// Translate a shell-style glob into an anchored regex. Supports `*`
1073 /// (non-separator run), `**` (any number of directories), `?` (one
1074 /// non-separator), and `{a,b}` alternation; everything else is matched
1075 /// literally. Used both by the `glob` tool (against relative paths) and by
1076 /// `search_files`' `file_glob` filter (against bare file names).
1077 fn glob_to_regex(glob: &str) -> String {
1078 let chars: Vec<char> = glob.chars().collect();
1079 let mut re = String::from("^");
1080 let mut brace_depth = 0usize;
1081 let mut i = 0;
1082
1083 while i < chars.len() {
1084 let c = chars[i];
1085 match c {
1086 '*' => {
1087 if i + 1 < chars.len() && chars[i + 1] == '*' {
1088 i += 1; // consume the second '*'
1089 if i + 1 < chars.len() && chars[i + 1] == '/' {
1090 // `**/` matches zero or more leading directories.
1091 re.push_str("(?:.*/)?");
1092 i += 1; // consume the '/'
1093 } else {
1094 re.push_str(".*");
1095 }
1096 } else {
1097 re.push_str("[^/]*");
1098 }
1099 }
1100 '?' => re.push_str("[^/]"),
1101 '{' => {
1102 brace_depth += 1;
1103 re.push_str("(?:");
1104 }
1105 '}' if brace_depth > 0 => {
1106 brace_depth -= 1;
1107 re.push(')');
1108 }
1109 ',' if brace_depth > 0 => re.push('|'),
1110 // Escape regex metacharacters so they match literally. (`{` is always
1111 // consumed by the brace arm above; an unmatched `}` lands here.)
1112 '.' | '+' | '(' | ')' | '|' | '^' | '$' | '\\' | '[' | ']' | '}' => {
1113 re.push('\\');
1114 re.push(c);
1115 }
1116 other => re.push(other),
1117 }
1118 i += 1;
1119 }
1120
1121 re.push('$');
1122 re
1123 }
1124
1125 fn exec_glob(arguments: &str) -> String {
1126 let args: Value = match serde_json::from_str(arguments) {
1127 Ok(v) => v,
1128 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1129 };
1130
1131 let pattern = match args.get("pattern").and_then(Value::as_str) {
1132 Some(p) => p,
1133 None => return "Error: missing required parameter \"pattern\"".to_string(),
1134 };
1135
1136 let re = match Regex::new(&glob_to_regex(pattern)) {
1137 Ok(r) => r,
1138 Err(err) => return format!("Error: invalid glob pattern: {err}"),
1139 };
1140
1141 let root_str = args.get("path").and_then(Value::as_str).unwrap_or(".");
1142 let absolute_root = absolute_path(Path::new(root_str));
1143 let absolute_root_str = absolute_root.display().to_string();
1144
1145 if !absolute_root.exists() {
1146 return format!("Error: path does not exist: {absolute_root_str}");
1147 }
1148 if !absolute_root.is_dir() {
1149 return format!("Error: path is not a directory: {absolute_root_str}");
1150 }
1151
1152 let mut found: Vec<(std::time::SystemTime, String)> = Vec::new();
1153 glob_walk(&absolute_root, &absolute_root, &re, &mut found);
1154
1155 if found.is_empty() {
1156 return format!("No files match glob: {pattern}");
1157 }
1158
1159 // Most-recently-modified first, like Claude Code's Glob.
1160 found.sort_by(|a, b| b.0.cmp(&a.0));
1161
1162 let total = found.len();
1163 let mut paths: Vec<String> = found.into_iter().map(|(_, p)| p).collect();
1164 if total > SEARCH_RESULTS_HARD_CAP {
1165 paths.truncate(SEARCH_RESULTS_HARD_CAP);
1166 paths.push(format!(
1167 "\n--- truncated (showing {SEARCH_RESULTS_HARD_CAP} of {total} files) ---"
1168 ));
1169 }
1170
1171 paths.join("\n")
1172 }
1173
1174 fn glob_walk(root: &Path, dir: &Path, re: &Regex, out: &mut Vec<(std::time::SystemTime, String)>) {
1175 if out.len() > SEARCH_RESULTS_HARD_CAP {
1176 return;
1177 }
1178
1179 let entries = match fs::read_dir(dir) {
1180 Ok(rd) => rd,
1181 Err(_) => return,
1182 };
1183
1184 let mut sorted: Vec<fs::DirEntry> = entries.filter_map(Result::ok).collect();
1185 sorted.sort_by_key(|e| e.file_name());
1186
1187 for entry in sorted {
1188 if out.len() > SEARCH_RESULTS_HARD_CAP {
1189 return;
1190 }
1191
1192 let path = entry.path();
1193 let name = entry.file_name();
1194 if name.to_string_lossy().starts_with('.') {
1195 continue;
1196 }
1197
1198 if path.is_dir() {
1199 glob_walk(root, &path, re, out);
1200 } else if path.is_file() {
1201 let relative = path
1202 .strip_prefix(root)
1203 .unwrap_or(&path)
1204 .to_string_lossy()
1205 .replace('\\', "/");
1206 if re.is_match(&relative) {
1207 let mtime = entry
1208 .metadata()
1209 .and_then(|m| m.modified())
1210 .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
1211 out.push((mtime, absolute_path_string(&path)));
1212 }
1213 }
1214 }
1215 }
1216
1217 // ── write_todos ──────────────────────────────────────────────────────────────
1218
1219 /// Renders the model's task checklist back as the tool result so the surface
1220 /// (TUI / ACP client) can show live progress. Pure presentation — the list is
1221 /// owned by the model, not persisted here.
1222 fn exec_write_todos(arguments: &str) -> String {
1223 let args: Value = match serde_json::from_str(arguments) {
1224 Ok(v) => v,
1225 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1226 };
1227
1228 let todos = match args.get("todos").and_then(Value::as_array) {
1229 Some(t) if !t.is_empty() => t,
1230 Some(_) => return "Error: \"todos\" must contain at least one item".to_string(),
1231 None => return "Error: missing required parameter \"todos\"".to_string(),
1232 };
1233
1234 let mut lines = Vec::with_capacity(todos.len());
1235 let mut completed = 0usize;
1236
1237 for (idx, todo) in todos.iter().enumerate() {
1238 let content = match todo.get("content").and_then(Value::as_str) {
1239 Some(c) => c.trim(),
1240 None => return format!("Error: todo #{} is missing \"content\"", idx + 1),
1241 };
1242 let status = todo
1243 .get("status")
1244 .and_then(Value::as_str)
1245 .unwrap_or("pending");
1246
1247 let marker = match status {
1248 "completed" => {
1249 completed += 1;
1250 "[x]"
1251 }
1252 "in_progress" => "[~]",
1253 _ => "[ ]",
1254 };
1255 lines.push(format!("{marker} {content}"));
1256 }
1257
1258 format!(
1259 "Task list updated ({completed}/{} done):\n{}",
1260 todos.len(),
1261 lines.join("\n")
1262 )
1263 }
1264
1265 // ── remember ─────────────────────────────────────────────────────────────────
1266
1267 /// Appends a durable note to the project's instruction file so it persists
1268 /// across sessions (the always-on counterpart to a one-off chat message).
1269 fn exec_remember(arguments: &str) -> String {
1270 let args: Value = match serde_json::from_str(arguments) {
1271 Ok(v) => v,
1272 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1273 };
1274
1275 let note = match args.get("note").and_then(Value::as_str) {
1276 Some(n) if !n.trim().is_empty() => n.trim(),
1277 Some(_) => return "Error: \"note\" must not be empty".to_string(),
1278 None => return "Error: missing required parameter \"note\"".to_string(),
1279 };
1280
1281 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1282 remember_at(&cwd, note)
1283 }
1284
1285 /// Core of `remember`, parameterized on the working directory so it can be
1286 /// tested without mutating the process-global current directory.
1287 fn remember_at(cwd: &Path, note: &str) -> String {
1288 let target = crate::instructions::memory_file(cwd);
1289 let target_str = target.display().to_string();
1290
1291 let existed = target.exists();
1292 let mut body = if existed {
1293 match fs::read_to_string(&target) {
1294 Ok(c) => c,
1295 Err(err) => return format!("Error: could not read {target_str}: {err}"),
1296 }
1297 } else {
1298 if let Some(parent) = target.parent()
1299 && !parent.as_os_str().is_empty()
1300 && !parent.exists()
1301 && let Err(err) = fs::create_dir_all(parent)
1302 {
1303 return format!("Error: could not create parent directories: {err}");
1304 }
1305 String::new()
1306 };
1307
1308 // Keep remembered notes grouped under one heading so the file stays tidy.
1309 const HEADING: &str = "## Remembered notes";
1310 if !body.contains(HEADING) {
1311 if !body.is_empty() && !body.ends_with('\n') {
1312 body.push('\n');
1313 }
1314 if !body.is_empty() {
1315 body.push('\n');
1316 }
1317 body.push_str(HEADING);
1318 body.push('\n');
1319 }
1320 if !body.ends_with('\n') {
1321 body.push('\n');
1322 }
1323 body.push_str("- ");
1324 body.push_str(note);
1325 body.push('\n');
1326
1327 match fs::write(&target, &body) {
1328 Ok(()) => {
1329 let verb = if existed { "Appended to" } else { "Created" };
1330 format!("{verb} {target_str}: remembered \"{note}\"")
1331 }
1332 Err(err) => format!("Error: could not write {target_str}: {err}"),
1333 }
1334 }
1335
1336 // ── delete_file ──────────────────────────────────────────────────────────────
1337
1338 /// only removes files or *empty* directories — no recursive deletes.
1339 fn exec_delete_file(arguments: &str) -> String {
1340 let args: Value = match serde_json::from_str(arguments) {
1341 Ok(v) => v,
1342 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1343 };
1344
1345 let path_str = match args.get("path").and_then(Value::as_str) {
1346 Some(p) => p,
1347 None => return "Error: missing required parameter \"path\"".to_string(),
1348 };
1349
1350 let path = Path::new(path_str);
1351 let absolute_path = absolute_path(path);
1352 let absolute_path_str = absolute_path.display().to_string();
1353
1354 if !absolute_path.exists() {
1355 return format!("Error: path does not exist: {absolute_path_str}");
1356 }
1357
1358 if absolute_path.is_dir() {
1359 match fs::remove_dir(&absolute_path) {
1360 Ok(()) => format!("Deleted empty directory: {absolute_path_str}"),
1361 Err(err) => format!(
1362 "Error: could not delete directory: {err}. \
1363 Only empty directories can be deleted."
1364 ),
1365 }
1366 } else {
1367 match fs::remove_file(&absolute_path) {
1368 Ok(()) => format!("Deleted file: {absolute_path_str}"),
1369 Err(err) => format!("Error: could not delete file: {err}"),
1370 }
1371 }
1372 }
1373
1374 // ── run_command ──────────────────────────────────────────────────────────────
1375
1376 const COMMAND_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
1377 const COMMAND_OUTPUT_LIMIT: usize = 50_000;
1378
1379 /// runs via `sh -c` / `cmd /C`; killed after COMMAND_TIMEOUT.
1380 fn exec_run_command(arguments: &str) -> String {
1381 let args: Value = match serde_json::from_str(arguments) {
1382 Ok(v) => v,
1383 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1384 };
1385
1386 let command_str = match args.get("command").and_then(Value::as_str) {
1387 Some(c) => c,
1388 None => return "Error: missing required parameter \"command\"".to_string(),
1389 };
1390
1391 let default_cwd = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
1392 let cwd = args
1393 .get("cwd")
1394 .and_then(Value::as_str)
1395 .unwrap_or(&default_cwd);
1396 let cwd_path = absolute_path(Path::new(cwd));
1397 let cwd_str = cwd_path.display().to_string();
1398
1399 if !cwd_path.exists() {
1400 return format!("Error: working directory does not exist: {cwd_str}");
1401 }
1402
1403 log::info!("run_command: `{command_str}` in `{cwd_str}`");
1404
1405 #[cfg(unix)]
1406 let mut child = match Command::new("sh")
1407 .arg("-c")
1408 .arg(command_str)
1409 .current_dir(&cwd_path)
1410 .stdout(std::process::Stdio::piped())
1411 .stderr(std::process::Stdio::piped())
1412 .spawn()
1413 {
1414 Ok(c) => c,
1415 Err(err) => return format!("Error: failed to spawn command: {err}"),
1416 };
1417
1418 #[cfg(windows)]
1419 let mut child = match Command::new("cmd")
1420 .arg("/C")
1421 .arg(command_str)
1422 .current_dir(&cwd_path)
1423 .stdout(std::process::Stdio::piped())
1424 .stderr(std::process::Stdio::piped())
1425 .spawn()
1426 {
1427 Ok(c) => c,
1428 Err(err) => return format!("Error: failed to spawn command: {err}"),
1429 };
1430
1431 let start = std::time::Instant::now();
1432 loop {
1433 match child.try_wait() {
1434 Ok(Some(_status)) => break,
1435 Ok(None) => {
1436 if start.elapsed() >= COMMAND_TIMEOUT {
1437 let _ = child.kill();
1438 return format!(
1439 "Error: command timed out after {} seconds and was killed.",
1440 COMMAND_TIMEOUT.as_secs()
1441 );
1442 }
1443 std::thread::sleep(std::time::Duration::from_millis(100));
1444 }
1445 Err(err) => return format!("Error: failed to wait on command: {err}"),
1446 }
1447 }
1448
1449 let output = match child.wait_with_output() {
1450 Ok(o) => o,
1451 Err(err) => return format!("Error: failed to read command output: {err}"),
1452 };
1453
1454 let exit_code = output.status.code().unwrap_or(-1);
1455 let mut combined = String::new();
1456 combined.push_str(&String::from_utf8_lossy(&output.stdout));
1457 combined.push_str(&String::from_utf8_lossy(&output.stderr));
1458
1459 let truncated = if combined.len() > COMMAND_OUTPUT_LIMIT {
1460 let truncated_str = &combined[..COMMAND_OUTPUT_LIMIT];
1461 format!("{truncated_str}\n\n… (output truncated at {COMMAND_OUTPUT_LIMIT} bytes)")
1462 } else {
1463 combined
1464 };
1465
1466 if output.status.success() {
1467 if truncated.is_empty() {
1468 format!("Command succeeded (exit code {exit_code}) with no output.")
1469 } else {
1470 format!("Exit code {exit_code}:\n{truncated}")
1471 }
1472 } else {
1473 format!("Command failed (exit code {exit_code}):\n{truncated}")
1474 }
1475 }
1476
1477 #[cfg(test)]
1478 mod tests {
1479 use super::*;
1480 use std::fs;
1481
1482 #[tokio::test]
1483 async fn test_execute_unknown_tool() {
1484 let result = execute_tool("nonexistent", "{}").await;
1485 assert!(result.starts_with("Unknown tool:"));
1486 }
1487
1488 #[test]
1489 fn test_read_file_missing_path_param() {
1490 let result = exec_read_file("{}");
1491 assert!(result.contains("missing required parameter"));
1492 }
1493
1494 #[test]
1495 fn test_read_file_nonexistent() {
1496 let result = exec_read_file(r#"{"path": "/tmp/__sigit_no_such_file_42__"}"#);
1497 assert!(result.contains("does not exist"));
1498 }
1499
1500 #[test]
1501 fn test_read_file_success() {
1502 let dir = std::env::temp_dir().join("sigit_test_read_file");
1503 let _ = fs::create_dir_all(&dir);
1504 let file_path = dir.join("hello.txt");
1505 fs::write(&file_path, "hello world").unwrap();
1506
1507 let args = serde_json::json!({ "path": file_path }).to_string();
1508 let result = exec_read_file(&args);
1509 assert_eq!(result, "hello world");
1510
1511 let _ = fs::remove_dir_all(&dir);
1512 }
1513
1514 #[test]
1515 fn test_list_directory_missing_path_param() {
1516 let result = exec_list_directory("{}");
1517 assert!(result.contains("missing required parameter"));
1518 }
1519
1520 #[test]
1521 fn test_list_directory_success() {
1522 let dir = std::env::temp_dir().join("sigit_test_list_dir");
1523 let _ = fs::remove_dir_all(&dir);
1524 fs::create_dir_all(dir.join("subdir")).unwrap();
1525 fs::write(dir.join("aaa.txt"), "").unwrap();
1526 fs::write(dir.join("bbb.rs"), "").unwrap();
1527
1528 let args = serde_json::json!({ "path": dir }).to_string();
1529 let result = exec_list_directory(&args);
1530
1531 assert!(result.contains("[DIR] subdir"));
1532 assert!(result.contains("[FILE] aaa.txt"));
1533 assert!(result.contains("[FILE] bbb.rs"));
1534
1535 // Directories should appear before files.
1536 let dir_pos = result.find("[DIR]").unwrap();
1537 let file_pos = result.find("[FILE]").unwrap();
1538 assert!(dir_pos < file_pos);
1539
1540 let _ = fs::remove_dir_all(&dir);
1541 }
1542
1543 #[test]
1544 fn test_search_files_invalid_regex() {
1545 let result = exec_search_files(r#"{"pattern": "[invalid", "path": "."}"#);
1546 assert!(result.contains("invalid regex"));
1547 }
1548
1549 #[test]
1550 fn test_search_files_success() {
1551 let dir = std::env::temp_dir().join("sigit_test_search");
1552 let _ = fs::remove_dir_all(&dir);
1553 fs::create_dir_all(&dir).unwrap();
1554 fs::write(
1555 dir.join("code.rs"),
1556 "fn main() {\n println!(\"hello\");\n}\n",
1557 )
1558 .unwrap();
1559 fs::write(dir.join("other.txt"), "no match here\n").unwrap();
1560
1561 let args = serde_json::json!({
1562 "pattern": "println",
1563 "path": dir
1564 })
1565 .to_string();
1566 let result = exec_search_files(&args);
1567
1568 assert!(result.contains("code.rs:2:"));
1569 assert!(result.contains("println"));
1570 assert!(!result.contains("other.txt"));
1571
1572 let _ = fs::remove_dir_all(&dir);
1573 }
1574
1575 #[test]
1576 fn test_search_files_no_matches() {
1577 let dir = std::env::temp_dir().join("sigit_test_search_none");
1578 let _ = fs::remove_dir_all(&dir);
1579 fs::create_dir_all(&dir).unwrap();
1580 fs::write(dir.join("empty.txt"), "nothing special").unwrap();
1581
1582 let args = serde_json::json!({
1583 "pattern": "zzz_will_not_match_42",
1584 "path": dir
1585 })
1586 .to_string();
1587 let result = exec_search_files(&args);
1588 assert!(result.contains("No matches found"));
1589
1590 let _ = fs::remove_dir_all(&dir);
1591 }
1592
1593 #[test]
1594 fn test_all_tools_count() {
1595 let tools = all_tools();
1596 assert_eq!(tools.len(), 13);
1597 assert_eq!(tools[0].name, "read_file");
1598 assert_eq!(tools[1].name, "create_directory");
1599 assert_eq!(tools[2].name, "list_directory");
1600 assert_eq!(tools[3].name, "search_files");
1601 assert_eq!(tools[4].name, "read_website");
1602 assert_eq!(tools[5].name, "create_file");
1603 assert_eq!(tools[6].name, "edit_file");
1604 assert_eq!(tools[7].name, "delete_file");
1605 assert_eq!(tools[8].name, "run_command");
1606 assert_eq!(tools[9].name, "multi_edit");
1607 assert_eq!(tools[10].name, "glob");
1608 assert_eq!(tools[11].name, "write_todos");
1609 assert_eq!(tools[12].name, "remember");
1610 }
1611
1612 #[test]
1613 fn test_edit_file_replace_all() {
1614 let dir = std::env::temp_dir().join("sigit_test_edit_replace_all");
1615 let _ = fs::remove_dir_all(&dir);
1616 fs::create_dir_all(&dir).unwrap();
1617 let file = dir.join("f.txt");
1618 fs::write(&file, "foo foo foo").unwrap();
1619
1620 // Without replace_all an ambiguous match is rejected.
1621 let args =
1622 serde_json::json!({ "path": &file, "old_text": "foo", "new_text": "bar" }).to_string();
1623 let result = exec_edit_file(&args);
1624 assert!(result.contains("appears 3 times"), "{result}");
1625
1626 // With replace_all every occurrence is changed.
1627 let args = serde_json::json!({
1628 "path": &file, "old_text": "foo", "new_text": "bar", "replace_all": true
1629 })
1630 .to_string();
1631 let result = exec_edit_file(&args);
1632 assert!(result.starts_with("Edited file:"), "{result}");
1633 assert_eq!(fs::read_to_string(&file).unwrap(), "bar bar bar");
1634
1635 let _ = fs::remove_dir_all(&dir);
1636 }
1637
1638 #[test]
1639 fn test_edit_file_whitespace_hint() {
1640 let dir = std::env::temp_dir().join("sigit_test_edit_hint");
1641 let _ = fs::remove_dir_all(&dir);
1642 fs::create_dir_all(&dir).unwrap();
1643 let file = dir.join("f.txt");
1644 fs::write(&file, "line one\n indented\nline three\n").unwrap();
1645
1646 // old_text has more indentation than the file, so it isn't a substring,
1647 // but its trimmed content still locates the intended line.
1648 let args = serde_json::json!({
1649 "path": &file, "old_text": " indented", "new_text": "x"
1650 })
1651 .to_string();
1652 let result = exec_edit_file(&args);
1653 assert!(result.contains("line 2"), "{result}");
1654 assert!(result.contains("whitespace"), "{result}");
1655
1656 let _ = fs::remove_dir_all(&dir);
1657 }
1658
1659 #[test]
1660 fn test_multi_edit_atomic_on_failure() {
1661 let dir = std::env::temp_dir().join("sigit_test_multi_edit");
1662 let _ = fs::remove_dir_all(&dir);
1663 fs::create_dir_all(&dir).unwrap();
1664 let file = dir.join("f.txt");
1665 fs::write(&file, "alpha beta gamma").unwrap();
1666
1667 // Second edit can't match -> nothing should be written.
1668 let args = serde_json::json!({
1669 "path": &file,
1670 "edits": [
1671 { "old_text": "alpha", "new_text": "ALPHA" },
1672 { "old_text": "nope", "new_text": "x" }
1673 ]
1674 })
1675 .to_string();
1676 let result = exec_multi_edit(&args);
1677 assert!(result.contains("edit #2 failed"), "{result}");
1678 assert_eq!(fs::read_to_string(&file).unwrap(), "alpha beta gamma");
1679
1680 // All-matching batch applies in sequence.
1681 let args = serde_json::json!({
1682 "path": &file,
1683 "edits": [
1684 { "old_text": "alpha", "new_text": "ALPHA" },
1685 { "old_text": "gamma", "new_text": "GAMMA" }
1686 ]
1687 })
1688 .to_string();
1689 let result = exec_multi_edit(&args);
1690 assert!(result.contains("Applied 2 edits"), "{result}");
1691 assert_eq!(fs::read_to_string(&file).unwrap(), "ALPHA beta GAMMA");
1692
1693 let _ = fs::remove_dir_all(&dir);
1694 }
1695
1696 #[test]
1697 fn test_glob_to_regex() {
1698 let re = Regex::new(&glob_to_regex("**/*.rs")).unwrap();
1699 assert!(re.is_match("src/tools.rs"));
1700 assert!(re.is_match("main.rs")); // `**/` matches zero directories too
1701 assert!(!re.is_match("src/tools.txt"));
1702
1703 let re = Regex::new(&glob_to_regex("*.{ts,tsx}")).unwrap();
1704 assert!(re.is_match("app.ts"));
1705 assert!(re.is_match("app.tsx"));
1706 assert!(!re.is_match("app.js"));
1707 }
1708
1709 #[test]
1710 fn test_glob_tool_success() {
1711 let dir = std::env::temp_dir().join("sigit_test_glob");
1712 let _ = fs::remove_dir_all(&dir);
1713 fs::create_dir_all(dir.join("src")).unwrap();
1714 fs::write(dir.join("Cargo.toml"), "").unwrap();
1715 fs::write(dir.join("src/main.rs"), "").unwrap();
1716 fs::write(dir.join("src/lib.rs"), "").unwrap();
1717
1718 let args = serde_json::json!({ "pattern": "**/*.rs", "path": &dir }).to_string();
1719 let result = exec_glob(&args);
1720 assert!(result.contains("main.rs"), "{result}");
1721 assert!(result.contains("lib.rs"), "{result}");
1722 assert!(!result.contains("Cargo.toml"), "{result}");
1723
1724 let _ = fs::remove_dir_all(&dir);
1725 }
1726
1727 #[test]
1728 fn test_search_files_file_glob_filter() {
1729 let dir = std::env::temp_dir().join("sigit_test_search_glob");
1730 let _ = fs::remove_dir_all(&dir);
1731 fs::create_dir_all(&dir).unwrap();
1732 fs::write(dir.join("code.rs"), "needle here\n").unwrap();
1733 fs::write(dir.join("notes.txt"), "needle here\n").unwrap();
1734
1735 let args = serde_json::json!({
1736 "pattern": "needle", "path": &dir, "file_glob": "*.rs"
1737 })
1738 .to_string();
1739 let result = exec_search_files(&args);
1740 assert!(result.contains("code.rs"), "{result}");
1741 assert!(!result.contains("notes.txt"), "{result}");
1742
1743 let _ = fs::remove_dir_all(&dir);
1744 }
1745
1746 #[test]
1747 fn test_write_todos_renders_checklist() {
1748 let args = serde_json::json!({
1749 "todos": [
1750 { "content": "Read code", "status": "completed" },
1751 { "content": "Make change", "status": "in_progress" },
1752 { "content": "Run tests", "status": "pending" }
1753 ]
1754 })
1755 .to_string();
1756 let result = exec_write_todos(&args);
1757 assert!(result.contains("1/3 done"), "{result}");
1758 assert!(result.contains("[x] Read code"), "{result}");
1759 assert!(result.contains("[~] Make change"), "{result}");
1760 assert!(result.contains("[ ] Run tests"), "{result}");
1761 }
1762
1763 #[test]
1764 fn test_remember_appends_to_instruction_file() {
1765 let dir = std::env::temp_dir().join("sigit_test_remember");
1766 let _ = fs::remove_dir_all(&dir);
1767 fs::create_dir_all(dir.join(".git")).unwrap();
1768 let claude_md = dir.join("CLAUDE.md");
1769 fs::write(&claude_md, "# Project\n").unwrap();
1770
1771 let target = crate::instructions::memory_file(&dir);
1772 // Should pick the existing CLAUDE.md at the repo root.
1773 assert_eq!(
1774 target.canonicalize().unwrap(),
1775 claude_md.canonicalize().unwrap()
1776 );
1777
1778 let result = remember_at(&dir, "remembered text");
1779 assert!(result.contains("remembered"), "{result}");
1780
1781 let updated = fs::read_to_string(&claude_md).unwrap();
1782 assert!(updated.contains("## Remembered notes"), "{updated}");
1783 assert!(updated.contains("- remembered text"), "{updated}");
1784
1785 let _ = fs::remove_dir_all(&dir);
1786 }
1787
1788 #[test]
1789 fn test_all_tools_schemas_are_valid_json_objects() {
1790 for tool in all_tools() {
1791 assert!(
1792 tool.parameters_schema.is_object(),
1793 "schema for {} is not an object",
1794 tool.name
1795 );
1796 let obj = tool.parameters_schema.as_object().unwrap();
1797 assert!(obj.contains_key("type"));
1798 assert!(obj.contains_key("properties"));
1799 assert!(obj.contains_key("required"));
1800 }
1801 }
1802
1803 // ── read_website tests ───────────────────────────────────────────────
1804
1805 #[test]
1806 fn test_read_website_missing_url() {
1807 let result = exec_read_website("{}");
1808 assert!(result.contains("missing required parameter"));
1809 }
1810
1811 #[test]
1812 fn test_read_website_invalid_scheme() {
1813 let result = exec_read_website(r#"{"url": "file:///tmp/test.html"}"#);
1814 assert!(result.contains("url must start with http:// or https://"));
1815 }
1816
1817 #[test]
1818 fn test_read_website_extracts_title_from_html() {
1819 let body = r#"
1820 <html>
1821 <head>
1822 <title>Qwen 3.6 27B</title>
1823 </head>
1824 <body>
1825 <h1>Model card</h1>
1826 <p>Large language model.</p>
1827 </body>
1828 </html>
1829 "#;
1830
1831 let title = Regex::new(r"(?is)<title[^>]*>(.*?)</title>")
1832 .unwrap()
1833 .captures(body)
1834 .and_then(|captures| captures.get(1))
1835 .map(|m| {
1836 Regex::new(r"\s+")
1837 .unwrap()
1838 .replace_all(m.as_str(), " ")
1839 .trim()
1840 .to_string()
1841 })
1842 .filter(|title| !title.is_empty());
1843
1844 assert_eq!(title.as_deref(), Some("Qwen 3.6 27B"));
1845 }
1846
1847 #[test]
1848 fn test_read_website_metadata_includes_final_url_header() {
1849 let final_url = "https://huggingface.co/Qwen/Qwen3.6-27B";
1850 let title = Some("Qwen 3.6 27B".to_string());
1851 let cleaned = "Model card\nLarge language model.".to_string();
1852
1853 let mut metadata = vec![format!("URL: {final_url}")];
1854 if let Some(title) = &title {
1855 metadata.push(format!("Title: {title}"));
1856 }
1857
1858 let body_text = match title {
1859 Some(_) => cleaned,
1860 None => cleaned,
1861 };
1862
1863 let output = format!("{}\n\n{}", metadata.join("\n"), body_text);
1864
1865 assert!(output.starts_with("URL: https://huggingface.co/Qwen/Qwen3.6-27B"));
1866 assert!(output.contains("\nTitle: Qwen 3.6 27B\n\n"));
1867 }
1868
1869 // ── create_directory tests ───────────────────────────────────────────
1870
1871 #[test]
1872 fn test_create_directory_missing_path() {
1873 let result = exec_create_directory("{}");
1874 assert!(result.contains("missing required parameter"));
1875 }
1876
1877 #[test]
1878 fn test_create_directory_success() {
1879 let dir = std::env::temp_dir()
1880 .join("sigit_test_create_directory")
1881 .join("nested")
1882 .join("child");
1883 let _ = fs::remove_dir_all(dir.parent().unwrap());
1884
1885 let args = serde_json::json!({ "path": dir }).to_string();
1886 let result = exec_create_directory(&args);
1887 assert!(result.starts_with("Created directory:"), "got: {result}");
1888 assert!(dir.exists());
1889 assert!(dir.is_dir());
1890
1891 let _ = fs::remove_dir_all(dir.parent().unwrap().parent().unwrap());
1892 }
1893
1894 #[test]
1895 fn test_create_directory_already_exists() {
1896 let dir = std::env::temp_dir().join("sigit_test_create_directory_exists");
1897 let _ = fs::remove_dir_all(&dir);
1898 fs::create_dir_all(&dir).unwrap();
1899
1900 let args = serde_json::json!({ "path": dir }).to_string();
1901 let result = exec_create_directory(&args);
1902 assert!(result.contains("Directory already exists"), "got: {result}");
1903
1904 let _ = fs::remove_dir_all(&dir);
1905 }
1906
1907 // ── create_file tests ────────────────────────────────────────────────
1908
1909 #[test]
1910 fn test_create_file_missing_path() {
1911 let result = exec_create_file(r#"{"content": "hello"}"#);
1912 assert!(result.contains("missing required parameter"));
1913 }
1914
1915 #[test]
1916 fn test_create_file_missing_content() {
1917 let result = exec_create_file(r#"{"path": "/tmp/sigit_test_nope.txt"}"#);
1918 assert!(result.contains("missing required parameter"));
1919 }
1920
1921 #[test]
1922 fn test_create_file_success() {
1923 let dir = std::env::temp_dir().join("sigit_test_create_file");
1924 let _ = fs::remove_dir_all(&dir);
1925
1926 let file_path = dir.join("sub").join("new_file.txt");
1927 let args = serde_json::json!({
1928 "path": file_path,
1929 "content": "hello world"
1930 })
1931 .to_string();
1932
1933 let result = exec_create_file(&args);
1934 assert!(result.starts_with("Created file:"), "got: {result}");
1935 assert!(file_path.exists());
1936 assert_eq!(fs::read_to_string(&file_path).unwrap(), "hello world");
1937
1938 let _ = fs::remove_dir_all(&dir);
1939 }
1940
1941 #[test]
1942 fn test_create_file_already_exists() {
1943 let dir = std::env::temp_dir().join("sigit_test_create_exists");
1944 let _ = fs::remove_dir_all(&dir);
1945 fs::create_dir_all(&dir).unwrap();
1946
1947 let file_path = dir.join("existing.txt");
1948 fs::write(&file_path, "original").unwrap();
1949
1950 let args = serde_json::json!({
1951 "path": file_path,
1952 "content": "overwrite attempt"
1953 })
1954 .to_string();
1955
1956 let result = exec_create_file(&args);
1957 assert!(result.contains("already exists"), "got: {result}");
1958 // Original content untouched.
1959 assert_eq!(fs::read_to_string(&file_path).unwrap(), "original");
1960
1961 let _ = fs::remove_dir_all(&dir);
1962 }
1963
1964 // ── edit_file tests ──────────────────────────────────────────────────
1965
1966 #[test]
1967 fn test_edit_file_missing_params() {
1968 let result = exec_edit_file(r#"{"path": "x"}"#);
1969 assert!(result.contains("missing required parameter"));
1970
1971 let result = exec_edit_file(r#"{"path": "x", "old_text": "a"}"#);
1972 assert!(result.contains("missing required parameter"));
1973 }
1974
1975 #[test]
1976 fn test_edit_file_nonexistent() {
1977 let result = exec_edit_file(
1978 r#"{"path": "/tmp/__sigit_no_such__", "old_text": "a", "new_text": "b"}"#,
1979 );
1980 assert!(result.contains("does not exist"));
1981 }
1982
1983 #[test]
1984 fn test_edit_file_success() {
1985 let dir = std::env::temp_dir().join("sigit_test_edit_file");
1986 let _ = fs::remove_dir_all(&dir);
1987 fs::create_dir_all(&dir).unwrap();
1988
1989 let file_path = dir.join("code.rs");
1990 fs::write(&file_path, "fn main() {\n println!(\"hello\");\n}\n").unwrap();
1991
1992 let args = serde_json::json!({
1993 "path": file_path,
1994 "old_text": "println!(\"hello\")",
1995 "new_text": "println!(\"world\")"
1996 })
1997 .to_string();
1998
1999 let result = exec_edit_file(&args);
2000 assert!(result.starts_with("Edited file:"), "got: {result}");
2001
2002 let updated = fs::read_to_string(&file_path).unwrap();
2003 assert!(updated.contains("println!(\"world\")"));
2004 assert!(!updated.contains("println!(\"hello\")"));
2005
2006 let _ = fs::remove_dir_all(&dir);
2007 }
2008
2009 #[test]
2010 fn test_edit_file_old_text_not_found() {
2011 let dir = std::env::temp_dir().join("sigit_test_edit_notfound");
2012 let _ = fs::remove_dir_all(&dir);
2013 fs::create_dir_all(&dir).unwrap();
2014
2015 let file_path = dir.join("data.txt");
2016 fs::write(&file_path, "aaa bbb ccc").unwrap();
2017
2018 let args = serde_json::json!({
2019 "path": file_path,
2020 "old_text": "zzz",
2021 "new_text": "yyy"
2022 })
2023 .to_string();
2024
2025 let result = exec_edit_file(&args);
2026 assert!(result.contains("old_text not found"), "got: {result}");
2027
2028 let _ = fs::remove_dir_all(&dir);
2029 }
2030
2031 #[test]
2032 fn test_edit_file_ambiguous_match() {
2033 let dir = std::env::temp_dir().join("sigit_test_edit_ambiguous");
2034 let _ = fs::remove_dir_all(&dir);
2035 fs::create_dir_all(&dir).unwrap();
2036
2037 let file_path = dir.join("repeat.txt");
2038 fs::write(&file_path, "foo bar foo bar foo").unwrap();
2039
2040 let args = serde_json::json!({
2041 "path": file_path,
2042 "old_text": "foo",
2043 "new_text": "baz"
2044 })
2045 .to_string();
2046
2047 let result = exec_edit_file(&args);
2048 assert!(result.contains("appears 3 times"), "got: {result}");
2049 // File should be unchanged.
2050 assert_eq!(
2051 fs::read_to_string(&file_path).unwrap(),
2052 "foo bar foo bar foo"
2053 );
2054
2055 let _ = fs::remove_dir_all(&dir);
2056 }
2057
2058 // ── delete_file tests ────────────────────────────────────────────────
2059
2060 #[test]
2061 fn test_delete_file_missing_path() {
2062 let result = exec_delete_file("{}");
2063 assert!(
2064 result.contains("missing required parameter"),
2065 "got: {result}"
2066 );
2067 }
2068
2069 #[test]
2070 fn test_delete_file_nonexistent() {
2071 let result = exec_delete_file(r#"{"path": "/tmp/sigit_test_no_such_file_xyz"}"#);
2072 assert!(result.contains("does not exist"), "got: {result}");
2073 }
2074
2075 #[test]
2076 fn test_delete_file_success() {
2077 let dir = std::env::temp_dir().join("sigit_test_delete_file");
2078 let _ = fs::remove_dir_all(&dir);
2079 fs::create_dir_all(&dir).unwrap();
2080
2081 let file_path = dir.join("to_delete.txt");
2082 fs::write(&file_path, "bye").unwrap();
2083 assert!(file_path.exists());
2084
2085 let args = serde_json::json!({ "path": file_path }).to_string();
2086 let result = exec_delete_file(&args);
2087 assert!(result.contains("Deleted file"), "got: {result}");
2088 assert!(!file_path.exists());
2089
2090 let _ = fs::remove_dir_all(&dir);
2091 }
2092
2093 #[test]
2094 fn test_delete_empty_directory() {
2095 let dir = std::env::temp_dir().join("sigit_test_delete_empty_dir");
2096 let _ = fs::remove_dir_all(&dir);
2097 fs::create_dir_all(&dir).unwrap();
2098
2099 let args = serde_json::json!({ "path": dir }).to_string();
2100 let result = exec_delete_file(&args);
2101 assert!(result.contains("Deleted empty directory"), "got: {result}");
2102 assert!(!dir.exists());
2103 }
2104
2105 #[test]
2106 fn test_delete_nonempty_directory() {
2107 let dir = std::env::temp_dir().join("sigit_test_delete_nonempty_dir");
2108 let _ = fs::remove_dir_all(&dir);
2109 fs::create_dir_all(&dir).unwrap();
2110 fs::write(dir.join("child.txt"), "content").unwrap();
2111
2112 let args = serde_json::json!({ "path": dir }).to_string();
2113 let result = exec_delete_file(&args);
2114 assert!(result.contains("Error"), "got: {result}");
2115 assert!(dir.exists(), "directory should not have been deleted");
2116
2117 let _ = fs::remove_dir_all(&dir);
2118 }
2119
2120 // ── run_command tests ────────────────────────────────────────────────
2121
2122 #[test]
2123 fn test_run_command_missing_command() {
2124 let result = exec_run_command("{}");
2125 assert!(
2126 result.contains("missing required parameter"),
2127 "got: {result}"
2128 );
2129 }
2130
2131 #[test]
2132 fn test_run_command_success() {
2133 let result = exec_run_command(r#"{"command": "echo hello"}"#);
2134 assert!(result.contains("hello"), "got: {result}");
2135 assert!(result.contains("Exit code 0"), "got: {result}");
2136 }
2137
2138 #[test]
2139 fn test_run_command_failure() {
2140 #[cfg(unix)]
2141 let command = "false";
2142 #[cfg(windows)]
2143 let command = "exit /b 1";
2144
2145 let args = serde_json::json!({ "command": command }).to_string();
2146 let result = exec_run_command(&args);
2147 assert!(result.contains("failed"), "got: {result}");
2148 }
2149
2150 #[test]
2151 fn test_run_command_with_cwd() {
2152 let dir = std::env::temp_dir().join("sigit_test_run_cmd_cwd");
2153 let _ = fs::remove_dir_all(&dir);
2154 fs::create_dir_all(&dir).unwrap();
2155
2156 #[cfg(unix)]
2157 let command = "pwd";
2158 #[cfg(windows)]
2159 let command = "cd";
2160
2161 let args = serde_json::json!({
2162 "command": command,
2163 "cwd": dir
2164 })
2165 .to_string();
2166 let result = exec_run_command(&args);
2167 // The output should contain the temp dir path.
2168 assert!(
2169 result.contains(&dir.to_string_lossy().to_string()),
2170 "got: {result}"
2171 );
2172
2173 let _ = fs::remove_dir_all(&dir);
2174 }
2175
2176 #[test]
2177 fn test_run_command_bad_cwd() {
2178 let missing_dir = std::env::temp_dir().join("sigit_no_such_dir_xyz");
2179 let _ = fs::remove_dir_all(&missing_dir);
2180
2181 let args = serde_json::json!({
2182 "command": "echo hi",
2183 "cwd": missing_dir
2184 })
2185 .to_string();
2186 let result = exec_run_command(&args);
2187 assert!(result.contains("does not exist"), "got: {result}");
2188 }
2189
2190 #[test]
2191 fn test_run_command_captures_stderr() {
2192 #[cfg(unix)]
2193 let command = "echo err >&2";
2194 #[cfg(windows)]
2195 let command = "echo err 1>&2";
2196
2197 let args = serde_json::json!({ "command": command }).to_string();
2198 let result = exec_run_command(&args);
2199 assert!(result.contains("err"), "got: {result}");
2200 }
2201 }