anveesa 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/tools.rs ADDED
@@ -0,0 +1,992 @@
1
+ use std::{
2
+ collections::VecDeque,
3
+ fs,
4
+ path::{Path, PathBuf},
5
+ process::Stdio,
6
+ time::Duration,
7
+ };
8
+
9
+ use anyhow::{Context, Result, anyhow, bail};
10
+ use serde::Deserialize;
11
+ use serde_json::{Value, json};
12
+
13
+ const MAX_DIR_ENTRIES: usize = 120;
14
+ const MAX_SEARCH_RESULTS: usize = 80;
15
+ const MAX_VISITED_PATHS: usize = 5_000;
16
+ const MAX_DEPTH: usize = 8;
17
+ const MAX_READ_LINES: usize = 200;
18
+ const MAX_TEXT_BYTES: u64 = 1_000_000;
19
+ const MAX_COMMAND_OUTPUT: usize = 20_000;
20
+ const DEFAULT_COMMAND_TIMEOUT_SECS: u64 = 60;
21
+ const MAX_COMMAND_TIMEOUT_SECS: u64 = 300;
22
+
23
+ /// System guidance describing the available tools to the model.
24
+ pub fn guidance(include_write: bool) -> String {
25
+ let mut text = String::from(
26
+ "You can use Anveesa tools to inspect the workspace: list directories, find files by name, \
27
+ search text, read capped file snippets, and do a basic public web lookup. Prefer tools over guessing.",
28
+ );
29
+ if include_write {
30
+ text.push_str(
31
+ " You may also modify the workspace with create_dir, write_file, edit_file, and run_command. \
32
+ These actions can require the user to approve them, so explain what you intend to do.",
33
+ );
34
+ }
35
+ text.push_str(
36
+ " For any multi-step task, start by calling set_plan with a list of the steps you will take. \
37
+ After each step completes, call complete_task with the zero-based index of that step. \
38
+ Do not describe your plan in prose — use set_plan instead.",
39
+ );
40
+ text.push_str(
41
+ " If a tool call fails or a command times out, do NOT retry it automatically. \
42
+ Stop immediately, report the exact error to the user, and wait for their input.",
43
+ );
44
+ text.push_str(" Never request or expose secrets such as API keys, SSH keys, or .env files.");
45
+ text
46
+ }
47
+
48
+ /// Whether a tool modifies the system and must pass the approval policy.
49
+ pub fn is_write_tool(name: &str) -> bool {
50
+ matches!(
51
+ name,
52
+ "create_dir" | "write_file" | "edit_file" | "run_command"
53
+ )
54
+ }
55
+
56
+ /// A short, human-readable summary of a tool call for confirmation prompts.
57
+ pub fn describe_call(name: &str, arguments: &str) -> String {
58
+ let args: Value = serde_json::from_str(arguments).unwrap_or(Value::Null);
59
+ let field = |key: &str| args.get(key).and_then(Value::as_str).unwrap_or("");
60
+ match name {
61
+ "create_dir" => format!("create directory {}", field("path")),
62
+ "write_file" => format!("write file {}", field("path")),
63
+ "edit_file" => format!("edit file {}", field("path")),
64
+ "run_command" => format!("run command `{}`", field("command")),
65
+ _ => format!("{name} {}", truncate(arguments, 80)),
66
+ }
67
+ }
68
+
69
+ pub fn definitions(include_write: bool) -> Vec<Value> {
70
+ let mut definitions = vec![
71
+ json!({
72
+ "type": "function",
73
+ "function": {
74
+ "name": "set_plan",
75
+ "description": "Display a numbered task checklist of what you will do. Call this once at the start of any multi-step task before taking any action.",
76
+ "parameters": {
77
+ "type": "object",
78
+ "properties": {
79
+ "steps": {
80
+ "type": "array",
81
+ "items": { "type": "string" },
82
+ "description": "Ordered list of task descriptions."
83
+ }
84
+ },
85
+ "required": ["steps"]
86
+ }
87
+ }
88
+ }),
89
+ json!({
90
+ "type": "function",
91
+ "function": {
92
+ "name": "complete_task",
93
+ "description": "Mark a step in the current plan as completed. Call this immediately after finishing each step.",
94
+ "parameters": {
95
+ "type": "object",
96
+ "properties": {
97
+ "index": {
98
+ "type": "integer",
99
+ "description": "Zero-based index of the completed step."
100
+ }
101
+ },
102
+ "required": ["index"]
103
+ }
104
+ }
105
+ }),
106
+ json!({
107
+ "type": "function",
108
+ "function": {
109
+ "name": "list_dir",
110
+ "description": "List files and directories at a path. Use this to inspect the current workspace or nearby folders.",
111
+ "parameters": {
112
+ "type": "object",
113
+ "properties": {
114
+ "path": { "type": "string", "description": "Directory path. Relative paths resolve from the terminal cwd." }
115
+ }
116
+ }
117
+ }
118
+ }),
119
+ json!({
120
+ "type": "function",
121
+ "function": {
122
+ "name": "find_files",
123
+ "description": "Search file and directory names recursively under a root path. Can search outside the project when given an absolute path.",
124
+ "parameters": {
125
+ "type": "object",
126
+ "properties": {
127
+ "root": { "type": "string", "description": "Root directory. Defaults to the terminal cwd." },
128
+ "query": { "type": "string", "description": "Case-insensitive filename substring to find." }
129
+ },
130
+ "required": ["query"]
131
+ }
132
+ }
133
+ }),
134
+ json!({
135
+ "type": "function",
136
+ "function": {
137
+ "name": "search_text",
138
+ "description": "Search text content recursively under a root path. Skips large, binary, generated, and sensitive files.",
139
+ "parameters": {
140
+ "type": "object",
141
+ "properties": {
142
+ "root": { "type": "string", "description": "Root directory. Defaults to the terminal cwd." },
143
+ "query": { "type": "string", "description": "Case-insensitive text to search for." }
144
+ },
145
+ "required": ["query"]
146
+ }
147
+ }
148
+ }),
149
+ json!({
150
+ "type": "function",
151
+ "function": {
152
+ "name": "read_file",
153
+ "description": "Read a capped range from a text file. Sensitive files such as secrets and SSH keys are blocked.",
154
+ "parameters": {
155
+ "type": "object",
156
+ "properties": {
157
+ "path": { "type": "string", "description": "File path. Relative paths resolve from the terminal cwd." },
158
+ "start_line": { "type": "integer", "minimum": 1, "description": "1-based line to start from." },
159
+ "max_lines": { "type": "integer", "minimum": 1, "maximum": 200, "description": "Maximum lines to return." }
160
+ },
161
+ "required": ["path"]
162
+ }
163
+ }
164
+ }),
165
+ json!({
166
+ "type": "function",
167
+ "function": {
168
+ "name": "web_search",
169
+ "description": "Do a basic web lookup for public information outside the local project.",
170
+ "parameters": {
171
+ "type": "object",
172
+ "properties": {
173
+ "query": { "type": "string", "description": "Search query." }
174
+ },
175
+ "required": ["query"]
176
+ }
177
+ }
178
+ }),
179
+ ];
180
+
181
+ if include_write {
182
+ definitions.extend([
183
+ json!({
184
+ "type": "function",
185
+ "function": {
186
+ "name": "create_dir",
187
+ "description": "Create a directory, including parent directories as needed. Use this for requests to make folders.",
188
+ "parameters": {
189
+ "type": "object",
190
+ "properties": {
191
+ "path": { "type": "string", "description": "Directory path. Relative paths resolve from the terminal cwd." }
192
+ },
193
+ "required": ["path"]
194
+ }
195
+ }
196
+ }),
197
+ json!({
198
+ "type": "function",
199
+ "function": {
200
+ "name": "write_file",
201
+ "description": "Create or overwrite a text file with the given content. Parent directories are created as needed.",
202
+ "parameters": {
203
+ "type": "object",
204
+ "properties": {
205
+ "path": { "type": "string", "description": "File path. Relative paths resolve from the terminal cwd." },
206
+ "content": { "type": "string", "description": "Full file content to write." }
207
+ },
208
+ "required": ["path", "content"]
209
+ }
210
+ }
211
+ }),
212
+ json!({
213
+ "type": "function",
214
+ "function": {
215
+ "name": "edit_file",
216
+ "description": "Replace a single, unique occurrence of old_string with new_string in an existing text file.",
217
+ "parameters": {
218
+ "type": "object",
219
+ "properties": {
220
+ "path": { "type": "string", "description": "File path. Relative paths resolve from the terminal cwd." },
221
+ "old_string": { "type": "string", "description": "Exact text to replace. Must appear exactly once." },
222
+ "new_string": { "type": "string", "description": "Replacement text." }
223
+ },
224
+ "required": ["path", "old_string", "new_string"]
225
+ }
226
+ }
227
+ }),
228
+ json!({
229
+ "type": "function",
230
+ "function": {
231
+ "name": "run_command",
232
+ "description": "Run a shell command in the terminal cwd and return its output. Use for builds, tests, git, and similar tasks.",
233
+ "parameters": {
234
+ "type": "object",
235
+ "properties": {
236
+ "command": { "type": "string", "description": "Shell command line to execute." },
237
+ "timeout_secs": { "type": "integer", "minimum": 1, "maximum": 300, "description": "Optional timeout in seconds (default 60)." }
238
+ },
239
+ "required": ["command"]
240
+ }
241
+ }
242
+ }),
243
+ ]);
244
+ }
245
+
246
+ definitions
247
+ }
248
+
249
+ pub async fn run(name: &str, arguments: &str) -> String {
250
+ let result = match name {
251
+ "list_dir" => list_dir(arguments).await,
252
+ "find_files" => find_files(arguments).await,
253
+ "search_text" => search_text(arguments).await,
254
+ "read_file" => read_file(arguments).await,
255
+ "web_search" => web_search(arguments).await,
256
+ "create_dir" => create_dir(arguments).await,
257
+ "write_file" => write_file(arguments).await,
258
+ "edit_file" => edit_file(arguments).await,
259
+ "run_command" => run_command(arguments).await,
260
+ _ => Err(anyhow!("unknown tool '{name}'")),
261
+ };
262
+
263
+ match result {
264
+ Ok(value) => value.to_string(),
265
+ Err(error) => json!({
266
+ "ok": false,
267
+ "error": error.to_string()
268
+ })
269
+ .to_string(),
270
+ }
271
+ }
272
+
273
+ async fn list_dir(arguments: &str) -> Result<Value> {
274
+ let args: PathArgs = parse_args(arguments)?;
275
+ let path = resolve_path(args.path.as_deref().unwrap_or("."))?;
276
+ if !path.is_dir() {
277
+ bail!("{} is not a directory", path.display());
278
+ }
279
+
280
+ let mut entries = Vec::new();
281
+ for entry in
282
+ fs::read_dir(&path).with_context(|| format!("failed to read {}", path.display()))?
283
+ {
284
+ let entry = entry?;
285
+ let entry_path = entry.path();
286
+ let name = entry.file_name().to_string_lossy().into_owned();
287
+ if should_skip_name(&name) {
288
+ continue;
289
+ }
290
+
291
+ entries.push(json!({
292
+ "name": name,
293
+ "path": entry_path.display().to_string(),
294
+ "kind": path_kind(&entry_path),
295
+ }));
296
+ if entries.len() >= MAX_DIR_ENTRIES {
297
+ break;
298
+ }
299
+ }
300
+
301
+ Ok(json!({
302
+ "ok": true,
303
+ "path": path.display().to_string(),
304
+ "entries": entries
305
+ }))
306
+ }
307
+
308
+ async fn find_files(arguments: &str) -> Result<Value> {
309
+ let args: SearchArgs = parse_args(arguments)?;
310
+ let query = normalized_query(&args.query)?;
311
+ let root = resolve_path(args.root.as_deref().unwrap_or("."))?;
312
+ if !root.is_dir() {
313
+ bail!("{} is not a directory", root.display());
314
+ }
315
+
316
+ let mut results = Vec::new();
317
+ walk_paths(&root, |path| {
318
+ let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
319
+ return Ok(true);
320
+ };
321
+ if name.to_lowercase().contains(&query) {
322
+ results.push(json!({
323
+ "path": path.display().to_string(),
324
+ "kind": path_kind(path),
325
+ }));
326
+ }
327
+ Ok(results.len() < MAX_SEARCH_RESULTS)
328
+ })?;
329
+
330
+ Ok(json!({
331
+ "ok": true,
332
+ "root": root.display().to_string(),
333
+ "query": args.query,
334
+ "results": results
335
+ }))
336
+ }
337
+
338
+ async fn search_text(arguments: &str) -> Result<Value> {
339
+ let args: SearchArgs = parse_args(arguments)?;
340
+ let query = normalized_query(&args.query)?;
341
+ let root = resolve_path(args.root.as_deref().unwrap_or("."))?;
342
+ if !root.is_dir() {
343
+ bail!("{} is not a directory", root.display());
344
+ }
345
+
346
+ let mut results = Vec::new();
347
+ walk_paths(&root, |path| {
348
+ if !path.is_file() || is_sensitive_path(path) || !is_small_text_candidate(path) {
349
+ return Ok(true);
350
+ }
351
+
352
+ let Ok(content) = fs::read_to_string(path) else {
353
+ return Ok(true);
354
+ };
355
+ let lower = content.to_lowercase();
356
+ if let Some(byte_index) = lower.find(&query) {
357
+ let line_number = content[..byte_index].lines().count() + 1;
358
+ let line = content
359
+ .lines()
360
+ .nth(line_number.saturating_sub(1))
361
+ .unwrap_or_default()
362
+ .trim();
363
+ results.push(json!({
364
+ "path": path.display().to_string(),
365
+ "line": line_number,
366
+ "preview": truncate(line, 240),
367
+ }));
368
+ }
369
+
370
+ Ok(results.len() < MAX_SEARCH_RESULTS)
371
+ })?;
372
+
373
+ Ok(json!({
374
+ "ok": true,
375
+ "root": root.display().to_string(),
376
+ "query": args.query,
377
+ "results": results
378
+ }))
379
+ }
380
+
381
+ async fn read_file(arguments: &str) -> Result<Value> {
382
+ let args: ReadFileArgs = parse_args(arguments)?;
383
+ let path = resolve_path(&args.path)?;
384
+ if !path.is_file() {
385
+ bail!("{} is not a file", path.display());
386
+ }
387
+ if is_sensitive_path(&path) {
388
+ bail!("refusing to read sensitive-looking file {}", path.display());
389
+ }
390
+ if !is_small_text_candidate(&path) {
391
+ bail!("file is too large or not a safe text candidate");
392
+ }
393
+
394
+ let start_line = args.start_line.unwrap_or(1).max(1);
395
+ let max_lines = args.max_lines.unwrap_or(120).clamp(1, MAX_READ_LINES);
396
+ let content =
397
+ fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
398
+ let lines = content
399
+ .lines()
400
+ .enumerate()
401
+ .skip(start_line - 1)
402
+ .take(max_lines)
403
+ .map(|(index, line)| {
404
+ json!({
405
+ "line": index + 1,
406
+ "text": line
407
+ })
408
+ })
409
+ .collect::<Vec<_>>();
410
+
411
+ Ok(json!({
412
+ "ok": true,
413
+ "path": path.display().to_string(),
414
+ "start_line": start_line,
415
+ "lines": lines
416
+ }))
417
+ }
418
+
419
+ async fn web_search(arguments: &str) -> Result<Value> {
420
+ let args: WebSearchArgs = parse_args(arguments)?;
421
+ let query = args.query.trim();
422
+ if query.is_empty() {
423
+ bail!("query is empty");
424
+ }
425
+
426
+ let url = format!(
427
+ "https://api.duckduckgo.com/?q={}&format=json&no_html=1&skip_disambig=1",
428
+ percent_encode(query)
429
+ );
430
+ let response: Value = reqwest::Client::new()
431
+ .get(&url)
432
+ .header("User-Agent", "anveesa-cli/0.1")
433
+ .send()
434
+ .await
435
+ .context("web search request failed")?
436
+ .json()
437
+ .await
438
+ .context("failed to parse web search response")?;
439
+
440
+ let mut results = Vec::new();
441
+ if let Some(abstract_text) = response.get("AbstractText").and_then(Value::as_str)
442
+ && !abstract_text.is_empty()
443
+ {
444
+ results.push(json!({
445
+ "title": response.get("Heading").and_then(Value::as_str).unwrap_or("DuckDuckGo"),
446
+ "snippet": abstract_text,
447
+ "url": response.get("AbstractURL").and_then(Value::as_str).unwrap_or("")
448
+ }));
449
+ }
450
+ collect_related_topics(response.get("RelatedTopics"), &mut results);
451
+ results.truncate(8);
452
+
453
+ Ok(json!({
454
+ "ok": true,
455
+ "query": query,
456
+ "results": results
457
+ }))
458
+ }
459
+
460
+ async fn create_dir(arguments: &str) -> Result<Value> {
461
+ let args: CreateDirArgs = parse_args(arguments)?;
462
+ let path = resolve_writable_path(&args.path)?;
463
+ if is_sensitive_path(&path) {
464
+ bail!(
465
+ "refusing to create sensitive-looking directory {}",
466
+ path.display()
467
+ );
468
+ }
469
+ if path.exists() && !path.is_dir() {
470
+ bail!("{} exists and is not a directory", path.display());
471
+ }
472
+
473
+ let existed = path.exists();
474
+ fs::create_dir_all(&path).with_context(|| format!("failed to create {}", path.display()))?;
475
+
476
+ Ok(json!({
477
+ "ok": true,
478
+ "path": path.display().to_string(),
479
+ "created": !existed,
480
+ }))
481
+ }
482
+
483
+ async fn write_file(arguments: &str) -> Result<Value> {
484
+ let args: WriteFileArgs = parse_args(arguments)?;
485
+ let path = resolve_writable_path(&args.path)?;
486
+ if is_sensitive_path(&path) {
487
+ bail!(
488
+ "refusing to write sensitive-looking file {}",
489
+ path.display()
490
+ );
491
+ }
492
+ if let Some(parent) = path.parent()
493
+ && !parent.as_os_str().is_empty()
494
+ {
495
+ fs::create_dir_all(parent)
496
+ .with_context(|| format!("failed to create {}", parent.display()))?;
497
+ }
498
+
499
+ let existed = path.exists();
500
+ fs::write(&path, &args.content)
501
+ .with_context(|| format!("failed to write {}", path.display()))?;
502
+
503
+ Ok(json!({
504
+ "ok": true,
505
+ "path": path.display().to_string(),
506
+ "created": !existed,
507
+ "bytes_written": args.content.len(),
508
+ }))
509
+ }
510
+
511
+ async fn edit_file(arguments: &str) -> Result<Value> {
512
+ let args: EditFileArgs = parse_args(arguments)?;
513
+ let path = resolve_writable_path(&args.path)?;
514
+ if !path.is_file() {
515
+ bail!("{} is not a file", path.display());
516
+ }
517
+ if is_sensitive_path(&path) {
518
+ bail!("refusing to edit sensitive-looking file {}", path.display());
519
+ }
520
+ if !is_small_text_candidate(&path) {
521
+ bail!("file is too large to edit safely");
522
+ }
523
+ if args.old_string.is_empty() {
524
+ bail!("old_string must not be empty");
525
+ }
526
+ if args.old_string == args.new_string {
527
+ bail!("old_string and new_string are identical");
528
+ }
529
+
530
+ let content =
531
+ fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
532
+ let occurrences = content.matches(&args.old_string).count();
533
+ match occurrences {
534
+ 0 => bail!("old_string was not found in {}", path.display()),
535
+ 1 => {}
536
+ n => bail!(
537
+ "old_string appears {n} times in {}; make it unique",
538
+ path.display()
539
+ ),
540
+ }
541
+
542
+ let updated = content.replacen(&args.old_string, &args.new_string, 1);
543
+ fs::write(&path, &updated).with_context(|| format!("failed to write {}", path.display()))?;
544
+
545
+ Ok(json!({
546
+ "ok": true,
547
+ "path": path.display().to_string(),
548
+ "replacements": 1,
549
+ }))
550
+ }
551
+
552
+ async fn run_command(arguments: &str) -> Result<Value> {
553
+ let args: RunCommandArgs = parse_args(arguments)?;
554
+ let command = args.command.trim();
555
+ if command.is_empty() {
556
+ bail!("command is empty");
557
+ }
558
+ let timeout = Duration::from_secs(
559
+ args.timeout_secs
560
+ .unwrap_or(DEFAULT_COMMAND_TIMEOUT_SECS)
561
+ .clamp(1, MAX_COMMAND_TIMEOUT_SECS),
562
+ );
563
+
564
+ let child = tokio::process::Command::new("sh")
565
+ .arg("-c")
566
+ .arg(command)
567
+ .stdin(Stdio::null())
568
+ .stdout(Stdio::piped())
569
+ .stderr(Stdio::piped())
570
+ .spawn()
571
+ .context("failed to spawn command")?;
572
+
573
+ let output = match tokio::time::timeout(timeout, child.wait_with_output()).await {
574
+ Ok(result) => result.context("failed to run command")?,
575
+ Err(_) => {
576
+ bail!(
577
+ "Command timed out after {}s. \
578
+ Do NOT retry this command — report the timeout to the user and ask for guidance.",
579
+ timeout.as_secs()
580
+ );
581
+ }
582
+ };
583
+
584
+ Ok(json!({
585
+ "ok": output.status.success(),
586
+ "exit_code": output.status.code(),
587
+ "stdout": cap_output(&output.stdout),
588
+ "stderr": cap_output(&output.stderr),
589
+ }))
590
+ }
591
+
592
+ fn cap_output(bytes: &[u8]) -> String {
593
+ let text = String::from_utf8_lossy(bytes);
594
+ if text.len() <= MAX_COMMAND_OUTPUT {
595
+ return text.into_owned();
596
+ }
597
+ let mut clipped: String = text.chars().take(MAX_COMMAND_OUTPUT).collect();
598
+ clipped.push_str("\n...[output truncated]");
599
+ clipped
600
+ }
601
+
602
+ #[derive(Debug, Deserialize)]
603
+ struct PathArgs {
604
+ path: Option<String>,
605
+ }
606
+
607
+ #[derive(Debug, Deserialize)]
608
+ struct CreateDirArgs {
609
+ path: String,
610
+ }
611
+
612
+ #[derive(Debug, Deserialize)]
613
+ struct WriteFileArgs {
614
+ path: String,
615
+ content: String,
616
+ }
617
+
618
+ #[derive(Debug, Deserialize)]
619
+ struct EditFileArgs {
620
+ path: String,
621
+ old_string: String,
622
+ new_string: String,
623
+ }
624
+
625
+ #[derive(Debug, Deserialize)]
626
+ struct RunCommandArgs {
627
+ command: String,
628
+ timeout_secs: Option<u64>,
629
+ }
630
+
631
+ #[derive(Debug, Deserialize)]
632
+ struct SearchArgs {
633
+ root: Option<String>,
634
+ query: String,
635
+ }
636
+
637
+ #[derive(Debug, Deserialize)]
638
+ struct ReadFileArgs {
639
+ path: String,
640
+ start_line: Option<usize>,
641
+ max_lines: Option<usize>,
642
+ }
643
+
644
+ #[derive(Debug, Deserialize)]
645
+ struct WebSearchArgs {
646
+ query: String,
647
+ }
648
+
649
+ fn parse_args<T: for<'de> Deserialize<'de>>(arguments: &str) -> Result<T> {
650
+ serde_json::from_str(arguments).with_context(|| format!("invalid tool arguments: {arguments}"))
651
+ }
652
+
653
+ fn resolve_path(path: &str) -> Result<PathBuf> {
654
+ let path = Path::new(path);
655
+ let path = if path.is_absolute() {
656
+ path.to_path_buf()
657
+ } else {
658
+ std::env::current_dir()
659
+ .context("failed to resolve current directory")?
660
+ .join(path)
661
+ };
662
+
663
+ path.canonicalize()
664
+ .with_context(|| format!("failed to resolve {}", path.display()))
665
+ }
666
+
667
+ /// Resolve a path that may not exist yet (for writes). Does not canonicalize the
668
+ /// final component, but anchors relative paths to the terminal cwd.
669
+ fn resolve_writable_path(path: &str) -> Result<PathBuf> {
670
+ let path = Path::new(path);
671
+ if path.is_absolute() {
672
+ return Ok(path.to_path_buf());
673
+ }
674
+ Ok(std::env::current_dir()
675
+ .context("failed to resolve current directory")?
676
+ .join(path))
677
+ }
678
+
679
+ fn walk_paths<F>(root: &Path, mut visit: F) -> Result<()>
680
+ where
681
+ F: FnMut(&Path) -> Result<bool>,
682
+ {
683
+ let mut queue = VecDeque::from([(root.to_path_buf(), 0usize)]);
684
+ let mut visited = 0usize;
685
+
686
+ while let Some((path, depth)) = queue.pop_front() {
687
+ if visited >= MAX_VISITED_PATHS {
688
+ break;
689
+ }
690
+ visited += 1;
691
+
692
+ if !visit(&path)? {
693
+ break;
694
+ }
695
+
696
+ if depth >= MAX_DEPTH || !path.is_dir() {
697
+ continue;
698
+ }
699
+
700
+ let Ok(entries) = fs::read_dir(&path) else {
701
+ continue;
702
+ };
703
+ for entry in entries.flatten() {
704
+ let name = entry.file_name().to_string_lossy().into_owned();
705
+ if should_skip_name(&name) {
706
+ continue;
707
+ }
708
+ queue.push_back((entry.path(), depth + 1));
709
+ }
710
+ }
711
+
712
+ Ok(())
713
+ }
714
+
715
+ fn should_skip_name(name: &str) -> bool {
716
+ matches!(
717
+ name,
718
+ ".git"
719
+ | ".next"
720
+ | ".turbo"
721
+ | ".cache"
722
+ | ".venv"
723
+ | "node_modules"
724
+ | "target"
725
+ | "dist"
726
+ | "build"
727
+ | "vendor"
728
+ | "Library"
729
+ )
730
+ }
731
+
732
+ fn path_kind(path: &Path) -> &'static str {
733
+ if path.is_dir() {
734
+ "dir"
735
+ } else if path.is_file() {
736
+ "file"
737
+ } else {
738
+ "other"
739
+ }
740
+ }
741
+
742
+ fn is_small_text_candidate(path: &Path) -> bool {
743
+ fs::metadata(path)
744
+ .map(|metadata| metadata.len() <= MAX_TEXT_BYTES)
745
+ .unwrap_or(false)
746
+ }
747
+
748
+ fn is_sensitive_path(path: &Path) -> bool {
749
+ let lower = path.display().to_string().to_lowercase();
750
+ lower.contains("/.ssh/")
751
+ || lower.contains("/.aws/")
752
+ || lower.contains("/.gnupg/")
753
+ || lower.ends_with("/.env")
754
+ || lower.contains("/.env.")
755
+ || lower.ends_with("/id_rsa")
756
+ || lower.ends_with("/id_dsa")
757
+ || lower.ends_with("/id_ed25519")
758
+ || lower.ends_with("/credentials")
759
+ || lower.contains("secret")
760
+ || lower.contains("private_key")
761
+ }
762
+
763
+ fn normalized_query(query: &str) -> Result<String> {
764
+ let query = query.trim();
765
+ if query.is_empty() {
766
+ bail!("query is empty");
767
+ }
768
+ Ok(query.to_lowercase())
769
+ }
770
+
771
+ fn truncate(value: &str, max_chars: usize) -> String {
772
+ let mut output = value.chars().take(max_chars).collect::<String>();
773
+ if value.chars().count() > max_chars {
774
+ output.push_str("...");
775
+ }
776
+ output
777
+ }
778
+
779
+ fn collect_related_topics(value: Option<&Value>, results: &mut Vec<Value>) {
780
+ let Some(Value::Array(topics)) = value else {
781
+ return;
782
+ };
783
+
784
+ for topic in topics {
785
+ if let Some(nested) = topic.get("Topics") {
786
+ collect_related_topics(Some(nested), results);
787
+ continue;
788
+ }
789
+
790
+ let text = topic
791
+ .get("Text")
792
+ .and_then(Value::as_str)
793
+ .unwrap_or_default();
794
+ if text.is_empty() {
795
+ continue;
796
+ }
797
+ results.push(json!({
798
+ "title": text.split(" - ").next().unwrap_or("Result"),
799
+ "snippet": text,
800
+ "url": topic.get("FirstURL").and_then(Value::as_str).unwrap_or("")
801
+ }));
802
+ }
803
+ }
804
+
805
+ fn percent_encode(value: &str) -> String {
806
+ value
807
+ .bytes()
808
+ .map(|byte| match byte {
809
+ b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
810
+ (byte as char).to_string()
811
+ }
812
+ _ => format!("%{byte:02X}"),
813
+ })
814
+ .collect()
815
+ }
816
+
817
+ #[cfg(test)]
818
+ mod tests {
819
+ use super::*;
820
+
821
+ #[test]
822
+ fn write_tools_only_when_permitted() {
823
+ let read_only = definitions(false);
824
+ assert!(!read_only.iter().any(|tool| tool_name(tool) == "write_file"));
825
+
826
+ let with_writes = definitions(true);
827
+ let names: Vec<&str> = with_writes.iter().map(tool_name).collect();
828
+ assert!(names.contains(&"create_dir"));
829
+ assert!(names.contains(&"write_file"));
830
+ assert!(names.contains(&"edit_file"));
831
+ assert!(names.contains(&"run_command"));
832
+ }
833
+
834
+ fn tool_name(tool: &Value) -> &str {
835
+ tool["function"]["name"].as_str().unwrap_or_default()
836
+ }
837
+
838
+ #[test]
839
+ fn classifies_write_tools() {
840
+ assert!(is_write_tool("create_dir"));
841
+ assert!(is_write_tool("write_file"));
842
+ assert!(is_write_tool("run_command"));
843
+ assert!(!is_write_tool("read_file"));
844
+ assert!(!is_write_tool("web_search"));
845
+ }
846
+
847
+ #[test]
848
+ fn describes_calls_for_confirmation() {
849
+ assert_eq!(
850
+ describe_call("create_dir", r#"{"path":"hello"}"#),
851
+ "create directory hello"
852
+ );
853
+ assert_eq!(
854
+ describe_call("write_file", r#"{"path":"a.txt","content":"x"}"#),
855
+ "write file a.txt"
856
+ );
857
+ assert_eq!(
858
+ describe_call("run_command", r#"{"command":"cargo test"}"#),
859
+ "run command `cargo test`"
860
+ );
861
+ }
862
+
863
+ #[test]
864
+ fn guidance_mentions_writes_only_when_enabled() {
865
+ assert!(!guidance(false).contains("write_file"));
866
+ assert!(guidance(true).contains("create_dir"));
867
+ assert!(guidance(true).contains("write_file"));
868
+ }
869
+
870
+ #[test]
871
+ fn flags_sensitive_paths() {
872
+ assert!(is_sensitive_path(Path::new("/home/u/.ssh/id_rsa")));
873
+ assert!(is_sensitive_path(Path::new("/proj/.env")));
874
+ assert!(is_sensitive_path(Path::new("/proj/secret.txt")));
875
+ assert!(!is_sensitive_path(Path::new("/proj/src/main.rs")));
876
+ }
877
+
878
+ #[test]
879
+ fn truncates_long_values() {
880
+ assert_eq!(truncate("hello", 10), "hello");
881
+ assert_eq!(truncate("hello", 3), "hel...");
882
+ }
883
+
884
+ #[test]
885
+ fn percent_encodes_reserved_characters() {
886
+ assert_eq!(percent_encode("a b"), "a%20b");
887
+ assert_eq!(percent_encode("rust-lang"), "rust-lang");
888
+ }
889
+
890
+ fn temp_dir(tag: &str) -> PathBuf {
891
+ let dir = std::env::temp_dir().join(format!("anveesa_test_{tag}_{}", std::process::id()));
892
+ let _ = fs::remove_dir_all(&dir);
893
+ fs::create_dir_all(&dir).unwrap();
894
+ dir
895
+ }
896
+
897
+ #[tokio::test]
898
+ async fn create_dir_creates_nested_directory() {
899
+ let dir = temp_dir("mkdir");
900
+ let path = dir.join("hello").join("world");
901
+ let result = create_dir(&json!({ "path": path.to_str().unwrap() }).to_string())
902
+ .await
903
+ .unwrap();
904
+ assert_eq!(result["ok"], json!(true));
905
+ assert_eq!(result["created"], json!(true));
906
+ assert!(path.is_dir());
907
+
908
+ let result = create_dir(&json!({ "path": path.to_str().unwrap() }).to_string())
909
+ .await
910
+ .unwrap();
911
+ assert_eq!(result["created"], json!(false));
912
+
913
+ fs::remove_dir_all(&dir).unwrap();
914
+ }
915
+
916
+ #[tokio::test]
917
+ async fn write_then_edit_file() {
918
+ let dir = temp_dir("write");
919
+ let path = dir.join("note.txt");
920
+ let path_str = path.to_str().unwrap();
921
+
922
+ let result = write_file(&json!({ "path": path_str, "content": "alpha beta" }).to_string())
923
+ .await
924
+ .unwrap();
925
+ assert_eq!(result["ok"], json!(true));
926
+ assert_eq!(result["created"], json!(true));
927
+ assert_eq!(fs::read_to_string(&path).unwrap(), "alpha beta");
928
+
929
+ edit_file(
930
+ &json!({ "path": path_str, "old_string": "beta", "new_string": "gamma" }).to_string(),
931
+ )
932
+ .await
933
+ .unwrap();
934
+ assert_eq!(fs::read_to_string(&path).unwrap(), "alpha gamma");
935
+
936
+ fs::remove_dir_all(&dir).unwrap();
937
+ }
938
+
939
+ #[tokio::test]
940
+ async fn edit_file_requires_unique_match() {
941
+ let dir = temp_dir("unique");
942
+ let path = dir.join("dup.txt");
943
+ fs::write(&path, "x and x").unwrap();
944
+ let path_str = path.to_str().unwrap();
945
+
946
+ let duplicate = edit_file(
947
+ &json!({ "path": path_str, "old_string": "x", "new_string": "y" }).to_string(),
948
+ )
949
+ .await;
950
+ assert!(duplicate.is_err());
951
+
952
+ let missing = edit_file(
953
+ &json!({ "path": path_str, "old_string": "zzz", "new_string": "y" }).to_string(),
954
+ )
955
+ .await;
956
+ assert!(missing.is_err());
957
+
958
+ fs::remove_dir_all(&dir).unwrap();
959
+ }
960
+
961
+ #[tokio::test]
962
+ async fn write_file_refuses_sensitive_paths() {
963
+ let dir = temp_dir("sensitive");
964
+ let path = dir.join(".env");
965
+ let result = write_file(
966
+ &json!({ "path": path.to_str().unwrap(), "content": "SECRET=1" }).to_string(),
967
+ )
968
+ .await;
969
+ assert!(result.is_err());
970
+ assert!(!path.exists());
971
+ fs::remove_dir_all(&dir).unwrap();
972
+ }
973
+
974
+ #[tokio::test]
975
+ async fn run_command_captures_output() {
976
+ let result = run_command(&json!({ "command": "printf hello" }).to_string())
977
+ .await
978
+ .unwrap();
979
+ assert_eq!(result["ok"], json!(true));
980
+ assert_eq!(result["exit_code"], json!(0));
981
+ assert_eq!(result["stdout"], json!("hello"));
982
+ }
983
+
984
+ #[tokio::test]
985
+ async fn run_command_reports_failure() {
986
+ let result = run_command(&json!({ "command": "exit 3" }).to_string())
987
+ .await
988
+ .unwrap();
989
+ assert_eq!(result["ok"], json!(false));
990
+ assert_eq!(result["exit_code"], json!(3));
991
+ }
992
+ }