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 // Tools discovered from MCP servers are namespaced `mcp__<server>__<tool>`
404 // and forwarded to the owning server.
405 _ if crate::mcp::is_mcp_tool(name) => crate::mcp::call_tool(name, arguments).await,
406 _ => format!("Unknown tool: {name}"),
407 }
408 }
409
410 fn absolute_path(path: &Path) -> PathBuf {
411 if path.is_absolute() {
412 path.to_path_buf()
413 } else {
414 std::env::current_dir()
415 .unwrap_or_else(|_| PathBuf::from("."))
416 .join(path)
417 }
418 }
419
420 fn absolute_path_string(path: &Path) -> String {
421 absolute_path(path).display().to_string()
422 }
423
424 // ── read_file ────────────────────────────────────────────────────────────────
425
426 fn exec_read_file(arguments: &str) -> String {
427 let args: Value = match serde_json::from_str(arguments) {
428 Ok(v) => v,
429 Err(err) => return format!("Error: failed to parse arguments: {err}"),
430 };
431
432 let path_str = match args.get("path").and_then(Value::as_str) {
433 Some(p) => p,
434 None => return "Error: missing required parameter \"path\"".to_string(),
435 };
436
437 let start_line = args
438 .get("start_line")
439 .and_then(Value::as_u64)
440 .map(|n| n as usize);
441 let end_line = args
442 .get("end_line")
443 .and_then(Value::as_u64)
444 .map(|n| n as usize);
445
446 let path = Path::new(path_str);
447 let absolute_path = absolute_path(path);
448 let absolute_path_str = absolute_path.display().to_string();
449
450 if !absolute_path.exists() {
451 return format!("Error: path does not exist: {absolute_path_str}");
452 }
453
454 if !absolute_path.is_file() {
455 return format!("Error: path is not a file: {absolute_path_str}");
456 }
457
458 match fs::read_to_string(&absolute_path) {
459 Ok(contents) => {
460 if start_line.is_some() || end_line.is_some() {
461 let lines: Vec<&str> = contents.lines().collect();
462 let total = lines.len();
463 let start = start_line.unwrap_or(1).max(1);
464 let end = end_line.unwrap_or(total).min(total);
465
466 if start > total {
467 return format!(
468 "Error: start_line {start} is beyond end of file ({total} lines)"
469 );
470 }
471
472 let selected: Vec<&str> = lines[(start - 1)..end].to_vec();
473 let range_text = selected.join("\n");
474 format!("Lines {start}-{end} of {total} in {absolute_path_str}:\n{range_text}")
475 } else if contents.len() > READ_FILE_CHAR_LIMIT {
476 let truncated: String = contents.chars().take(READ_FILE_CHAR_LIMIT).collect();
477 format!(
478 "{truncated}\n\n--- truncated (showing {READ_FILE_CHAR_LIMIT} of {} characters) ---",
479 contents.len()
480 )
481 } else {
482 contents
483 }
484 }
485 Err(err) => format!("Error: could not read file: {err}"),
486 }
487 }
488
489 // ── list_directory ───────────────────────────────────────────────────────────
490
491 fn exec_list_directory(arguments: &str) -> String {
492 let args: Value = match serde_json::from_str(arguments) {
493 Ok(v) => v,
494 Err(err) => return format!("Error: failed to parse arguments: {err}"),
495 };
496
497 let path_str = match args.get("path").and_then(Value::as_str) {
498 Some(p) => p,
499 None => return "Error: missing required parameter \"path\"".to_string(),
500 };
501
502 let path = Path::new(path_str);
503 let absolute_path = absolute_path(path);
504 let absolute_path_str = absolute_path.display().to_string();
505
506 if !absolute_path.exists() {
507 return format!("Error: path does not exist: {absolute_path_str}");
508 }
509
510 if !absolute_path.is_dir() {
511 return format!("Error: path is not a directory: {absolute_path_str}");
512 }
513
514 let entries = match fs::read_dir(&absolute_path) {
515 Ok(rd) => rd,
516 Err(err) => return format!("Error: could not read directory: {err}"),
517 };
518
519 let mut dirs: Vec<String> = Vec::new();
520 let mut files: Vec<String> = Vec::new();
521
522 for entry in entries {
523 let entry = match entry {
524 Ok(e) => e,
525 Err(err) => {
526 files.push(format!("[ERR] {err}"));
527 continue;
528 }
529 };
530
531 let name = entry.file_name().to_string_lossy().to_string();
532
533 let is_dir = match entry.file_type() {
534 Ok(ft) => ft.is_dir(),
535 Err(_) => false,
536 };
537
538 if is_dir {
539 dirs.push(format!("[DIR] {name}"));
540 } else {
541 files.push(format!("[FILE] {name}"));
542 }
543 }
544
545 dirs.sort();
546 files.sort();
547
548 dirs.extend(files);
549
550 if dirs.is_empty() {
551 return format!("(empty directory: {absolute_path_str})");
552 }
553
554 dirs.join("\n")
555 }
556
557 // ── search_files ─────────────────────────────────────────────────────────────
558
559 fn exec_search_files(arguments: &str) -> String {
560 let args: Value = match serde_json::from_str(arguments) {
561 Ok(v) => v,
562 Err(err) => return format!("Error: failed to parse arguments: {err}"),
563 };
564
565 let pattern_str = match args.get("pattern").and_then(Value::as_str) {
566 Some(p) => p,
567 None => return "Error: missing required parameter \"pattern\"".to_string(),
568 };
569
570 let root_str = args.get("path").and_then(Value::as_str).unwrap_or(".");
571
572 let re = match Regex::new(pattern_str) {
573 Ok(r) => r,
574 Err(err) => return format!("Error: invalid regex pattern: {err}"),
575 };
576
577 // Optional file-name filter compiled from a glob (e.g. "*.rs").
578 let name_filter = match args.get("file_glob").and_then(Value::as_str) {
579 Some(glob) => match Regex::new(&glob_to_regex(glob)) {
580 Ok(r) => Some(r),
581 Err(err) => return format!("Error: invalid file_glob: {err}"),
582 },
583 None => None,
584 };
585
586 let limit = args
587 .get("max_results")
588 .and_then(Value::as_u64)
589 .map(|n| (n as usize).clamp(1, SEARCH_RESULTS_HARD_CAP))
590 .unwrap_or(SEARCH_FILES_MATCH_LIMIT);
591
592 let root = Path::new(root_str);
593 let absolute_root = absolute_path(root);
594 let absolute_root_str = absolute_root.display().to_string();
595
596 if !absolute_root.exists() {
597 return format!("Error: path does not exist: {absolute_root_str}");
598 }
599
600 if !absolute_root.is_dir() {
601 return format!("Error: path is not a directory: {absolute_root_str}");
602 }
603
604 let mut matches: Vec<String> = Vec::new();
605 walk_and_search(
606 &absolute_root,
607 &re,
608 name_filter.as_ref(),
609 limit,
610 &mut matches,
611 );
612
613 if matches.is_empty() {
614 return format!("No matches found for pattern: {pattern_str}");
615 }
616
617 let total = matches.len();
618 if total > limit {
619 matches.truncate(limit);
620 matches.push(format!(
621 "\n--- truncated (showing {limit} of {total}+ matches; raise max_results to see more) ---"
622 ));
623 }
624
625 matches.join("\n")
626 }
627
628 /// Collects up to `limit + 1` matches (the extra signals truncation) so a broad
629 /// pattern can't walk an entire tree once enough hits are found.
630 fn walk_and_search(
631 dir: &Path,
632 re: &Regex,
633 name_filter: Option<&Regex>,
634 limit: usize,
635 matches: &mut Vec<String>,
636 ) {
637 let entries = match fs::read_dir(dir) {
638 Ok(rd) => rd,
639 Err(_) => return,
640 };
641
642 let mut sorted: Vec<fs::DirEntry> = entries.filter_map(Result::ok).collect();
643 sorted.sort_by_key(|e| e.file_name());
644
645 for entry in sorted {
646 if matches.len() > limit {
647 return;
648 }
649
650 let path = entry.path();
651 let name = entry.file_name();
652 let name_str = name.to_string_lossy();
653
654 if name_str.starts_with('.') {
655 continue;
656 }
657
658 if path.is_dir() {
659 walk_and_search(&path, re, name_filter, limit, matches);
660 } else if path.is_file() {
661 if let Some(filter) = name_filter
662 && !filter.is_match(&name_str)
663 {
664 continue;
665 }
666 search_file(&path, re, matches);
667 }
668 }
669 }
670
671 /// skips non-UTF-8 files (probably binary).
672 fn search_file(path: &Path, re: &Regex, matches: &mut Vec<String>) {
673 let contents = match fs::read_to_string(path) {
674 Ok(c) => c,
675 Err(_) => return,
676 };
677
678 let display_path = absolute_path_string(path);
679
680 for (line_idx, line) in contents.lines().enumerate() {
681 if re.is_match(line) {
682 let line_number = line_idx + 1;
683 matches.push(format!("{display_path}:{line_number}: {line}"));
684 }
685 }
686 }
687
688 // ── read_website ─────────────────────────────────────────────────────────────
689
690 fn exec_read_website(arguments: &str) -> String {
691 let args: Value = match serde_json::from_str(arguments) {
692 Ok(v) => v,
693 Err(err) => return format!("Error: failed to parse arguments: {err}"),
694 };
695
696 let url = match args.get("url").and_then(Value::as_str) {
697 Some(u) => u,
698 None => return "Error: missing required parameter \"url\"".to_string(),
699 };
700
701 if !(url.starts_with("http://") || url.starts_with("https://")) {
702 return format!("Error: url must start with http:// or https://: {url}");
703 }
704
705 let client = match reqwest::blocking::Client::builder()
706 .timeout(WEBSITE_READ_TIMEOUT)
707 .user_agent(WEBSITE_USER_AGENT)
708 .build()
709 {
710 Ok(client) => client,
711 Err(err) => return format!("Error: failed to build website client: {err}"),
712 };
713
714 let response = match client.get(url).send() {
715 Ok(r) => r,
716 Err(err) => return format!("Error: failed to fetch website: {err}"),
717 };
718
719 let final_url = response.url().to_string();
720 let status = response.status();
721 if !status.is_success() {
722 return format!("Error: website returned HTTP {status} for {final_url}");
723 }
724
725 let body = match response.text() {
726 Ok(text) => text,
727 Err(err) => return format!("Error: failed to read website body: {err}"),
728 };
729
730 let title = Regex::new(r"(?is)<title[^>]*>(.*?)</title>")
731 .unwrap()
732 .captures(&body)
733 .and_then(|captures| captures.get(1))
734 .map(|m| {
735 Regex::new(r"\s+")
736 .unwrap()
737 .replace_all(m.as_str(), " ")
738 .trim()
739 .to_string()
740 })
741 .filter(|title| !title.is_empty());
742
743 let with_block_breaks = Regex::new(
744 r"(?is)</?(?:p|div|section|article|main|aside|header|footer|nav|li|ul|ol|h1|h2|h3|h4|h5|h6|br|tr|td|th)[^>]*>",
745 )
746 .unwrap()
747 .replace_all(&body, "\n");
748 let without_scripts = Regex::new(r"(?is)<script[^>]*>.*?</script>")
749 .unwrap()
750 .replace_all(&with_block_breaks, " ");
751 let without_styles = Regex::new(r"(?is)<style[^>]*>.*?</style>")
752 .unwrap()
753 .replace_all(&without_scripts, " ");
754 let without_tags = Regex::new(r"(?is)<[^>]+>")
755 .unwrap()
756 .replace_all(&without_styles, " ");
757 let normalized_newlines = without_tags
758 .replace("&nbsp;", " ")
759 .replace("&amp;", "&")
760 .replace("&lt;", "<")
761 .replace("&gt;", ">")
762 .replace("&quot;", "\"")
763 .replace("&#39;", "'");
764 let collapsed_lines = Regex::new(r"[ \t]+")
765 .unwrap()
766 .replace_all(&normalized_newlines, " ");
767 let collapsed_breaks = Regex::new(r"\n\s*\n+")
768 .unwrap()
769 .replace_all(&collapsed_lines, "\n\n");
770 let cleaned = collapsed_breaks
771 .lines()
772 .map(str::trim)
773 .filter(|line| !line.is_empty())
774 .collect::<Vec<_>>()
775 .join("\n");
776
777 if cleaned.is_empty() {
778 return format!("Fetched {url}, but no readable text content was found.");
779 }
780
781 let mut metadata = vec![format!("URL: {final_url}")];
782 if let Some(title) = &title {
783 metadata.push(format!("Title: {title}"));
784 }
785
786 let body_text = match title {
787 Some(title) if !cleaned.starts_with(&title) => cleaned,
788 _ => cleaned,
789 };
790
791 let output = format!("{}\n\n{}", metadata.join("\n"), body_text);
792
793 if output.len() > WEBSITE_READ_CHAR_LIMIT {
794 let truncated: String = output.chars().take(WEBSITE_READ_CHAR_LIMIT).collect();
795 return format!(
796 "{truncated}\n\n--- truncated (showing {WEBSITE_READ_CHAR_LIMIT} of {} characters) ---",
797 output.len()
798 );
799 }
800
801 output
802 }
803
804 // ── create_directory ─────────────────────────────────────────────────────────
805
806 fn exec_create_directory(arguments: &str) -> String {
807 let args: Value = match serde_json::from_str(arguments) {
808 Ok(v) => v,
809 Err(err) => return format!("Error: failed to parse arguments: {err}"),
810 };
811
812 let path_str = match args.get("path").and_then(Value::as_str) {
813 Some(p) => p,
814 None => return "Error: missing required parameter \"path\"".to_string(),
815 };
816
817 let path = Path::new(path_str);
818 let absolute_path = absolute_path(path);
819 let absolute_path_str = absolute_path.display().to_string();
820
821 if absolute_path.exists() {
822 if absolute_path.is_dir() {
823 return format!("Directory already exists: {absolute_path_str}");
824 }
825 return format!("Error: path exists and is not a directory: {absolute_path_str}");
826 }
827
828 match fs::create_dir_all(&absolute_path) {
829 Ok(()) => format!("Created directory: {absolute_path_str}"),
830 Err(err) => format!("Error: could not create directory: {err}"),
831 }
832 }
833
834 /// fails if file exists so the LLM is forced to use `edit_file` for modifications.
835 fn exec_create_file(arguments: &str) -> String {
836 let args: Value = match serde_json::from_str(arguments) {
837 Ok(v) => v,
838 Err(err) => return format!("Error: failed to parse arguments: {err}"),
839 };
840
841 let path_str = match args.get("path").and_then(Value::as_str) {
842 Some(p) => p,
843 None => return "Error: missing required parameter \"path\"".to_string(),
844 };
845
846 let content = match args.get("content").and_then(Value::as_str) {
847 Some(c) => c,
848 None => return "Error: missing required parameter \"content\"".to_string(),
849 };
850
851 let path = Path::new(path_str);
852 let absolute_path = absolute_path(path);
853 let absolute_path_str = absolute_path.display().to_string();
854
855 if absolute_path.exists() {
856 return format!(
857 "Error: file already exists: {absolute_path_str} — use edit_file to modify existing files"
858 );
859 }
860
861 if let Some(parent) = absolute_path.parent()
862 && !parent.as_os_str().is_empty()
863 && !parent.exists()
864 && let Err(err) = fs::create_dir_all(parent)
865 {
866 return format!("Error: could not create parent directories: {err}");
867 }
868
869 match fs::write(&absolute_path, content) {
870 Ok(()) => format!(
871 "Created file: {absolute_path_str} ({} bytes)",
872 content.len()
873 ),
874 Err(err) => format!("Error: could not write file: {err}"),
875 }
876 }
877
878 // ── edit_file / multi_edit ─────────────────────────────────────────────────
879
880 /// Apply one exact-substring replacement to `contents`. Returns the updated
881 /// string, or a human-readable explanation of why the match failed so the model
882 /// can correct itself in a single follow-up instead of guessing blindly.
883 fn apply_edit(
884 contents: &str,
885 old_text: &str,
886 new_text: &str,
887 replace_all: bool,
888 ) -> Result<String, String> {
889 if old_text.is_empty() {
890 return Err("old_text is empty; nothing to match".to_string());
891 }
892 if old_text == new_text {
893 return Err("old_text and new_text are identical; no change to make".to_string());
894 }
895
896 let occurrences = contents.matches(old_text).count();
897
898 if occurrences == 0 {
899 return Err(format!(
900 "old_text not found. Use read_file to copy the exact text \
901 (including whitespace and indentation) to replace.{}",
902 nearest_line_hint(contents, old_text)
903 ));
904 }
905
906 if occurrences > 1 && !replace_all {
907 return Err(format!(
908 "old_text appears {occurrences} times; include more surrounding context so it \
909 matches exactly once, or set replace_all to true to change every occurrence."
910 ));
911 }
912
913 if replace_all {
914 Ok(contents.replace(old_text, new_text))
915 } else {
916 Ok(contents.replacen(old_text, new_text, 1))
917 }
918 }
919
920 /// When `old_text` doesn't match verbatim, point at the line whose trimmed text
921 /// equals the first trimmed line of `old_text` — the usual culprit is a
922 /// whitespace/indentation mismatch, and naming the line lets the model fix it.
923 fn nearest_line_hint(contents: &str, old_text: &str) -> String {
924 let first = old_text.lines().find(|l| !l.trim().is_empty());
925 let Some(first) = first.map(str::trim) else {
926 return String::new();
927 };
928 for (idx, line) in contents.lines().enumerate() {
929 if line.trim() == first {
930 return format!(
931 " (the first line of old_text appears at line {}, so the difference is likely \
932 whitespace or indentation)",
933 idx + 1
934 );
935 }
936 }
937 String::new()
938 }
939
940 fn exec_edit_file(arguments: &str) -> String {
941 let args: Value = match serde_json::from_str(arguments) {
942 Ok(v) => v,
943 Err(err) => return format!("Error: failed to parse arguments: {err}"),
944 };
945
946 let path_str = match args.get("path").and_then(Value::as_str) {
947 Some(p) => p,
948 None => return "Error: missing required parameter \"path\"".to_string(),
949 };
950
951 let old_text = match args.get("old_text").and_then(Value::as_str) {
952 Some(t) => t,
953 None => return "Error: missing required parameter \"old_text\"".to_string(),
954 };
955
956 let new_text = match args.get("new_text").and_then(Value::as_str) {
957 Some(t) => t,
958 None => return "Error: missing required parameter \"new_text\"".to_string(),
959 };
960
961 let replace_all = args
962 .get("replace_all")
963 .and_then(Value::as_bool)
964 .unwrap_or(false);
965
966 let path = Path::new(path_str);
967 let absolute_path = absolute_path(path);
968 let absolute_path_str = absolute_path.display().to_string();
969
970 if !absolute_path.exists() {
971 return format!(
972 "Error: file does not exist: {absolute_path_str} — use create_file for new files"
973 );
974 }
975
976 if !absolute_path.is_file() {
977 return format!("Error: path is not a file: {absolute_path_str}");
978 }
979
980 let contents = match fs::read_to_string(&absolute_path) {
981 Ok(c) => c,
982 Err(err) => return format!("Error: could not read file: {err}"),
983 };
984
985 let updated = match apply_edit(&contents, old_text, new_text, replace_all) {
986 Ok(updated) => updated,
987 Err(why) => return format!("Error: {why} (in {absolute_path_str})"),
988 };
989
990 match fs::write(&absolute_path, &updated) {
991 Ok(()) => format!(
992 "Edited file: {absolute_path_str} ({} bytes written)",
993 updated.len()
994 ),
995 Err(err) => format!("Error: could not write file: {err}"),
996 }
997 }
998
999 /// Apply a batch of edits to one file atomically: each edit is applied to the
1000 /// result of the previous one, and the file is only written if *every* edit
1001 /// matches. A failure leaves the file untouched.
1002 fn exec_multi_edit(arguments: &str) -> String {
1003 let args: Value = match serde_json::from_str(arguments) {
1004 Ok(v) => v,
1005 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1006 };
1007
1008 let path_str = match args.get("path").and_then(Value::as_str) {
1009 Some(p) => p,
1010 None => return "Error: missing required parameter \"path\"".to_string(),
1011 };
1012
1013 let edits = match args.get("edits").and_then(Value::as_array) {
1014 Some(e) if !e.is_empty() => e,
1015 Some(_) => return "Error: \"edits\" must contain at least one edit".to_string(),
1016 None => return "Error: missing required parameter \"edits\"".to_string(),
1017 };
1018
1019 let path = Path::new(path_str);
1020 let absolute_path = absolute_path(path);
1021 let absolute_path_str = absolute_path.display().to_string();
1022
1023 if !absolute_path.exists() {
1024 return format!(
1025 "Error: file does not exist: {absolute_path_str} — use create_file for new files"
1026 );
1027 }
1028
1029 if !absolute_path.is_file() {
1030 return format!("Error: path is not a file: {absolute_path_str}");
1031 }
1032
1033 let mut working = match fs::read_to_string(&absolute_path) {
1034 Ok(c) => c,
1035 Err(err) => return format!("Error: could not read file: {err}"),
1036 };
1037
1038 for (idx, edit) in edits.iter().enumerate() {
1039 let old_text = match edit.get("old_text").and_then(Value::as_str) {
1040 Some(t) => t,
1041 None => return format!("Error: edit #{} is missing \"old_text\"", idx + 1),
1042 };
1043 let new_text = match edit.get("new_text").and_then(Value::as_str) {
1044 Some(t) => t,
1045 None => return format!("Error: edit #{} is missing \"new_text\"", idx + 1),
1046 };
1047 let replace_all = edit
1048 .get("replace_all")
1049 .and_then(Value::as_bool)
1050 .unwrap_or(false);
1051
1052 match apply_edit(&working, old_text, new_text, replace_all) {
1053 Ok(updated) => working = updated,
1054 Err(why) => {
1055 return format!(
1056 "Error: edit #{} failed: {why}. No changes were written to {absolute_path_str}.",
1057 idx + 1
1058 );
1059 }
1060 }
1061 }
1062
1063 match fs::write(&absolute_path, &working) {
1064 Ok(()) => format!(
1065 "Applied {} edits to {absolute_path_str} ({} bytes written)",
1066 edits.len(),
1067 working.len()
1068 ),
1069 Err(err) => format!("Error: could not write file: {err}"),
1070 }
1071 }
1072
1073 // ── glob ─────────────────────────────────────────────────────────────────────
1074
1075 /// Translate a shell-style glob into an anchored regex. Supports `*`
1076 /// (non-separator run), `**` (any number of directories), `?` (one
1077 /// non-separator), and `{a,b}` alternation; everything else is matched
1078 /// literally. Used both by the `glob` tool (against relative paths) and by
1079 /// `search_files`' `file_glob` filter (against bare file names).
1080 fn glob_to_regex(glob: &str) -> String {
1081 let chars: Vec<char> = glob.chars().collect();
1082 let mut re = String::from("^");
1083 let mut brace_depth = 0usize;
1084 let mut i = 0;
1085
1086 while i < chars.len() {
1087 let c = chars[i];
1088 match c {
1089 '*' => {
1090 if i + 1 < chars.len() && chars[i + 1] == '*' {
1091 i += 1; // consume the second '*'
1092 if i + 1 < chars.len() && chars[i + 1] == '/' {
1093 // `**/` matches zero or more leading directories.
1094 re.push_str("(?:.*/)?");
1095 i += 1; // consume the '/'
1096 } else {
1097 re.push_str(".*");
1098 }
1099 } else {
1100 re.push_str("[^/]*");
1101 }
1102 }
1103 '?' => re.push_str("[^/]"),
1104 '{' => {
1105 brace_depth += 1;
1106 re.push_str("(?:");
1107 }
1108 '}' if brace_depth > 0 => {
1109 brace_depth -= 1;
1110 re.push(')');
1111 }
1112 ',' if brace_depth > 0 => re.push('|'),
1113 // Escape regex metacharacters so they match literally. (`{` is always
1114 // consumed by the brace arm above; an unmatched `}` lands here.)
1115 '.' | '+' | '(' | ')' | '|' | '^' | '$' | '\\' | '[' | ']' | '}' => {
1116 re.push('\\');
1117 re.push(c);
1118 }
1119 other => re.push(other),
1120 }
1121 i += 1;
1122 }
1123
1124 re.push('$');
1125 re
1126 }
1127
1128 fn exec_glob(arguments: &str) -> String {
1129 let args: Value = match serde_json::from_str(arguments) {
1130 Ok(v) => v,
1131 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1132 };
1133
1134 let pattern = match args.get("pattern").and_then(Value::as_str) {
1135 Some(p) => p,
1136 None => return "Error: missing required parameter \"pattern\"".to_string(),
1137 };
1138
1139 let re = match Regex::new(&glob_to_regex(pattern)) {
1140 Ok(r) => r,
1141 Err(err) => return format!("Error: invalid glob pattern: {err}"),
1142 };
1143
1144 let root_str = args.get("path").and_then(Value::as_str).unwrap_or(".");
1145 let absolute_root = absolute_path(Path::new(root_str));
1146 let absolute_root_str = absolute_root.display().to_string();
1147
1148 if !absolute_root.exists() {
1149 return format!("Error: path does not exist: {absolute_root_str}");
1150 }
1151 if !absolute_root.is_dir() {
1152 return format!("Error: path is not a directory: {absolute_root_str}");
1153 }
1154
1155 let mut found: Vec<(std::time::SystemTime, String)> = Vec::new();
1156 glob_walk(&absolute_root, &absolute_root, &re, &mut found);
1157
1158 if found.is_empty() {
1159 return format!("No files match glob: {pattern}");
1160 }
1161
1162 // Most-recently-modified first.
1163 found.sort_by_key(|(mtime, _)| std::cmp::Reverse(*mtime));
1164
1165 let total = found.len();
1166 let mut paths: Vec<String> = found.into_iter().map(|(_, p)| p).collect();
1167 if total > SEARCH_RESULTS_HARD_CAP {
1168 paths.truncate(SEARCH_RESULTS_HARD_CAP);
1169 paths.push(format!(
1170 "\n--- truncated (showing {SEARCH_RESULTS_HARD_CAP} of {total} files) ---"
1171 ));
1172 }
1173
1174 paths.join("\n")
1175 }
1176
1177 fn glob_walk(root: &Path, dir: &Path, re: &Regex, out: &mut Vec<(std::time::SystemTime, String)>) {
1178 if out.len() > SEARCH_RESULTS_HARD_CAP {
1179 return;
1180 }
1181
1182 let entries = match fs::read_dir(dir) {
1183 Ok(rd) => rd,
1184 Err(_) => return,
1185 };
1186
1187 let mut sorted: Vec<fs::DirEntry> = entries.filter_map(Result::ok).collect();
1188 sorted.sort_by_key(|e| e.file_name());
1189
1190 for entry in sorted {
1191 if out.len() > SEARCH_RESULTS_HARD_CAP {
1192 return;
1193 }
1194
1195 let path = entry.path();
1196 let name = entry.file_name();
1197 if name.to_string_lossy().starts_with('.') {
1198 continue;
1199 }
1200
1201 if path.is_dir() {
1202 glob_walk(root, &path, re, out);
1203 } else if path.is_file() {
1204 let relative = path
1205 .strip_prefix(root)
1206 .unwrap_or(&path)
1207 .to_string_lossy()
1208 .replace('\\', "/");
1209 if re.is_match(&relative) {
1210 let mtime = entry
1211 .metadata()
1212 .and_then(|m| m.modified())
1213 .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
1214 out.push((mtime, absolute_path_string(&path)));
1215 }
1216 }
1217 }
1218 }
1219
1220 // ── write_todos ──────────────────────────────────────────────────────────────
1221
1222 /// Renders the model's task checklist back as the tool result so the surface
1223 /// (TUI / ACP client) can show live progress. Pure presentation — the list is
1224 /// owned by the model, not persisted here.
1225 fn exec_write_todos(arguments: &str) -> String {
1226 let args: Value = match serde_json::from_str(arguments) {
1227 Ok(v) => v,
1228 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1229 };
1230
1231 let todos = match args.get("todos").and_then(Value::as_array) {
1232 Some(t) if !t.is_empty() => t,
1233 Some(_) => return "Error: \"todos\" must contain at least one item".to_string(),
1234 None => return "Error: missing required parameter \"todos\"".to_string(),
1235 };
1236
1237 let mut lines = Vec::with_capacity(todos.len());
1238 let mut completed = 0usize;
1239
1240 for (idx, todo) in todos.iter().enumerate() {
1241 let content = match todo.get("content").and_then(Value::as_str) {
1242 Some(c) => c.trim(),
1243 None => return format!("Error: todo #{} is missing \"content\"", idx + 1),
1244 };
1245 let status = todo
1246 .get("status")
1247 .and_then(Value::as_str)
1248 .unwrap_or("pending");
1249
1250 let marker = match status {
1251 "completed" => {
1252 completed += 1;
1253 "[x]"
1254 }
1255 "in_progress" => "[~]",
1256 _ => "[ ]",
1257 };
1258 lines.push(format!("{marker} {content}"));
1259 }
1260
1261 format!(
1262 "Task list updated ({completed}/{} done):\n{}",
1263 todos.len(),
1264 lines.join("\n")
1265 )
1266 }
1267
1268 // ── remember ─────────────────────────────────────────────────────────────────
1269
1270 /// Appends a durable note to the project's instruction file so it persists
1271 /// across sessions (the always-on counterpart to a one-off chat message).
1272 fn exec_remember(arguments: &str) -> String {
1273 let args: Value = match serde_json::from_str(arguments) {
1274 Ok(v) => v,
1275 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1276 };
1277
1278 let note = match args.get("note").and_then(Value::as_str) {
1279 Some(n) if !n.trim().is_empty() => n.trim(),
1280 Some(_) => return "Error: \"note\" must not be empty".to_string(),
1281 None => return "Error: missing required parameter \"note\"".to_string(),
1282 };
1283
1284 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1285 remember_at(&cwd, note)
1286 }
1287
1288 /// Core of `remember`, parameterized on the working directory so it can be
1289 /// tested without mutating the process-global current directory.
1290 fn remember_at(cwd: &Path, note: &str) -> String {
1291 let target = crate::instructions::memory_file(cwd);
1292 let target_str = target.display().to_string();
1293
1294 let existed = target.exists();
1295 let mut body = if existed {
1296 match fs::read_to_string(&target) {
1297 Ok(c) => c,
1298 Err(err) => return format!("Error: could not read {target_str}: {err}"),
1299 }
1300 } else {
1301 if let Some(parent) = target.parent()
1302 && !parent.as_os_str().is_empty()
1303 && !parent.exists()
1304 && let Err(err) = fs::create_dir_all(parent)
1305 {
1306 return format!("Error: could not create parent directories: {err}");
1307 }
1308 String::new()
1309 };
1310
1311 // Keep remembered notes grouped under one heading so the file stays tidy.
1312 const HEADING: &str = "## Remembered notes";
1313 if !body.contains(HEADING) {
1314 if !body.is_empty() && !body.ends_with('\n') {
1315 body.push('\n');
1316 }
1317 if !body.is_empty() {
1318 body.push('\n');
1319 }
1320 body.push_str(HEADING);
1321 body.push('\n');
1322 }
1323 if !body.ends_with('\n') {
1324 body.push('\n');
1325 }
1326 body.push_str("- ");
1327 body.push_str(note);
1328 body.push('\n');
1329
1330 match fs::write(&target, &body) {
1331 Ok(()) => {
1332 let verb = if existed { "Appended to" } else { "Created" };
1333 format!("{verb} {target_str}: remembered \"{note}\"")
1334 }
1335 Err(err) => format!("Error: could not write {target_str}: {err}"),
1336 }
1337 }
1338
1339 // ── delete_file ──────────────────────────────────────────────────────────────
1340
1341 /// only removes files or *empty* directories — no recursive deletes.
1342 fn exec_delete_file(arguments: &str) -> String {
1343 let args: Value = match serde_json::from_str(arguments) {
1344 Ok(v) => v,
1345 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1346 };
1347
1348 let path_str = match args.get("path").and_then(Value::as_str) {
1349 Some(p) => p,
1350 None => return "Error: missing required parameter \"path\"".to_string(),
1351 };
1352
1353 let path = Path::new(path_str);
1354 let absolute_path = absolute_path(path);
1355 let absolute_path_str = absolute_path.display().to_string();
1356
1357 if !absolute_path.exists() {
1358 return format!("Error: path does not exist: {absolute_path_str}");
1359 }
1360
1361 if absolute_path.is_dir() {
1362 match fs::remove_dir(&absolute_path) {
1363 Ok(()) => format!("Deleted empty directory: {absolute_path_str}"),
1364 Err(err) => format!(
1365 "Error: could not delete directory: {err}. \
1366 Only empty directories can be deleted."
1367 ),
1368 }
1369 } else {
1370 match fs::remove_file(&absolute_path) {
1371 Ok(()) => format!("Deleted file: {absolute_path_str}"),
1372 Err(err) => format!("Error: could not delete file: {err}"),
1373 }
1374 }
1375 }
1376
1377 // ── run_command ──────────────────────────────────────────────────────────────
1378
1379 const COMMAND_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
1380 const COMMAND_OUTPUT_LIMIT: usize = 50_000;
1381
1382 /// Trailer identifying siGit Code as the co-author of commits it creates.
1383 /// GitHub detects `Co-authored-by:` trailers on the last lines of a commit
1384 /// message (separated from the body by a blank line) and lists the agent
1385 /// alongside the human author; `sigit@sigit.si` belongs to the
1386 /// <https://github.com/sigitc> account ("siGit Code"), so the co-author is
1387 /// rendered with that account's avatar and profile link. The system prompt
1388 /// asks the model to add this itself; [`ensure_commit_co_author`] is the
1389 /// safety net when it forgets.
1390 pub const COMMIT_CO_AUTHOR_TRAILER: &str = "Co-Authored-By: siGit Code <sigit@sigit.si>";
1391
1392 /// Run `git <args>` in `cwd`, returning trimmed stdout on success.
1393 fn git_stdout(cwd: &Path, args: &[&str]) -> Option<String> {
1394 let output = Command::new("git")
1395 .args(args)
1396 .current_dir(cwd)
1397 .output()
1398 .ok()?;
1399 output
1400 .status
1401 .success()
1402 .then(|| String::from_utf8_lossy(&output.stdout).trim().to_string())
1403 }
1404
1405 fn git_head(cwd: &Path) -> Option<String> {
1406 git_stdout(cwd, &["rev-parse", "HEAD"])
1407 }
1408
1409 /// Deterministic co-author attribution: if the commit at HEAD lacks the
1410 /// siGit Code trailer, amend it in (via `git commit --amend --trailer`, which
1411 /// places it after a blank line — the format GitHub detects). Never rewrites
1412 /// a commit that is already on a remote. Returns a note describing the amend
1413 /// so the model and user can see it happened.
1414 fn ensure_commit_co_author(cwd: &Path) -> Option<String> {
1415 let message = git_stdout(cwd, &["log", "-1", "--format=%B"])?;
1416 if message
1417 .to_lowercase()
1418 .contains("co-authored-by: sigit code")
1419 {
1420 return None;
1421 }
1422 // Amending changes the commit id; a commit that any remote ref already
1423 // contains must be left alone or the branch diverges from its upstream.
1424 match git_stdout(cwd, &["branch", "-r", "--contains", "HEAD"]) {
1425 Some(remotes) if remotes.is_empty() => {}
1426 _ => return None,
1427 }
1428 let amend = Command::new("git")
1429 .args(["commit", "--amend", "--no-edit", "--trailer"])
1430 .arg(COMMIT_CO_AUTHOR_TRAILER)
1431 .current_dir(cwd)
1432 .output()
1433 .ok()?;
1434 if amend.status.success() {
1435 log::info!(
1436 "appended co-author trailer to the new commit in {}",
1437 cwd.display()
1438 );
1439 Some(format!(
1440 "[siGit Code] The new commit was amended to append the co-author trailer \
1441 \"{COMMIT_CO_AUTHOR_TRAILER}\" (its hash changed)."
1442 ))
1443 } else {
1444 log::warn!(
1445 "could not append co-author trailer in {}: {}",
1446 cwd.display(),
1447 String::from_utf8_lossy(&amend.stderr).trim()
1448 );
1449 None
1450 }
1451 }
1452
1453 /// runs via `sh -c` / `cmd /C`; killed after COMMAND_TIMEOUT.
1454 fn exec_run_command(arguments: &str) -> String {
1455 let args: Value = match serde_json::from_str(arguments) {
1456 Ok(v) => v,
1457 Err(err) => return format!("Error: failed to parse arguments: {err}"),
1458 };
1459
1460 let command_str = match args.get("command").and_then(Value::as_str) {
1461 Some(c) => c,
1462 None => return "Error: missing required parameter \"command\"".to_string(),
1463 };
1464
1465 let default_cwd = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
1466 let cwd = args
1467 .get("cwd")
1468 .and_then(Value::as_str)
1469 .unwrap_or(&default_cwd);
1470 let cwd_path = absolute_path(Path::new(cwd));
1471 let cwd_str = cwd_path.display().to_string();
1472
1473 if !cwd_path.exists() {
1474 return format!("Error: working directory does not exist: {cwd_str}");
1475 }
1476
1477 log::info!("run_command: `{command_str}` in `{cwd_str}`");
1478
1479 // Co-author attribution: note where HEAD is before a command that looks
1480 // like it may commit, so a new commit can be detected afterwards. The
1481 // string check is only a cheap trigger — a false positive costs one
1482 // `git rev-parse` and nothing else.
1483 let may_commit = command_str.contains("git") && command_str.contains("commit");
1484 let head_before = if may_commit {
1485 git_head(&cwd_path)
1486 } else {
1487 None
1488 };
1489
1490 #[cfg(unix)]
1491 let mut child = match Command::new("sh")
1492 .arg("-c")
1493 .arg(command_str)
1494 .current_dir(&cwd_path)
1495 .stdout(std::process::Stdio::piped())
1496 .stderr(std::process::Stdio::piped())
1497 .spawn()
1498 {
1499 Ok(c) => c,
1500 Err(err) => return format!("Error: failed to spawn command: {err}"),
1501 };
1502
1503 #[cfg(windows)]
1504 let mut child = match Command::new("cmd")
1505 .arg("/C")
1506 .arg(command_str)
1507 .current_dir(&cwd_path)
1508 .stdout(std::process::Stdio::piped())
1509 .stderr(std::process::Stdio::piped())
1510 .spawn()
1511 {
1512 Ok(c) => c,
1513 Err(err) => return format!("Error: failed to spawn command: {err}"),
1514 };
1515
1516 let start = std::time::Instant::now();
1517 loop {
1518 match child.try_wait() {
1519 Ok(Some(_status)) => break,
1520 Ok(None) => {
1521 if start.elapsed() >= COMMAND_TIMEOUT {
1522 let _ = child.kill();
1523 return format!(
1524 "Error: command timed out after {} seconds and was killed.",
1525 COMMAND_TIMEOUT.as_secs()
1526 );
1527 }
1528 std::thread::sleep(std::time::Duration::from_millis(100));
1529 }
1530 Err(err) => return format!("Error: failed to wait on command: {err}"),
1531 }
1532 }
1533
1534 let output = match child.wait_with_output() {
1535 Ok(o) => o,
1536 Err(err) => return format!("Error: failed to read command output: {err}"),
1537 };
1538
1539 let exit_code = output.status.code().unwrap_or(-1);
1540 let mut combined = String::new();
1541 combined.push_str(&String::from_utf8_lossy(&output.stdout));
1542 combined.push_str(&String::from_utf8_lossy(&output.stderr));
1543
1544 // A new commit appeared under this command: make sure it carries the
1545 // siGit co-author trailer (see `ensure_commit_co_author`).
1546 if may_commit {
1547 let head_after = git_head(&cwd_path);
1548 if head_after.is_some()
1549 && head_after != head_before
1550 && let Some(note) = ensure_commit_co_author(&cwd_path)
1551 {
1552 if !combined.is_empty() && !combined.ends_with('\n') {
1553 combined.push('\n');
1554 }
1555 combined.push_str(&note);
1556 }
1557 }
1558
1559 let truncated = if combined.len() > COMMAND_OUTPUT_LIMIT {
1560 let truncated_str = &combined[..COMMAND_OUTPUT_LIMIT];
1561 format!("{truncated_str}\n\n… (output truncated at {COMMAND_OUTPUT_LIMIT} bytes)")
1562 } else {
1563 combined
1564 };
1565
1566 if output.status.success() {
1567 if truncated.is_empty() {
1568 format!("Command succeeded (exit code {exit_code}) with no output.")
1569 } else {
1570 format!("Exit code {exit_code}:\n{truncated}")
1571 }
1572 } else {
1573 format!("Command failed (exit code {exit_code}):\n{truncated}")
1574 }
1575 }
1576
1577 #[cfg(test)]
1578 mod tests {
1579 use super::*;
1580 use std::fs;
1581
1582 #[tokio::test]
1583 async fn test_execute_unknown_tool() {
1584 let result = execute_tool("nonexistent", "{}").await;
1585 assert!(result.starts_with("Unknown tool:"));
1586 }
1587
1588 #[test]
1589 fn test_read_file_missing_path_param() {
1590 let result = exec_read_file("{}");
1591 assert!(result.contains("missing required parameter"));
1592 }
1593
1594 #[test]
1595 fn test_read_file_nonexistent() {
1596 let result = exec_read_file(r#"{"path": "/tmp/__sigit_no_such_file_42__"}"#);
1597 assert!(result.contains("does not exist"));
1598 }
1599
1600 #[test]
1601 fn test_read_file_success() {
1602 let dir = std::env::temp_dir().join("sigit_test_read_file");
1603 let _ = fs::create_dir_all(&dir);
1604 let file_path = dir.join("hello.txt");
1605 fs::write(&file_path, "hello world").unwrap();
1606
1607 let args = serde_json::json!({ "path": file_path }).to_string();
1608 let result = exec_read_file(&args);
1609 assert_eq!(result, "hello world");
1610
1611 let _ = fs::remove_dir_all(&dir);
1612 }
1613
1614 #[test]
1615 fn test_list_directory_missing_path_param() {
1616 let result = exec_list_directory("{}");
1617 assert!(result.contains("missing required parameter"));
1618 }
1619
1620 #[test]
1621 fn test_list_directory_success() {
1622 let dir = std::env::temp_dir().join("sigit_test_list_dir");
1623 let _ = fs::remove_dir_all(&dir);
1624 fs::create_dir_all(dir.join("subdir")).unwrap();
1625 fs::write(dir.join("aaa.txt"), "").unwrap();
1626 fs::write(dir.join("bbb.rs"), "").unwrap();
1627
1628 let args = serde_json::json!({ "path": dir }).to_string();
1629 let result = exec_list_directory(&args);
1630
1631 assert!(result.contains("[DIR] subdir"));
1632 assert!(result.contains("[FILE] aaa.txt"));
1633 assert!(result.contains("[FILE] bbb.rs"));
1634
1635 // Directories should appear before files.
1636 let dir_pos = result.find("[DIR]").unwrap();
1637 let file_pos = result.find("[FILE]").unwrap();
1638 assert!(dir_pos < file_pos);
1639
1640 let _ = fs::remove_dir_all(&dir);
1641 }
1642
1643 #[test]
1644 fn test_search_files_invalid_regex() {
1645 let result = exec_search_files(r#"{"pattern": "[invalid", "path": "."}"#);
1646 assert!(result.contains("invalid regex"));
1647 }
1648
1649 #[test]
1650 fn test_search_files_success() {
1651 let dir = std::env::temp_dir().join("sigit_test_search");
1652 let _ = fs::remove_dir_all(&dir);
1653 fs::create_dir_all(&dir).unwrap();
1654 fs::write(
1655 dir.join("code.rs"),
1656 "fn main() {\n println!(\"hello\");\n}\n",
1657 )
1658 .unwrap();
1659 fs::write(dir.join("other.txt"), "no match here\n").unwrap();
1660
1661 let args = serde_json::json!({
1662 "pattern": "println",
1663 "path": dir
1664 })
1665 .to_string();
1666 let result = exec_search_files(&args);
1667
1668 assert!(result.contains("code.rs:2:"));
1669 assert!(result.contains("println"));
1670 assert!(!result.contains("other.txt"));
1671
1672 let _ = fs::remove_dir_all(&dir);
1673 }
1674
1675 #[test]
1676 fn test_search_files_no_matches() {
1677 let dir = std::env::temp_dir().join("sigit_test_search_none");
1678 let _ = fs::remove_dir_all(&dir);
1679 fs::create_dir_all(&dir).unwrap();
1680 fs::write(dir.join("empty.txt"), "nothing special").unwrap();
1681
1682 let args = serde_json::json!({
1683 "pattern": "zzz_will_not_match_42",
1684 "path": dir
1685 })
1686 .to_string();
1687 let result = exec_search_files(&args);
1688 assert!(result.contains("No matches found"));
1689
1690 let _ = fs::remove_dir_all(&dir);
1691 }
1692
1693 #[test]
1694 fn test_all_tools_count() {
1695 let tools = all_tools();
1696 assert_eq!(tools.len(), 13);
1697 assert_eq!(tools[0].name, "read_file");
1698 assert_eq!(tools[1].name, "create_directory");
1699 assert_eq!(tools[2].name, "list_directory");
1700 assert_eq!(tools[3].name, "search_files");
1701 assert_eq!(tools[4].name, "read_website");
1702 assert_eq!(tools[5].name, "create_file");
1703 assert_eq!(tools[6].name, "edit_file");
1704 assert_eq!(tools[7].name, "delete_file");
1705 assert_eq!(tools[8].name, "run_command");
1706 assert_eq!(tools[9].name, "multi_edit");
1707 assert_eq!(tools[10].name, "glob");
1708 assert_eq!(tools[11].name, "write_todos");
1709 assert_eq!(tools[12].name, "remember");
1710 }
1711
1712 #[test]
1713 fn test_edit_file_replace_all() {
1714 let dir = std::env::temp_dir().join("sigit_test_edit_replace_all");
1715 let _ = fs::remove_dir_all(&dir);
1716 fs::create_dir_all(&dir).unwrap();
1717 let file = dir.join("f.txt");
1718 fs::write(&file, "foo foo foo").unwrap();
1719
1720 // Without replace_all an ambiguous match is rejected.
1721 let args =
1722 serde_json::json!({ "path": &file, "old_text": "foo", "new_text": "bar" }).to_string();
1723 let result = exec_edit_file(&args);
1724 assert!(result.contains("appears 3 times"), "{result}");
1725
1726 // With replace_all every occurrence is changed.
1727 let args = serde_json::json!({
1728 "path": &file, "old_text": "foo", "new_text": "bar", "replace_all": true
1729 })
1730 .to_string();
1731 let result = exec_edit_file(&args);
1732 assert!(result.starts_with("Edited file:"), "{result}");
1733 assert_eq!(fs::read_to_string(&file).unwrap(), "bar bar bar");
1734
1735 let _ = fs::remove_dir_all(&dir);
1736 }
1737
1738 #[test]
1739 fn test_edit_file_whitespace_hint() {
1740 let dir = std::env::temp_dir().join("sigit_test_edit_hint");
1741 let _ = fs::remove_dir_all(&dir);
1742 fs::create_dir_all(&dir).unwrap();
1743 let file = dir.join("f.txt");
1744 fs::write(&file, "line one\n indented\nline three\n").unwrap();
1745
1746 // old_text has more indentation than the file, so it isn't a substring,
1747 // but its trimmed content still locates the intended line.
1748 let args = serde_json::json!({
1749 "path": &file, "old_text": " indented", "new_text": "x"
1750 })
1751 .to_string();
1752 let result = exec_edit_file(&args);
1753 assert!(result.contains("line 2"), "{result}");
1754 assert!(result.contains("whitespace"), "{result}");
1755
1756 let _ = fs::remove_dir_all(&dir);
1757 }
1758
1759 #[test]
1760 fn test_multi_edit_atomic_on_failure() {
1761 let dir = std::env::temp_dir().join("sigit_test_multi_edit");
1762 let _ = fs::remove_dir_all(&dir);
1763 fs::create_dir_all(&dir).unwrap();
1764 let file = dir.join("f.txt");
1765 fs::write(&file, "alpha beta gamma").unwrap();
1766
1767 // Second edit can't match -> nothing should be written.
1768 let args = serde_json::json!({
1769 "path": &file,
1770 "edits": [
1771 { "old_text": "alpha", "new_text": "ALPHA" },
1772 { "old_text": "nope", "new_text": "x" }
1773 ]
1774 })
1775 .to_string();
1776 let result = exec_multi_edit(&args);
1777 assert!(result.contains("edit #2 failed"), "{result}");
1778 assert_eq!(fs::read_to_string(&file).unwrap(), "alpha beta gamma");
1779
1780 // All-matching batch applies in sequence.
1781 let args = serde_json::json!({
1782 "path": &file,
1783 "edits": [
1784 { "old_text": "alpha", "new_text": "ALPHA" },
1785 { "old_text": "gamma", "new_text": "GAMMA" }
1786 ]
1787 })
1788 .to_string();
1789 let result = exec_multi_edit(&args);
1790 assert!(result.contains("Applied 2 edits"), "{result}");
1791 assert_eq!(fs::read_to_string(&file).unwrap(), "ALPHA beta GAMMA");
1792
1793 let _ = fs::remove_dir_all(&dir);
1794 }
1795
1796 #[test]
1797 fn test_glob_to_regex() {
1798 let re = Regex::new(&glob_to_regex("**/*.rs")).unwrap();
1799 assert!(re.is_match("src/tools.rs"));
1800 assert!(re.is_match("main.rs")); // `**/` matches zero directories too
1801 assert!(!re.is_match("src/tools.txt"));
1802
1803 let re = Regex::new(&glob_to_regex("*.{ts,tsx}")).unwrap();
1804 assert!(re.is_match("app.ts"));
1805 assert!(re.is_match("app.tsx"));
1806 assert!(!re.is_match("app.js"));
1807 }
1808
1809 #[test]
1810 fn test_glob_tool_success() {
1811 let dir = std::env::temp_dir().join("sigit_test_glob");
1812 let _ = fs::remove_dir_all(&dir);
1813 fs::create_dir_all(dir.join("src")).unwrap();
1814 fs::write(dir.join("Cargo.toml"), "").unwrap();
1815 fs::write(dir.join("src/main.rs"), "").unwrap();
1816 fs::write(dir.join("src/lib.rs"), "").unwrap();
1817
1818 let args = serde_json::json!({ "pattern": "**/*.rs", "path": &dir }).to_string();
1819 let result = exec_glob(&args);
1820 assert!(result.contains("main.rs"), "{result}");
1821 assert!(result.contains("lib.rs"), "{result}");
1822 assert!(!result.contains("Cargo.toml"), "{result}");
1823
1824 let _ = fs::remove_dir_all(&dir);
1825 }
1826
1827 #[test]
1828 fn test_search_files_file_glob_filter() {
1829 let dir = std::env::temp_dir().join("sigit_test_search_glob");
1830 let _ = fs::remove_dir_all(&dir);
1831 fs::create_dir_all(&dir).unwrap();
1832 fs::write(dir.join("code.rs"), "needle here\n").unwrap();
1833 fs::write(dir.join("notes.txt"), "needle here\n").unwrap();
1834
1835 let args = serde_json::json!({
1836 "pattern": "needle", "path": &dir, "file_glob": "*.rs"
1837 })
1838 .to_string();
1839 let result = exec_search_files(&args);
1840 assert!(result.contains("code.rs"), "{result}");
1841 assert!(!result.contains("notes.txt"), "{result}");
1842
1843 let _ = fs::remove_dir_all(&dir);
1844 }
1845
1846 #[test]
1847 fn test_write_todos_renders_checklist() {
1848 let args = serde_json::json!({
1849 "todos": [
1850 { "content": "Read code", "status": "completed" },
1851 { "content": "Make change", "status": "in_progress" },
1852 { "content": "Run tests", "status": "pending" }
1853 ]
1854 })
1855 .to_string();
1856 let result = exec_write_todos(&args);
1857 assert!(result.contains("1/3 done"), "{result}");
1858 assert!(result.contains("[x] Read code"), "{result}");
1859 assert!(result.contains("[~] Make change"), "{result}");
1860 assert!(result.contains("[ ] Run tests"), "{result}");
1861 }
1862
1863 #[test]
1864 fn test_remember_appends_to_instruction_file() {
1865 let dir = std::env::temp_dir().join("sigit_test_remember");
1866 let _ = fs::remove_dir_all(&dir);
1867 fs::create_dir_all(dir.join(".git")).unwrap();
1868 let claude_md = dir.join("CLAUDE.md");
1869 fs::write(&claude_md, "# Project\n").unwrap();
1870
1871 let target = crate::instructions::memory_file(&dir);
1872 // Should pick the existing CLAUDE.md at the repo root.
1873 assert_eq!(
1874 target.canonicalize().unwrap(),
1875 claude_md.canonicalize().unwrap()
1876 );
1877
1878 let result = remember_at(&dir, "remembered text");
1879 assert!(result.contains("remembered"), "{result}");
1880
1881 let updated = fs::read_to_string(&claude_md).unwrap();
1882 assert!(updated.contains("## Remembered notes"), "{updated}");
1883 assert!(updated.contains("- remembered text"), "{updated}");
1884
1885 let _ = fs::remove_dir_all(&dir);
1886 }
1887
1888 #[test]
1889 fn test_all_tools_schemas_are_valid_json_objects() {
1890 for tool in all_tools() {
1891 assert!(
1892 tool.parameters_schema.is_object(),
1893 "schema for {} is not an object",
1894 tool.name
1895 );
1896 let obj = tool.parameters_schema.as_object().unwrap();
1897 assert!(obj.contains_key("type"));
1898 assert!(obj.contains_key("properties"));
1899 assert!(obj.contains_key("required"));
1900 }
1901 }
1902
1903 // ── read_website tests ───────────────────────────────────────────────
1904
1905 #[test]
1906 fn test_read_website_missing_url() {
1907 let result = exec_read_website("{}");
1908 assert!(result.contains("missing required parameter"));
1909 }
1910
1911 #[test]
1912 fn test_read_website_invalid_scheme() {
1913 let result = exec_read_website(r#"{"url": "file:///tmp/test.html"}"#);
1914 assert!(result.contains("url must start with http:// or https://"));
1915 }
1916
1917 #[test]
1918 fn test_read_website_extracts_title_from_html() {
1919 let body = r#"
1920 <html>
1921 <head>
1922 <title>Qwen 3.6 27B</title>
1923 </head>
1924 <body>
1925 <h1>Model card</h1>
1926 <p>Large language model.</p>
1927 </body>
1928 </html>
1929 "#;
1930
1931 let title = Regex::new(r"(?is)<title[^>]*>(.*?)</title>")
1932 .unwrap()
1933 .captures(body)
1934 .and_then(|captures| captures.get(1))
1935 .map(|m| {
1936 Regex::new(r"\s+")
1937 .unwrap()
1938 .replace_all(m.as_str(), " ")
1939 .trim()
1940 .to_string()
1941 })
1942 .filter(|title| !title.is_empty());
1943
1944 assert_eq!(title.as_deref(), Some("Qwen 3.6 27B"));
1945 }
1946
1947 #[test]
1948 fn test_read_website_metadata_includes_final_url_header() {
1949 let final_url = "https://huggingface.co/Qwen/Qwen3.6-27B";
1950 let title = Some("Qwen 3.6 27B".to_string());
1951 let cleaned = "Model card\nLarge language model.".to_string();
1952
1953 let mut metadata = vec![format!("URL: {final_url}")];
1954 if let Some(title) = &title {
1955 metadata.push(format!("Title: {title}"));
1956 }
1957
1958 let body_text = match title {
1959 Some(_) => cleaned,
1960 None => cleaned,
1961 };
1962
1963 let output = format!("{}\n\n{}", metadata.join("\n"), body_text);
1964
1965 assert!(output.starts_with("URL: https://huggingface.co/Qwen/Qwen3.6-27B"));
1966 assert!(output.contains("\nTitle: Qwen 3.6 27B\n\n"));
1967 }
1968
1969 // ── create_directory tests ───────────────────────────────────────────
1970
1971 #[test]
1972 fn test_create_directory_missing_path() {
1973 let result = exec_create_directory("{}");
1974 assert!(result.contains("missing required parameter"));
1975 }
1976
1977 #[test]
1978 fn test_create_directory_success() {
1979 let dir = std::env::temp_dir()
1980 .join("sigit_test_create_directory")
1981 .join("nested")
1982 .join("child");
1983 let _ = fs::remove_dir_all(dir.parent().unwrap());
1984
1985 let args = serde_json::json!({ "path": dir }).to_string();
1986 let result = exec_create_directory(&args);
1987 assert!(result.starts_with("Created directory:"), "got: {result}");
1988 assert!(dir.exists());
1989 assert!(dir.is_dir());
1990
1991 let _ = fs::remove_dir_all(dir.parent().unwrap().parent().unwrap());
1992 }
1993
1994 #[test]
1995 fn test_create_directory_already_exists() {
1996 let dir = std::env::temp_dir().join("sigit_test_create_directory_exists");
1997 let _ = fs::remove_dir_all(&dir);
1998 fs::create_dir_all(&dir).unwrap();
1999
2000 let args = serde_json::json!({ "path": dir }).to_string();
2001 let result = exec_create_directory(&args);
2002 assert!(result.contains("Directory already exists"), "got: {result}");
2003
2004 let _ = fs::remove_dir_all(&dir);
2005 }
2006
2007 // ── create_file tests ────────────────────────────────────────────────
2008
2009 #[test]
2010 fn test_create_file_missing_path() {
2011 let result = exec_create_file(r#"{"content": "hello"}"#);
2012 assert!(result.contains("missing required parameter"));
2013 }
2014
2015 #[test]
2016 fn test_create_file_missing_content() {
2017 let result = exec_create_file(r#"{"path": "/tmp/sigit_test_nope.txt"}"#);
2018 assert!(result.contains("missing required parameter"));
2019 }
2020
2021 #[test]
2022 fn test_create_file_success() {
2023 let dir = std::env::temp_dir().join("sigit_test_create_file");
2024 let _ = fs::remove_dir_all(&dir);
2025
2026 let file_path = dir.join("sub").join("new_file.txt");
2027 let args = serde_json::json!({
2028 "path": file_path,
2029 "content": "hello world"
2030 })
2031 .to_string();
2032
2033 let result = exec_create_file(&args);
2034 assert!(result.starts_with("Created file:"), "got: {result}");
2035 assert!(file_path.exists());
2036 assert_eq!(fs::read_to_string(&file_path).unwrap(), "hello world");
2037
2038 let _ = fs::remove_dir_all(&dir);
2039 }
2040
2041 #[test]
2042 fn test_create_file_already_exists() {
2043 let dir = std::env::temp_dir().join("sigit_test_create_exists");
2044 let _ = fs::remove_dir_all(&dir);
2045 fs::create_dir_all(&dir).unwrap();
2046
2047 let file_path = dir.join("existing.txt");
2048 fs::write(&file_path, "original").unwrap();
2049
2050 let args = serde_json::json!({
2051 "path": file_path,
2052 "content": "overwrite attempt"
2053 })
2054 .to_string();
2055
2056 let result = exec_create_file(&args);
2057 assert!(result.contains("already exists"), "got: {result}");
2058 // Original content untouched.
2059 assert_eq!(fs::read_to_string(&file_path).unwrap(), "original");
2060
2061 let _ = fs::remove_dir_all(&dir);
2062 }
2063
2064 // ── edit_file tests ──────────────────────────────────────────────────
2065
2066 #[test]
2067 fn test_edit_file_missing_params() {
2068 let result = exec_edit_file(r#"{"path": "x"}"#);
2069 assert!(result.contains("missing required parameter"));
2070
2071 let result = exec_edit_file(r#"{"path": "x", "old_text": "a"}"#);
2072 assert!(result.contains("missing required parameter"));
2073 }
2074
2075 #[test]
2076 fn test_edit_file_nonexistent() {
2077 let result = exec_edit_file(
2078 r#"{"path": "/tmp/__sigit_no_such__", "old_text": "a", "new_text": "b"}"#,
2079 );
2080 assert!(result.contains("does not exist"));
2081 }
2082
2083 #[test]
2084 fn test_edit_file_success() {
2085 let dir = std::env::temp_dir().join("sigit_test_edit_file");
2086 let _ = fs::remove_dir_all(&dir);
2087 fs::create_dir_all(&dir).unwrap();
2088
2089 let file_path = dir.join("code.rs");
2090 fs::write(&file_path, "fn main() {\n println!(\"hello\");\n}\n").unwrap();
2091
2092 let args = serde_json::json!({
2093 "path": file_path,
2094 "old_text": "println!(\"hello\")",
2095 "new_text": "println!(\"world\")"
2096 })
2097 .to_string();
2098
2099 let result = exec_edit_file(&args);
2100 assert!(result.starts_with("Edited file:"), "got: {result}");
2101
2102 let updated = fs::read_to_string(&file_path).unwrap();
2103 assert!(updated.contains("println!(\"world\")"));
2104 assert!(!updated.contains("println!(\"hello\")"));
2105
2106 let _ = fs::remove_dir_all(&dir);
2107 }
2108
2109 #[test]
2110 fn test_edit_file_old_text_not_found() {
2111 let dir = std::env::temp_dir().join("sigit_test_edit_notfound");
2112 let _ = fs::remove_dir_all(&dir);
2113 fs::create_dir_all(&dir).unwrap();
2114
2115 let file_path = dir.join("data.txt");
2116 fs::write(&file_path, "aaa bbb ccc").unwrap();
2117
2118 let args = serde_json::json!({
2119 "path": file_path,
2120 "old_text": "zzz",
2121 "new_text": "yyy"
2122 })
2123 .to_string();
2124
2125 let result = exec_edit_file(&args);
2126 assert!(result.contains("old_text not found"), "got: {result}");
2127
2128 let _ = fs::remove_dir_all(&dir);
2129 }
2130
2131 #[test]
2132 fn test_edit_file_ambiguous_match() {
2133 let dir = std::env::temp_dir().join("sigit_test_edit_ambiguous");
2134 let _ = fs::remove_dir_all(&dir);
2135 fs::create_dir_all(&dir).unwrap();
2136
2137 let file_path = dir.join("repeat.txt");
2138 fs::write(&file_path, "foo bar foo bar foo").unwrap();
2139
2140 let args = serde_json::json!({
2141 "path": file_path,
2142 "old_text": "foo",
2143 "new_text": "baz"
2144 })
2145 .to_string();
2146
2147 let result = exec_edit_file(&args);
2148 assert!(result.contains("appears 3 times"), "got: {result}");
2149 // File should be unchanged.
2150 assert_eq!(
2151 fs::read_to_string(&file_path).unwrap(),
2152 "foo bar foo bar foo"
2153 );
2154
2155 let _ = fs::remove_dir_all(&dir);
2156 }
2157
2158 // ── delete_file tests ────────────────────────────────────────────────
2159
2160 #[test]
2161 fn test_delete_file_missing_path() {
2162 let result = exec_delete_file("{}");
2163 assert!(
2164 result.contains("missing required parameter"),
2165 "got: {result}"
2166 );
2167 }
2168
2169 #[test]
2170 fn test_delete_file_nonexistent() {
2171 let result = exec_delete_file(r#"{"path": "/tmp/sigit_test_no_such_file_xyz"}"#);
2172 assert!(result.contains("does not exist"), "got: {result}");
2173 }
2174
2175 #[test]
2176 fn test_delete_file_success() {
2177 let dir = std::env::temp_dir().join("sigit_test_delete_file");
2178 let _ = fs::remove_dir_all(&dir);
2179 fs::create_dir_all(&dir).unwrap();
2180
2181 let file_path = dir.join("to_delete.txt");
2182 fs::write(&file_path, "bye").unwrap();
2183 assert!(file_path.exists());
2184
2185 let args = serde_json::json!({ "path": file_path }).to_string();
2186 let result = exec_delete_file(&args);
2187 assert!(result.contains("Deleted file"), "got: {result}");
2188 assert!(!file_path.exists());
2189
2190 let _ = fs::remove_dir_all(&dir);
2191 }
2192
2193 #[test]
2194 fn test_delete_empty_directory() {
2195 let dir = std::env::temp_dir().join("sigit_test_delete_empty_dir");
2196 let _ = fs::remove_dir_all(&dir);
2197 fs::create_dir_all(&dir).unwrap();
2198
2199 let args = serde_json::json!({ "path": dir }).to_string();
2200 let result = exec_delete_file(&args);
2201 assert!(result.contains("Deleted empty directory"), "got: {result}");
2202 assert!(!dir.exists());
2203 }
2204
2205 #[test]
2206 fn test_delete_nonempty_directory() {
2207 let dir = std::env::temp_dir().join("sigit_test_delete_nonempty_dir");
2208 let _ = fs::remove_dir_all(&dir);
2209 fs::create_dir_all(&dir).unwrap();
2210 fs::write(dir.join("child.txt"), "content").unwrap();
2211
2212 let args = serde_json::json!({ "path": dir }).to_string();
2213 let result = exec_delete_file(&args);
2214 assert!(result.contains("Error"), "got: {result}");
2215 assert!(dir.exists(), "directory should not have been deleted");
2216
2217 let _ = fs::remove_dir_all(&dir);
2218 }
2219
2220 // ── run_command tests ────────────────────────────────────────────────
2221
2222 #[test]
2223 fn test_run_command_missing_command() {
2224 let result = exec_run_command("{}");
2225 assert!(
2226 result.contains("missing required parameter"),
2227 "got: {result}"
2228 );
2229 }
2230
2231 #[test]
2232 fn test_run_command_success() {
2233 let result = exec_run_command(r#"{"command": "echo hello"}"#);
2234 assert!(result.contains("hello"), "got: {result}");
2235 assert!(result.contains("Exit code 0"), "got: {result}");
2236 }
2237
2238 /// Fresh git repo with one commit, test identity, and signing off (the
2239 /// developer's global gpgsign must not leak into sandbox commits).
2240 fn init_test_repo(name: &str) -> std::path::PathBuf {
2241 let dir = std::env::temp_dir().join(format!("sigit_test_{name}_{}", std::process::id()));
2242 let _ = fs::remove_dir_all(&dir);
2243 fs::create_dir_all(&dir).unwrap();
2244 test_git(&dir, &["init", "-q", "-b", "main"]);
2245 test_git(&dir, &["config", "user.name", "Test User"]);
2246 test_git(&dir, &["config", "user.email", "test@example.com"]);
2247 test_git(&dir, &["config", "commit.gpgsign", "false"]);
2248 fs::write(dir.join("file.txt"), "one\n").unwrap();
2249 test_git(&dir, &["add", "file.txt"]);
2250 test_git(&dir, &["commit", "-q", "-m", "Initial"]);
2251 dir
2252 }
2253
2254 fn test_git(dir: &Path, args: &[&str]) {
2255 let out = Command::new("git")
2256 .args(args)
2257 .current_dir(dir)
2258 .output()
2259 .unwrap();
2260 assert!(
2261 out.status.success(),
2262 "git {args:?} failed: {}",
2263 String::from_utf8_lossy(&out.stderr)
2264 );
2265 }
2266
2267 #[test]
2268 fn run_command_appends_co_author_trailer_to_new_commits() {
2269 let dir = init_test_repo("coauthor_append");
2270 fs::write(dir.join("file.txt"), "two\n").unwrap();
2271 // Quote-free command: `cmd /C` does not strip double quotes the way
2272 // `sh -c` does, so quoted arguments would break on Windows.
2273 let args = serde_json::json!({
2274 "command": "git add file.txt && git commit -m Update",
2275 "cwd": dir.display().to_string(),
2276 })
2277 .to_string();
2278
2279 let result = exec_run_command(&args);
2280 assert!(result.contains("co-author trailer"), "got: {result}");
2281
2282 let message = git_stdout(&dir, &["log", "-1", "--format=%B"]).unwrap();
2283 assert!(
2284 message.ends_with(COMMIT_CO_AUTHOR_TRAILER),
2285 "trailer must be the last line: {message:?}"
2286 );
2287 assert!(
2288 message.contains(&format!("\n\n{COMMIT_CO_AUTHOR_TRAILER}")),
2289 "trailer needs a blank line before it for GitHub to detect it: {message:?}"
2290 );
2291 let _ = fs::remove_dir_all(&dir);
2292 }
2293
2294 #[test]
2295 fn run_command_keeps_existing_co_author_trailer() {
2296 let dir = init_test_repo("coauthor_present");
2297 fs::write(dir.join("file.txt"), "two\n").unwrap();
2298 // The trailer contains spaces and angle brackets, which `cmd /C`
2299 // mis-tokenizes (`<` is redirection), so create the trailer-carrying
2300 // commit with direct git args and let run_command amend it without
2301 // editing: HEAD changes, the message already has the trailer, and the
2302 // gate must leave it alone.
2303 test_git(&dir, &["add", "file.txt"]);
2304 test_git(
2305 &dir,
2306 &[
2307 "commit",
2308 "-q",
2309 "-m",
2310 &format!("Update file\n\n{COMMIT_CO_AUTHOR_TRAILER}"),
2311 ],
2312 );
2313 let args = serde_json::json!({
2314 "command": "git commit --amend --no-edit",
2315 "cwd": dir.display().to_string(),
2316 })
2317 .to_string();
2318
2319 let result = exec_run_command(&args);
2320 assert!(
2321 !result.contains("[siGit Code]"),
2322 "no amend expected: {result}"
2323 );
2324
2325 let message = git_stdout(&dir, &["log", "-1", "--format=%B"]).unwrap();
2326 assert_eq!(
2327 message.matches("Co-Authored-By: siGit Code").count(),
2328 1,
2329 "trailer must not be duplicated: {message:?}"
2330 );
2331 let _ = fs::remove_dir_all(&dir);
2332 }
2333
2334 #[test]
2335 fn run_command_never_amends_pushed_commits() {
2336 let dir = init_test_repo("coauthor_pushed");
2337 let remote = std::env::temp_dir().join(format!(
2338 "sigit_test_coauthor_remote_{}.git",
2339 std::process::id()
2340 ));
2341 let _ = fs::remove_dir_all(&remote);
2342 fs::create_dir_all(&remote).unwrap();
2343 test_git(&remote, &["init", "-q", "--bare"]);
2344 test_git(&dir, &["remote", "add", "origin", remote.to_str().unwrap()]);
2345
2346 fs::write(dir.join("file.txt"), "two\n").unwrap();
2347 let args = serde_json::json!({
2348 "command": "git add file.txt && git commit -m Update && git push -q origin main",
2349 "cwd": dir.display().to_string(),
2350 })
2351 .to_string();
2352
2353 let result = exec_run_command(&args);
2354 assert!(
2355 !result.contains("[siGit Code]"),
2356 "no amend expected: {result}"
2357 );
2358
2359 // Already on the remote when the gate ran, so it must be untouched.
2360 let message = git_stdout(&dir, &["log", "-1", "--format=%B"]).unwrap();
2361 assert!(
2362 !message.contains("Co-Authored-By"),
2363 "pushed commit must not be rewritten: {message:?}"
2364 );
2365 let _ = fs::remove_dir_all(&dir);
2366 let _ = fs::remove_dir_all(&remote);
2367 }
2368
2369 #[test]
2370 fn test_run_command_failure() {
2371 #[cfg(unix)]
2372 let command = "false";
2373 #[cfg(windows)]
2374 let command = "exit /b 1";
2375
2376 let args = serde_json::json!({ "command": command }).to_string();
2377 let result = exec_run_command(&args);
2378 assert!(result.contains("failed"), "got: {result}");
2379 }
2380
2381 #[test]
2382 fn test_run_command_with_cwd() {
2383 let dir = std::env::temp_dir().join("sigit_test_run_cmd_cwd");
2384 let _ = fs::remove_dir_all(&dir);
2385 fs::create_dir_all(&dir).unwrap();
2386
2387 #[cfg(unix)]
2388 let command = "pwd";
2389 #[cfg(windows)]
2390 let command = "cd";
2391
2392 let args = serde_json::json!({
2393 "command": command,
2394 "cwd": dir
2395 })
2396 .to_string();
2397 let result = exec_run_command(&args);
2398 // The output should contain the temp dir path.
2399 assert!(
2400 result.contains(&dir.to_string_lossy().to_string()),
2401 "got: {result}"
2402 );
2403
2404 let _ = fs::remove_dir_all(&dir);
2405 }
2406
2407 #[test]
2408 fn test_run_command_bad_cwd() {
2409 let missing_dir = std::env::temp_dir().join("sigit_no_such_dir_xyz");
2410 let _ = fs::remove_dir_all(&missing_dir);
2411
2412 let args = serde_json::json!({
2413 "command": "echo hi",
2414 "cwd": missing_dir
2415 })
2416 .to_string();
2417 let result = exec_run_command(&args);
2418 assert!(result.contains("does not exist"), "got: {result}");
2419 }
2420
2421 #[test]
2422 fn test_run_command_captures_stderr() {
2423 #[cfg(unix)]
2424 let command = "echo err >&2";
2425 #[cfg(windows)]
2426 let command = "echo err 1>&2";
2427
2428 let args = serde_json::json!({ "command": command }).to_string();
2429 let result = exec_run_command(&args);
2430 assert!(result.contains("err"), "got: {result}");
2431 }
2432 }