|
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(" ", " ") |
|
578
|
.replace("&", "&") |
|
579
|
.replace("<", "<") |
|
580
|
.replace(">", ">") |
|
581
|
.replace(""", "\"") |
|
582
|
.replace("'", "'"); |
|
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
|
} |