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
17 // ── Tool schemas ─────────────────────────────────────────────────────────────
18
19 pub struct AgentTool {
20 pub name: &'static str,
21 pub description: &'static str,
22 pub parameters_schema: Value,
23 }
24
25 pub fn all_tools() -> Vec<AgentTool> {
26 vec![
27 AgentTool {
28 name: "read_file",
29 description: "Read the contents of a file at the given path. \
30 Prefer an absolute path when possible. Use start_line and \
31 end_line to read a specific range instead of the whole file — \
32 strongly prefer this when you already know which lines matter. \
33 Output is truncated to 10 000 characters.",
34 parameters_schema: json!({
35 "type": "object",
36 "properties": {
37 "path": {
38 "type": "string",
39 "description": "Absolute or relative path to the file to read."
40 },
41 "start_line": {
42 "type": "integer",
43 "description": "First line to read (1-based, inclusive). Omit to start from the beginning."
44 },
45 "end_line": {
46 "type": "integer",
47 "description": "Last line to read (1-based, inclusive). Omit to read to the end."
48 }
49 },
50 "required": ["path"],
51 "additionalProperties": false
52 }),
53 },
54 AgentTool {
55 name: "create_directory",
56 description: "Create a directory at the given path. \
57 Prefer an absolute path when possible. Missing parent \
58 directories are created automatically. Use this before \
59 create_file when the parent path does not exist. Succeeds \
60 if the directory already exists.",
61 parameters_schema: json!({
62 "type": "object",
63 "properties": {
64 "path": {
65 "type": "string",
66 "description": "Absolute or relative path to the directory to create."
67 }
68 },
69 "required": ["path"],
70 "additionalProperties": false
71 }),
72 },
73 AgentTool {
74 name: "list_directory",
75 description: "List files and directories at the given path. \
76 Prefer an absolute path when possible. Each entry is \
77 prefixed with [DIR] or [FILE]. Directories are listed \
78 first, sorted alphabetically.",
79 parameters_schema: json!({
80 "type": "object",
81 "properties": {
82 "path": {
83 "type": "string",
84 "description": "Absolute or relative path to the directory to list."
85 }
86 },
87 "required": ["path"],
88 "additionalProperties": false
89 }),
90 },
91 AgentTool {
92 name: "search_files",
93 description: "Search for a regex pattern across files in a directory tree. \
94 Prefer an absolute root path when possible. Returns matching \
95 lines in `file:line_number: content` format. Skips binary \
96 files and hidden directories. Limited to the first 50 matches.",
97 parameters_schema: json!({
98 "type": "object",
99 "properties": {
100 "pattern": {
101 "type": "string",
102 "description": "Regular expression pattern to search for."
103 },
104 "path": {
105 "type": "string",
106 "description": "Root directory to search in. Defaults to \".\" (current directory)."
107 }
108 },
109 "required": ["pattern"],
110 "additionalProperties": false
111 }),
112 },
113 AgentTool {
114 name: "read_website",
115 description: "Fetch a web page and return readable text content. \
116 Use this when the user gives you a URL and asks you to read, \
117 summarize, inspect, or extract information from the page. \
118 Supports normal http and https URLs. Output is truncated if the \
119 page is very large.",
120 parameters_schema: json!({
121 "type": "object",
122 "properties": {
123 "url": {
124 "type": "string",
125 "description": "Absolute http or https URL to fetch."
126 }
127 },
128 "required": ["url"],
129 "additionalProperties": false
130 }),
131 },
132 AgentTool {
133 name: "create_file",
134 description: "Create a new file at the given path with the provided content. \
135 Prefer an absolute path when possible. Parent directories are \
136 created automatically if they do not exist. Fails if the file \
137 already exists — use edit_file to modify existing files.",
138 parameters_schema: json!({
139 "type": "object",
140 "properties": {
141 "path": {
142 "type": "string",
143 "description": "Absolute or relative path for the new file."
144 },
145 "content": {
146 "type": "string",
147 "description": "The full text content to write into the new file."
148 }
149 },
150 "required": ["path", "content"],
151 "additionalProperties": false
152 }),
153 },
154 AgentTool {
155 name: "edit_file",
156 description: "Edit an existing file by replacing an exact substring (old_text) with \
157 new text (new_text). Prefer an absolute path when possible. The \
158 old_text must appear exactly once in the file. Use read_file first \
159 to see the current content and identify the exact text to replace. \
160 To append to a file, match the last few lines as old_text and \
161 include them plus the new content as new_text.",
162 parameters_schema: json!({
163 "type": "object",
164 "properties": {
165 "path": {
166 "type": "string",
167 "description": "Path to the existing file to edit."
168 },
169 "old_text": {
170 "type": "string",
171 "description": "The exact text span to find and replace. Must match exactly once."
172 },
173 "new_text": {
174 "type": "string",
175 "description": "The replacement text that will take the place of old_text."
176 }
177 },
178 "required": ["path", "old_text", "new_text"],
179 "additionalProperties": false
180 }),
181 },
182 AgentTool {
183 name: "delete_file",
184 description: "Delete a file or empty directory at the given path. \
185 Prefer an absolute path when possible. Refuses to delete \
186 non-empty directories to prevent accidental data loss. \
187 Use read_file or list_directory first to confirm the target.",
188 parameters_schema: json!({
189 "type": "object",
190 "properties": {
191 "path": {
192 "type": "string",
193 "description": "Absolute or relative path to the file or empty directory to delete."
194 }
195 },
196 "required": ["path"],
197 "additionalProperties": false
198 }),
199 },
200 AgentTool {
201 name: "run_command",
202 description: "Run a shell command and return its combined stdout and stderr output. \
203 The command runs in the given working directory (defaults to the \
204 user's home directory). Always use an absolute working directory \
205 path. Use this for build tools (cargo, npm, make), package managers, \
206 linters, test runners, and git commands, including git init, \
207 porcelain commands like status/add/commit/checkout, and plumbing \
208 commands like rev-parse, hash-object, update-ref, and cat-file. \
209 For `git clone`, always specify the full absolute destination path \
210 as the last argument (e.g. `git clone <url> /absolute/path/to/dir`) \
211 and set cwd to the parent directory. Never run `git clone` without \
212 an explicit destination. If the user asks for a new repo or scaffold, \
213 use this for `git clone`, `git init`, and normal repo setup steps. \
214 In smbCloud repos, prefer existing workspace commands, Rails \
215 conventions, and deploy flows over inventing new command sequences. \
216 Commands that run indefinitely (servers, watchers) will be killed \
217 after 120 seconds.",
218 parameters_schema: json!({
219 "type": "object",
220 "properties": {
221 "command": {
222 "type": "string",
223 "description": "The shell command to execute (e.g. \"cargo update\", \"git status\", \"git rev-parse HEAD\")."
224 },
225 "cwd": {
226 "type": "string",
227 "description": "Working directory for the command. Defaults to \".\" (current directory)."
228 }
229 },
230 "required": ["command"],
231 "additionalProperties": false
232 }),
233 },
234 ]
235 }
236
237 // ── Tool execution ───────────────────────────────────────────────────────────
238
239 pub async fn execute_tool(name: &str, arguments: &str) -> String {
240 match name {
241 "read_file" => exec_read_file(arguments),
242 "list_directory" => exec_list_directory(arguments),
243 "search_files" => exec_search_files(arguments),
244 "read_website" => {
245 // reqwest::blocking panics inside a tokio runtime, so run on the blocking pool.
246 let args = arguments.to_owned();
247 tokio::task::spawn_blocking(move || exec_read_website(&args))
248 .await
249 .unwrap_or_else(|err| format!("Error: read_website task failed: {err}"))
250 }
251 "create_directory" => exec_create_directory(arguments),
252 "create_file" => exec_create_file(arguments),
253 "edit_file" => exec_edit_file(arguments),
254 "delete_file" => exec_delete_file(arguments),
255 "run_command" => exec_run_command(arguments),
256 _ => format!("Unknown tool: {name}"),
257 }
258 }
259
260 fn absolute_path(path: &Path) -> PathBuf {
261 if path.is_absolute() {
262 path.to_path_buf()
263 } else {
264 std::env::current_dir()
265 .unwrap_or_else(|_| PathBuf::from("."))
266 .join(path)
267 }
268 }
269
270 fn absolute_path_string(path: &Path) -> String {
271 absolute_path(path).display().to_string()
272 }
273
274 // ── read_file ────────────────────────────────────────────────────────────────
275
276 fn exec_read_file(arguments: &str) -> String {
277 let args: Value = match serde_json::from_str(arguments) {
278 Ok(v) => v,
279 Err(err) => return format!("Error: failed to parse arguments: {err}"),
280 };
281
282 let path_str = match args.get("path").and_then(Value::as_str) {
283 Some(p) => p,
284 None => return "Error: missing required parameter \"path\"".to_string(),
285 };
286
287 let start_line = args
288 .get("start_line")
289 .and_then(Value::as_u64)
290 .map(|n| n as usize);
291 let end_line = args
292 .get("end_line")
293 .and_then(Value::as_u64)
294 .map(|n| n as usize);
295
296 let path = Path::new(path_str);
297 let absolute_path = absolute_path(path);
298 let absolute_path_str = absolute_path.display().to_string();
299
300 if !absolute_path.exists() {
301 return format!("Error: path does not exist: {absolute_path_str}");
302 }
303
304 if !absolute_path.is_file() {
305 return format!("Error: path is not a file: {absolute_path_str}");
306 }
307
308 match fs::read_to_string(&absolute_path) {
309 Ok(contents) => {
310 if start_line.is_some() || end_line.is_some() {
311 let lines: Vec<&str> = contents.lines().collect();
312 let total = lines.len();
313 let start = start_line.unwrap_or(1).max(1);
314 let end = end_line.unwrap_or(total).min(total);
315
316 if start > total {
317 return format!(
318 "Error: start_line {start} is beyond end of file ({total} lines)"
319 );
320 }
321
322 let selected: Vec<&str> = lines[(start - 1)..end].to_vec();
323 let range_text = selected.join("\n");
324 format!("Lines {start}-{end} of {total} in {absolute_path_str}:\n{range_text}")
325 } else if contents.len() > READ_FILE_CHAR_LIMIT {
326 let truncated: String = contents.chars().take(READ_FILE_CHAR_LIMIT).collect();
327 format!(
328 "{truncated}\n\n--- truncated (showing {READ_FILE_CHAR_LIMIT} of {} characters) ---",
329 contents.len()
330 )
331 } else {
332 contents
333 }
334 }
335 Err(err) => format!("Error: could not read file: {err}"),
336 }
337 }
338
339 // ── list_directory ───────────────────────────────────────────────────────────
340
341 fn exec_list_directory(arguments: &str) -> String {
342 let args: Value = match serde_json::from_str(arguments) {
343 Ok(v) => v,
344 Err(err) => return format!("Error: failed to parse arguments: {err}"),
345 };
346
347 let path_str = match args.get("path").and_then(Value::as_str) {
348 Some(p) => p,
349 None => return "Error: missing required parameter \"path\"".to_string(),
350 };
351
352 let path = Path::new(path_str);
353 let absolute_path = absolute_path(path);
354 let absolute_path_str = absolute_path.display().to_string();
355
356 if !absolute_path.exists() {
357 return format!("Error: path does not exist: {absolute_path_str}");
358 }
359
360 if !absolute_path.is_dir() {
361 return format!("Error: path is not a directory: {absolute_path_str}");
362 }
363
364 let entries = match fs::read_dir(&absolute_path) {
365 Ok(rd) => rd,
366 Err(err) => return format!("Error: could not read directory: {err}"),
367 };
368
369 let mut dirs: Vec<String> = Vec::new();
370 let mut files: Vec<String> = Vec::new();
371
372 for entry in entries {
373 let entry = match entry {
374 Ok(e) => e,
375 Err(err) => {
376 files.push(format!("[ERR] {err}"));
377 continue;
378 }
379 };
380
381 let name = entry.file_name().to_string_lossy().to_string();
382
383 let is_dir = match entry.file_type() {
384 Ok(ft) => ft.is_dir(),
385 Err(_) => false,
386 };
387
388 if is_dir {
389 dirs.push(format!("[DIR] {name}"));
390 } else {
391 files.push(format!("[FILE] {name}"));
392 }
393 }
394
395 dirs.sort();
396 files.sort();
397
398 dirs.extend(files);
399
400 if dirs.is_empty() {
401 return format!("(empty directory: {absolute_path_str})");
402 }
403
404 dirs.join("\n")
405 }
406
407 // ── search_files ─────────────────────────────────────────────────────────────
408
409 fn exec_search_files(arguments: &str) -> String {
410 let args: Value = match serde_json::from_str(arguments) {
411 Ok(v) => v,
412 Err(err) => return format!("Error: failed to parse arguments: {err}"),
413 };
414
415 let pattern_str = match args.get("pattern").and_then(Value::as_str) {
416 Some(p) => p,
417 None => return "Error: missing required parameter \"pattern\"".to_string(),
418 };
419
420 let root_str = args.get("path").and_then(Value::as_str).unwrap_or(".");
421
422 let re = match Regex::new(pattern_str) {
423 Ok(r) => r,
424 Err(err) => return format!("Error: invalid regex pattern: {err}"),
425 };
426
427 let root = Path::new(root_str);
428 let absolute_root = absolute_path(root);
429 let absolute_root_str = absolute_root.display().to_string();
430
431 if !absolute_root.exists() {
432 return format!("Error: path does not exist: {absolute_root_str}");
433 }
434
435 if !absolute_root.is_dir() {
436 return format!("Error: path is not a directory: {absolute_root_str}");
437 }
438
439 let mut matches: Vec<String> = Vec::new();
440 walk_and_search(&absolute_root, &re, &mut matches);
441
442 if matches.is_empty() {
443 return format!("No matches found for pattern: {pattern_str}");
444 }
445
446 let total = matches.len();
447 if total > SEARCH_FILES_MATCH_LIMIT {
448 matches.truncate(SEARCH_FILES_MATCH_LIMIT);
449 matches.push(format!(
450 "\n--- truncated (showing {SEARCH_FILES_MATCH_LIMIT} of {total} matches) ---"
451 ));
452 }
453
454 matches.join("\n")
455 }
456
457 /// caps collected matches at 2x the public limit to bound work on large trees.
458 fn walk_and_search(dir: &Path, re: &Regex, matches: &mut Vec<String>) {
459 const WALK_CAP: usize = SEARCH_FILES_MATCH_LIMIT * 2;
460
461 let entries = match fs::read_dir(dir) {
462 Ok(rd) => rd,
463 Err(_) => return,
464 };
465
466 let mut sorted: Vec<fs::DirEntry> = entries.filter_map(Result::ok).collect();
467 sorted.sort_by_key(|e| e.file_name());
468
469 for entry in sorted {
470 if matches.len() >= WALK_CAP {
471 return;
472 }
473
474 let path = entry.path();
475 let name = entry.file_name();
476 let name_str = name.to_string_lossy();
477
478 if name_str.starts_with('.') {
479 continue;
480 }
481
482 if path.is_dir() {
483 walk_and_search(&path, re, matches);
484 } else if path.is_file() {
485 search_file(&path, re, matches);
486 }
487 }
488 }
489
490 /// skips non-UTF-8 files (probably binary).
491 fn search_file(path: &Path, re: &Regex, matches: &mut Vec<String>) {
492 let contents = match fs::read_to_string(path) {
493 Ok(c) => c,
494 Err(_) => return,
495 };
496
497 let display_path = absolute_path_string(path);
498
499 for (line_idx, line) in contents.lines().enumerate() {
500 if re.is_match(line) {
501 let line_number = line_idx + 1;
502 matches.push(format!("{display_path}:{line_number}: {line}"));
503 }
504 }
505 }
506
507 // ── read_website ─────────────────────────────────────────────────────────────
508
509 fn exec_read_website(arguments: &str) -> String {
510 let args: Value = match serde_json::from_str(arguments) {
511 Ok(v) => v,
512 Err(err) => return format!("Error: failed to parse arguments: {err}"),
513 };
514
515 let url = match args.get("url").and_then(Value::as_str) {
516 Some(u) => u,
517 None => return "Error: missing required parameter \"url\"".to_string(),
518 };
519
520 if !(url.starts_with("http://") || url.starts_with("https://")) {
521 return format!("Error: url must start with http:// or https://: {url}");
522 }
523
524 let client = match reqwest::blocking::Client::builder()
525 .timeout(WEBSITE_READ_TIMEOUT)
526 .user_agent(WEBSITE_USER_AGENT)
527 .build()
528 {
529 Ok(client) => client,
530 Err(err) => return format!("Error: failed to build website client: {err}"),
531 };
532
533 let response = match client.get(url).send() {
534 Ok(r) => r,
535 Err(err) => return format!("Error: failed to fetch website: {err}"),
536 };
537
538 let final_url = response.url().to_string();
539 let status = response.status();
540 if !status.is_success() {
541 return format!("Error: website returned HTTP {status} for {final_url}");
542 }
543
544 let body = match response.text() {
545 Ok(text) => text,
546 Err(err) => return format!("Error: failed to read website body: {err}"),
547 };
548
549 let title = Regex::new(r"(?is)<title[^>]*>(.*?)</title>")
550 .unwrap()
551 .captures(&body)
552 .and_then(|captures| captures.get(1))
553 .map(|m| {
554 Regex::new(r"\s+")
555 .unwrap()
556 .replace_all(m.as_str(), " ")
557 .trim()
558 .to_string()
559 })
560 .filter(|title| !title.is_empty());
561
562 let with_block_breaks = Regex::new(
563 r"(?is)</?(?:p|div|section|article|main|aside|header|footer|nav|li|ul|ol|h1|h2|h3|h4|h5|h6|br|tr|td|th)[^>]*>",
564 )
565 .unwrap()
566 .replace_all(&body, "\n");
567 let without_scripts = Regex::new(r"(?is)<script[^>]*>.*?</script>")
568 .unwrap()
569 .replace_all(&with_block_breaks, " ");
570 let without_styles = Regex::new(r"(?is)<style[^>]*>.*?</style>")
571 .unwrap()
572 .replace_all(&without_scripts, " ");
573 let without_tags = Regex::new(r"(?is)<[^>]+>")
574 .unwrap()
575 .replace_all(&without_styles, " ");
576 let normalized_newlines = without_tags
577 .replace("&nbsp;", " ")
578 .replace("&amp;", "&")
579 .replace("&lt;", "<")
580 .replace("&gt;", ">")
581 .replace("&quot;", "\"")
582 .replace("&#39;", "'");
583 let collapsed_lines = Regex::new(r"[ \t]+")
584 .unwrap()
585 .replace_all(&normalized_newlines, " ");
586 let collapsed_breaks = Regex::new(r"\n\s*\n+")
587 .unwrap()
588 .replace_all(&collapsed_lines, "\n\n");
589 let cleaned = collapsed_breaks
590 .lines()
591 .map(str::trim)
592 .filter(|line| !line.is_empty())
593 .collect::<Vec<_>>()
594 .join("\n");
595
596 if cleaned.is_empty() {
597 return format!("Fetched {url}, but no readable text content was found.");
598 }
599
600 let mut metadata = vec![format!("URL: {final_url}")];
601 if let Some(title) = &title {
602 metadata.push(format!("Title: {title}"));
603 }
604
605 let body_text = match title {
606 Some(title) if !cleaned.starts_with(&title) => cleaned,
607 _ => cleaned,
608 };
609
610 let output = format!("{}\n\n{}", metadata.join("\n"), body_text);
611
612 if output.len() > WEBSITE_READ_CHAR_LIMIT {
613 let truncated: String = output.chars().take(WEBSITE_READ_CHAR_LIMIT).collect();
614 return format!(
615 "{truncated}\n\n--- truncated (showing {WEBSITE_READ_CHAR_LIMIT} of {} characters) ---",
616 output.len()
617 );
618 }
619
620 output
621 }
622
623 // ── create_directory ─────────────────────────────────────────────────────────
624
625 fn exec_create_directory(arguments: &str) -> String {
626 let args: Value = match serde_json::from_str(arguments) {
627 Ok(v) => v,
628 Err(err) => return format!("Error: failed to parse arguments: {err}"),
629 };
630
631 let path_str = match args.get("path").and_then(Value::as_str) {
632 Some(p) => p,
633 None => return "Error: missing required parameter \"path\"".to_string(),
634 };
635
636 let path = Path::new(path_str);
637 let absolute_path = absolute_path(path);
638 let absolute_path_str = absolute_path.display().to_string();
639
640 if absolute_path.exists() {
641 if absolute_path.is_dir() {
642 return format!("Directory already exists: {absolute_path_str}");
643 }
644 return format!("Error: path exists and is not a directory: {absolute_path_str}");
645 }
646
647 match fs::create_dir_all(&absolute_path) {
648 Ok(()) => format!("Created directory: {absolute_path_str}"),
649 Err(err) => format!("Error: could not create directory: {err}"),
650 }
651 }
652
653 /// fails if file exists so the LLM is forced to use `edit_file` for modifications.
654 fn exec_create_file(arguments: &str) -> String {
655 let args: Value = match serde_json::from_str(arguments) {
656 Ok(v) => v,
657 Err(err) => return format!("Error: failed to parse arguments: {err}"),
658 };
659
660 let path_str = match args.get("path").and_then(Value::as_str) {
661 Some(p) => p,
662 None => return "Error: missing required parameter \"path\"".to_string(),
663 };
664
665 let content = match args.get("content").and_then(Value::as_str) {
666 Some(c) => c,
667 None => return "Error: missing required parameter \"content\"".to_string(),
668 };
669
670 let path = Path::new(path_str);
671 let absolute_path = absolute_path(path);
672 let absolute_path_str = absolute_path.display().to_string();
673
674 if absolute_path.exists() {
675 return format!(
676 "Error: file already exists: {absolute_path_str} — use edit_file to modify existing files"
677 );
678 }
679
680 if let Some(parent) = absolute_path.parent()
681 && !parent.as_os_str().is_empty()
682 && !parent.exists()
683 && let Err(err) = fs::create_dir_all(parent)
684 {
685 return format!("Error: could not create parent directories: {err}");
686 }
687
688 match fs::write(&absolute_path, content) {
689 Ok(()) => format!(
690 "Created file: {absolute_path_str} ({} bytes)",
691 content.len()
692 ),
693 Err(err) => format!("Error: could not write file: {err}"),
694 }
695 }
696
697 // ── edit_file ────────────────────────────────────────────────────────────────
698
699 /// `old_text` must match exactly once — ambiguity means the LLM didn't read the file first.
700 fn exec_edit_file(arguments: &str) -> String {
701 let args: Value = match serde_json::from_str(arguments) {
702 Ok(v) => v,
703 Err(err) => return format!("Error: failed to parse arguments: {err}"),
704 };
705
706 let path_str = match args.get("path").and_then(Value::as_str) {
707 Some(p) => p,
708 None => return "Error: missing required parameter \"path\"".to_string(),
709 };
710
711 let old_text = match args.get("old_text").and_then(Value::as_str) {
712 Some(t) => t,
713 None => return "Error: missing required parameter \"old_text\"".to_string(),
714 };
715
716 let new_text = match args.get("new_text").and_then(Value::as_str) {
717 Some(t) => t,
718 None => return "Error: missing required parameter \"new_text\"".to_string(),
719 };
720
721 let path = Path::new(path_str);
722 let absolute_path = absolute_path(path);
723 let absolute_path_str = absolute_path.display().to_string();
724
725 if !absolute_path.exists() {
726 return format!(
727 "Error: file does not exist: {absolute_path_str} — use create_file for new files"
728 );
729 }
730
731 if !absolute_path.is_file() {
732 return format!("Error: path is not a file: {absolute_path_str}");
733 }
734
735 let contents = match fs::read_to_string(&absolute_path) {
736 Ok(c) => c,
737 Err(err) => return format!("Error: could not read file: {err}"),
738 };
739
740 let occurrences = contents.matches(old_text).count();
741
742 if occurrences == 0 {
743 return format!(
744 "Error: old_text not found in {absolute_path_str}. \
745 Use read_file to see the current content and copy the exact text to replace."
746 );
747 }
748
749 if occurrences > 1 {
750 return format!(
751 "Error: old_text appears {occurrences} times in {absolute_path_str}. \
752 Include more surrounding context in old_text so it matches exactly once."
753 );
754 }
755
756 let updated = contents.replacen(old_text, new_text, 1);
757
758 match fs::write(&absolute_path, &updated) {
759 Ok(()) => format!(
760 "Edited file: {absolute_path_str} ({} bytes written)",
761 updated.len()
762 ),
763 Err(err) => format!("Error: could not write file: {err}"),
764 }
765 }
766
767 // ── delete_file ──────────────────────────────────────────────────────────────
768
769 /// only removes files or *empty* directories — no recursive deletes.
770 fn exec_delete_file(arguments: &str) -> String {
771 let args: Value = match serde_json::from_str(arguments) {
772 Ok(v) => v,
773 Err(err) => return format!("Error: failed to parse arguments: {err}"),
774 };
775
776 let path_str = match args.get("path").and_then(Value::as_str) {
777 Some(p) => p,
778 None => return "Error: missing required parameter \"path\"".to_string(),
779 };
780
781 let path = Path::new(path_str);
782 let absolute_path = absolute_path(path);
783 let absolute_path_str = absolute_path.display().to_string();
784
785 if !absolute_path.exists() {
786 return format!("Error: path does not exist: {absolute_path_str}");
787 }
788
789 if absolute_path.is_dir() {
790 match fs::remove_dir(&absolute_path) {
791 Ok(()) => format!("Deleted empty directory: {absolute_path_str}"),
792 Err(err) => format!(
793 "Error: could not delete directory: {err}. \
794 Only empty directories can be deleted."
795 ),
796 }
797 } else {
798 match fs::remove_file(&absolute_path) {
799 Ok(()) => format!("Deleted file: {absolute_path_str}"),
800 Err(err) => format!("Error: could not delete file: {err}"),
801 }
802 }
803 }
804
805 // ── run_command ──────────────────────────────────────────────────────────────
806
807 const COMMAND_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
808 const COMMAND_OUTPUT_LIMIT: usize = 50_000;
809
810 /// runs via `sh -c` / `cmd /C`; killed after COMMAND_TIMEOUT.
811 fn exec_run_command(arguments: &str) -> String {
812 let args: Value = match serde_json::from_str(arguments) {
813 Ok(v) => v,
814 Err(err) => return format!("Error: failed to parse arguments: {err}"),
815 };
816
817 let command_str = match args.get("command").and_then(Value::as_str) {
818 Some(c) => c,
819 None => return "Error: missing required parameter \"command\"".to_string(),
820 };
821
822 let default_cwd = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
823 let cwd = args
824 .get("cwd")
825 .and_then(Value::as_str)
826 .unwrap_or(&default_cwd);
827 let cwd_path = absolute_path(Path::new(cwd));
828 let cwd_str = cwd_path.display().to_string();
829
830 if !cwd_path.exists() {
831 return format!("Error: working directory does not exist: {cwd_str}");
832 }
833
834 log::info!("run_command: `{command_str}` in `{cwd_str}`");
835
836 #[cfg(unix)]
837 let mut child = match Command::new("sh")
838 .arg("-c")
839 .arg(command_str)
840 .current_dir(&cwd_path)
841 .stdout(std::process::Stdio::piped())
842 .stderr(std::process::Stdio::piped())
843 .spawn()
844 {
845 Ok(c) => c,
846 Err(err) => return format!("Error: failed to spawn command: {err}"),
847 };
848
849 #[cfg(windows)]
850 let mut child = match Command::new("cmd")
851 .arg("/C")
852 .arg(command_str)
853 .current_dir(&cwd_path)
854 .stdout(std::process::Stdio::piped())
855 .stderr(std::process::Stdio::piped())
856 .spawn()
857 {
858 Ok(c) => c,
859 Err(err) => return format!("Error: failed to spawn command: {err}"),
860 };
861
862 let start = std::time::Instant::now();
863 loop {
864 match child.try_wait() {
865 Ok(Some(_status)) => break,
866 Ok(None) => {
867 if start.elapsed() >= COMMAND_TIMEOUT {
868 let _ = child.kill();
869 return format!(
870 "Error: command timed out after {} seconds and was killed.",
871 COMMAND_TIMEOUT.as_secs()
872 );
873 }
874 std::thread::sleep(std::time::Duration::from_millis(100));
875 }
876 Err(err) => return format!("Error: failed to wait on command: {err}"),
877 }
878 }
879
880 let output = match child.wait_with_output() {
881 Ok(o) => o,
882 Err(err) => return format!("Error: failed to read command output: {err}"),
883 };
884
885 let exit_code = output.status.code().unwrap_or(-1);
886 let mut combined = String::new();
887 combined.push_str(&String::from_utf8_lossy(&output.stdout));
888 combined.push_str(&String::from_utf8_lossy(&output.stderr));
889
890 let truncated = if combined.len() > COMMAND_OUTPUT_LIMIT {
891 let truncated_str = &combined[..COMMAND_OUTPUT_LIMIT];
892 format!("{truncated_str}\n\n… (output truncated at {COMMAND_OUTPUT_LIMIT} bytes)")
893 } else {
894 combined
895 };
896
897 if output.status.success() {
898 if truncated.is_empty() {
899 format!("Command succeeded (exit code {exit_code}) with no output.")
900 } else {
901 format!("Exit code {exit_code}:\n{truncated}")
902 }
903 } else {
904 format!("Command failed (exit code {exit_code}):\n{truncated}")
905 }
906 }
907
908 #[cfg(test)]
909 mod tests {
910 use super::*;
911 use std::fs;
912
913 #[tokio::test]
914 async fn test_execute_unknown_tool() {
915 let result = execute_tool("nonexistent", "{}").await;
916 assert!(result.starts_with("Unknown tool:"));
917 }
918
919 #[test]
920 fn test_read_file_missing_path_param() {
921 let result = exec_read_file("{}");
922 assert!(result.contains("missing required parameter"));
923 }
924
925 #[test]
926 fn test_read_file_nonexistent() {
927 let result = exec_read_file(r#"{"path": "/tmp/__sigit_no_such_file_42__"}"#);
928 assert!(result.contains("does not exist"));
929 }
930
931 #[test]
932 fn test_read_file_success() {
933 let dir = std::env::temp_dir().join("sigit_test_read_file");
934 let _ = fs::create_dir_all(&dir);
935 let file_path = dir.join("hello.txt");
936 fs::write(&file_path, "hello world").unwrap();
937
938 let args = serde_json::json!({ "path": file_path }).to_string();
939 let result = exec_read_file(&args);
940 assert_eq!(result, "hello world");
941
942 let _ = fs::remove_dir_all(&dir);
943 }
944
945 #[test]
946 fn test_list_directory_missing_path_param() {
947 let result = exec_list_directory("{}");
948 assert!(result.contains("missing required parameter"));
949 }
950
951 #[test]
952 fn test_list_directory_success() {
953 let dir = std::env::temp_dir().join("sigit_test_list_dir");
954 let _ = fs::remove_dir_all(&dir);
955 fs::create_dir_all(dir.join("subdir")).unwrap();
956 fs::write(dir.join("aaa.txt"), "").unwrap();
957 fs::write(dir.join("bbb.rs"), "").unwrap();
958
959 let args = serde_json::json!({ "path": dir }).to_string();
960 let result = exec_list_directory(&args);
961
962 assert!(result.contains("[DIR] subdir"));
963 assert!(result.contains("[FILE] aaa.txt"));
964 assert!(result.contains("[FILE] bbb.rs"));
965
966 // Directories should appear before files.
967 let dir_pos = result.find("[DIR]").unwrap();
968 let file_pos = result.find("[FILE]").unwrap();
969 assert!(dir_pos < file_pos);
970
971 let _ = fs::remove_dir_all(&dir);
972 }
973
974 #[test]
975 fn test_search_files_invalid_regex() {
976 let result = exec_search_files(r#"{"pattern": "[invalid", "path": "."}"#);
977 assert!(result.contains("invalid regex"));
978 }
979
980 #[test]
981 fn test_search_files_success() {
982 let dir = std::env::temp_dir().join("sigit_test_search");
983 let _ = fs::remove_dir_all(&dir);
984 fs::create_dir_all(&dir).unwrap();
985 fs::write(
986 dir.join("code.rs"),
987 "fn main() {\n println!(\"hello\");\n}\n",
988 )
989 .unwrap();
990 fs::write(dir.join("other.txt"), "no match here\n").unwrap();
991
992 let args = serde_json::json!({
993 "pattern": "println",
994 "path": dir
995 })
996 .to_string();
997 let result = exec_search_files(&args);
998
999 assert!(result.contains("code.rs:2:"));
1000 assert!(result.contains("println"));
1001 assert!(!result.contains("other.txt"));
1002
1003 let _ = fs::remove_dir_all(&dir);
1004 }
1005
1006 #[test]
1007 fn test_search_files_no_matches() {
1008 let dir = std::env::temp_dir().join("sigit_test_search_none");
1009 let _ = fs::remove_dir_all(&dir);
1010 fs::create_dir_all(&dir).unwrap();
1011 fs::write(dir.join("empty.txt"), "nothing special").unwrap();
1012
1013 let args = serde_json::json!({
1014 "pattern": "zzz_will_not_match_42",
1015 "path": dir
1016 })
1017 .to_string();
1018 let result = exec_search_files(&args);
1019 assert!(result.contains("No matches found"));
1020
1021 let _ = fs::remove_dir_all(&dir);
1022 }
1023
1024 #[test]
1025 fn test_all_tools_count() {
1026 let tools = all_tools();
1027 assert_eq!(tools.len(), 9);
1028 assert_eq!(tools[0].name, "read_file");
1029 assert_eq!(tools[1].name, "create_directory");
1030 assert_eq!(tools[2].name, "list_directory");
1031 assert_eq!(tools[3].name, "search_files");
1032 assert_eq!(tools[4].name, "read_website");
1033 assert_eq!(tools[5].name, "create_file");
1034 assert_eq!(tools[6].name, "edit_file");
1035 assert_eq!(tools[7].name, "delete_file");
1036 assert_eq!(tools[8].name, "run_command");
1037 }
1038
1039 #[test]
1040 fn test_all_tools_schemas_are_valid_json_objects() {
1041 for tool in all_tools() {
1042 assert!(
1043 tool.parameters_schema.is_object(),
1044 "schema for {} is not an object",
1045 tool.name
1046 );
1047 let obj = tool.parameters_schema.as_object().unwrap();
1048 assert!(obj.contains_key("type"));
1049 assert!(obj.contains_key("properties"));
1050 assert!(obj.contains_key("required"));
1051 }
1052 }
1053
1054 // ── read_website tests ───────────────────────────────────────────────
1055
1056 #[test]
1057 fn test_read_website_missing_url() {
1058 let result = exec_read_website("{}");
1059 assert!(result.contains("missing required parameter"));
1060 }
1061
1062 #[test]
1063 fn test_read_website_invalid_scheme() {
1064 let result = exec_read_website(r#"{"url": "file:///tmp/test.html"}"#);
1065 assert!(result.contains("url must start with http:// or https://"));
1066 }
1067
1068 #[test]
1069 fn test_read_website_extracts_title_from_html() {
1070 let body = r#"
1071 <html>
1072 <head>
1073 <title>Qwen 3.6 27B</title>
1074 </head>
1075 <body>
1076 <h1>Model card</h1>
1077 <p>Large language model.</p>
1078 </body>
1079 </html>
1080 "#;
1081
1082 let title = Regex::new(r"(?is)<title[^>]*>(.*?)</title>")
1083 .unwrap()
1084 .captures(body)
1085 .and_then(|captures| captures.get(1))
1086 .map(|m| {
1087 Regex::new(r"\s+")
1088 .unwrap()
1089 .replace_all(m.as_str(), " ")
1090 .trim()
1091 .to_string()
1092 })
1093 .filter(|title| !title.is_empty());
1094
1095 assert_eq!(title.as_deref(), Some("Qwen 3.6 27B"));
1096 }
1097
1098 #[test]
1099 fn test_read_website_metadata_includes_final_url_header() {
1100 let final_url = "https://huggingface.co/Qwen/Qwen3.6-27B";
1101 let title = Some("Qwen 3.6 27B".to_string());
1102 let cleaned = "Model card\nLarge language model.".to_string();
1103
1104 let mut metadata = vec![format!("URL: {final_url}")];
1105 if let Some(title) = &title {
1106 metadata.push(format!("Title: {title}"));
1107 }
1108
1109 let body_text = match title {
1110 Some(_) => cleaned,
1111 None => cleaned,
1112 };
1113
1114 let output = format!("{}\n\n{}", metadata.join("\n"), body_text);
1115
1116 assert!(output.starts_with("URL: https://huggingface.co/Qwen/Qwen3.6-27B"));
1117 assert!(output.contains("\nTitle: Qwen 3.6 27B\n\n"));
1118 }
1119
1120 // ── create_directory tests ───────────────────────────────────────────
1121
1122 #[test]
1123 fn test_create_directory_missing_path() {
1124 let result = exec_create_directory("{}");
1125 assert!(result.contains("missing required parameter"));
1126 }
1127
1128 #[test]
1129 fn test_create_directory_success() {
1130 let dir = std::env::temp_dir()
1131 .join("sigit_test_create_directory")
1132 .join("nested")
1133 .join("child");
1134 let _ = fs::remove_dir_all(dir.parent().unwrap());
1135
1136 let args = serde_json::json!({ "path": dir }).to_string();
1137 let result = exec_create_directory(&args);
1138 assert!(result.starts_with("Created directory:"), "got: {result}");
1139 assert!(dir.exists());
1140 assert!(dir.is_dir());
1141
1142 let _ = fs::remove_dir_all(dir.parent().unwrap().parent().unwrap());
1143 }
1144
1145 #[test]
1146 fn test_create_directory_already_exists() {
1147 let dir = std::env::temp_dir().join("sigit_test_create_directory_exists");
1148 let _ = fs::remove_dir_all(&dir);
1149 fs::create_dir_all(&dir).unwrap();
1150
1151 let args = serde_json::json!({ "path": dir }).to_string();
1152 let result = exec_create_directory(&args);
1153 assert!(result.contains("Directory already exists"), "got: {result}");
1154
1155 let _ = fs::remove_dir_all(&dir);
1156 }
1157
1158 // ── create_file tests ────────────────────────────────────────────────
1159
1160 #[test]
1161 fn test_create_file_missing_path() {
1162 let result = exec_create_file(r#"{"content": "hello"}"#);
1163 assert!(result.contains("missing required parameter"));
1164 }
1165
1166 #[test]
1167 fn test_create_file_missing_content() {
1168 let result = exec_create_file(r#"{"path": "/tmp/sigit_test_nope.txt"}"#);
1169 assert!(result.contains("missing required parameter"));
1170 }
1171
1172 #[test]
1173 fn test_create_file_success() {
1174 let dir = std::env::temp_dir().join("sigit_test_create_file");
1175 let _ = fs::remove_dir_all(&dir);
1176
1177 let file_path = dir.join("sub").join("new_file.txt");
1178 let args = serde_json::json!({
1179 "path": file_path,
1180 "content": "hello world"
1181 })
1182 .to_string();
1183
1184 let result = exec_create_file(&args);
1185 assert!(result.starts_with("Created file:"), "got: {result}");
1186 assert!(file_path.exists());
1187 assert_eq!(fs::read_to_string(&file_path).unwrap(), "hello world");
1188
1189 let _ = fs::remove_dir_all(&dir);
1190 }
1191
1192 #[test]
1193 fn test_create_file_already_exists() {
1194 let dir = std::env::temp_dir().join("sigit_test_create_exists");
1195 let _ = fs::remove_dir_all(&dir);
1196 fs::create_dir_all(&dir).unwrap();
1197
1198 let file_path = dir.join("existing.txt");
1199 fs::write(&file_path, "original").unwrap();
1200
1201 let args = serde_json::json!({
1202 "path": file_path,
1203 "content": "overwrite attempt"
1204 })
1205 .to_string();
1206
1207 let result = exec_create_file(&args);
1208 assert!(result.contains("already exists"), "got: {result}");
1209 // Original content untouched.
1210 assert_eq!(fs::read_to_string(&file_path).unwrap(), "original");
1211
1212 let _ = fs::remove_dir_all(&dir);
1213 }
1214
1215 // ── edit_file tests ──────────────────────────────────────────────────
1216
1217 #[test]
1218 fn test_edit_file_missing_params() {
1219 let result = exec_edit_file(r#"{"path": "x"}"#);
1220 assert!(result.contains("missing required parameter"));
1221
1222 let result = exec_edit_file(r#"{"path": "x", "old_text": "a"}"#);
1223 assert!(result.contains("missing required parameter"));
1224 }
1225
1226 #[test]
1227 fn test_edit_file_nonexistent() {
1228 let result = exec_edit_file(
1229 r#"{"path": "/tmp/__sigit_no_such__", "old_text": "a", "new_text": "b"}"#,
1230 );
1231 assert!(result.contains("does not exist"));
1232 }
1233
1234 #[test]
1235 fn test_edit_file_success() {
1236 let dir = std::env::temp_dir().join("sigit_test_edit_file");
1237 let _ = fs::remove_dir_all(&dir);
1238 fs::create_dir_all(&dir).unwrap();
1239
1240 let file_path = dir.join("code.rs");
1241 fs::write(&file_path, "fn main() {\n println!(\"hello\");\n}\n").unwrap();
1242
1243 let args = serde_json::json!({
1244 "path": file_path,
1245 "old_text": "println!(\"hello\")",
1246 "new_text": "println!(\"world\")"
1247 })
1248 .to_string();
1249
1250 let result = exec_edit_file(&args);
1251 assert!(result.starts_with("Edited file:"), "got: {result}");
1252
1253 let updated = fs::read_to_string(&file_path).unwrap();
1254 assert!(updated.contains("println!(\"world\")"));
1255 assert!(!updated.contains("println!(\"hello\")"));
1256
1257 let _ = fs::remove_dir_all(&dir);
1258 }
1259
1260 #[test]
1261 fn test_edit_file_old_text_not_found() {
1262 let dir = std::env::temp_dir().join("sigit_test_edit_notfound");
1263 let _ = fs::remove_dir_all(&dir);
1264 fs::create_dir_all(&dir).unwrap();
1265
1266 let file_path = dir.join("data.txt");
1267 fs::write(&file_path, "aaa bbb ccc").unwrap();
1268
1269 let args = serde_json::json!({
1270 "path": file_path,
1271 "old_text": "zzz",
1272 "new_text": "yyy"
1273 })
1274 .to_string();
1275
1276 let result = exec_edit_file(&args);
1277 assert!(result.contains("old_text not found"), "got: {result}");
1278
1279 let _ = fs::remove_dir_all(&dir);
1280 }
1281
1282 #[test]
1283 fn test_edit_file_ambiguous_match() {
1284 let dir = std::env::temp_dir().join("sigit_test_edit_ambiguous");
1285 let _ = fs::remove_dir_all(&dir);
1286 fs::create_dir_all(&dir).unwrap();
1287
1288 let file_path = dir.join("repeat.txt");
1289 fs::write(&file_path, "foo bar foo bar foo").unwrap();
1290
1291 let args = serde_json::json!({
1292 "path": file_path,
1293 "old_text": "foo",
1294 "new_text": "baz"
1295 })
1296 .to_string();
1297
1298 let result = exec_edit_file(&args);
1299 assert!(result.contains("appears 3 times"), "got: {result}");
1300 // File should be unchanged.
1301 assert_eq!(
1302 fs::read_to_string(&file_path).unwrap(),
1303 "foo bar foo bar foo"
1304 );
1305
1306 let _ = fs::remove_dir_all(&dir);
1307 }
1308
1309 // ── delete_file tests ────────────────────────────────────────────────
1310
1311 #[test]
1312 fn test_delete_file_missing_path() {
1313 let result = exec_delete_file("{}");
1314 assert!(
1315 result.contains("missing required parameter"),
1316 "got: {result}"
1317 );
1318 }
1319
1320 #[test]
1321 fn test_delete_file_nonexistent() {
1322 let result = exec_delete_file(r#"{"path": "/tmp/sigit_test_no_such_file_xyz"}"#);
1323 assert!(result.contains("does not exist"), "got: {result}");
1324 }
1325
1326 #[test]
1327 fn test_delete_file_success() {
1328 let dir = std::env::temp_dir().join("sigit_test_delete_file");
1329 let _ = fs::remove_dir_all(&dir);
1330 fs::create_dir_all(&dir).unwrap();
1331
1332 let file_path = dir.join("to_delete.txt");
1333 fs::write(&file_path, "bye").unwrap();
1334 assert!(file_path.exists());
1335
1336 let args = serde_json::json!({ "path": file_path }).to_string();
1337 let result = exec_delete_file(&args);
1338 assert!(result.contains("Deleted file"), "got: {result}");
1339 assert!(!file_path.exists());
1340
1341 let _ = fs::remove_dir_all(&dir);
1342 }
1343
1344 #[test]
1345 fn test_delete_empty_directory() {
1346 let dir = std::env::temp_dir().join("sigit_test_delete_empty_dir");
1347 let _ = fs::remove_dir_all(&dir);
1348 fs::create_dir_all(&dir).unwrap();
1349
1350 let args = serde_json::json!({ "path": dir }).to_string();
1351 let result = exec_delete_file(&args);
1352 assert!(result.contains("Deleted empty directory"), "got: {result}");
1353 assert!(!dir.exists());
1354 }
1355
1356 #[test]
1357 fn test_delete_nonempty_directory() {
1358 let dir = std::env::temp_dir().join("sigit_test_delete_nonempty_dir");
1359 let _ = fs::remove_dir_all(&dir);
1360 fs::create_dir_all(&dir).unwrap();
1361 fs::write(dir.join("child.txt"), "content").unwrap();
1362
1363 let args = serde_json::json!({ "path": dir }).to_string();
1364 let result = exec_delete_file(&args);
1365 assert!(result.contains("Error"), "got: {result}");
1366 assert!(dir.exists(), "directory should not have been deleted");
1367
1368 let _ = fs::remove_dir_all(&dir);
1369 }
1370
1371 // ── run_command tests ────────────────────────────────────────────────
1372
1373 #[test]
1374 fn test_run_command_missing_command() {
1375 let result = exec_run_command("{}");
1376 assert!(
1377 result.contains("missing required parameter"),
1378 "got: {result}"
1379 );
1380 }
1381
1382 #[test]
1383 fn test_run_command_success() {
1384 let result = exec_run_command(r#"{"command": "echo hello"}"#);
1385 assert!(result.contains("hello"), "got: {result}");
1386 assert!(result.contains("Exit code 0"), "got: {result}");
1387 }
1388
1389 #[test]
1390 fn test_run_command_failure() {
1391 #[cfg(unix)]
1392 let command = "false";
1393 #[cfg(windows)]
1394 let command = "exit /b 1";
1395
1396 let args = serde_json::json!({ "command": command }).to_string();
1397 let result = exec_run_command(&args);
1398 assert!(result.contains("failed"), "got: {result}");
1399 }
1400
1401 #[test]
1402 fn test_run_command_with_cwd() {
1403 let dir = std::env::temp_dir().join("sigit_test_run_cmd_cwd");
1404 let _ = fs::remove_dir_all(&dir);
1405 fs::create_dir_all(&dir).unwrap();
1406
1407 #[cfg(unix)]
1408 let command = "pwd";
1409 #[cfg(windows)]
1410 let command = "cd";
1411
1412 let args = serde_json::json!({
1413 "command": command,
1414 "cwd": dir
1415 })
1416 .to_string();
1417 let result = exec_run_command(&args);
1418 // The output should contain the temp dir path.
1419 assert!(
1420 result.contains(&dir.to_string_lossy().to_string()),
1421 "got: {result}"
1422 );
1423
1424 let _ = fs::remove_dir_all(&dir);
1425 }
1426
1427 #[test]
1428 fn test_run_command_bad_cwd() {
1429 let missing_dir = std::env::temp_dir().join("sigit_no_such_dir_xyz");
1430 let _ = fs::remove_dir_all(&missing_dir);
1431
1432 let args = serde_json::json!({
1433 "command": "echo hi",
1434 "cwd": missing_dir
1435 })
1436 .to_string();
1437 let result = exec_run_command(&args);
1438 assert!(result.contains("does not exist"), "got: {result}");
1439 }
1440
1441 #[test]
1442 fn test_run_command_captures_stderr() {
1443 #[cfg(unix)]
1444 let command = "echo err >&2";
1445 #[cfg(windows)]
1446 let command = "echo err 1>&2";
1447
1448 let args = serde_json::json!({ "command": command }).to_string();
1449 let result = exec_run_command(&args);
1450 assert!(result.contains("err"), "got: {result}");
1451 }
1452 }