1use std::io::{Read, Write};
13use std::path::{Path, PathBuf};
14use std::process;
15
16use crate::extract;
17use crate::graph;
18use crate::hookio;
19use crate::initcmd;
20use crate::manifest;
21use crate::mapcmd;
22use crate::parse;
23use crate::query;
24use crate::render;
25use crate::store;
26use crate::suggest;
27
28fn print_out(out: &str) {
35 use std::io::Write;
36 let stdout = std::io::stdout();
37 let mut lock = stdout.lock();
38 let _ = lock
39 .write_all(out.as_bytes())
40 .and_then(|()| lock.write_all(b"\n"));
41}
42
43const EXIT_NO_RESULT: i32 = 3;
47
48const ZERO_HIT_FIND: &str = "devscout find: zero hits — the manifest was searched and nothing matched. Not an error; fall back to text search (rg/grep) rather than rephrasing.";
52const ZERO_HIT_REFS: &str = "devscout refs: zero hits — the graph was searched and no symbol matched. Not an error; fall back to text search (rg/grep) rather than rephrasing.";
53const ZERO_HIT_READ: &str = "devscout read: zero hits — the graph was searched and no symbol matched. Not an error; fall back to text search (rg/grep) rather than rephrasing.";
54const ZERO_HIT_IMPACT: &str = "devscout impact: zero hits — the graph was searched and no affected file came back. Not an error; fall back to text search (rg/grep) rather than rephrasing.";
55const ZERO_HIT_TESTS: &str = "devscout tests: zero hits — the graph was searched and no symbol matched. Not an error; fall back to text search (rg/grep) rather than rephrasing.";
56
57fn emit_zero_hit_note(code: i32, note: &str, cwd: &Path, query: Option<&str>) {
63 if code != EXIT_NO_RESULT {
64 return;
65 }
66 let mut text = String::from(note);
67 let rows = query.map(|q| nearest_names(cwd, q)).unwrap_or_default();
68 if !rows.is_empty() {
69 text.push_str("\ndid you mean:");
70 for row in rows {
71 text.push('\n');
72 text.push_str(&row);
73 }
74 }
75 let stderr = std::io::stderr();
76 let mut lock = stderr.lock();
77 let _ = lock
78 .write_all(text.as_bytes())
79 .and_then(|()| lock.write_all(b"\n"));
80}
81
82fn emit_freshness_warning(cwd: &Path) {
91 let Some(root) = crate::repo::find_scout_root(cwd).or_else(|| crate::repo::find_repo_root(cwd))
92 else {
93 return;
94 };
95 let Some(warning) = manifest::freshness_warning(&root) else {
96 return;
97 };
98 let stderr = std::io::stderr();
99 let mut lock = stderr.lock();
100 let _ = lock
101 .write_all(warning.as_bytes())
102 .and_then(|()| lock.write_all(b"\n"));
103}
104
105fn nearest_names(cwd: &Path, query: &str) -> Vec<String> {
110 let Ok(root) = require_repo(cwd) else {
111 return Vec::new();
112 };
113 let Some(g) = graph::read_graph(&root) else {
114 return Vec::new();
115 };
116 suggest::suggestion_lines(&g.names, query)
117}
118
119fn first_positional(args: &[String]) -> Option<&str> {
123 args.iter()
124 .find(|a| !a.starts_with("--"))
125 .map(String::as_str)
126}
127
128const HELP: &str = "\
132devscout -- fast code index for C# and TypeScript codebases.
133
134usage: devscout <command> [args]
135
136index
137 init [scope ...] register this repo, install hooks, first map
138 [--label L] [--no-hooks] [--no-map]
139 map [scope ...] [--refresh] build the index for the given scopes
140 clear --older-than <days> drop freshness rows older than N days
141 clear --session <id> drop one session's freshness rows
142
143query
144 find <query> [--resources] search the manifest by name or purpose
145 refs <symbol> references to a symbol [--out --all --json|--compact]
146 read <symbol> decl span + inbound refs [--json|--compact]
147 impact <file|symbol> blast radius [--hops N --json|--compact]
148 tests <symbol> tests reaching a symbol [--json|--compact]
149 stats index + cache summary for this repo
150
151plumbing
152 parse <file.cs> dump the parse tree
153 spans <file.cs> dump declaration spans
154 extract-dump <file.cs> dump extraction records
155 hook <read|bash> agent hook filters, stdin -> stdout
156 noop exit 0 (harness probe)
157
158 -C <dir> run as if devscout started in <dir>
159 -h, --help show this help
160 -V, --version show the version
161";
162
163pub fn dispatch(args: Vec<String>) {
165 let (cwd, args) = match apply_global_options(¤t_dir(), &args) {
166 Ok(v) => v,
167 Err(e) => {
168 print_out(&format!("error: {e}"));
169 process::exit(1);
170 }
171 };
172 match args.get(1).map(String::as_str) {
173 Some("noop") => process::exit(0),
174 Some("help") | Some("--help") | Some("-h") => {
175 print!("{HELP}");
176 process::exit(0);
177 }
178 Some("--version") | Some("-V") => {
179 println!("devscout {}", env!("CARGO_PKG_VERSION"));
180 process::exit(0);
181 }
182 Some("parse") => {
183 let Some(path) = args.get(2) else {
184 eprintln!("usage: devscout parse <file.cs>");
185 process::exit(1);
186 };
187 parse::run_parse(path);
188 }
189 Some("spans") => {
190 let Some(path) = args.get(2) else {
191 eprintln!("usage: devscout spans <file.cs>");
192 process::exit(1);
193 };
194 parse::run_spans(path);
195 }
196 Some("extract-dump") => {
197 let Some(path) = args.get(2) else {
198 eprintln!("usage: devscout extract-dump <file.cs>");
199 process::exit(1);
200 };
201 extract::run_extract_dump(path);
202 }
203 Some("hook") => match args.get(2).map(String::as_str) {
204 Some("read") => run_hook(hookio::run_read),
205 Some("bash") => run_hook(hookio::run_bash),
206 _ => {
207 eprintln!("usage: devscout hook <read|bash>");
208 process::exit(1);
209 }
210 },
211 Some("refs") => {
212 let (code, out) = cmd_refs(&cwd, &args[2..]);
213 print_out(&out);
214 emit_freshness_warning(&cwd);
215 emit_zero_hit_note(code, ZERO_HIT_REFS, &cwd, first_positional(&args[2..]));
216 process::exit(code);
217 }
218 Some("read") => {
219 let (code, out) = cmd_read(&cwd, &args[2..]);
220 print_out(&out);
221 emit_freshness_warning(&cwd);
222 emit_zero_hit_note(code, ZERO_HIT_READ, &cwd, first_positional(&args[2..]));
223 process::exit(code);
224 }
225 Some("impact") => {
226 let (code, out) = cmd_impact(&cwd, &args[2..]);
227 print_out(&out);
228 emit_freshness_warning(&cwd);
229 emit_zero_hit_note(code, ZERO_HIT_IMPACT, &cwd, None);
230 process::exit(code);
231 }
232 Some("tests") => {
233 let (code, out) = cmd_tests(&cwd, &args[2..]);
234 print_out(&out);
235 emit_zero_hit_note(code, ZERO_HIT_TESTS, &cwd, None);
236 process::exit(code);
237 }
238 Some("find") => {
239 let resources = args[2..].iter().any(|a| a == "--resources");
243 let query_str = args[2..]
244 .iter()
245 .filter(|a| a.as_str() != "--resources")
246 .cloned()
247 .collect::<Vec<_>>()
248 .join(" ");
249 let (code, out) = cmd_find(&cwd, &query_str, resources);
250 print_out(&out);
251 emit_freshness_warning(&cwd);
252 emit_zero_hit_note(code, ZERO_HIT_FIND, &cwd, Some(query_str.as_str()));
253 process::exit(code);
254 }
255 Some("map") => {
256 let (code, out) = cmd_map(&cwd, &args[2..]);
257 print_out(&out);
258 process::exit(code);
259 }
260 Some("stats") => {
261 let (code, out) = cmd_stats(&cwd);
262 print_out(&out);
263 process::exit(code);
264 }
265 Some("clear") => {
266 let (code, out) = cmd_clear(&cwd, &args[2..]);
267 print_out(&out);
268 process::exit(code);
269 }
270 Some("init") => {
271 let (code, out) = initcmd::cmd_init_full(&cwd, &args[2..]);
272 print_out(&out);
273 process::exit(code);
274 }
275 _ => {
276 eprintln!("usage: devscout <noop|parse|spans|extract-dump|hook|refs|read|impact|tests|find|map|stats|clear|init> [args]");
277 process::exit(1);
278 }
279 }
280}
281
282fn run_hook(handler: fn(&[u8]) -> Vec<u8>) {
289 let mut buf = Vec::new();
290 let _ = std::io::stdin().read_to_end(&mut buf);
291 let out = handler(&buf);
292 if !out.is_empty() {
293 let _ = std::io::stdout().write_all(&out);
294 }
295 process::exit(0);
296}
297
298fn current_dir() -> PathBuf {
307 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
308}
309
310fn require_repo(cwd: &Path) -> Result<PathBuf, String> {
314 require_repo_for_path(cwd, None)
315}
316
317fn require_repo_for_path(cwd: &Path, arg_path: Option<&str>) -> Result<PathBuf, String> {
322 crate::repo::find_scout_root(cwd)
323 .or_else(|| crate::repo::find_repo_root(cwd))
324 .or_else(|| root_from_arg(cwd, arg_path))
325 .ok_or_else(|| {
326 "no .scout or .git ancestor; run 'devscout init' from the repo or directory root"
327 .to_string()
328 })
329}
330
331fn root_from_arg(cwd: &Path, arg_path: Option<&str>) -> Option<PathBuf> {
336 let arg_path = arg_path?;
337 if !query::looks_like_file_path(arg_path) {
338 return None;
339 }
340 let abs = crate::repo::resolve_from(cwd, Path::new(arg_path));
341 if !abs.exists() {
342 return None;
343 }
344 crate::repo::find_scout_root(&abs).or_else(|| crate::repo::find_repo_root(&abs))
345}
346
347fn repo_relative_arg(cwd: &Path, root: &Path, arg: &str) -> String {
354 if !query::looks_like_file_path(arg) {
355 return arg.to_string();
356 }
357 let abs = crate::repo::resolve_from(cwd, Path::new(arg));
358 if !abs.exists() {
359 return arg.to_string();
360 }
361 match abs.strip_prefix(root) {
362 Ok(rest) if !rest.as_os_str().is_empty() => crate::repo::rel_path(root, &abs),
363 _ => arg.to_string(),
364 }
365}
366
367fn apply_global_options(cwd: &Path, args: &[String]) -> Result<(PathBuf, Vec<String>), String> {
373 let mut cwd = cwd.to_path_buf();
374 let mut idx = 1;
375 while args.get(idx).map(String::as_str) == Some("-C") {
376 let Some(dir) = args.get(idx + 1) else {
377 return Err("no directory given for '-C' option".to_string());
378 };
379 let next = crate::repo::resolve_from(&cwd, Path::new(dir));
380 if !next.is_dir() {
381 return Err(format!("cannot change to '{dir}': no such directory"));
382 }
383 cwd = next;
384 idx += 2;
385 }
386 if idx == 1 {
387 return Ok((cwd, args.to_vec()));
388 }
389 let mut rest = vec![args[0].clone()];
390 rest.extend_from_slice(&args[idx..]);
391 Ok((cwd, rest))
392}
393
394fn ambiguous_candidates_out(index: &query::GraphIndex, q: &str, ids: &[String]) -> (i32, String) {
399 let mut rows: Vec<String> = ids
400 .iter()
401 .map(|id| {
402 let d = index
407 .def(id)
408 .expect("ambiguous candidate id must resolve to a graph def");
409 format!("{id} {}:{} {}", d.file, d.line, d.kind)
410 })
411 .collect();
412 rows.sort();
416 let mut out = vec![format!(
417 "ambiguous symbol \"{q}\" — {} candidates:",
418 rows.len()
419 )];
420 out.append(&mut rows);
421 (1, out.join("\n"))
422}
423
424fn parse_int_js(s: &str) -> Option<i64> {
430 let s = s.trim_start();
431 let bytes = s.as_bytes();
432 let mut idx = 0;
433 if idx < bytes.len() && (bytes[idx] == b'+' || bytes[idx] == b'-') {
434 idx += 1;
435 }
436 let digits_start = idx;
437 while idx < bytes.len() && bytes[idx].is_ascii_digit() {
438 idx += 1;
439 }
440 if idx == digits_start {
441 return None;
442 }
443 s[..idx].parse::<i64>().ok()
444}
445
446fn cmd_refs(cwd: &Path, args: &[String]) -> (i32, String) {
451 let json = args.iter().any(|a| a == "--json");
452 let compact = args.iter().any(|a| a == "--compact");
453 if json && compact {
454 return (
455 1,
456 "devscout refs: --compact and --json are mutually exclusive".to_string(),
457 );
458 }
459 let Some(q) = first_positional(args) else {
460 return (
461 2,
462 "usage: devscout refs <symbol> [--out] [--all] [--json|--compact]".to_string(),
463 );
464 };
465 let out = args.iter().any(|a| a == "--out");
466 let all_out = args.iter().any(|a| a == "--all");
469 let root = match require_repo(cwd) {
470 Ok(r) => r,
471 Err(e) => return (1, format!("error: {e}")),
472 };
473 let Some(g) = graph::read_graph(&root) else {
474 return (
475 1,
476 "no graph.json for this repo — run `devscout map` on a C# scope first".to_string(),
477 );
478 };
479 let index = query::load_graph_index(&g, &root);
480
481 match query::build_refs_model(
482 &index,
483 q,
484 out,
485 query::DEFAULT_CAP,
486 query::INBOUND_CAP,
487 query::OUTBOUND_CAP,
488 all_out,
489 ) {
490 query::RefsResult::NotFound => (EXIT_NO_RESULT, format!("no symbol matches \"{q}\"")),
491 query::RefsResult::Ambiguous(ids) => ambiguous_candidates_out(&index, q, &ids),
492 query::RefsResult::Members(models) => {
497 if json {
498 (0, member_refs_to_json(q, &models))
499 } else if compact {
500 (
501 0,
502 models
503 .iter()
504 .map(render::render_refs_compact)
505 .collect::<Vec<_>>()
506 .join("\n"),
507 )
508 } else {
509 (
510 0,
511 models
512 .iter()
513 .map(render::render_refs_text)
514 .collect::<Vec<_>>()
515 .join("\n"),
516 )
517 }
518 }
519 query::RefsResult::Resolved(model) => {
520 if json {
521 (0, refs_model_to_json(&model))
522 } else if compact {
523 (0, render::render_refs_compact(&model))
524 } else {
525 (0, render::render_refs_text(&model))
526 }
527 }
528 }
529}
530
531fn cmd_read(cwd: &Path, args: &[String]) -> (i32, String) {
538 let json = args.iter().any(|a| a == "--json");
539 let compact = args.iter().any(|a| a == "--compact");
540 if json && compact {
541 return (
542 1,
543 "devscout read: --compact and --json are mutually exclusive".to_string(),
544 );
545 }
546 let Some(q) = first_positional(args) else {
547 return (
548 2,
549 "usage: devscout read <symbol> [--json|--compact]".to_string(),
550 );
551 };
552 let root = match require_repo(cwd) {
553 Ok(r) => r,
554 Err(e) => return (1, format!("error: {e}")),
555 };
556 let Some(g) = graph::read_graph(&root) else {
557 return (
558 1,
559 "no graph.json for this repo — run `devscout map` on a C# scope first".to_string(),
560 );
561 };
562 let index = query::load_graph_index(&g, &root);
563
564 match query::build_read_model(&index, q) {
565 query::ReadResult::NotFound => (EXIT_NO_RESULT, format!("no symbol matches \"{q}\"")),
566 query::ReadResult::Ambiguous(ids) => ambiguous_candidates_out(&index, q, &ids),
567 query::ReadResult::Members(models) => {
572 if json {
573 (0, member_refs_to_json(q, &models))
574 } else if compact {
575 (
576 0,
577 models
578 .iter()
579 .map(render::render_refs_compact)
580 .collect::<Vec<_>>()
581 .join("\n"),
582 )
583 } else {
584 (
585 0,
586 models
587 .iter()
588 .map(render::render_refs_text)
589 .collect::<Vec<_>>()
590 .join("\n"),
591 )
592 }
593 }
594 query::ReadResult::Resolved(model) => {
595 if json {
596 (0, read_model_to_json(&model))
597 } else if compact {
598 (0, render::render_read_compact(&model))
599 } else {
600 (0, render::render_read_text(&model))
601 }
602 }
603 }
604}
605
606fn cmd_impact(cwd: &Path, args: &[String]) -> (i32, String) {
610 let json = args.iter().any(|a| a == "--json");
611 let compact = args.iter().any(|a| a == "--compact");
612 if json && compact {
613 return (
614 1,
615 "devscout impact: --compact and --json are mutually exclusive".to_string(),
616 );
617 }
618
619 let mut hops: u32 = query::DEFAULT_HOPS;
620 if let Some(idx) = args.iter().position(|a| a == "--hops") {
621 let raw = args.get(idx + 1).map(String::as_str).unwrap_or("");
622 match parse_int_js(raw) {
623 Some(h) if h >= 1 => hops = h as u32,
624 _ => return (2, "usage: devscout impact <file|symbol> [--hops N] [--no-iface] [--iface-max-fanin N] [--hub-max-indegree N] [--json|--compact]".to_string()),
625 }
626 }
627
628 let mut iface_max_fanin: usize = query::DEFAULT_IFACE_MAX_FANIN;
632 if let Some(idx) = args.iter().position(|a| a == "--iface-max-fanin") {
633 let raw = args.get(idx + 1).map(String::as_str).unwrap_or("");
634 match parse_int_js(raw) {
635 Some(n) if n >= 0 => iface_max_fanin = n as usize,
636 _ => return (2, "usage: devscout impact <file|symbol> [--hops N] [--no-iface] [--iface-max-fanin N] [--hub-max-indegree N] [--json|--compact]".to_string()),
637 }
638 }
639
640 let mut hub_max_indegree: usize = query::DEFAULT_HUB_MAX_INDEGREE;
643 if let Some(idx) = args.iter().position(|a| a == "--hub-max-indegree") {
644 let raw = args.get(idx + 1).map(String::as_str).unwrap_or("");
645 match parse_int_js(raw) {
646 Some(n) if n >= 0 => hub_max_indegree = n as usize,
647 _ => return (2, "usage: devscout impact <file|symbol> [--hops N] [--no-iface] [--iface-max-fanin N] [--hub-max-indegree N] [--json|--compact]".to_string()),
648 }
649 }
650
651 let mut q: Option<&str> = None;
654 for (i, a) in args.iter().enumerate() {
655 if a.starts_with("--") {
656 continue;
657 }
658 if i > 0
659 && (args[i - 1] == "--hops"
660 || args[i - 1] == "--iface-max-fanin"
661 || args[i - 1] == "--hub-max-indegree")
662 {
663 continue;
664 }
665 q = Some(a.as_str());
666 break;
667 }
668 let Some(q) = q else {
669 return (2, "usage: devscout impact <file|symbol> [--hops N] [--no-iface] [--iface-max-fanin N] [--hub-max-indegree N] [--json|--compact]".to_string());
670 };
671
672 let root = match require_repo_for_path(cwd, Some(q)) {
673 Ok(r) => r,
674 Err(e) => return (1, format!("error: {e}")),
675 };
676 let q = repo_relative_arg(cwd, &root, q);
677 let q = q.as_str();
678 let Some(g) = graph::read_graph(&root) else {
679 return (
680 1,
681 "no graph.json for this repo — run `devscout map` on a C# scope first".to_string(),
682 );
683 };
684 let index = query::load_graph_index(&g, &root);
685
686 match query::build_impact_model(
687 &index,
688 q,
689 hops,
690 query::DEFAULT_CAP,
691 !args.iter().any(|a| a == "--no-iface"),
692 iface_max_fanin,
693 hub_max_indegree,
694 ) {
695 query::ImpactResult::NotFound { kind } => (
696 EXIT_NO_RESULT,
697 format!("no {} match for \"{q}\"", render::seed_kind_str(kind)),
698 ),
699 query::ImpactResult::Ambiguous { ids, .. } => ambiguous_candidates_out(&index, q, &ids),
700 query::ImpactResult::Resolved(model) => {
701 let out = if json {
702 impact_model_to_json(q, &model)
703 } else if compact {
704 render::render_impact_compact(q, &model)
705 } else {
706 render::render_impact_text(q, &model)
707 };
708 if model.rows.is_empty() {
712 (EXIT_NO_RESULT, out)
713 } else {
714 (0, out)
715 }
716 }
717 }
718}
719
720fn cmd_tests(cwd: &Path, args: &[String]) -> (i32, String) {
723 let json = args.iter().any(|a| a == "--json");
724 let compact = args.iter().any(|a| a == "--compact");
725 if json && compact {
726 return (
727 1,
728 "devscout tests: --compact and --json are mutually exclusive".to_string(),
729 );
730 }
731 let Some(q) = args.iter().find(|a| !a.starts_with("--")) else {
732 return (
733 2,
734 "usage: devscout tests <symbol> [--json|--compact]".to_string(),
735 );
736 };
737 let root = match require_repo(cwd) {
738 Ok(r) => r,
739 Err(e) => return (1, format!("error: {e}")),
740 };
741 let Some(g) = graph::read_graph(&root) else {
742 return (
743 1,
744 "no graph.json for this repo — run `devscout map` on a C# scope first".to_string(),
745 );
746 };
747 let index = query::load_graph_index(&g, &root);
748
749 match query::build_tests_model(&index, q) {
750 query::TestsResult::NotFound => (EXIT_NO_RESULT, format!("no symbol matches \"{q}\"")),
751 query::TestsResult::Ambiguous(ids) => ambiguous_candidates_out(&index, q, &ids),
752 query::TestsResult::Resolved(model) => {
753 if json {
754 (0, tests_model_to_json(&model))
755 } else if compact {
756 (0, render::render_tests_compact(&model))
757 } else {
758 (0, render::render_tests_text(&model))
759 }
760 }
761 }
762}
763
764const FIND_FULL_CAP: usize = 25;
772const FIND_FALLBACK_CAP: usize = 10;
773const FIND_NAMES_CAP: usize = 25;
778
779fn cmd_find(cwd: &Path, query_str: &str, resources: bool) -> (i32, String) {
784 let root = match require_repo(cwd) {
785 Ok(r) => r,
786 Err(e) => return (1, format!("error: {e}")),
787 };
788 if query_str.is_empty() {
789 return (2, "usage: devscout find <query> [--resources]".to_string());
790 }
791 let graph = graph::read_graph(&root);
804 let inbound_counts = graph
809 .as_ref()
810 .map(query::file_inbound_counts)
811 .unwrap_or_default();
812 let (decl_lines, resource_count): (Vec<String>, usize) = match graph.as_ref() {
813 None => (Vec::new(), 0),
814 Some(g) => {
815 let named = query::find_names(g, query_str);
816 let mut primary: Vec<&graph::GraphName> = Vec::new();
817 let mut resource_count = 0usize;
818 for n in named.iter() {
819 if query::name_tier(&n.kind) <= 2 {
820 primary.push(n);
821 } else {
822 resource_count += 1;
823 }
824 }
825 let pool: Vec<&graph::GraphName> = if resources { named } else { primary };
826 let mut out: Vec<String> = pool
827 .iter()
828 .take(FIND_NAMES_CAP)
829 .map(|n| {
830 let text = query::source_line(&root, &n.file, n.line);
831 if text.is_empty() {
832 format!("{}:{}", n.file, n.line)
833 } else {
834 format!("{}:{} {text}", n.file, n.line)
835 }
836 })
837 .collect();
838 if pool.len() > FIND_NAMES_CAP {
839 out.push(format!(
840 "… +{} more declarations (refine query)",
841 pool.len() - FIND_NAMES_CAP
842 ));
843 }
844 (out, resource_count)
845 }
846 };
847 match manifest::find_in_manifest_detailed(&root, query_str, &inbound_counts) {
848 Ok(r) if r.hits.is_empty() && decl_lines.is_empty() => (
849 EXIT_NO_RESULT,
850 format!("no matches for \"{query_str}\" (run 'devscout map' if manifest is missing)"),
851 ),
852 Ok(r) => {
853 let cap = if r.fallback {
854 FIND_FALLBACK_CAP
855 } else {
856 FIND_FULL_CAP
857 };
858 let mut lines: Vec<String> = decl_lines;
859 if !resources && resource_count > 0 {
860 lines.push(format!(
861 "+{resource_count} resource-key hits, use --resources"
862 ));
863 }
864 let decl_line_by_file = graph
869 .as_ref()
870 .map(query::first_decl_line_by_file)
871 .unwrap_or_default();
872 lines.extend(r.hits.iter().take(cap).map(|h| {
873 let purpose = h.purpose.as_deref().unwrap_or("undefined");
877 let agent = if h.source == "agent" { " [agent]" } else { "" };
878 let line = decl_line_by_file.get(&h.path).copied().unwrap_or(1);
879 format!("{}:{line}: {purpose}{agent}", h.path)
880 }));
881 if r.hits.len() > cap {
882 lines.push(format!("… +{} more (refine query)", r.hits.len() - cap));
883 }
884 (0, lines.join("\n"))
885 }
886 Err(e) => (1, format!("error: {e}")),
887 }
888}
889
890fn cmd_map(cwd: &Path, args: &[String]) -> (i32, String) {
897 let root = match require_repo(cwd) {
898 Ok(r) => r,
899 Err(e) => return (1, format!("error: {e}")),
900 };
901 let dirs: Vec<String> = args
902 .iter()
903 .filter(|a| a.as_str() != "--refresh")
904 .cloned()
905 .collect();
906 match mapcmd::map_repo(&root, &dirs, mapcmd::MapOptions::from_env()) {
907 Ok(report) => (0, report.summary_line()),
908 Err(e) => (1, format!("error: {e}")),
909 }
910}
911
912fn cmd_stats(cwd: &Path) -> (i32, String) {
918 let root = match require_repo(cwd) {
919 Ok(r) => r,
920 Err(e) => return (1, format!("error: {e}")),
921 };
922 let db = match store::open_store(&root) {
923 Ok(c) => c,
924 Err(e) => return (1, format!("error: {e}")),
925 };
926 let s = match store::stats_for(&db) {
927 Ok(s) => s,
928 Err(e) => return (1, format!("error: {e}")),
929 };
930
931 let mut lines = vec![
932 format!("devscout stats ({}):", root.display()),
933 format!(" files tracked: {}", s.distinct_files),
934 format!(" reads deduped (stubs): {}", s.total_stubs),
935 format!(" lines saved: {}", s.lines_saved),
936 format!(" bytes saved: {}", s.bytes_saved),
937 format!(
938 " est tokens saved: {}",
939 js_math_round(s.bytes_saved as f64 / 4.0)
940 ),
941 ];
942
943 let b = match store::bash_stats_for(&db) {
944 Ok(b) => b,
945 Err(e) => return (1, format!("error: {e}")),
946 };
947 if b.commands_tracked > 0 {
948 lines.push(format!(" bash commands tracked: {}", b.commands_tracked));
949 lines.push(format!(" bash dedups (stubs): {}", b.total_stubs));
950 lines.push(format!(
951 " bash est tokens saved: {}",
952 js_math_round(b.bytes_saved as f64 / 4.0)
953 ));
954 }
955
956 if let Ok(cs_conn) = store::open_content_store() {
958 if let Ok(cs) = store::content_stats_for(&cs_conn) {
959 if cs.total_stubs > 0 {
960 lines.push(format!(
961 " cross-repo dedups (all roots, this machine): {}",
962 cs.total_stubs
963 ));
964 lines.push(format!(
965 " cross-repo est tokens saved: {}",
966 js_math_round(cs.bytes_saved as f64 / 4.0)
967 ));
968 }
969 }
970 }
971
972 let per_session = match store::session_stats(&db) {
973 Ok(v) => v,
974 Err(e) => return (1, format!("error: {e}")),
975 };
976 if !per_session.is_empty() {
977 lines.push(String::new());
978 lines.push(" per session:".to_string());
979 for r in &per_session {
980 let sid: String = r.session_id.chars().take(8).collect();
983 lines.push(format!(
984 " {sid:<8} files {:>4} stubs {:>4} lines saved {:>6} est tokens {}",
985 r.files,
986 r.stubs,
987 r.lines_saved,
988 js_math_round(r.bytes_saved as f64 / 4.0),
989 ));
990 }
991 }
992
993 let top = match store::top_stubbed(&db, 5) {
994 Ok(v) => v,
995 Err(e) => return (1, format!("error: {e}")),
996 };
997 if !top.is_empty() {
998 lines.push(String::new());
999 lines.push(" top stubbed files:".to_string());
1000 for r in &top {
1001 let sid: String = r.session_id.chars().take(8).collect();
1002 lines.push(format!(
1003 " {}x {} ({} lines, session {sid})",
1004 r.stub_count, r.rel_path, r.lines
1005 ));
1006 }
1007 }
1008
1009 (0, lines.join("\n"))
1010}
1011
1012fn cmd_clear(cwd: &Path, args: &[String]) -> (i32, String) {
1021 let root = match require_repo(cwd) {
1022 Ok(r) => r,
1023 Err(e) => return (1, format!("error: {e}")),
1024 };
1025
1026 if let Some(idx) = args.iter().position(|a| a == "--older-than") {
1027 let raw = args.get(idx + 1).map(String::as_str).unwrap_or("");
1028 let days = match parse_int_js(raw) {
1029 Some(d) if d >= 0 => d,
1030 _ => return (2, "usage: devscout clear --older-than <days>".to_string()),
1031 };
1032 let db = match store::open_store(&root) {
1033 Ok(c) => c,
1034 Err(e) => return (1, format!("error: {e}")),
1035 };
1036 let deleted = match store::prune(&db, Some(days as f64), None) {
1037 Ok(n) => n,
1038 Err(e) => return (1, format!("error: {e}")),
1039 };
1040 let suffix = if deleted == 1 { "" } else { "s" };
1041 return (
1042 0,
1043 format!("deleted {deleted} row{suffix} older than {days}d"),
1044 );
1045 }
1046
1047 if let Some(idx) = args.iter().position(|a| a == "--session") {
1048 let prefix = args.get(idx + 1).map(String::as_str).unwrap_or("");
1049 if prefix.is_empty() {
1050 return (
1051 2,
1052 "usage: devscout clear --session <id-or-prefix>".to_string(),
1053 );
1054 }
1055 let db = match store::open_store(&root) {
1056 Ok(c) => c,
1057 Err(e) => return (1, format!("error: {e}")),
1058 };
1059 let matches = match store::session_ids_by_prefix(&db, prefix) {
1060 Ok(v) => v,
1061 Err(e) => return (1, format!("error: {e}")),
1062 };
1063 if matches.is_empty() {
1064 return (0, format!("no sessions match \"{prefix}\""));
1065 }
1066 if matches.len() > 1 {
1067 return (
1068 2,
1069 format!(
1070 "ambiguous session prefix \"{prefix}\": {}",
1071 matches.join(", ")
1072 ),
1073 );
1074 }
1075 let deleted = match store::prune(&db, None, Some(matches[0].as_str())) {
1076 Ok(n) => n,
1077 Err(e) => return (1, format!("error: {e}")),
1078 };
1079 let suffix = if deleted == 1 { "" } else { "s" };
1080 let session = &matches[0];
1081 return (
1082 0,
1083 format!("deleted {deleted} row{suffix} for session {session}"),
1084 );
1085 }
1086
1087 let db_path = crate::repo::scout_dir(&root).join("cache.db");
1088 if db_path.exists() {
1089 if let Err(e) = std::fs::remove_file(&db_path) {
1090 return (1, format!("error: {e}"));
1091 }
1092 }
1093 (0, "cache cleared".to_string())
1094}
1095
1096fn js_math_round(x: f64) -> i64 {
1101 (x + 0.5).floor() as i64
1102}
1103
1104enum J {
1120 Str(String),
1121 UInt(u64),
1122 RawNum(String),
1123 Bool(bool),
1126 Arr(Vec<J>),
1127 Obj(Vec<(&'static str, J)>),
1128}
1129
1130impl J {
1131 fn write(&self, out: &mut String) {
1132 match self {
1133 J::Str(s) => {
1134 out.push_str(&serde_json::to_string(s).expect("string JSON encoding cannot fail"))
1135 }
1136 J::UInt(n) => out.push_str(&n.to_string()),
1137 J::RawNum(s) => out.push_str(s),
1138 J::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
1139 J::Arr(items) => {
1140 out.push('[');
1141 for (i, item) in items.iter().enumerate() {
1142 if i > 0 {
1143 out.push(',');
1144 }
1145 item.write(out);
1146 }
1147 out.push(']');
1148 }
1149 J::Obj(entries) => {
1150 out.push('{');
1151 for (i, (k, v)) in entries.iter().enumerate() {
1152 if i > 0 {
1153 out.push(',');
1154 }
1155 out.push_str(&serde_json::to_string(k).expect("key JSON encoding cannot fail"));
1156 out.push(':');
1157 v.write(out);
1158 }
1159 out.push('}');
1160 }
1161 }
1162 }
1163
1164 fn to_json_string(&self) -> String {
1165 let mut s = String::new();
1166 self.write(&mut s);
1167 s
1168 }
1169}
1170
1171fn js_float_string(v: f64) -> String {
1182 if !v.is_finite() {
1183 return "null".to_string();
1184 }
1185 if v == 0.0 {
1186 return "0".to_string();
1187 }
1188 let s = serde_json::Number::from_f64(v)
1189 .expect("finite, checked above")
1190 .to_string();
1191 if !s.contains('e') && s.ends_with(".0") {
1192 s[..s.len() - 2].to_string()
1193 } else {
1194 s
1195 }
1196}
1197
1198fn j_table<R>(t: &query::Table<R>, row: impl Fn(&R) -> J) -> J {
1199 J::Obj(vec![
1200 ("total", J::UInt(t.total as u64)),
1201 ("dropped", J::UInt(t.dropped as u64)),
1202 ("rows", J::Arr(t.rows.iter().map(row).collect())),
1203 ])
1204}
1205
1206fn push_heuristic(fields: &mut Vec<(&'static str, J)>, heuristic: bool) {
1210 if heuristic {
1211 fields.push(("heuristic", J::Bool(true)));
1212 }
1213}
1214
1215fn j_inbound_row(r: &query::InboundRow) -> J {
1216 let mut fields = vec![
1217 ("file", J::Str(r.file.clone())),
1218 ("line", J::UInt(r.line as u64)),
1219 ];
1220 push_heuristic(&mut fields, r.heuristic);
1221 if !r.source.is_empty() {
1224 fields.push(("source", J::Str(r.source.clone())));
1225 }
1226 J::Obj(fields)
1227}
1228fn j_outbound_row(r: &query::OutboundRow) -> J {
1229 let mut fields = vec![
1230 ("file", J::Str(r.file.clone())),
1231 ("line", J::UInt(r.line as u64)),
1232 ("toFile", J::Str(r.to_file.clone())),
1233 ("to", J::Str(r.to.clone())),
1234 ];
1235 push_heuristic(&mut fields, r.heuristic);
1236 if !r.source.is_empty() {
1239 fields.push(("source", J::Str(r.source.clone())));
1240 }
1241 J::Obj(fields)
1242}
1243fn j_import_row(r: &query::ImportRow) -> J {
1244 let mut fields = vec![
1245 ("file", J::Str(r.file.clone())),
1246 ("line", J::UInt(r.line as u64)),
1247 ("target", J::Str(r.target.clone())),
1248 ];
1249 if !r.source.is_empty() {
1250 fields.push(("source", J::Str(r.source.clone())));
1251 }
1252 J::Obj(fields)
1253}
1254fn j_ambiguous_row(r: &query::AmbiguousRow) -> J {
1255 J::Obj(vec![
1256 ("file", J::Str(r.file.clone())),
1257 ("line", J::UInt(r.line as u64)),
1258 ("origin", J::Str(r.origin.clone())),
1259 ("raw", J::Str(r.raw.clone())),
1260 ("candidateCount", J::UInt(r.candidate_count as u64)),
1261 ])
1262}
1263
1264fn refs_model_to_json(model: &query::RefsModel) -> String {
1270 refs_model_j(model).to_json_string()
1271}
1272
1273fn read_model_to_json(model: &query::ReadModel) -> String {
1280 let mut fields = refs_model_fields(&model.refs);
1281 let tail = fields.split_off(4);
1284 if let Some(sp) = &model.span {
1285 fields.push((
1286 "span",
1287 J::Obj(vec![
1288 ("file", J::Str(sp.file.clone())),
1289 ("startLine", J::UInt(sp.start_line as u64)),
1290 ("endLine", J::UInt(sp.end_line as u64)),
1291 ("source", J::Str(sp.source.clone())),
1292 ]),
1293 ));
1294 }
1295 fields.extend(tail);
1296 J::Obj(fields).to_json_string()
1297}
1298
1299fn member_refs_to_json(query_str: &str, models: &[query::RefsModel]) -> String {
1303 J::Obj(vec![
1304 ("status", J::Str("members".to_string())),
1305 ("query", J::Str(query_str.to_string())),
1306 ("members", J::Arr(models.iter().map(refs_model_j).collect())),
1307 ])
1308 .to_json_string()
1309}
1310
1311fn refs_model_j(model: &query::RefsModel) -> J {
1312 J::Obj(refs_model_fields(model))
1313}
1314
1315fn refs_model_fields(model: &query::RefsModel) -> Vec<(&'static str, J)> {
1316 let mut fields = vec![
1317 ("status", J::Str("resolved".to_string())),
1318 ("query", J::Str(model.query.clone())),
1319 ("id", J::Str(model.id.clone())),
1320 ("kind", J::Str(model.kind.clone())),
1321 (
1322 "sites",
1323 J::Arr(
1324 model
1325 .sites
1326 .iter()
1327 .map(|s| {
1328 J::Obj(vec![
1329 ("file", J::Str(s.file.clone())),
1330 ("line", J::UInt(s.line as u64)),
1331 ])
1332 })
1333 .collect(),
1334 ),
1335 ),
1336 (
1337 "inbound",
1338 J::Obj(vec![
1339 ("inherits", j_table(&model.inbound.inherits, j_inbound_row)),
1340 (
1341 "uses-type",
1342 j_table(&model.inbound.uses_type, j_inbound_row),
1343 ),
1344 (
1345 "uses-member",
1346 j_table(&model.inbound.uses_member, j_inbound_row),
1347 ),
1348 ]),
1349 ),
1350 ];
1351 if let Some(ob) = &model.outbound {
1352 fields.push((
1353 "outbound",
1354 J::Obj(vec![
1355 ("inherits", j_table(&ob.inherits, j_outbound_row)),
1356 ("uses-type", j_table(&ob.uses_type, j_outbound_row)),
1357 ("uses-member", j_table(&ob.uses_member, j_outbound_row)),
1358 ("imports", j_table(&ob.imports, j_import_row)),
1359 ]),
1360 ));
1361 }
1362 fields.push((
1363 "ambiguous",
1364 J::Obj(vec![
1365 (
1366 "inbound",
1367 j_table(&model.ambiguous.inbound, j_ambiguous_row),
1368 ),
1369 (
1370 "outbound",
1371 j_table(&model.ambiguous.outbound, j_ambiguous_row),
1372 ),
1373 ]),
1374 ));
1375 fields.push(("manifestGap", J::UInt(model.manifest_gap as u64)));
1376 if let Some(m) = &model.member_refs {
1380 fields.push((
1381 "memberRefs",
1382 J::Obj(vec![
1383 ("total", J::UInt(m.total as u64)),
1384 ("memberCount", J::UInt(m.member_count as u64)),
1385 (
1386 "members",
1387 J::Arr(
1388 m.members
1389 .iter()
1390 .map(|e| {
1391 J::Obj(vec![
1392 ("name", J::Str(e.name.clone())),
1393 ("count", J::UInt(e.count as u64)),
1394 ])
1395 })
1396 .collect(),
1397 ),
1398 ),
1399 ("dropped", J::UInt(m.dropped as u64)),
1400 ]),
1401 ));
1402 }
1403 fields
1404}
1405
1406fn j_impact_row(r: &query::ImpactRow) -> J {
1407 let mut fields = vec![
1408 ("file", J::Str(r.file.clone())),
1409 ("hop", J::UInt(r.hop as u64)),
1410 ("viaCount", J::UInt(r.via_count as u64)),
1411 ("ambiguousCount", J::UInt(r.ambiguous_count as u64)),
1412 (
1413 "topSymbols",
1414 J::Arr(r.top_symbols.iter().map(|s| J::Str(s.clone())).collect()),
1415 ),
1416 ("topSymbolsMore", J::UInt(r.top_symbols_more as u64)),
1417 ("score", J::RawNum(js_float_string(r.score))),
1418 ];
1419 if r.heuristic {
1423 fields.push(("heuristicCount", J::UInt(r.heuristic_count as u64)));
1424 fields.push(("heuristic", J::Bool(true)));
1425 }
1426 if !r.iface_via.is_empty() {
1428 fields.push((
1429 "ifaceVia",
1430 J::Arr(r.iface_via.iter().map(|s| J::Str(s.clone())).collect()),
1431 ));
1432 }
1433 if !r.from_lines.is_empty() {
1437 fields.push((
1438 "fromLines",
1439 J::Obj(
1440 r.from_lines
1441 .iter()
1442 .map(|(kind, line)| (*kind, J::UInt(*line as u64)))
1443 .collect(),
1444 ),
1445 ));
1446 }
1447 if r.infra {
1450 fields.push(("class", J::Str("infra".to_string())));
1451 }
1452 J::Obj(fields)
1453}
1454
1455fn impact_model_to_json(query_str: &str, model: &query::ImpactModel) -> String {
1460 J::Obj(
1461 vec![
1462 ("query", J::Str(query_str.to_string())),
1463 ("status", J::Str("resolved".to_string())),
1464 (
1465 "kind",
1466 J::Str(render::seed_kind_str(model.kind).to_string()),
1467 ),
1468 (
1469 "seedFiles",
1470 J::Arr(model.seed_files.iter().map(|f| J::Str(f.clone())).collect()),
1471 ),
1472 ("hops", J::UInt(model.hops as u64)),
1473 ("totalAffected", J::UInt(model.total_affected as u64)),
1474 (
1475 "rows",
1476 J::Arr(model.rows.iter().map(j_impact_row).collect()),
1477 ),
1478 ("dropped", J::UInt(model.dropped as u64)),
1479 ("manifestGap", J::UInt(model.manifest_gap as u64)),
1480 (
1483 "heuristicAffected",
1484 J::UInt(model.heuristic_affected as u64),
1485 ),
1486 ("testsAffected", J::UInt(model.tests_affected as u64)),
1488 ]
1489 .into_iter()
1490 .chain(
1496 if model.braked.is_empty() && model.braked_files.is_empty() {
1497 None
1498 } else {
1499 Some((
1500 "braked",
1501 J::Arr(
1502 model
1503 .braked
1504 .iter()
1505 .map(|b| {
1506 J::Obj(vec![
1507 ("iface", J::Str(b.iface.clone())),
1508 ("fanin", J::UInt(b.fanin as u64)),
1509 ])
1510 })
1511 .chain(model.braked_files.iter().map(|b| {
1512 J::Obj(vec![
1513 ("file", J::Str(b.file.clone())),
1514 ("indegree", J::UInt(b.indegree as u64)),
1515 ])
1516 }))
1517 .collect(),
1518 ),
1519 ))
1520 },
1521 )
1522 .collect::<Vec<_>>(),
1523 )
1524 .to_json_string()
1525}
1526
1527fn tests_model_to_json(model: &query::TestsModel) -> String {
1532 J::Obj(vec![
1533 ("status", J::Str("resolved".to_string())),
1534 ("query", J::Str(model.query.clone())),
1535 ("symbol", J::Str(model.symbol.clone())),
1536 (
1537 "defFiles",
1538 J::Arr(model.def_files.iter().map(|f| J::Str(f.clone())).collect()),
1539 ),
1540 (
1541 "rows",
1542 J::Arr(
1543 model
1544 .rows
1545 .iter()
1546 .map(|r| {
1547 let mut fields = vec![
1548 ("file", J::Str(r.file.clone())),
1549 (
1550 "testDefs",
1551 J::Arr(r.test_defs.iter().map(|d| J::Str(d.clone())).collect()),
1552 ),
1553 (
1554 "lines",
1555 J::Arr(r.lines.iter().map(|l| J::UInt(*l as u64)).collect()),
1556 ),
1557 ("refCount", J::UInt(r.ref_count as u64)),
1558 ];
1559 push_heuristic(&mut fields, r.heuristic);
1560 J::Obj(fields)
1561 })
1562 .collect(),
1563 ),
1564 ),
1565 ("testFileCount", J::UInt(model.test_file_count as u64)),
1566 ("refCount", J::UInt(model.ref_count as u64)),
1567 (
1568 "heuristicFileCount",
1569 J::UInt(model.heuristic_file_count as u64),
1570 ),
1571 (
1572 "heuristicRefCount",
1573 J::UInt(model.heuristic_ref_count as u64),
1574 ),
1575 ])
1576 .to_json_string()
1577}
1578
1579#[cfg(test)]
1580mod tests {
1581 use super::*;
1582
1583 #[test]
1584 fn js_float_string_matches_node_json_stringify_on_checked_values() {
1585 assert_eq!(js_float_string(1.0), "1");
1587 assert_eq!(js_float_string(100.0), "100");
1588 assert_eq!(js_float_string(0.0), "0");
1589 assert_eq!(js_float_string(-0.0), "0");
1590 assert_eq!(js_float_string(0.15), "0.15");
1591 assert_eq!(js_float_string(0.1 + 0.2), "0.30000000000000004");
1592 assert_eq!(js_float_string(1.0 / 3.0), "0.3333333333333333");
1593 assert_eq!(js_float_string(1e21), "1e+21");
1594 assert_eq!(js_float_string(0.0000001), "1e-7");
1595 assert_eq!(js_float_string(f64::NAN), "null");
1596 assert_eq!(js_float_string(f64::INFINITY), "null");
1597 }
1598
1599 #[test]
1600 fn js_math_round_matches_node_math_round_on_checked_values() {
1601 assert_eq!(js_math_round(0.0), 0);
1603 assert_eq!(js_math_round(1.4), 1);
1604 assert_eq!(js_math_round(1.5), 2);
1605 assert_eq!(js_math_round(1.49999), 1);
1606 assert_eq!(js_math_round(2.5), 3);
1607 assert_eq!(js_math_round(100.0 / 4.0), 25);
1608 assert_eq!(js_math_round(101.0 / 4.0), 25);
1609 assert_eq!(js_math_round(103.0 / 4.0), 26);
1610 }
1611
1612 #[test]
1613 fn parse_int_js_matches_node_parseint_on_checked_values() {
1614 assert_eq!(parse_int_js("3"), Some(3));
1615 assert_eq!(parse_int_js("3abc"), Some(3));
1616 assert_eq!(parse_int_js("-5"), Some(-5));
1617 assert_eq!(parse_int_js(" 7"), Some(7));
1618 assert_eq!(parse_int_js(""), None);
1619 assert_eq!(parse_int_js("abc"), None);
1620 assert_eq!(parse_int_js("-"), None);
1621 }
1622
1623 #[test]
1624 fn j_object_and_array_serialize_with_no_extra_whitespace_like_json_stringify() {
1625 let j = J::Obj(vec![
1626 ("a", J::UInt(1)),
1627 ("b", J::Arr(vec![J::Str("x".to_string()), J::UInt(2)])),
1628 ]);
1629 assert_eq!(j.to_json_string(), r#"{"a":1,"b":["x",2]}"#);
1630 }
1631
1632 #[test]
1633 fn j_string_escapes_control_chars_and_quotes_like_json_stringify() {
1634 let j = J::Str("a\"b\\c\nd".to_string());
1635 assert_eq!(j.to_json_string(), r#""a\"b\\c\nd""#);
1636 }
1637
1638 fn json_refs_model(rows: Vec<query::InboundRow>, out: bool) -> query::RefsModel {
1646 let empty_in = || query::Table {
1647 total: 0,
1648 dropped: 0,
1649 rows: Vec::<query::InboundRow>::new(),
1650 };
1651 let empty_out = || query::Table {
1652 total: 0,
1653 dropped: 0,
1654 rows: Vec::<query::OutboundRow>::new(),
1655 };
1656 query::RefsModel {
1657 query: "Widget".to_string(),
1658 id: "App.Widget".to_string(),
1659 kind: "class".to_string(),
1660 sites: vec![query::DefSite {
1661 file: "src/Widget.cs".to_string(),
1662 line: 3,
1663 }],
1664 inbound: query::InboundTables {
1665 inherits: empty_in(),
1666 uses_type: empty_in(),
1667 uses_member: query::Table {
1668 total: rows.len(),
1669 dropped: 0,
1670 rows,
1671 },
1672 },
1673 outbound: out.then(|| query::OutboundTables {
1674 inherits: empty_out(),
1675 uses_type: empty_out(),
1676 uses_member: empty_out(),
1677 imports: query::Table {
1678 total: 0,
1679 dropped: 0,
1680 rows: Vec::new(),
1681 },
1682 }),
1683 ambiguous: query::AmbiguousTables {
1684 inbound: query::Table {
1685 total: 0,
1686 dropped: 0,
1687 rows: Vec::new(),
1688 },
1689 outbound: query::Table {
1690 total: 0,
1691 dropped: 0,
1692 rows: Vec::new(),
1693 },
1694 },
1695 manifest_gap: 0,
1696 member_refs: None,
1697 }
1698 }
1699
1700 #[test]
1701 fn refs_json_appends_heuristic_then_source_last_and_omits_each_when_it_has_no_value() {
1702 let model = json_refs_model(
1703 vec![
1704 query::InboundRow {
1705 file: "src/Fact.cs".into(),
1706 line: 4,
1707 heuristic: false,
1708 source: String::new(),
1709 },
1710 query::InboundRow {
1711 file: "src/Guess.cs".into(),
1712 line: 9,
1713 heuristic: true,
1714 source: "var w = new Widget();".into(),
1715 },
1716 ],
1717 true,
1718 );
1719 let json = refs_model_to_json(&model);
1720 assert!(
1721 json.contains(
1722 r#""rows":[{"file":"src/Fact.cs","line":4},{"file":"src/Guess.cs","line":9,"heuristic":true,"source":"var w = new Widget();"}]"#
1723 ),
1724 "{json}"
1725 );
1726 }
1727
1728 #[test]
1729 fn refs_json_omits_the_outbound_key_entirely_without_out_and_keeps_its_slot_with_it() {
1730 let row = || {
1731 vec![query::InboundRow {
1732 file: "src/Fact.cs".into(),
1733 line: 4,
1734 heuristic: false,
1735 source: String::new(),
1736 }]
1737 };
1738 let without = refs_model_to_json(&json_refs_model(row(), false));
1739 assert!(
1740 !without.contains(r#""outbound":{"inherits""#),
1741 "the default model must carry no outbound tables: {without}"
1742 );
1743 assert!(without.contains(r#""ambiguous":{"inbound""#), "{without}");
1744
1745 let with = refs_model_to_json(&json_refs_model(row(), true));
1746 let outbound_at = with
1747 .find(r#""outbound":{"inherits""#)
1748 .expect("--out must emit the outbound tables");
1749 let inbound_at = with
1750 .find(r#""inbound":{"inherits""#)
1751 .expect("inbound is always emitted");
1752 let ambiguous_at = with
1753 .find(r#""ambiguous":{"inbound""#)
1754 .expect("ambiguous is always emitted");
1755 assert!(
1756 inbound_at < outbound_at && outbound_at < ambiguous_at,
1757 "outbound keeps JS's key slot: {with}"
1758 );
1759 }
1760
1761 #[test]
1762 fn member_refs_json_wraps_unchanged_resolved_models_under_status_query_members() {
1763 let row = || {
1764 vec![query::InboundRow {
1765 file: "src/Fact.cs".into(),
1766 line: 4,
1767 heuristic: false,
1768 source: String::new(),
1769 }]
1770 };
1771 let one = json_refs_model(row(), false);
1772 let json = member_refs_to_json("Widget", std::slice::from_ref(&one));
1773 assert!(
1774 json.starts_with(
1775 r#"{"status":"members","query":"Widget","members":[{"status":"resolved""#
1776 ),
1777 "{json}"
1778 );
1779 assert!(json.ends_with("]}"), "{json}");
1780 assert!(
1781 json.contains(&refs_model_to_json(&one)),
1782 "a member entry is the resolved object unchanged: {json}"
1783 );
1784 }
1785
1786 #[test]
1787 fn impact_json_appends_heuristic_count_then_heuristic_after_score_and_heuristic_affected_after_manifest_gap(
1788 ) {
1789 let row = |file: &str, heuristic: bool| query::ImpactRow {
1790 file: file.to_string(),
1791 hop: 1,
1792 via_count: if heuristic { 0 } else { 1 },
1793 ambiguous_count: 0,
1794 top_symbols: vec!["Widget".to_string()],
1795 top_symbols_more: 0,
1796 score: 0.5,
1797 heuristic_count: if heuristic { 2 } else { 0 },
1798 heuristic,
1799 iface_via: vec![],
1800 from_lines: vec![],
1801 infra: false,
1802 };
1803 let model = query::ImpactModel {
1804 kind: query::SeedKind::Symbol,
1805 seed_files: vec!["src/Widget.cs".to_string()],
1806 hops: 2,
1807 total_affected: 1,
1808 rows: vec![row("src/Direct.cs", false), row("src/Guessed.cs", true)],
1809 dropped: 0,
1810 manifest_gap: 0,
1811 heuristic_affected: 1,
1812 tests_affected: 0,
1813 braked: vec![],
1814 braked_files: vec![],
1815 };
1816 let json = impact_model_to_json("Widget", &model);
1817 assert!(
1818 json.contains(
1819 r#"{"file":"src/Direct.cs","hop":1,"viaCount":1,"ambiguousCount":0,"topSymbols":["Widget"],"topSymbolsMore":0,"score":0.5}"#
1820 ),
1821 "{json}"
1822 );
1823 assert!(
1824 json.contains(
1825 r#"{"file":"src/Guessed.cs","hop":1,"viaCount":0,"ambiguousCount":0,"topSymbols":["Widget"],"topSymbolsMore":0,"score":0.5,"heuristicCount":2,"heuristic":true}"#
1826 ),
1827 "{json}"
1828 );
1829 assert!(
1830 json.ends_with(
1831 r#","dropped":0,"manifestGap":0,"heuristicAffected":1,"testsAffected":0}"#
1832 ),
1833 "{json}"
1834 );
1835 }
1836
1837 fn find_cap_root(prefix: &str, entry_count: usize) -> PathBuf {
1840 let root = std::env::temp_dir().join(format!("scout-cli-{prefix}-{}", std::process::id()));
1841 let _ = std::fs::remove_dir_all(&root);
1842 let scout = root.join(".scout");
1843 std::fs::create_dir_all(&scout).unwrap();
1844 let entries: Vec<String> = (0..entry_count)
1845 .map(|i| {
1846 format!(
1847 r#""src/widget-{i:02}.cs": {{ "purpose": "widget number {i}", "mtime": {i} }}"#
1848 )
1849 })
1850 .collect();
1851 let json = format!(r#"{{ "entries": {{ {} }} }}"#, entries.join(", "));
1852 std::fs::write(scout.join("manifest.json"), json).unwrap();
1853 root
1854 }
1855
1856 #[test]
1857 fn cmd_find_caps_full_pool_at_25_with_honest_tail() {
1858 let root = find_cap_root("findcap-full", 30);
1859 let (code, out) = cmd_find(&root, "widget", false);
1860 assert_eq!(code, 0);
1861 let lines: Vec<&str> = out.split('\n').collect();
1862 assert_eq!(lines.len(), 26);
1863 assert_eq!(lines[25], "… +5 more (refine query)");
1864 assert!(lines[..25].iter().all(|l| l.contains("widget")));
1865 }
1866
1867 #[test]
1868 fn cmd_find_caps_fallback_pool_at_10_with_honest_tail() {
1869 let root = find_cap_root("findcap-fallback", 12);
1870 let (code, out) = cmd_find(&root, "widget zzz123nomatch", false);
1871 assert_eq!(code, 0);
1872 let lines: Vec<&str> = out.split('\n').collect();
1873 assert_eq!(lines.len(), 11);
1874 assert_eq!(lines[10], "… +2 more (refine query)");
1875 }
1876
1877 #[test]
1881 fn cmd_find_reports_a_zero_hit_on_its_own_exit_code_with_stdout_unchanged() {
1882 let root = find_cap_root("findcap-zero", 5);
1883 let (code, out) = cmd_find(&root, "zzz123nosuchpurpose", false);
1884 assert_eq!(code, EXIT_NO_RESULT);
1885 assert_eq!(
1886 out,
1887 "no matches for \"zzz123nosuchpurpose\" (run 'devscout map' if manifest is missing)"
1888 );
1889 }
1890
1891 #[test]
1892 fn cmd_find_below_cap_prints_no_tail() {
1893 let root = find_cap_root("findcap-small", 5);
1894 let (code, out) = cmd_find(&root, "widget", false);
1895 assert_eq!(code, 0);
1896 assert_eq!(out.split('\n').count(), 5);
1897 assert!(!out.contains("more (refine query)"));
1898 }
1899
1900 const LEDGER_CS: &str = "namespace Gadgets;\n\npublic class WidgetLedger\n{\n\tprivate int _entryCount;\n\n\tpublic string Label { get; set; }\n\n\tpublic event EventHandler Retired;\n\n\tprivate void PopulateSlots() { }\n}\n";
1903 const PANEL_XAML: &str = "<UserControl\n\tx:Class=\"Gadgets.PanelView\">\n\t<Button x:Name=\"ShipButton\" />\n</UserControl>\n";
1904 const STRINGS_RESW: &str =
1905 "<root>\n\t<resheader name=\"resmimetype\" />\n\t<data name=\"ShipButton.Content\" xml:space=\"preserve\" />\n</root>\n";
1906
1907 fn name_index_root(prefix: &str) -> PathBuf {
1911 let root = std::env::temp_dir().join(format!("scout-cli-{prefix}-{}", std::process::id()));
1912 let _ = std::fs::remove_dir_all(&root);
1913 std::fs::create_dir_all(root.join("src")).unwrap();
1914 std::fs::write(root.join("src").join("WidgetLedger.cs"), LEDGER_CS).unwrap();
1915 std::fs::write(root.join("src").join("Panel.xaml"), PANEL_XAML).unwrap();
1916 std::fs::write(root.join("src").join("Strings.resw"), STRINGS_RESW).unwrap();
1917 std::fs::create_dir_all(root.join(".scout")).unwrap();
1918 std::fs::write(
1919 root.join(".scout").join("manifest.json"),
1920 r#"{ "entries": {} }"#,
1921 )
1922 .unwrap();
1923
1924 let fragments = vec![
1925 (
1926 "src/WidgetLedger.cs".to_string(),
1927 graph::fragment_from_extraction(&crate::extract::extract(LEDGER_CS)),
1928 ),
1929 (
1930 "src/Panel.xaml".to_string(),
1931 graph::markup_fragment(&root, "src/Panel.xaml").unwrap(),
1932 ),
1933 (
1934 "src/Strings.resw".to_string(),
1935 graph::markup_fragment(&root, "src/Strings.resw").unwrap(),
1936 ),
1937 ];
1938 let g = crate::resolve::resolve_graph(&root, &fragments);
1939 std::fs::create_dir_all(root.join(".scout").join("graph")).unwrap();
1940 std::fs::write(
1941 root.join(".scout").join("graph").join("graph.json"),
1942 serde_json::to_string(&g).unwrap(),
1943 )
1944 .unwrap();
1945 root
1946 }
1947
1948 #[test]
1949 fn cmd_find_resolves_every_code_and_markup_category_to_its_declaration_line() {
1950 let root = name_index_root("findnames");
1954 for (query, expected) in [
1955 (
1956 "PopulateSlots",
1957 "src/WidgetLedger.cs:11 private void PopulateSlots() { }",
1958 ),
1959 (
1960 "Label",
1961 "src/WidgetLedger.cs:7 public string Label { get; set; }",
1962 ),
1963 (
1964 "_entryCount",
1965 "src/WidgetLedger.cs:5 private int _entryCount;",
1966 ),
1967 (
1968 "Retired",
1969 "src/WidgetLedger.cs:9 public event EventHandler Retired;",
1970 ),
1971 (
1972 "Gadgets.PanelView",
1973 "src/Panel.xaml:2 x:Class=\"Gadgets.PanelView\">",
1974 ),
1975 ] {
1976 let (code, out) = cmd_find(&root, query, false);
1977 assert_eq!(code, 0, "{query} should be a hit");
1978 assert_eq!(out, expected, "{query}");
1979 }
1980 }
1981
1982 #[test]
1985 fn cmd_find_a_resource_key_only_query_brakes_correctly_by_default_and_is_a_hit_under_resources()
1986 {
1987 let root = name_index_root("findnames-resource-only");
1993 let (code, out) = cmd_find(&root, "ShipButton.Content", false);
1994 assert_eq!(
1995 code, EXIT_NO_RESULT,
1996 "a resource-key-only match is a zero hit by default"
1997 );
1998 assert_eq!(
1999 out,
2000 "no matches for \"ShipButton.Content\" (run 'devscout map' if manifest is missing)"
2001 );
2002
2003 let (code, out) = cmd_find(&root, "ShipButton.Content", true);
2004 assert_eq!(code, 0, "--resources lifts the demotion");
2005 assert_eq!(
2006 out,
2007 "src/Strings.resw:3 <data name=\"ShipButton.Content\" xml:space=\"preserve\" />"
2008 );
2009 }
2010
2011 #[test]
2012 fn cmd_find_default_view_hides_resource_keys_behind_a_trailer_and_resources_shows_them_inline()
2013 {
2014 let root = name_index_root("findnames-tiering");
2019 let (code, out) = cmd_find(&root, "ShipButton", false);
2020 assert_eq!(code, 0);
2021 assert_eq!(
2022 out,
2023 "src/Panel.xaml:3 <Button x:Name=\"ShipButton\" />\n+1 resource-key hits, use --resources",
2024 "tier 3 is a trailer, never an inline row, by default"
2025 );
2026
2027 let (code, out) = cmd_find(&root, "ShipButton", true);
2028 assert_eq!(code, 0);
2029 assert_eq!(
2030 out,
2031 "src/Panel.xaml:3 <Button x:Name=\"ShipButton\" />\nsrc/Strings.resw:3 <data name=\"ShipButton.Content\" xml:space=\"preserve\" />",
2032 "--resources includes the resource-key row inline and drops the trailer"
2033 );
2034 }
2035
2036 #[test]
2037 fn cmd_find_puts_the_declaration_block_above_the_manifest_block() {
2038 let root = name_index_root("findnames-order");
2039 std::fs::write(
2040 root.join(".scout").join("manifest.json"),
2041 r#"{ "entries": { "src/WidgetLedger.cs": { "purpose": "class WidgetLedger", "mtime": 1 } } }"#,
2042 )
2043 .unwrap();
2044 let (code, out) = cmd_find(&root, "WidgetLedger", false);
2045 assert_eq!(code, 0);
2046 assert_eq!(
2047 out,
2048 "src/WidgetLedger.cs:3 public class WidgetLedger\nsrc/WidgetLedger.cs:3: class WidgetLedger"
2049 );
2050 }
2051
2052 #[test]
2058 fn cmd_find_manifest_pool_row_carries_its_files_first_declaration_line() {
2059 let root = name_index_root("findnames-manifest-line");
2060 std::fs::write(
2061 root.join(".scout").join("manifest.json"),
2062 r#"{ "entries": { "src/WidgetLedger.cs": { "purpose": "class WidgetLedger; Ship", "mtime": 1 } } }"#,
2063 )
2064 .unwrap();
2065 let (code, out) = cmd_find(&root, "WidgetLedger", false);
2066 assert_eq!(code, 0);
2067 let lines: Vec<&str> = out.split('\n').collect();
2068 let manifest_row = lines
2069 .iter()
2070 .find(|l| l.starts_with("src/WidgetLedger.cs:") && l.contains("; Ship"));
2071 assert_eq!(
2072 manifest_row,
2073 Some(&"src/WidgetLedger.cs:3: class WidgetLedger; Ship"),
2074 "{out}"
2075 );
2076 }
2077
2078 #[test]
2082 fn cmd_find_manifest_pool_row_for_a_file_with_no_declaration_falls_back_to_line_1() {
2083 let root = name_index_root("findnames-manifest-no-decl");
2084 std::fs::write(root.join("src").join("Notes.md"), "# widget notes\n").unwrap();
2085 std::fs::write(
2086 root.join(".scout").join("manifest.json"),
2087 r#"{ "entries": { "src/Notes.md": { "purpose": "widget notes doc", "mtime": 1 } } }"#,
2088 )
2089 .unwrap();
2090 let (code, out) = cmd_find(&root, "widget", false);
2091 assert_eq!(code, 0);
2092 assert!(out.contains("src/Notes.md:1: widget notes doc"), "{out}");
2093 }
2094
2095 #[test]
2096 fn cmd_find_still_takes_the_zero_hit_exit_when_neither_index_matches() {
2097 let root = name_index_root("findnames-zero");
2098 let (code, out) = cmd_find(&root, "Zzzznomatch", false);
2099 assert_eq!(code, EXIT_NO_RESULT);
2100 assert_eq!(
2101 out,
2102 "no matches for \"Zzzznomatch\" (run 'devscout map' if manifest is missing)"
2103 );
2104 }
2105
2106 fn clear_root(prefix: &str) -> PathBuf {
2112 let root = std::env::temp_dir().join(format!("scout-cli-{prefix}-{}", std::process::id()));
2113 let _ = std::fs::remove_dir_all(&root);
2114 std::fs::create_dir_all(root.join(".scout")).unwrap();
2115 root
2116 }
2117
2118 fn seed_row(conn: &rusqlite::Connection, session_id: &str, rel_path: &str) {
2119 store::record_fresh(
2120 conn,
2121 &store::RecordFresh {
2122 session_id,
2123 agent_id: "",
2124 rel_path,
2125 sha256: "sha",
2126 size: 10,
2127 mtime: 1,
2128 lines: 1,
2129 delivered: true,
2130 },
2131 )
2132 .unwrap();
2133 }
2134
2135 #[test]
2136 fn cmd_clear_whole_store_removes_cache_db_and_answers_cache_cleared() {
2137 let root = clear_root("clear-whole");
2138 {
2139 let conn = store::open_store(&root).unwrap();
2140 seed_row(&conn, "s1", "a.ts");
2141 }
2142 assert!(root.join(".scout").join("cache.db").exists());
2143 let (code, out) = cmd_clear(&root, &[]);
2144 assert_eq!(code, 0);
2145 assert_eq!(out, "cache cleared");
2146 assert!(!root.join(".scout").join("cache.db").exists());
2147 }
2148
2149 #[test]
2150 fn cmd_clear_older_than_rejects_negative_and_non_numeric_with_usage_code_2() {
2151 let root = clear_root("clear-older-bad");
2152 for bad in ["-5", "abc", ""] {
2153 let (code, out) = cmd_clear(&root, &["--older-than".to_string(), bad.to_string()]);
2154 assert_eq!(code, 2, "arg {bad:?}");
2155 assert_eq!(
2156 out, "usage: devscout clear --older-than <days>",
2157 "arg {bad:?}"
2158 );
2159 }
2160 let (code, out) = cmd_clear(&root, &["--older-than".to_string()]);
2162 assert_eq!(code, 2);
2163 assert_eq!(out, "usage: devscout clear --older-than <days>");
2164 assert!(
2165 !root.join(".scout").join("cache.db").exists(),
2166 "a rejected flag must not create a store"
2167 );
2168 }
2169
2170 #[test]
2171 fn cmd_clear_session_unique_prefix_deletes_only_that_sessions_rows() {
2172 let root = clear_root("clear-session-unique");
2173 {
2174 let conn = store::open_store(&root).unwrap();
2175 seed_row(&conn, "abc-1111", "a.ts");
2176 seed_row(&conn, "abc-2222", "b.ts");
2177 }
2178 let (code, out) = cmd_clear(&root, &["--session".to_string(), "abc-1".to_string()]);
2179 assert_eq!(code, 0);
2180 assert_eq!(out, "deleted 1 row for session abc-1111");
2181
2182 let conn = store::open_store(&root).unwrap();
2183 assert!(store::lookup_read(&conn, "abc-1111", "a.ts", "")
2184 .unwrap()
2185 .is_none());
2186 assert!(store::lookup_read(&conn, "abc-2222", "b.ts", "")
2187 .unwrap()
2188 .is_some());
2189 }
2190
2191 #[test]
2195 fn cmd_clear_session_ambiguous_prefix_refuses_with_code_2_and_lists_both_ids() {
2196 let root = clear_root("clear-session-ambiguous");
2197 {
2198 let conn = store::open_store(&root).unwrap();
2199 seed_row(&conn, "abc-1111", "a.ts");
2200 seed_row(&conn, "abc-2222", "b.ts");
2201 }
2202 let (code, out) = cmd_clear(&root, &["--session".to_string(), "abc".to_string()]);
2203 assert_eq!(code, 2);
2204 assert_eq!(out, "ambiguous session prefix \"abc\": abc-1111, abc-2222");
2205
2206 let conn = store::open_store(&root).unwrap();
2208 assert!(store::lookup_read(&conn, "abc-1111", "a.ts", "")
2209 .unwrap()
2210 .is_some());
2211 assert!(store::lookup_read(&conn, "abc-2222", "b.ts", "")
2212 .unwrap()
2213 .is_some());
2214 }
2215
2216 #[test]
2217 fn cmd_clear_session_no_match_is_a_success_not_a_refusal() {
2218 let root = clear_root("clear-session-none");
2219 {
2220 let conn = store::open_store(&root).unwrap();
2221 seed_row(&conn, "abc-1111", "a.ts");
2222 }
2223 let (code, out) = cmd_clear(&root, &["--session".to_string(), "zzz".to_string()]);
2224 assert_eq!(code, 0);
2225 assert_eq!(out, "no sessions match \"zzz\"");
2226 }
2227
2228 #[test]
2229 fn cmd_clear_session_missing_value_is_a_usage_error() {
2230 let root = clear_root("clear-session-missing");
2231 let (code, out) = cmd_clear(&root, &["--session".to_string()]);
2232 assert_eq!(code, 2);
2233 assert_eq!(out, "usage: devscout clear --session <id-or-prefix>");
2234 }
2235
2236 #[test]
2237 fn cmd_clear_older_than_flag_takes_priority_over_session_flag_like_node_indexof_order() {
2238 let root = clear_root("clear-precedence");
2241 {
2242 let conn = store::open_store(&root).unwrap();
2243 seed_row(&conn, "abc-1111", "a.ts");
2244 }
2245 let (code, out) = cmd_clear(
2246 &root,
2247 &[
2248 "--session".to_string(),
2249 "abc-1111".to_string(),
2250 "--older-than".to_string(),
2251 "9999".to_string(),
2252 ],
2253 );
2254 assert_eq!(code, 0);
2255 assert_eq!(
2256 out, "deleted 0 rows older than 9999d",
2257 "the --older-than branch must win"
2258 );
2259 }
2260}