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),
571
Err(err) => return format!("Error: invalid regex pattern: {err}"),
572
};
573
574
+ // Optional file-name filter compiled from a glob (e.g. "*.rs").
575
+ let name_filter = match args.get("file_glob").and_then(Value::as_str) {
576
+ Some(glob) => match Regex::new(&glob_to_regex(glob)) {
577
+ Ok(r) => Some(r),
578
+ Err(err) => return format!("Error: invalid file_glob: {err}"),
579
+ },
580
+ None => None,
581
+ };
582
+
583
+ let limit = args
584
+ .get("max_results")
585
+ .and_then(Value::as_u64)
586
+ .map(|n| (n as usize).clamp(1, SEARCH_RESULTS_HARD_CAP))
587
+ .unwrap_or(SEARCH_FILES_MATCH_LIMIT);
588
+
589
let root = Path::new(root_str);
590
let absolute_root = absolute_path(root);
591
let absolute_root_str = absolute_root.display().to_string();
599
}
600
601
let mut matches: Vec<String> = Vec::new();
441
- walk_and_search(&absolute_root, &re, &mut matches);
602
+ walk_and_search(
603
+ &absolute_root,
604
+ &re,
605
+ name_filter.as_ref(),
606
+ limit,
607
+ &mut matches,
608
+ );
609
610
if matches.is_empty() {
611
return format!("No matches found for pattern: {pattern_str}");
612
}
613
614
let total = matches.len();
448
- if total > SEARCH_FILES_MATCH_LIMIT {
449
- matches.truncate(SEARCH_FILES_MATCH_LIMIT);
615
+ if total > limit {
616
+ matches.truncate(limit);
617
matches.push(format!(
451
- "\n--- truncated (showing {SEARCH_FILES_MATCH_LIMIT} of {total} matches) ---"
618
+ "\n--- truncated (showing {limit} of {total}+ matches; raise max_results to see more) ---"
619
));
620
}
621
622
matches.join("\n")
623
}
624
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
-
625
+/// Collects up to `limit + 1` matches (the extra signals truncation) so a broad
626
+/// pattern can't walk an entire tree once enough hits are found.
627
+fn walk_and_search(
628
+ dir: &Path,
629
+ re: &Regex,
630
+ name_filter: Option<&Regex>,
631
+ limit: usize,
632
+ matches: &mut Vec<String>,
633
+) {
634
let entries = match fs::read_dir(dir) {
635
Ok(rd) => rd,
636
Err(_) => return,
640
sorted.sort_by_key(|e| e.file_name());
641
642
for entry in sorted {
471
- if matches.len() >= WALK_CAP {
643
+ if matches.len() > limit {
644
return;
645
}
646
653
}
654
655
if path.is_dir() {
484
- walk_and_search(&path, re, matches);
656
+ walk_and_search(&path, re, name_filter, limit, matches);
657
} else if path.is_file() {
658
+ if let Some(filter) = name_filter
659
+ && !filter.is_match(&name_str)
660
+ {
661
+ continue;
662
+ }
663
search_file(&path, re, matches);
664
}
665
}
872
}
873
}
874
698
-// ── edit_file ────────────────────────────────────────────────────────────────
875
+// ── edit_file / multi_edit ─────────────────────────────────────────────────
876
+
877
+/// Apply one exact-substring replacement to `contents`. Returns the updated
878
+/// string, or a human-readable explanation of why the match failed so the model
879
+/// can correct itself in a single follow-up instead of guessing blindly.
880
+fn apply_edit(
881
+ contents: &str,
882
+ old_text: &str,
883
+ new_text: &str,
884
+ replace_all: bool,
885
+) -> Result<String, String> {
886
+ if old_text.is_empty() {
887
+ return Err("old_text is empty; nothing to match".to_string());
888
+ }
889
+ if old_text == new_text {
890
+ return Err("old_text and new_text are identical; no change to make".to_string());
891
+ }
892
+
893
+ let occurrences = contents.matches(old_text).count();
894
+
895
+ if occurrences == 0 {
896
+ return Err(format!(
897
+ "old_text not found. Use read_file to copy the exact text \
898
+ (including whitespace and indentation) to replace.{}",
899
+ nearest_line_hint(contents, old_text)
900
+ ));
901
+ }
902
+
903
+ if occurrences > 1 && !replace_all {
904
+ return Err(format!(
905
+ "old_text appears {occurrences} times; include more surrounding context so it \
906
+ matches exactly once, or set replace_all to true to change every occurrence."
907
+ ));
908
+ }
909
+
910
+ if replace_all {
911
+ Ok(contents.replace(old_text, new_text))
912
+ } else {
913
+ Ok(contents.replacen(old_text, new_text, 1))
914
+ }
915
+}
916
+
917
+/// When `old_text` doesn't match verbatim, point at the line whose trimmed text
918
+/// equals the first trimmed line of `old_text` — the usual culprit is a
919
+/// whitespace/indentation mismatch, and naming the line lets the model fix it.
920
+fn nearest_line_hint(contents: &str, old_text: &str) -> String {
921
+ let first = old_text.lines().find(|l| !l.trim().is_empty());
922
+ let Some(first) = first.map(str::trim) else {
923
+ return String::new();
924
+ };
925
+ for (idx, line) in contents.lines().enumerate() {
926
+ if line.trim() == first {
927
+ return format!(
928
+ " (the first line of old_text appears at line {}, so the difference is likely \
929
+ whitespace or indentation)",
930
+ idx + 1
931
+ );
932
+ }
933
+ }
934
+ String::new()
935
+}
936
700
-/// `old_text` must match exactly once — ambiguity means the LLM didn't read the file first.
937
fn exec_edit_file(arguments: &str) -> String {
938
let args: Value = match serde_json::from_str(arguments) {
939
Ok(v) => v,
955
None => return "Error: missing required parameter \"new_text\"".to_string(),
956
};
957
958
+ let replace_all = args
959
+ .get("replace_all")
960
+ .and_then(Value::as_bool)
961
+ .unwrap_or(false);
962
+
963
let path = Path::new(path_str);
964
let absolute_path = absolute_path(path);
965
let absolute_path_str = absolute_path.display().to_string();
979
Err(err) => return format!("Error: could not read file: {err}"),
980
};
981
741
- let occurrences = contents.matches(old_text).count();
982
+ let updated = match apply_edit(&contents, old_text, new_text, replace_all) {
983
+ Ok(updated) => updated,
984
+ Err(why) => return format!("Error: {why} (in {absolute_path_str})"),
985
+ };
986
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
- );
987
+ match fs::write(&absolute_path, &updated) {
988
+ Ok(()) => format!(
989
+ "Edited file: {absolute_path_str} ({} bytes written)",
990
+ updated.len()
991
+ ),
992
+ Err(err) => format!("Error: could not write file: {err}"),
993
}
994
+}
995
+
996
+/// Apply a batch of edits to one file atomically: each edit is applied to the
997
+/// result of the previous one, and the file is only written if *every* edit
998
+/// matches. A failure leaves the file untouched.
999
+fn exec_multi_edit(arguments: &str) -> String {
1000
+ let args: Value = match serde_json::from_str(arguments) {
1001
+ Ok(v) => v,
1002
+ Err(err) => return format!("Error: failed to parse arguments: {err}"),
1003
+ };
1004
+
1005
+ let path_str = match args.get("path").and_then(Value::as_str) {
1006
+ Some(p) => p,
1007
+ None => return "Error: missing required parameter \"path\"".to_string(),
1008
+ };
1009
+
1010
+ let edits = match args.get("edits").and_then(Value::as_array) {
1011
+ Some(e) if !e.is_empty() => e,
1012
+ Some(_) => return "Error: \"edits\" must contain at least one edit".to_string(),
1013
+ None => return "Error: missing required parameter \"edits\"".to_string(),
1014
+ };
1015
+
1016
+ let path = Path::new(path_str);
1017
+ let absolute_path = absolute_path(path);
1018
+ let absolute_path_str = absolute_path.display().to_string();
1019
750
- if occurrences > 1 {
1020
+ if !absolute_path.exists() {
1021
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."
1022
+ "Error: file does not exist: {absolute_path_str} — use create_file for new files"
1023
);
1024
}
1025
757
- let updated = contents.replacen(old_text, new_text, 1);
1026
+ if !absolute_path.is_file() {
1027
+ return format!("Error: path is not a file: {absolute_path_str}");
1028
+ }
1029
759
- match fs::write(&absolute_path, &updated) {
1030
+ let mut working = match fs::read_to_string(&absolute_path) {
1031
+ Ok(c) => c,
1032
+ Err(err) => return format!("Error: could not read file: {err}"),
1033
+ };
1034
+
1035
+ for (idx, edit) in edits.iter().enumerate() {
1036
+ let old_text = match edit.get("old_text").and_then(Value::as_str) {
1037
+ Some(t) => t,
1038
+ None => return format!("Error: edit #{} is missing \"old_text\"", idx + 1),
1039
+ };
1040
+ let new_text = match edit.get("new_text").and_then(Value::as_str) {
1041
+ Some(t) => t,
1042
+ None => return format!("Error: edit #{} is missing \"new_text\"", idx + 1),
1043
+ };
1044
+ let replace_all = edit
1045
+ .get("replace_all")
1046
+ .and_then(Value::as_bool)
1047
+ .unwrap_or(false);
1048
+
1049
+ match apply_edit(&working, old_text, new_text, replace_all) {
1050
+ Ok(updated) => working = updated,
1051
+ Err(why) => {
1052
+ return format!(
1053
+ "Error: edit #{} failed: {why}. No changes were written to {absolute_path_str}.",
1054
+ idx + 1
1055
+ );
1056
+ }
1057
+ }
1058
+ }
1059
+
1060
+ match fs::write(&absolute_path, &working) {
1061
Ok(()) => format!(
761
- "Edited file: {absolute_path_str} ({} bytes written)",
762
- updated.len()
1062
+ "Applied {} edits to {absolute_path_str} ({} bytes written)",
1063
+ edits.len(),
1064
+ working.len()
1065
),
1066
Err(err) => format!("Error: could not write file: {err}"),
1067
}
1068
}
1069
1070
+// ── glob ─────────────────────────────────────────────────────────────────────
1071
+
1072
+/// Translate a shell-style glob into an anchored regex. Supports `*`
1073
+/// (non-separator run), `**` (any number of directories), `?` (one
1074
+/// non-separator), and `{a,b}` alternation; everything else is matched
1075
+/// literally. Used both by the `glob` tool (against relative paths) and by
1076
+/// `search_files`' `file_glob` filter (against bare file names).
1077
+fn glob_to_regex(glob: &str) -> String {
1078
+ let chars: Vec<char> = glob.chars().collect();
1079
+ let mut re = String::from("^");
1080
+ let mut brace_depth = 0usize;
1081
+ let mut i = 0;
1082
+
1083
+ while i < chars.len() {
1084
+ let c = chars[i];
1085
+ match c {
1086
+ '*' => {
1087
+ if i + 1 < chars.len() && chars[i + 1] == '*' {
1088
+ i += 1; // consume the second '*'
1089
+ if i + 1 < chars.len() && chars[i + 1] == '/' {
1090
+ // `**/` matches zero or more leading directories.
1091
+ re.push_str("(?:.*/)?");
1092
+ i += 1; // consume the '/'
1093
+ } else {
1094
+ re.push_str(".*");
1095
+ }
1096
+ } else {
1097
+ re.push_str("[^/]*");
1098
+ }
1099
+ }
1100
+ '?' => re.push_str("[^/]"),
1101
+ '{' => {
1102
+ brace_depth += 1;
1103
+ re.push_str("(?:");
1104
+ }
1105
+ '}' if brace_depth > 0 => {
1106
+ brace_depth -= 1;
1107
+ re.push(')');
1108
+ }
1109
+ ',' if brace_depth > 0 => re.push('|'),
1110
+ // Escape regex metacharacters so they match literally. (`{` is always
1111
+ // consumed by the brace arm above; an unmatched `}` lands here.)
1112
+ '.' | '+' | '(' | ')' | '|' | '^' | '$' | '\\' | '[' | ']' | '}' => {
1113
+ re.push('\\');
1114
+ re.push(c);
1115
+ }
1116
+ other => re.push(other),
1117
+ }
1118
+ i += 1;
1119
+ }
1120
+
1121
+ re.push('$');
1122
+ re
1123
+}
1124
+
1125
+fn exec_glob(arguments: &str) -> String {
1126
+ let args: Value = match serde_json::from_str(arguments) {
1127
+ Ok(v) => v,
1128
+ Err(err) => return format!("Error: failed to parse arguments: {err}"),
1129
+ };
1130
+
1131
+ let pattern = match args.get("pattern").and_then(Value::as_str) {
1132
+ Some(p) => p,
1133
+ None => return "Error: missing required parameter \"pattern\"".to_string(),
1134
+ };
1135
+
1136
+ let re = match Regex::new(&glob_to_regex(pattern)) {
1137
+ Ok(r) => r,
1138
+ Err(err) => return format!("Error: invalid glob pattern: {err}"),
1139
+ };
1140
+
1141
+ let root_str = args.get("path").and_then(Value::as_str).unwrap_or(".");
1142
+ let absolute_root = absolute_path(Path::new(root_str));
1143
+ let absolute_root_str = absolute_root.display().to_string();
1144
+
1145
+ if !absolute_root.exists() {
1146
+ return format!("Error: path does not exist: {absolute_root_str}");
1147
+ }
1148
+ if !absolute_root.is_dir() {
1149
+ return format!("Error: path is not a directory: {absolute_root_str}");
1150
+ }
1151
+
1152
+ let mut found: Vec<(std::time::SystemTime, String)> = Vec::new();
1153
+ glob_walk(&absolute_root, &absolute_root, &re, &mut found);
1154
+
1155
+ if found.is_empty() {
1156
+ return format!("No files match glob: {pattern}");
1157
+ }
1158
+
1159
+ // Most-recently-modified first, like Claude Code's Glob.
1160
+ found.sort_by(|a, b| b.0.cmp(&a.0));
1161
+
1162
+ let total = found.len();
1163
+ let mut paths: Vec<String> = found.into_iter().map(|(_, p)| p).collect();
1164
+ if total > SEARCH_RESULTS_HARD_CAP {
1165
+ paths.truncate(SEARCH_RESULTS_HARD_CAP);
1166
+ paths.push(format!(
1167
+ "\n--- truncated (showing {SEARCH_RESULTS_HARD_CAP} of {total} files) ---"
1168
+ ));
1169
+ }
1170
+
1171
+ paths.join("\n")
1172
+}
1173
+
1174
+fn glob_walk(root: &Path, dir: &Path, re: &Regex, out: &mut Vec<(std::time::SystemTime, String)>) {
1175
+ if out.len() > SEARCH_RESULTS_HARD_CAP {
1176
+ return;
1177
+ }
1178
+
1179
+ let entries = match fs::read_dir(dir) {
1180
+ Ok(rd) => rd,
1181
+ Err(_) => return,
1182
+ };
1183
+
1184
+ let mut sorted: Vec<fs::DirEntry> = entries.filter_map(Result::ok).collect();
1185
+ sorted.sort_by_key(|e| e.file_name());
1186
+
1187
+ for entry in sorted {
1188
+ if out.len() > SEARCH_RESULTS_HARD_CAP {
1189
+ return;
1190
+ }
1191
+
1192
+ let path = entry.path();
1193
+ let name = entry.file_name();
1194
+ if name.to_string_lossy().starts_with('.') {
1195
+ continue;
1196
+ }
1197
+
1198
+ if path.is_dir() {
1199
+ glob_walk(root, &path, re, out);
1200
+ } else if path.is_file() {
1201
+ let relative = path
1202
+ .strip_prefix(root)
1203
+ .unwrap_or(&path)
1204
+ .to_string_lossy()
1205
+ .replace('\\', "/");
1206
+ if re.is_match(&relative) {
1207
+ let mtime = entry
1208
+ .metadata()
1209
+ .and_then(|m| m.modified())
1210
+ .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
1211
+ out.push((mtime, absolute_path_string(&path)));
1212
+ }
1213
+ }
1214
+ }
1215
+}
1216
+
1217
+// ── write_todos ──────────────────────────────────────────────────────────────
1218
+
1219
+/// Renders the model's task checklist back as the tool result so the surface
1220
+/// (TUI / ACP client) can show live progress. Pure presentation — the list is
1221
+/// owned by the model, not persisted here.
1222
+fn exec_write_todos(arguments: &str) -> String {
1223
+ let args: Value = match serde_json::from_str(arguments) {
1224
+ Ok(v) => v,
1225
+ Err(err) => return format!("Error: failed to parse arguments: {err}"),
1226
+ };
1227
+
1228
+ let todos = match args.get("todos").and_then(Value::as_array) {
1229
+ Some(t) if !t.is_empty() => t,
1230
+ Some(_) => return "Error: \"todos\" must contain at least one item".to_string(),
1231
+ None => return "Error: missing required parameter \"todos\"".to_string(),
1232
+ };
1233
+
1234
+ let mut lines = Vec::with_capacity(todos.len());
1235
+ let mut completed = 0usize;
1236
+
1237
+ for (idx, todo) in todos.iter().enumerate() {
1238
+ let content = match todo.get("content").and_then(Value::as_str) {
1239
+ Some(c) => c.trim(),
1240
+ None => return format!("Error: todo #{} is missing \"content\"", idx + 1),
1241
+ };
1242
+ let status = todo
1243
+ .get("status")
1244
+ .and_then(Value::as_str)
1245
+ .unwrap_or("pending");
1246
+
1247
+ let marker = match status {
1248
+ "completed" => {
1249
+ completed += 1;
1250
+ "[x]"
1251
+ }
1252
+ "in_progress" => "[~]",
1253
+ _ => "[ ]",
1254
+ };
1255
+ lines.push(format!("{marker} {content}"));
1256
+ }
1257
+
1258
+ format!(
1259
+ "Task list updated ({completed}/{} done):\n{}",
1260
+ todos.len(),
1261
+ lines.join("\n")
1262
+ )
1263
+}
1264
+
1265
+// ── remember ─────────────────────────────────────────────────────────────────
1266
+
1267
+/// Appends a durable note to the project's instruction file so it persists
1268
+/// across sessions (the always-on counterpart to a one-off chat message).
1269
+fn exec_remember(arguments: &str) -> String {
1270
+ let args: Value = match serde_json::from_str(arguments) {
1271
+ Ok(v) => v,
1272
+ Err(err) => return format!("Error: failed to parse arguments: {err}"),
1273
+ };
1274
+
1275
+ let note = match args.get("note").and_then(Value::as_str) {
1276
+ Some(n) if !n.trim().is_empty() => n.trim(),
1277
+ Some(_) => return "Error: \"note\" must not be empty".to_string(),
1278
+ None => return "Error: missing required parameter \"note\"".to_string(),
1279
+ };
1280
+
1281
+ let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1282
+ remember_at(&cwd, note)
1283
+}
1284
+
1285
+/// Core of `remember`, parameterized on the working directory so it can be
1286
+/// tested without mutating the process-global current directory.
1287
+fn remember_at(cwd: &Path, note: &str) -> String {
1288
+ let target = crate::instructions::memory_file(cwd);
1289
+ let target_str = target.display().to_string();
1290
+
1291
+ let existed = target.exists();
1292
+ let mut body = if existed {
1293
+ match fs::read_to_string(&target) {
1294
+ Ok(c) => c,
1295
+ Err(err) => return format!("Error: could not read {target_str}: {err}"),
1296
+ }
1297
+ } else {
1298
+ if let Some(parent) = target.parent()
1299
+ && !parent.as_os_str().is_empty()
1300
+ && !parent.exists()
1301
+ && let Err(err) = fs::create_dir_all(parent)
1302
+ {
1303
+ return format!("Error: could not create parent directories: {err}");
1304
+ }
1305
+ String::new()
1306
+ };
1307
+
1308
+ // Keep remembered notes grouped under one heading so the file stays tidy.
1309
+ const HEADING: &str = "## Remembered notes";
1310
+ if !body.contains(HEADING) {
1311
+ if !body.is_empty() && !body.ends_with('\n') {
1312
+ body.push('\n');
1313
+ }
1314
+ if !body.is_empty() {
1315
+ body.push('\n');
1316
+ }
1317
+ body.push_str(HEADING);
1318
+ body.push('\n');
1319
+ }
1320
+ if !body.ends_with('\n') {
1321
+ body.push('\n');
1322
+ }
1323
+ body.push_str("- ");
1324
+ body.push_str(note);
1325
+ body.push('\n');
1326
+
1327
+ match fs::write(&target, &body) {
1328
+ Ok(()) => {
1329
+ let verb = if existed { "Appended to" } else { "Created" };
1330
+ format!("{verb} {target_str}: remembered \"{note}\"")
1331
+ }
1332
+ Err(err) => format!("Error: could not write {target_str}: {err}"),
1333
+ }
1334
+}
1335
+
1336
// ── delete_file ──────────────────────────────────────────────────────────────
1337
1338
/// only removes files or *empty* directories — no recursive deletes.
1593
#[test]
1594
fn test_all_tools_count() {
1595
let tools = all_tools();
1028
- assert_eq!(tools.len(), 9);
1596
+ assert_eq!(tools.len(), 13);
1597
assert_eq!(tools[0].name, "read_file");
1598
assert_eq!(tools[1].name, "create_directory");
1599
assert_eq!(tools[2].name, "list_directory");
1603
assert_eq!(tools[6].name, "edit_file");
1604
assert_eq!(tools[7].name, "delete_file");
1605
assert_eq!(tools[8].name, "run_command");
1606
+ assert_eq!(tools[9].name, "multi_edit");
1607
+ assert_eq!(tools[10].name, "glob");
1608
+ assert_eq!(tools[11].name, "write_todos");
1609
+ assert_eq!(tools[12].name, "remember");
1610
+ }
1611
+
1612
+ #[test]
1613
+ fn test_edit_file_replace_all() {
1614
+ let dir = std::env::temp_dir().join("sigit_test_edit_replace_all");
1615
+ let _ = fs::remove_dir_all(&dir);
1616
+ fs::create_dir_all(&dir).unwrap();
1617
+ let file = dir.join("f.txt");
1618
+ fs::write(&file, "foo foo foo").unwrap();
1619
+
1620
+ // Without replace_all an ambiguous match is rejected.
1621
+ let args =
1622
+ serde_json::json!({ "path": &file, "old_text": "foo", "new_text": "bar" }).to_string();
1623
+ let result = exec_edit_file(&args);
1624
+ assert!(result.contains("appears 3 times"), "{result}");
1625
+
1626
+ // With replace_all every occurrence is changed.
1627
+ let args = serde_json::json!({
1628
+ "path": &file, "old_text": "foo", "new_text": "bar", "replace_all": true
1629
+ })
1630
+ .to_string();
1631
+ let result = exec_edit_file(&args);
1632
+ assert!(result.starts_with("Edited file:"), "{result}");
1633
+ assert_eq!(fs::read_to_string(&file).unwrap(), "bar bar bar");
1634
+
1635
+ let _ = fs::remove_dir_all(&dir);
1636
+ }
1637
+
1638
+ #[test]
1639
+ fn test_edit_file_whitespace_hint() {
1640
+ let dir = std::env::temp_dir().join("sigit_test_edit_hint");
1641
+ let _ = fs::remove_dir_all(&dir);
1642
+ fs::create_dir_all(&dir).unwrap();
1643
+ let file = dir.join("f.txt");
1644
+ fs::write(&file, "line one\n indented\nline three\n").unwrap();
1645
+
1646
+ // old_text has more indentation than the file, so it isn't a substring,
1647
+ // but its trimmed content still locates the intended line.
1648
+ let args = serde_json::json!({
1649
+ "path": &file, "old_text": " indented", "new_text": "x"
1650
+ })
1651
+ .to_string();
1652
+ let result = exec_edit_file(&args);
1653
+ assert!(result.contains("line 2"), "{result}");
1654
+ assert!(result.contains("whitespace"), "{result}");
1655
+
1656
+ let _ = fs::remove_dir_all(&dir);
1657
+ }
1658
+
1659
+ #[test]
1660
+ fn test_multi_edit_atomic_on_failure() {
1661
+ let dir = std::env::temp_dir().join("sigit_test_multi_edit");
1662
+ let _ = fs::remove_dir_all(&dir);
1663
+ fs::create_dir_all(&dir).unwrap();
1664
+ let file = dir.join("f.txt");
1665
+ fs::write(&file, "alpha beta gamma").unwrap();
1666
+
1667
+ // Second edit can't match -> nothing should be written.
1668
+ let args = serde_json::json!({
1669
+ "path": &file,
1670
+ "edits": [
1671
+ { "old_text": "alpha", "new_text": "ALPHA" },
1672
+ { "old_text": "nope", "new_text": "x" }
1673
+ ]
1674
+ })
1675
+ .to_string();
1676
+ let result = exec_multi_edit(&args);
1677
+ assert!(result.contains("edit #2 failed"), "{result}");
1678
+ assert_eq!(fs::read_to_string(&file).unwrap(), "alpha beta gamma");
1679
+
1680
+ // All-matching batch applies in sequence.
1681
+ let args = serde_json::json!({
1682
+ "path": &file,
1683
+ "edits": [
1684
+ { "old_text": "alpha", "new_text": "ALPHA" },
1685
+ { "old_text": "gamma", "new_text": "GAMMA" }
1686
+ ]
1687
+ })
1688
+ .to_string();
1689
+ let result = exec_multi_edit(&args);
1690
+ assert!(result.contains("Applied 2 edits"), "{result}");
1691
+ assert_eq!(fs::read_to_string(&file).unwrap(), "ALPHA beta GAMMA");
1692
+
1693
+ let _ = fs::remove_dir_all(&dir);
1694
+ }
1695
+
1696
+ #[test]
1697
+ fn test_glob_to_regex() {
1698
+ let re = Regex::new(&glob_to_regex("**/*.rs")).unwrap();
1699
+ assert!(re.is_match("src/tools.rs"));
1700
+ assert!(re.is_match("main.rs")); // `**/` matches zero directories too
1701
+ assert!(!re.is_match("src/tools.txt"));
1702
+
1703
+ let re = Regex::new(&glob_to_regex("*.{ts,tsx}")).unwrap();
1704
+ assert!(re.is_match("app.ts"));
1705
+ assert!(re.is_match("app.tsx"));
1706
+ assert!(!re.is_match("app.js"));
1707
+ }
1708
+
1709
+ #[test]
1710
+ fn test_glob_tool_success() {
1711
+ let dir = std::env::temp_dir().join("sigit_test_glob");
1712
+ let _ = fs::remove_dir_all(&dir);
1713
+ fs::create_dir_all(dir.join("src")).unwrap();
1714
+ fs::write(dir.join("Cargo.toml"), "").unwrap();
1715
+ fs::write(dir.join("src/main.rs"), "").unwrap();
1716
+ fs::write(dir.join("src/lib.rs"), "").unwrap();
1717
+
1718
+ let args = serde_json::json!({ "pattern": "**/*.rs", "path": &dir }).to_string();
1719
+ let result = exec_glob(&args);
1720
+ assert!(result.contains("main.rs"), "{result}");
1721
+ assert!(result.contains("lib.rs"), "{result}");
1722
+ assert!(!result.contains("Cargo.toml"), "{result}");
1723
+
1724
+ let _ = fs::remove_dir_all(&dir);
1725
+ }
1726
+
1727
+ #[test]
1728
+ fn test_search_files_file_glob_filter() {
1729
+ let dir = std::env::temp_dir().join("sigit_test_search_glob");
1730
+ let _ = fs::remove_dir_all(&dir);
1731
+ fs::create_dir_all(&dir).unwrap();
1732
+ fs::write(dir.join("code.rs"), "needle here\n").unwrap();
1733
+ fs::write(dir.join("notes.txt"), "needle here\n").unwrap();
1734
+
1735
+ let args = serde_json::json!({
1736
+ "pattern": "needle", "path": &dir, "file_glob": "*.rs"
1737
+ })
1738
+ .to_string();
1739
+ let result = exec_search_files(&args);
1740
+ assert!(result.contains("code.rs"), "{result}");
1741
+ assert!(!result.contains("notes.txt"), "{result}");
1742
+
1743
+ let _ = fs::remove_dir_all(&dir);
1744
+ }
1745
+
1746
+ #[test]
1747
+ fn test_write_todos_renders_checklist() {
1748
+ let args = serde_json::json!({
1749
+ "todos": [
1750
+ { "content": "Read code", "status": "completed" },
1751
+ { "content": "Make change", "status": "in_progress" },
1752
+ { "content": "Run tests", "status": "pending" }
1753
+ ]
1754
+ })
1755
+ .to_string();
1756
+ let result = exec_write_todos(&args);
1757
+ assert!(result.contains("1/3 done"), "{result}");
1758
+ assert!(result.contains("[x] Read code"), "{result}");
1759
+ assert!(result.contains("[~] Make change"), "{result}");
1760
+ assert!(result.contains("[ ] Run tests"), "{result}");
1761
+ }
1762
+
1763
+ #[test]
1764
+ fn test_remember_appends_to_instruction_file() {
1765
+ let dir = std::env::temp_dir().join("sigit_test_remember");
1766
+ let _ = fs::remove_dir_all(&dir);
1767
+ fs::create_dir_all(dir.join(".git")).unwrap();
1768
+ let claude_md = dir.join("CLAUDE.md");
1769
+ fs::write(&claude_md, "# Project\n").unwrap();
1770
+
1771
+ let target = crate::instructions::memory_file(&dir);
1772
+ // Should pick the existing CLAUDE.md at the repo root.
1773
+ assert_eq!(
1774
+ target.canonicalize().unwrap(),
1775
+ claude_md.canonicalize().unwrap()
1776
+ );
1777
+
1778
+ let result = remember_at(&dir, "remembered text");
1779
+ assert!(result.contains("remembered"), "{result}");
1780
+
1781
+ let updated = fs::read_to_string(&claude_md).unwrap();
1782
+ assert!(updated.contains("## Remembered notes"), "{updated}");
1783
+ assert!(updated.contains("- remembered text"), "{updated}");
1784
+
1785
+ let _ = fs::remove_dir_all(&dir);
1786
}
1787
1788
#[test]