buildwithnexus 0.10.3 → 0.10.4

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.
@@ -8,6 +8,8 @@ use std::process::Command;
8
8
 
9
9
  use serde_json::{json, Value};
10
10
 
11
+ use crate::checkpoint;
12
+
11
13
  pub struct ToolDef {
12
14
  pub name: &'static str,
13
15
  pub description: &'static str,
@@ -25,6 +27,8 @@ fn err(s: impl Into<String>) -> Outcome { Outcome { content: s.into(), is_error:
25
27
 
26
28
  const MAX_READ: usize = 100 * 1024;
27
29
  const MAX_OUT: usize = 16 * 1024;
30
+ const MAX_SEARCH_FILES: usize = 10_000;
31
+ const DEFAULT_SEARCH_LIMIT: usize = 100;
28
32
 
29
33
  // Exposed only for the criterion perf suite (see provider::bench).
30
34
  #[doc(hidden)]
@@ -36,10 +40,14 @@ pub mod bench {
36
40
 
37
41
  pub fn defs(include_subagent: bool) -> Vec<ToolDef> {
38
42
  let mut v = vec![
39
- ToolDef { name: "read_file", description: "Read a UTF-8 text file and return its contents. Works anywhere on the filesystem.",
40
- schema: json!({"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}) },
43
+ ToolDef { name: "read_file", description: "Read a UTF-8 text file and return its contents. Optional start_line/end_line return a bounded line range. Works anywhere on the filesystem.",
44
+ schema: json!({"type":"object","properties":{"path":{"type":"string"},"start_line":{"type":"integer","minimum":1},"end_line":{"type":"integer","minimum":1}},"required":["path"]}) },
41
45
  ToolDef { name: "list_dir", description: "List entries in a directory. Works anywhere on the filesystem.",
42
46
  schema: json!({"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}) },
47
+ ToolDef { name: "find_files", description: "Recursively find files by a simple glob pattern such as `*.rs` or `src/*test*`. Skips heavy build/dependency directories.",
48
+ schema: json!({"type":"object","properties":{"root":{"type":"string","default":"."},"pattern":{"type":"string"},"max":{"type":"integer","minimum":1,"maximum":500}},"required":["pattern"]}) },
49
+ ToolDef { name: "grep_files", description: "Search text files for a literal pattern and return path:line matches. Optional file_pattern limits searched files, e.g. `*.rs`.",
50
+ schema: json!({"type":"object","properties":{"root":{"type":"string","default":"."},"pattern":{"type":"string"},"file_pattern":{"type":"string"},"case_sensitive":{"type":"boolean","default":false},"max":{"type":"integer","minimum":1,"maximum":500}},"required":["pattern"]}) },
43
51
  ToolDef { name: "write_file", description: "Create or overwrite a file with the given contents.",
44
52
  schema: json!({"type":"object","properties":{"path":{"type":"string"},"content":{"type":"string"}},"required":["path","content"]}) },
45
53
  ToolDef { name: "edit_file", description: "Replace the unique occurrence of `old` with `new` in a file.",
@@ -98,6 +106,8 @@ pub fn preview(name: &str, input: &Value) -> String {
98
106
  "write_file" => format!("write {}", input["path"].as_str().unwrap_or("?")),
99
107
  "edit_file" => format!("edit {}", input["path"].as_str().unwrap_or("?")),
100
108
  "run_command" => format!("run: {}", input["command"].as_str().unwrap_or("?")),
109
+ "find_files" => format!("find files: {}", input["pattern"].as_str().unwrap_or("?")),
110
+ "grep_files" => format!("grep: {}", input["pattern"].as_str().unwrap_or("?")),
101
111
  "spawn_subagent" => format!("subagent: {}", input["task"].as_str().unwrap_or("?")),
102
112
  "fetch_url" => format!("GET {}", input["url"].as_str().unwrap_or("?")),
103
113
  _ => name.to_string(),
@@ -120,6 +130,7 @@ pub fn touched_path(name: &str, input: &Value, cwd: &Path) -> Option<PathBuf> {
120
130
  "read_file" | "list_dir" | "write_file" | "edit_file" => {
121
131
  Some(resolve(cwd, input["path"].as_str().unwrap_or("")))
122
132
  }
133
+ "find_files" | "grep_files" => Some(resolve(cwd, input["root"].as_str().unwrap_or("."))),
123
134
  _ => None,
124
135
  }
125
136
  }
@@ -329,12 +340,116 @@ fn truncate(s: String, max: usize) -> String {
329
340
  out
330
341
  }
331
342
 
343
+ fn line_range(input: &Value) -> Option<(usize, usize)> {
344
+ let start = input["start_line"].as_u64().map(|n| n.max(1) as usize);
345
+ let end = input["end_line"].as_u64().map(|n| n.max(1) as usize);
346
+ match (start, end) {
347
+ (Some(s), Some(e)) => Some((s, e.max(s))),
348
+ (Some(s), None) => Some((s, usize::MAX)),
349
+ (None, Some(e)) => Some((1, e)),
350
+ (None, None) => None,
351
+ }
352
+ }
353
+
354
+ fn apply_line_range(text: &str, range: Option<(usize, usize)>) -> String {
355
+ let Some((start, end)) = range else {
356
+ return text.to_string();
357
+ };
358
+ text.lines()
359
+ .enumerate()
360
+ .filter_map(|(i, line)| {
361
+ let line_no = i + 1;
362
+ if line_no >= start && line_no <= end { Some(line) } else { None }
363
+ })
364
+ .collect::<Vec<_>>()
365
+ .join("\n")
366
+ }
367
+
368
+ fn skip_dir(path: &Path) -> bool {
369
+ let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
370
+ return false;
371
+ };
372
+ matches!(
373
+ name,
374
+ ".git" | "node_modules" | "target" | "dist" | "build" | ".next" | ".turbo"
375
+ | ".cache" | "vendor" | "__pycache__"
376
+ )
377
+ }
378
+
379
+ fn collect_files(root: &Path, out: &mut Vec<PathBuf>, seen: &mut usize) {
380
+ if *seen >= MAX_SEARCH_FILES {
381
+ return;
382
+ }
383
+ let Ok(rd) = fs::read_dir(root) else {
384
+ return;
385
+ };
386
+ for entry in rd.filter_map(|e| e.ok()) {
387
+ if *seen >= MAX_SEARCH_FILES {
388
+ break;
389
+ }
390
+ let path = entry.path();
391
+ if path.is_dir() {
392
+ if !skip_dir(&path) {
393
+ collect_files(&path, out, seen);
394
+ }
395
+ } else if path.is_file() {
396
+ *seen += 1;
397
+ out.push(path);
398
+ }
399
+ }
400
+ }
401
+
402
+ fn simple_glob_match(pattern: &str, text: &str) -> bool {
403
+ if pattern.is_empty() || pattern == "*" {
404
+ return true;
405
+ }
406
+ if !pattern.contains('*') {
407
+ return text == pattern || text.ends_with(pattern);
408
+ }
409
+ let anchored_start = !pattern.starts_with('*');
410
+ let anchored_end = !pattern.ends_with('*');
411
+ let parts: Vec<&str> = pattern.split('*').filter(|p| !p.is_empty()).collect();
412
+ if parts.is_empty() {
413
+ return true;
414
+ }
415
+ let mut rest = text;
416
+ for (idx, part) in parts.iter().enumerate() {
417
+ let Some(pos) = rest.find(part) else {
418
+ return false;
419
+ };
420
+ if idx == 0 && anchored_start && pos != 0 {
421
+ return false;
422
+ }
423
+ rest = &rest[pos + part.len()..];
424
+ }
425
+ if anchored_end {
426
+ if let Some(last) = parts.last() {
427
+ return text.ends_with(last);
428
+ }
429
+ }
430
+ true
431
+ }
432
+
433
+ fn display_path(path: &Path, cwd: &Path) -> String {
434
+ path.strip_prefix(cwd)
435
+ .unwrap_or(path)
436
+ .to_string_lossy()
437
+ .replace('\\', "/")
438
+ }
439
+
440
+ fn search_limit(input: &Value) -> usize {
441
+ input["max"]
442
+ .as_u64()
443
+ .map(|n| n.clamp(1, 500) as usize)
444
+ .unwrap_or(DEFAULT_SEARCH_LIMIT)
445
+ }
446
+
332
447
  pub fn run(name: &str, input: &Value, cwd: &Path) -> Outcome {
333
448
  match name {
334
449
  "read_file" => {
335
450
  let p = resolve(cwd, input["path"].as_str().unwrap_or(""));
336
451
  match fs::read_to_string(&p) {
337
- Ok(c) => ok(truncate(c, MAX_READ)),
452
+ Ok(c) => ok(truncate(apply_line_range(&c, line_range(input)), MAX_READ)),
338
453
  Err(e) => err(format!("cannot read {}: {e}", p.display())),
339
454
  }
340
455
  }
@@ -352,12 +467,80 @@ pub fn run(name: &str, input: &Value, cwd: &Path) -> Outcome {
352
467
  Err(e) => err(format!("cannot list {}: {e}", p.display())),
353
468
  }
354
469
  }
470
+ "find_files" => {
471
+ let root = resolve(cwd, input["root"].as_str().unwrap_or("."));
472
+ let pattern = input["pattern"].as_str().unwrap_or("").trim();
473
+ if pattern.is_empty() {
474
+ return err("pattern is required");
475
+ }
476
+ let limit = search_limit(input);
477
+ let mut files = Vec::new();
478
+ let mut seen = 0;
479
+ collect_files(&root, &mut files, &mut seen);
480
+ files.sort();
481
+ let matches: Vec<String> = files
482
+ .into_iter()
483
+ .filter_map(|path| {
484
+ let rel = display_path(&path, cwd);
485
+ let name_match = path
486
+ .file_name()
487
+ .and_then(|n| n.to_str())
488
+ .is_some_and(|name| simple_glob_match(pattern, name));
489
+ if simple_glob_match(pattern, &rel) || name_match { Some(rel) } else { None }
490
+ })
491
+ .take(limit)
492
+ .collect();
493
+ if matches.is_empty() { ok(format!("no files matched {pattern}")) } else { ok(matches.join("\n")) }
494
+ }
495
+ "grep_files" => {
496
+ let root = resolve(cwd, input["root"].as_str().unwrap_or("."));
497
+ let pattern = input["pattern"].as_str().unwrap_or("");
498
+ if pattern.is_empty() {
499
+ return err("pattern is required");
500
+ }
501
+ let file_pattern = input["file_pattern"].as_str().unwrap_or("*");
502
+ let case_sensitive = input["case_sensitive"].as_bool().unwrap_or(false);
503
+ let needle = if case_sensitive { pattern.to_string() } else { pattern.to_lowercase() };
504
+ let limit = search_limit(input);
505
+ let mut files = Vec::new();
506
+ let mut seen = 0;
507
+ collect_files(&root, &mut files, &mut seen);
508
+ files.sort();
509
+ let mut matches = Vec::new();
510
+ for path in files {
511
+ if matches.len() >= limit {
512
+ break;
513
+ }
514
+ let rel = display_path(&path, cwd);
515
+ let basename_matches = path
516
+ .file_name()
517
+ .and_then(|n| n.to_str())
518
+ .is_some_and(|name| simple_glob_match(file_pattern, name));
519
+ if !simple_glob_match(file_pattern, &rel) && !basename_matches {
520
+ continue;
521
+ }
522
+ let Ok(text) = fs::read_to_string(&path) else {
523
+ continue;
524
+ };
525
+ for (idx, line) in text.lines().enumerate() {
526
+ let haystack = if case_sensitive { line.to_string() } else { line.to_lowercase() };
527
+ if haystack.contains(&needle) {
528
+ matches.push(format!("{}:{}:{}", rel, idx + 1, line.trim_end()));
529
+ if matches.len() >= limit {
530
+ break;
531
+ }
532
+ }
533
+ }
534
+ }
535
+ if matches.is_empty() { ok(format!("no matches for {pattern}")) } else { ok(matches.join("\n")) }
536
+ }
355
537
  "write_file" => {
356
538
  let p = resolve(cwd, input["path"].as_str().unwrap_or(""));
357
539
  let content = input["content"].as_str().unwrap_or("");
358
540
  if let Some(dir) = p.parent() {
359
541
  let _ = fs::create_dir_all(dir);
360
542
  }
543
+ checkpoint::record(cwd, &p, "write_file");
361
544
  match fs::write(&p, content) {
362
545
  Ok(_) => ok(format!("wrote {} ({} bytes)", p.display(), content.len())),
363
546
  Err(e) => err(format!("cannot write {}: {e}", p.display())),
@@ -382,6 +565,7 @@ pub fn run(name: &str, input: &Value, cwd: &Path) -> Outcome {
382
565
  if body[first + old.len()..].contains(old) {
383
566
  return err("`old` text is not unique — add surrounding context");
384
567
  }
568
+ checkpoint::record(cwd, &p, "edit_file");
385
569
  match fs::write(&p, body.replacen(old, new, 1)) {
386
570
  Ok(_) => ok(format!("edited {}", p.display())),
387
571
  Err(e) => err(format!("cannot write {}: {e}", p.display())),
@@ -650,6 +834,37 @@ mod tests {
650
834
  let _ = fs::remove_dir_all(&d);
651
835
  }
652
836
 
837
+ #[test]
838
+ fn run_read_file_line_range() {
839
+ let d = tempdir();
840
+ run("write_file", &json!({"path": "f.txt", "content": "one\ntwo\nthree\nfour"}), &d);
841
+ let r = run("read_file", &json!({"path": "f.txt", "start_line": 2, "end_line": 3}), &d);
842
+ assert_eq!(r.content, "two\nthree");
843
+ let _ = fs::remove_dir_all(&d);
844
+ }
845
+
846
+ #[test]
847
+ fn run_find_files_matches_glob_and_skips_heavy_dirs() {
848
+ let d = tempdir();
849
+ run("write_file", &json!({"path": "src/main.rs", "content": ""}), &d);
850
+ run("write_file", &json!({"path": "target/debug/skip.rs", "content": ""}), &d);
851
+ let r = run("find_files", &json!({"root": ".", "pattern": "*.rs"}), &d);
852
+ assert!(r.content.contains("src/main.rs"), "{}", r.content);
853
+ assert!(!r.content.contains("target/debug/skip.rs"), "{}", r.content);
854
+ let _ = fs::remove_dir_all(&d);
855
+ }
856
+
857
+ #[test]
858
+ fn run_grep_files_returns_literal_path_line_matches() {
859
+ let d = tempdir();
860
+ run("write_file", &json!({"path": "src/lib.rs", "content": "alpha\nNeedle\nbeta"}), &d);
861
+ run("write_file", &json!({"path": "README.md", "content": "Needle"}), &d);
862
+ let r = run("grep_files", &json!({"root": ".", "pattern": "needle", "file_pattern": "*.rs"}), &d);
863
+ assert!(r.content.contains("src/lib.rs:2:Needle"), "{}", r.content);
864
+ assert!(!r.content.contains("README.md"), "{}", r.content);
865
+ let _ = fs::remove_dir_all(&d);
866
+ }
867
+
653
868
  #[test]
654
869
  fn run_edit_unique_match() {
655
870
  let d = tempdir();