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