13
14
const READ_FILE_CHAR_LIMIT: usize = 10_000;
15
const SEARCH_FILES_MATCH_LIMIT: usize = 50;
16
+/// Upper bound on `max_results` for `search_files` and the number of paths
17
+/// returned by `glob`, so a broad pattern can't flood the context window.
18
+const SEARCH_RESULTS_HARD_CAP: usize = 1_000;
19
20
// ── Tool schemas ─────────────────────────────────────────────────────────────
21
96
description: "Search for a regex pattern across files in a directory tree. \
97
Prefer an absolute root path when possible. Returns matching \
98
lines in `file:line_number: content` format. Skips binary \
96
- files and hidden directories. Limited to the first 50 matches.",
99
+ files and hidden directories. Pass `file_glob` to restrict the \
100
+ search to files whose name matches a glob (e.g. \"*.rs\"), and \
101
+ `max_results` to raise or lower the default cap of 50 matches.",
102
parameters_schema: json!({
103
"type": "object",
104
"properties": {
109
"path": {
110
"type": "string",
111
"description": "Root directory to search in. Defaults to \".\" (current directory)."
112
+ },
113
+ "file_glob": {
114
+ "type": "string",
115
+ "description": "Optional glob on the file name (not the full path), e.g. \"*.rs\" or \"*.{ts,tsx}\". Only matching files are searched."
116
+ },
117
+ "max_results": {
118
+ "type": "integer",
119
+ "description": "Maximum number of matching lines to return (default 50, capped at 1000)."
120
}
121
},
122
"required": ["pattern"],
167
AgentTool {
168
name: "edit_file",
169
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.",
170
+ new text (new_text). Prefer an absolute path when possible. By \
171
+ default old_text must appear exactly once; set replace_all to true \
172
+ to replace every occurrence (useful for renaming a symbol). Use \
173
+ read_file first to see the current content and identify the exact \
174
+ text to replace. To append to a file, match the last few lines as \
175
+ old_text and include them plus the new content as new_text.",
176
parameters_schema: json!({
177
"type": "object",
178
"properties": {
182
},
183
"old_text": {
184
"type": "string",
171
- "description": "The exact text span to find and replace. Must match exactly once."
185
+ "description": "The exact text span to find and replace. Must match exactly once unless replace_all is true."
186
},
187
"new_text": {
188
"type": "string",
189
"description": "The replacement text that will take the place of old_text."
190
+ },
191
+ "replace_all": {
192
+ "type": "boolean",
193
+ "description": "Replace every occurrence of old_text instead of requiring a unique match. Defaults to false."
194
}
195
},
196
"required": ["path", "old_text", "new_text"],
249
"additionalProperties": false
250
}),
251
},
252
+ AgentTool {
253
+ name: "multi_edit",
254
+ description: "Apply several exact-substring edits to a single file in one call. \
255
+ Edits are applied in order, each to the result of the previous one, \
256
+ and the whole batch is atomic — if any edit fails to match, the file \
257
+ is left untouched and an error explains which edit failed. Prefer \
258
+ this over multiple edit_file calls when changing several spots in the \
259
+ same file. Each edit has old_text (must match exactly once, or every \
260
+ time when replace_all is true) and new_text.",
261
+ parameters_schema: json!({
262
+ "type": "object",
263
+ "properties": {
264
+ "path": {
265
+ "type": "string",
266
+ "description": "Path to the existing file to edit."
267
+ },
268
+ "edits": {
269
+ "type": "array",
270
+ "description": "Ordered list of edits to apply to the file.",
271
+ "items": {
272
+ "type": "object",
273
+ "properties": {
274
+ "old_text": {
275
+ "type": "string",
276
+ "description": "The exact text span to find and replace."
277
+ },
278
+ "new_text": {
279
+ "type": "string",
280
+ "description": "The replacement text."
281
+ },
282
+ "replace_all": {
283
+ "type": "boolean",
284
+ "description": "Replace every occurrence instead of requiring a unique match. Defaults to false."
285
+ }
286
+ },
287
+ "required": ["old_text", "new_text"],
288
+ "additionalProperties": false
289
+ }
290
+ }
291
+ },
292
+ "required": ["path", "edits"],
293
+ "additionalProperties": false
294
+ }),
295
+ },
296
+ AgentTool {
297
+ name: "glob",
298
+ description: "Find files by name using a glob pattern (e.g. \"**/*.rs\", \
299
+ \"src/**/*.{ts,tsx}\", \"Cargo.toml\"). Returns matching file paths, \
300
+ most-recently-modified first. Supports `*` (any run of non-separator \
301
+ characters), `**` (any number of directories), `?` (one character), \
302
+ and `{a,b}` alternation. Use this to locate files by name; use \
303
+ search_files to search file contents.",
304
+ parameters_schema: json!({
305
+ "type": "object",
306
+ "properties": {
307
+ "pattern": {
308
+ "type": "string",
309
+ "description": "Glob pattern matched against paths relative to the search root."
310
+ },
311
+ "path": {
312
+ "type": "string",
313
+ "description": "Root directory to search in. Defaults to \".\" (current directory)."
314
+ }
315
+ },
316
+ "required": ["pattern"],
317
+ "additionalProperties": false
318
+ }),
319
+ },
320
+ AgentTool {
321
+ name: "write_todos",
322
+ description: "Record or update a checklist of the steps for the current task. \
323
+ Use this for any multi-step task to plan the work and show progress: \
324
+ call it once up front with all the steps as `pending`, then call it \
325
+ again whenever a step's status changes. Mark exactly one step \
326
+ `in_progress` at a time and `completed` as soon as it is done. \
327
+ Keep the list short and outcome-focused.",
328
+ parameters_schema: json!({
329
+ "type": "object",
330
+ "properties": {
331
+ "todos": {
332
+ "type": "array",
333
+ "description": "The full, current checklist (replaces any previous list).",
334
+ "items": {
335
+ "type": "object",
336
+ "properties": {
337
+ "content": {
338
+ "type": "string",
339
+ "description": "Short imperative description of the step."
340
+ },
341
+ "status": {
342
+ "type": "string",
343
+ "enum": ["pending", "in_progress", "completed"],
344
+ "description": "Current status of the step."
345
+ }
346
+ },
347
+ "required": ["content", "status"],
348
+ "additionalProperties": false
349
+ }
350
+ }
351
+ },
352
+ "required": ["todos"],
353
+ "additionalProperties": false
354
+ }),
355
+ },
356
+ AgentTool {
357
+ name: "remember",
358
+ description: "Persist a durable note, preference, or convention by appending it to \
359
+ this project's instruction file (AGENTS.md / CLAUDE.md). Use this \
360
+ when the user asks you to remember something for next time, or states \
361
+ a lasting preference about how to work in this project. The note is \
362
+ written to the nearest existing instruction file, or a new CLAUDE.md \
363
+ at the repository root if none exists yet.",
364
+ parameters_schema: json!({
365
+ "type": "object",
366
+ "properties": {
367
+ "note": {
368
+ "type": "string",
369
+ "description": "The fact or preference to remember, phrased as a standalone instruction."
370
+ }
371
+ },
372
+ "required": ["note"],
373
+ "additionalProperties": false
374
+ }),
375
+ },
376
]
377
}
378
393
"create_directory" => exec_create_directory(arguments),
394
"create_file" => exec_create_file(arguments),
395
"edit_file" => exec_edit_file(arguments),
396
+ "multi_edit" => exec_multi_edit(arguments),
397
+ "glob" => exec_glob(arguments),
398
+ "write_todos" => exec_write_todos(arguments),
399
+ "remember" => exec_remember(arguments),
400
"delete_file" => exec_delete_file(arguments),
401
"run_command" => exec_run_command(arguments),
402
"skill" => crate::skills::activate_skill(arguments),
574
Err(err) => return format!("Error: invalid regex pattern: {err}"),
575
};
576
577
+ // Optional file-name filter compiled from a glob (e.g. "*.rs").
578
+ let name_filter = match args.get("file_glob").and_then(Value::as_str) {
579
+ Some(glob) => match Regex::new(&glob_to_regex(glob)) {
580
+ Ok(r) => Some(r),
581
+ Err(err) => return format!("Error: invalid file_glob: {err}"),
582
+ },
583
+ None => None,
584
+ };
585
+
586
+ let limit = args
587
+ .get("max_results")
588
+ .and_then(Value::as_u64)
589
+ .map(|n| (n as usize).clamp(1, SEARCH_RESULTS_HARD_CAP))
590
+ .unwrap_or(SEARCH_FILES_MATCH_LIMIT);
591
+
592
let root = Path::new(root_str);
593
let absolute_root = absolute_path(root);
594
let absolute_root_str = absolute_root.display().to_string();
602
}
603
604
let mut matches: Vec<String> = Vec::new();
444
- walk_and_search(&absolute_root, &re, &mut matches);
605
+ walk_and_search(
606
+ &absolute_root,
607
+ &re,
608
+ name_filter.as_ref(),
609
+ limit,
610
+ &mut matches,
611
+ );
612
613
if matches.is_empty() {
614
return format!("No matches found for pattern: {pattern_str}");
615
}
616
617
let total = matches.len();
451
- if total > SEARCH_FILES_MATCH_LIMIT {
452
- matches.truncate(SEARCH_FILES_MATCH_LIMIT);
618
+ if total > limit {
619
+ matches.truncate(limit);
620
matches.push(format!(
454
- "\n--- truncated (showing {SEARCH_FILES_MATCH_LIMIT} of {total} matches) ---"
621
+ "\n--- truncated (showing {limit} of {total}+ matches; raise max_results to see more) ---"
622
));
623
}
624
625
matches.join("\n")
626
}
627
461
-/// caps collected matches at 2x the public limit to bound work on large trees.
462
-fn walk_and_search(dir: &Path, re: &Regex, matches: &mut Vec<String>) {
463
- const WALK_CAP: usize = SEARCH_FILES_MATCH_LIMIT * 2;
464
-
628
+/// Collects up to `limit + 1` matches (the extra signals truncation) so a broad
629
+/// pattern can't walk an entire tree once enough hits are found.
630
+fn walk_and_search(
631
+ dir: &Path,
632
+ re: &Regex,
633
+ name_filter: Option<&Regex>,
634
+ limit: usize,
635
+ matches: &mut Vec<String>,
636
+) {
637
let entries = match fs::read_dir(dir) {
638
Ok(rd) => rd,
639
Err(_) => return,
643
sorted.sort_by_key(|e| e.file_name());
644
645
for entry in sorted {
474
- if matches.len() >= WALK_CAP {
646
+ if matches.len() > limit {
647
return;
648
}
649
656
}
657
658
if path.is_dir() {
487
- walk_and_search(&path, re, matches);
659
+ walk_and_search(&path, re, name_filter, limit, matches);
660
} else if path.is_file() {
661
+ if let Some(filter) = name_filter
662
+ && !filter.is_match(&name_str)
663
+ {
664
+ continue;
665
+ }
666
search_file(&path, re, matches);
667
}
668
}
875
}
876
}
877
701
-// ── edit_file ────────────────────────────────────────────────────────────────
878
+// ── edit_file / multi_edit ─────────────────────────────────────────────────
879
+
880
+/// Apply one exact-substring replacement to `contents`. Returns the updated
881
+/// string, or a human-readable explanation of why the match failed so the model
882
+/// can correct itself in a single follow-up instead of guessing blindly.
883
+fn apply_edit(
884
+ contents: &str,
885
+ old_text: &str,
886
+ new_text: &str,
887
+ replace_all: bool,
888
+) -> Result<String, String> {
889
+ if old_text.is_empty() {
890
+ return Err("old_text is empty; nothing to match".to_string());
891
+ }
892
+ if old_text == new_text {
893
+ return Err("old_text and new_text are identical; no change to make".to_string());
894
+ }
895
+
896
+ let occurrences = contents.matches(old_text).count();
897
+
898
+ if occurrences == 0 {
899
+ return Err(format!(
900
+ "old_text not found. Use read_file to copy the exact text \
901
+ (including whitespace and indentation) to replace.{}",
902
+ nearest_line_hint(contents, old_text)
903
+ ));
904
+ }
905
+
906
+ if occurrences > 1 && !replace_all {
907
+ return Err(format!(
908
+ "old_text appears {occurrences} times; include more surrounding context so it \
909
+ matches exactly once, or set replace_all to true to change every occurrence."
910
+ ));
911
+ }
912
+
913
+ if replace_all {
914
+ Ok(contents.replace(old_text, new_text))
915
+ } else {
916
+ Ok(contents.replacen(old_text, new_text, 1))
917
+ }
918
+}
919
+
920
+/// When `old_text` doesn't match verbatim, point at the line whose trimmed text
921
+/// equals the first trimmed line of `old_text` — the usual culprit is a
922
+/// whitespace/indentation mismatch, and naming the line lets the model fix it.
923
+fn nearest_line_hint(contents: &str, old_text: &str) -> String {
924
+ let first = old_text.lines().find(|l| !l.trim().is_empty());
925
+ let Some(first) = first.map(str::trim) else {
926
+ return String::new();
927
+ };
928
+ for (idx, line) in contents.lines().enumerate() {
929
+ if line.trim() == first {
930
+ return format!(
931
+ " (the first line of old_text appears at line {}, so the difference is likely \
932
+ whitespace or indentation)",
933
+ idx + 1
934
+ );
935
+ }
936
+ }
937
+ String::new()
938
+}
939
703
-/// `old_text` must match exactly once — ambiguity means the LLM didn't read the file first.
940
fn exec_edit_file(arguments: &str) -> String {
941
let args: Value = match serde_json::from_str(arguments) {
942
Ok(v) => v,
958
None => return "Error: missing required parameter \"new_text\"".to_string(),
959
};
960
961
+ let replace_all = args
962
+ .get("replace_all")
963
+ .and_then(Value::as_bool)
964
+ .unwrap_or(false);
965
+
966
let path = Path::new(path_str);
967
let absolute_path = absolute_path(path);
968
let absolute_path_str = absolute_path.display().to_string();
982
Err(err) => return format!("Error: could not read file: {err}"),
983
};
984
744
- let occurrences = contents.matches(old_text).count();
985
+ let updated = match apply_edit(&contents, old_text, new_text, replace_all) {
986
+ Ok(updated) => updated,
987
+ Err(why) => return format!("Error: {why} (in {absolute_path_str})"),
988
+ };
989
746
- if occurrences == 0 {
747
- return format!(
748
- "Error: old_text not found in {absolute_path_str}. \
749
- Use read_file to see the current content and copy the exact text to replace."
750
- );
990
+ match fs::write(&absolute_path, &updated) {
991
+ Ok(()) => format!(
992
+ "Edited file: {absolute_path_str} ({} bytes written)",
993
+ updated.len()
994
+ ),
995
+ Err(err) => format!("Error: could not write file: {err}"),
996
}
997
+}
998
+
999
+/// Apply a batch of edits to one file atomically: each edit is applied to the
1000
+/// result of the previous one, and the file is only written if *every* edit
1001
+/// matches. A failure leaves the file untouched.
1002
+fn exec_multi_edit(arguments: &str) -> String {
1003
+ let args: Value = match serde_json::from_str(arguments) {
1004
+ Ok(v) => v,
1005
+ Err(err) => return format!("Error: failed to parse arguments: {err}"),
1006
+ };
1007
+
1008
+ let path_str = match args.get("path").and_then(Value::as_str) {
1009
+ Some(p) => p,
1010
+ None => return "Error: missing required parameter \"path\"".to_string(),
1011
+ };
1012
+
1013
+ let edits = match args.get("edits").and_then(Value::as_array) {
1014
+ Some(e) if !e.is_empty() => e,
1015
+ Some(_) => return "Error: \"edits\" must contain at least one edit".to_string(),
1016
+ None => return "Error: missing required parameter \"edits\"".to_string(),
1017
+ };
1018
+
1019
+ let path = Path::new(path_str);
1020
+ let absolute_path = absolute_path(path);
1021
+ let absolute_path_str = absolute_path.display().to_string();
1022
753
- if occurrences > 1 {
1023
+ if !absolute_path.exists() {
1024
return format!(
755
- "Error: old_text appears {occurrences} times in {absolute_path_str}. \
756
- Include more surrounding context in old_text so it matches exactly once."
1025
+ "Error: file does not exist: {absolute_path_str} — use create_file for new files"
1026
);
1027
}
1028
760
- let updated = contents.replacen(old_text, new_text, 1);
1029
+ if !absolute_path.is_file() {
1030
+ return format!("Error: path is not a file: {absolute_path_str}");
1031
+ }
1032
762
- match fs::write(&absolute_path, &updated) {
1033
+ let mut working = match fs::read_to_string(&absolute_path) {
1034
+ Ok(c) => c,
1035
+ Err(err) => return format!("Error: could not read file: {err}"),
1036
+ };
1037
+
1038
+ for (idx, edit) in edits.iter().enumerate() {
1039
+ let old_text = match edit.get("old_text").and_then(Value::as_str) {
1040
+ Some(t) => t,
1041
+ None => return format!("Error: edit #{} is missing \"old_text\"", idx + 1),
1042
+ };
1043
+ let new_text = match edit.get("new_text").and_then(Value::as_str) {
1044
+ Some(t) => t,
1045
+ None => return format!("Error: edit #{} is missing \"new_text\"", idx + 1),
1046
+ };
1047
+ let replace_all = edit
1048
+ .get("replace_all")
1049
+ .and_then(Value::as_bool)
1050
+ .unwrap_or(false);
1051
+
1052
+ match apply_edit(&working, old_text, new_text, replace_all) {
1053
+ Ok(updated) => working = updated,
1054
+ Err(why) => {
1055
+ return format!(
1056
+ "Error: edit #{} failed: {why}. No changes were written to {absolute_path_str}.",
1057
+ idx + 1
1058
+ );
1059
+ }
1060
+ }
1061
+ }
1062
+
1063
+ match fs::write(&absolute_path, &working) {
1064
Ok(()) => format!(
764
- "Edited file: {absolute_path_str} ({} bytes written)",
765
- updated.len()
1065
+ "Applied {} edits to {absolute_path_str} ({} bytes written)",
1066
+ edits.len(),
1067
+ working.len()
1068
),
1069
Err(err) => format!("Error: could not write file: {err}"),
1070
}
1071
}
1072
1073
+// ── glob ─────────────────────────────────────────────────────────────────────
1074
+
1075
+/// Translate a shell-style glob into an anchored regex. Supports `*`
1076
+/// (non-separator run), `**` (any number of directories), `?` (one
1077
+/// non-separator), and `{a,b}` alternation; everything else is matched
1078
+/// literally. Used both by the `glob` tool (against relative paths) and by
1079
+/// `search_files`' `file_glob` filter (against bare file names).
1080
+fn glob_to_regex(glob: &str) -> String {
1081
+ let chars: Vec<char> = glob.chars().collect();
1082
+ let mut re = String::from("^");
1083
+ let mut brace_depth = 0usize;
1084
+ let mut i = 0;
1085
+
1086
+ while i < chars.len() {
1087
+ let c = chars[i];
1088
+ match c {
1089
+ '*' => {
1090
+ if i + 1 < chars.len() && chars[i + 1] == '*' {
1091
+ i += 1; // consume the second '*'
1092
+ if i + 1 < chars.len() && chars[i + 1] == '/' {
1093
+ // `**/` matches zero or more leading directories.
1094
+ re.push_str("(?:.*/)?");
1095
+ i += 1; // consume the '/'
1096
+ } else {
1097
+ re.push_str(".*");
1098
+ }
1099
+ } else {
1100
+ re.push_str("[^/]*");
1101
+ }
1102
+ }
1103
+ '?' => re.push_str("[^/]"),
1104
+ '{' => {
1105
+ brace_depth += 1;
1106
+ re.push_str("(?:");
1107
+ }
1108
+ '}' if brace_depth > 0 => {
1109
+ brace_depth -= 1;
1110
+ re.push(')');
1111
+ }
1112
+ ',' if brace_depth > 0 => re.push('|'),
1113
+ // Escape regex metacharacters so they match literally. (`{` is always
1114
+ // consumed by the brace arm above; an unmatched `}` lands here.)
1115
+ '.' | '+' | '(' | ')' | '|' | '^' | '$' | '\\' | '[' | ']' | '}' => {
1116
+ re.push('\\');
1117
+ re.push(c);
1118
+ }
1119
+ other => re.push(other),
1120
+ }
1121
+ i += 1;
1122
+ }
1123
+
1124
+ re.push('$');
1125
+ re
1126
+}
1127
+
1128
+fn exec_glob(arguments: &str) -> String {
1129
+ let args: Value = match serde_json::from_str(arguments) {
1130
+ Ok(v) => v,
1131
+ Err(err) => return format!("Error: failed to parse arguments: {err}"),
1132
+ };
1133
+
1134
+ let pattern = match args.get("pattern").and_then(Value::as_str) {
1135
+ Some(p) => p,
1136
+ None => return "Error: missing required parameter \"pattern\"".to_string(),
1137
+ };
1138
+
1139
+ let re = match Regex::new(&glob_to_regex(pattern)) {
1140
+ Ok(r) => r,
1141
+ Err(err) => return format!("Error: invalid glob pattern: {err}"),
1142
+ };
1143
+
1144
+ let root_str = args.get("path").and_then(Value::as_str).unwrap_or(".");
1145
+ let absolute_root = absolute_path(Path::new(root_str));
1146
+ let absolute_root_str = absolute_root.display().to_string();
1147
+
1148
+ if !absolute_root.exists() {
1149
+ return format!("Error: path does not exist: {absolute_root_str}");
1150
+ }
1151
+ if !absolute_root.is_dir() {
1152
+ return format!("Error: path is not a directory: {absolute_root_str}");
1153
+ }
1154
+
1155
+ let mut found: Vec<(std::time::SystemTime, String)> = Vec::new();
1156
+ glob_walk(&absolute_root, &absolute_root, &re, &mut found);
1157
+
1158
+ if found.is_empty() {
1159
+ return format!("No files match glob: {pattern}");
1160
+ }
1161
+
1162
+ // Most-recently-modified first, like Claude Code's Glob.
1163
+ found.sort_by(|a, b| b.0.cmp(&a.0));
1164
+
1165
+ let total = found.len();
1166
+ let mut paths: Vec<String> = found.into_iter().map(|(_, p)| p).collect();
1167
+ if total > SEARCH_RESULTS_HARD_CAP {
1168
+ paths.truncate(SEARCH_RESULTS_HARD_CAP);
1169
+ paths.push(format!(
1170
+ "\n--- truncated (showing {SEARCH_RESULTS_HARD_CAP} of {total} files) ---"
1171
+ ));
1172
+ }
1173
+
1174
+ paths.join("\n")
1175
+}
1176
+
1177
+fn glob_walk(root: &Path, dir: &Path, re: &Regex, out: &mut Vec<(std::time::SystemTime, String)>) {
1178
+ if out.len() > SEARCH_RESULTS_HARD_CAP {
1179
+ return;
1180
+ }
1181
+
1182
+ let entries = match fs::read_dir(dir) {
1183
+ Ok(rd) => rd,
1184
+ Err(_) => return,
1185
+ };
1186
+
1187
+ let mut sorted: Vec<fs::DirEntry> = entries.filter_map(Result::ok).collect();
1188
+ sorted.sort_by_key(|e| e.file_name());
1189
+
1190
+ for entry in sorted {
1191
+ if out.len() > SEARCH_RESULTS_HARD_CAP {
1192
+ return;
1193
+ }
1194
+
1195
+ let path = entry.path();
1196
+ let name = entry.file_name();
1197
+ if name.to_string_lossy().starts_with('.') {
1198
+ continue;
1199
+ }
1200
+
1201
+ if path.is_dir() {
1202
+ glob_walk(root, &path, re, out);
1203
+ } else if path.is_file() {
1204
+ let relative = path
1205
+ .strip_prefix(root)
1206
+ .unwrap_or(&path)
1207
+ .to_string_lossy()
1208
+ .replace('\\', "/");
1209
+ if re.is_match(&relative) {
1210
+ let mtime = entry
1211
+ .metadata()
1212
+ .and_then(|m| m.modified())
1213
+ .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
1214
+ out.push((mtime, absolute_path_string(&path)));
1215
+ }
1216
+ }
1217
+ }
1218
+}
1219
+
1220
+// ── write_todos ──────────────────────────────────────────────────────────────
1221
+
1222
+/// Renders the model's task checklist back as the tool result so the surface
1223
+/// (TUI / ACP client) can show live progress. Pure presentation — the list is
1224
+/// owned by the model, not persisted here.
1225
+fn exec_write_todos(arguments: &str) -> String {
1226
+ let args: Value = match serde_json::from_str(arguments) {
1227
+ Ok(v) => v,
1228
+ Err(err) => return format!("Error: failed to parse arguments: {err}"),
1229
+ };
1230
+
1231
+ let todos = match args.get("todos").and_then(Value::as_array) {
1232
+ Some(t) if !t.is_empty() => t,
1233
+ Some(_) => return "Error: \"todos\" must contain at least one item".to_string(),
1234
+ None => return "Error: missing required parameter \"todos\"".to_string(),
1235
+ };
1236
+
1237
+ let mut lines = Vec::with_capacity(todos.len());
1238
+ let mut completed = 0usize;
1239
+
1240
+ for (idx, todo) in todos.iter().enumerate() {
1241
+ let content = match todo.get("content").and_then(Value::as_str) {
1242
+ Some(c) => c.trim(),
1243
+ None => return format!("Error: todo #{} is missing \"content\"", idx + 1),
1244
+ };
1245
+ let status = todo
1246
+ .get("status")
1247
+ .and_then(Value::as_str)
1248
+ .unwrap_or("pending");
1249
+
1250
+ let marker = match status {
1251
+ "completed" => {
1252
+ completed += 1;
1253
+ "[x]"
1254
+ }
1255
+ "in_progress" => "[~]",
1256
+ _ => "[ ]",
1257
+ };
1258
+ lines.push(format!("{marker} {content}"));
1259
+ }
1260
+
1261
+ format!(
1262
+ "Task list updated ({completed}/{} done):\n{}",
1263
+ todos.len(),
1264
+ lines.join("\n")
1265
+ )
1266
+}
1267
+
1268
+// ── remember ─────────────────────────────────────────────────────────────────
1269
+
1270
+/// Appends a durable note to the project's instruction file so it persists
1271
+/// across sessions (the always-on counterpart to a one-off chat message).
1272
+fn exec_remember(arguments: &str) -> String {
1273
+ let args: Value = match serde_json::from_str(arguments) {
1274
+ Ok(v) => v,
1275
+ Err(err) => return format!("Error: failed to parse arguments: {err}"),
1276
+ };
1277
+
1278
+ let note = match args.get("note").and_then(Value::as_str) {
1279
+ Some(n) if !n.trim().is_empty() => n.trim(),
1280
+ Some(_) => return "Error: \"note\" must not be empty".to_string(),
1281
+ None => return "Error: missing required parameter \"note\"".to_string(),
1282
+ };
1283
+
1284
+ let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1285
+ remember_at(&cwd, note)
1286
+}
1287
+
1288
+/// Core of `remember`, parameterized on the working directory so it can be
1289
+/// tested without mutating the process-global current directory.
1290
+fn remember_at(cwd: &Path, note: &str) -> String {
1291
+ let target = crate::instructions::memory_file(cwd);
1292
+ let target_str = target.display().to_string();
1293
+
1294
+ let existed = target.exists();
1295
+ let mut body = if existed {
1296
+ match fs::read_to_string(&target) {
1297
+ Ok(c) => c,
1298
+ Err(err) => return format!("Error: could not read {target_str}: {err}"),
1299
+ }
1300
+ } else {
1301
+ if let Some(parent) = target.parent()
1302
+ && !parent.as_os_str().is_empty()
1303
+ && !parent.exists()
1304
+ && let Err(err) = fs::create_dir_all(parent)
1305
+ {
1306
+ return format!("Error: could not create parent directories: {err}");
1307
+ }
1308
+ String::new()
1309
+ };
1310
+
1311
+ // Keep remembered notes grouped under one heading so the file stays tidy.
1312
+ const HEADING: &str = "## Remembered notes";
1313
+ if !body.contains(HEADING) {
1314
+ if !body.is_empty() && !body.ends_with('\n') {
1315
+ body.push('\n');
1316
+ }
1317
+ if !body.is_empty() {
1318
+ body.push('\n');
1319
+ }
1320
+ body.push_str(HEADING);
1321
+ body.push('\n');
1322
+ }
1323
+ if !body.ends_with('\n') {
1324
+ body.push('\n');
1325
+ }
1326
+ body.push_str("- ");
1327
+ body.push_str(note);
1328
+ body.push('\n');
1329
+
1330
+ match fs::write(&target, &body) {
1331
+ Ok(()) => {
1332
+ let verb = if existed { "Appended to" } else { "Created" };
1333
+ format!("{verb} {target_str}: remembered \"{note}\"")
1334
+ }
1335
+ Err(err) => format!("Error: could not write {target_str}: {err}"),
1336
+ }
1337
+}
1338
+
1339
// ── delete_file ──────────────────────────────────────────────────────────────
1340
1341
/// only removes files or *empty* directories — no recursive deletes.
1596
#[test]
1597
fn test_all_tools_count() {
1598
let tools = all_tools();
1031
- assert_eq!(tools.len(), 9);
1599
+ assert_eq!(tools.len(), 13);
1600
assert_eq!(tools[0].name, "read_file");
1601
assert_eq!(tools[1].name, "create_directory");
1602
assert_eq!(tools[2].name, "list_directory");
1606
assert_eq!(tools[6].name, "edit_file");
1607
assert_eq!(tools[7].name, "delete_file");
1608
assert_eq!(tools[8].name, "run_command");
1609
+ assert_eq!(tools[9].name, "multi_edit");
1610
+ assert_eq!(tools[10].name, "glob");
1611
+ assert_eq!(tools[11].name, "write_todos");
1612
+ assert_eq!(tools[12].name, "remember");
1613
+ }
1614
+
1615
+ #[test]
1616
+ fn test_edit_file_replace_all() {
1617
+ let dir = std::env::temp_dir().join("sigit_test_edit_replace_all");
1618
+ let _ = fs::remove_dir_all(&dir);
1619
+ fs::create_dir_all(&dir).unwrap();
1620
+ let file = dir.join("f.txt");
1621
+ fs::write(&file, "foo foo foo").unwrap();
1622
+
1623
+ // Without replace_all an ambiguous match is rejected.
1624
+ let args =
1625
+ serde_json::json!({ "path": &file, "old_text": "foo", "new_text": "bar" }).to_string();
1626
+ let result = exec_edit_file(&args);
1627
+ assert!(result.contains("appears 3 times"), "{result}");
1628
+
1629
+ // With replace_all every occurrence is changed.
1630
+ let args = serde_json::json!({
1631
+ "path": &file, "old_text": "foo", "new_text": "bar", "replace_all": true
1632
+ })
1633
+ .to_string();
1634
+ let result = exec_edit_file(&args);
1635
+ assert!(result.starts_with("Edited file:"), "{result}");
1636
+ assert_eq!(fs::read_to_string(&file).unwrap(), "bar bar bar");
1637
+
1638
+ let _ = fs::remove_dir_all(&dir);
1639
+ }
1640
+
1641
+ #[test]
1642
+ fn test_edit_file_whitespace_hint() {
1643
+ let dir = std::env::temp_dir().join("sigit_test_edit_hint");
1644
+ let _ = fs::remove_dir_all(&dir);
1645
+ fs::create_dir_all(&dir).unwrap();
1646
+ let file = dir.join("f.txt");
1647
+ fs::write(&file, "line one\n indented\nline three\n").unwrap();
1648
+
1649
+ // old_text has more indentation than the file, so it isn't a substring,
1650
+ // but its trimmed content still locates the intended line.
1651
+ let args = serde_json::json!({
1652
+ "path": &file, "old_text": " indented", "new_text": "x"
1653
+ })
1654
+ .to_string();
1655
+ let result = exec_edit_file(&args);
1656
+ assert!(result.contains("line 2"), "{result}");
1657
+ assert!(result.contains("whitespace"), "{result}");
1658
+
1659
+ let _ = fs::remove_dir_all(&dir);
1660
+ }
1661
+
1662
+ #[test]
1663
+ fn test_multi_edit_atomic_on_failure() {
1664
+ let dir = std::env::temp_dir().join("sigit_test_multi_edit");
1665
+ let _ = fs::remove_dir_all(&dir);
1666
+ fs::create_dir_all(&dir).unwrap();
1667
+ let file = dir.join("f.txt");
1668
+ fs::write(&file, "alpha beta gamma").unwrap();
1669
+
1670
+ // Second edit can't match -> nothing should be written.
1671
+ let args = serde_json::json!({
1672
+ "path": &file,
1673
+ "edits": [
1674
+ { "old_text": "alpha", "new_text": "ALPHA" },
1675
+ { "old_text": "nope", "new_text": "x" }
1676
+ ]
1677
+ })
1678
+ .to_string();
1679
+ let result = exec_multi_edit(&args);
1680
+ assert!(result.contains("edit #2 failed"), "{result}");
1681
+ assert_eq!(fs::read_to_string(&file).unwrap(), "alpha beta gamma");
1682
+
1683
+ // All-matching batch applies in sequence.
1684
+ let args = serde_json::json!({
1685
+ "path": &file,
1686
+ "edits": [
1687
+ { "old_text": "alpha", "new_text": "ALPHA" },
1688
+ { "old_text": "gamma", "new_text": "GAMMA" }
1689
+ ]
1690
+ })
1691
+ .to_string();
1692
+ let result = exec_multi_edit(&args);
1693
+ assert!(result.contains("Applied 2 edits"), "{result}");
1694
+ assert_eq!(fs::read_to_string(&file).unwrap(), "ALPHA beta GAMMA");
1695
+
1696
+ let _ = fs::remove_dir_all(&dir);
1697
+ }
1698
+
1699
+ #[test]
1700
+ fn test_glob_to_regex() {
1701
+ let re = Regex::new(&glob_to_regex("**/*.rs")).unwrap();
1702
+ assert!(re.is_match("src/tools.rs"));
1703
+ assert!(re.is_match("main.rs")); // `**/` matches zero directories too
1704
+ assert!(!re.is_match("src/tools.txt"));
1705
+
1706
+ let re = Regex::new(&glob_to_regex("*.{ts,tsx}")).unwrap();
1707
+ assert!(re.is_match("app.ts"));
1708
+ assert!(re.is_match("app.tsx"));
1709
+ assert!(!re.is_match("app.js"));
1710
+ }
1711
+
1712
+ #[test]
1713
+ fn test_glob_tool_success() {
1714
+ let dir = std::env::temp_dir().join("sigit_test_glob");
1715
+ let _ = fs::remove_dir_all(&dir);
1716
+ fs::create_dir_all(dir.join("src")).unwrap();
1717
+ fs::write(dir.join("Cargo.toml"), "").unwrap();
1718
+ fs::write(dir.join("src/main.rs"), "").unwrap();
1719
+ fs::write(dir.join("src/lib.rs"), "").unwrap();
1720
+
1721
+ let args = serde_json::json!({ "pattern": "**/*.rs", "path": &dir }).to_string();
1722
+ let result = exec_glob(&args);
1723
+ assert!(result.contains("main.rs"), "{result}");
1724
+ assert!(result.contains("lib.rs"), "{result}");
1725
+ assert!(!result.contains("Cargo.toml"), "{result}");
1726
+
1727
+ let _ = fs::remove_dir_all(&dir);
1728
+ }
1729
+
1730
+ #[test]
1731
+ fn test_search_files_file_glob_filter() {
1732
+ let dir = std::env::temp_dir().join("sigit_test_search_glob");
1733
+ let _ = fs::remove_dir_all(&dir);
1734
+ fs::create_dir_all(&dir).unwrap();
1735
+ fs::write(dir.join("code.rs"), "needle here\n").unwrap();
1736
+ fs::write(dir.join("notes.txt"), "needle here\n").unwrap();
1737
+
1738
+ let args = serde_json::json!({
1739
+ "pattern": "needle", "path": &dir, "file_glob": "*.rs"
1740
+ })
1741
+ .to_string();
1742
+ let result = exec_search_files(&args);
1743
+ assert!(result.contains("code.rs"), "{result}");
1744
+ assert!(!result.contains("notes.txt"), "{result}");
1745
+
1746
+ let _ = fs::remove_dir_all(&dir);
1747
+ }
1748
+
1749
+ #[test]
1750
+ fn test_write_todos_renders_checklist() {
1751
+ let args = serde_json::json!({
1752
+ "todos": [
1753
+ { "content": "Read code", "status": "completed" },
1754
+ { "content": "Make change", "status": "in_progress" },
1755
+ { "content": "Run tests", "status": "pending" }
1756
+ ]
1757
+ })
1758
+ .to_string();
1759
+ let result = exec_write_todos(&args);
1760
+ assert!(result.contains("1/3 done"), "{result}");
1761
+ assert!(result.contains("[x] Read code"), "{result}");
1762
+ assert!(result.contains("[~] Make change"), "{result}");
1763
+ assert!(result.contains("[ ] Run tests"), "{result}");
1764
+ }
1765
+
1766
+ #[test]
1767
+ fn test_remember_appends_to_instruction_file() {
1768
+ let dir = std::env::temp_dir().join("sigit_test_remember");
1769
+ let _ = fs::remove_dir_all(&dir);
1770
+ fs::create_dir_all(dir.join(".git")).unwrap();
1771
+ let claude_md = dir.join("CLAUDE.md");
1772
+ fs::write(&claude_md, "# Project\n").unwrap();
1773
+
1774
+ let target = crate::instructions::memory_file(&dir);
1775
+ // Should pick the existing CLAUDE.md at the repo root.
1776
+ assert_eq!(
1777
+ target.canonicalize().unwrap(),
1778
+ claude_md.canonicalize().unwrap()
1779
+ );
1780
+
1781
+ let result = remember_at(&dir, "remembered text");
1782
+ assert!(result.contains("remembered"), "{result}");
1783
+
1784
+ let updated = fs::read_to_string(&claude_md).unwrap();
1785
+ assert!(updated.contains("## Remembered notes"), "{updated}");
1786
+ assert!(updated.contains("- remembered text"), "{updated}");
1787
+
1788
+ let _ = fs::remove_dir_all(&dir);
1789
}
1790
1791
#[test]