1use std::io::{Read, Write};
13use std::path::{Path, PathBuf};
14use std::process;
15use std::time::Instant;
16
17use crate::audit;
18use crate::extract;
19use crate::graph;
20use crate::hookio;
21use crate::initcmd;
22use crate::manifest;
23use crate::mapcmd;
24use crate::parse;
25use crate::query;
26use crate::query::json::J;
27use crate::render;
28use crate::store;
29use crate::suggest;
30use crate::telemetry;
31
32fn print_out(out: &str) {
39 use std::io::Write;
40 let stdout = std::io::stdout();
41 let mut lock = stdout.lock();
42 let _ = lock
43 .write_all(out.as_bytes())
44 .and_then(|()| lock.write_all(b"\n"));
45}
46
47const EXIT_NO_RESULT: i32 = 3;
51
52const 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.";
56const 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.";
57const 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.";
58const 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.";
59const 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.";
60
61fn emit_zero_hit_note(code: i32, note: Option<&str>, cwd: &Path, query: Option<&str>) {
69 let Some(note) = note else { return };
70 if code != EXIT_NO_RESULT {
71 return;
72 }
73 let mut text = String::from(note);
74 let rows = query.map(|q| nearest_names(cwd, q)).unwrap_or_default();
75 if !rows.is_empty() {
76 text.push_str("\ndid you mean:");
77 for row in rows {
78 text.push('\n');
79 text.push_str(&row);
80 }
81 }
82 let stderr = std::io::stderr();
83 let mut lock = stderr.lock();
84 let _ = lock
85 .write_all(text.as_bytes())
86 .and_then(|()| lock.write_all(b"\n"));
87}
88
89fn emit_freshness_warning(cwd: &Path) {
98 let Some(root) = crate::repo::find_scout_root(cwd).or_else(|| crate::repo::find_repo_root(cwd))
99 else {
100 return;
101 };
102 let Some(warning) = manifest::freshness_warning(&root) else {
103 return;
104 };
105 let stderr = std::io::stderr();
106 let mut lock = stderr.lock();
107 let _ = lock
108 .write_all(warning.as_bytes())
109 .and_then(|()| lock.write_all(b"\n"));
110}
111
112fn nearest_names(cwd: &Path, query: &str) -> Vec<String> {
117 let Ok(root) = require_repo(cwd) else {
118 return Vec::new();
119 };
120 let Some(g) = graph::read_graph(&root) else {
121 return Vec::new();
122 };
123 suggest::suggestion_lines(&g.names, query)
124}
125
126fn first_positional(args: &[String]) -> Option<&str> {
133 let mut skip_next = false;
134 for a in args {
135 if skip_next {
136 skip_next = false;
137 continue;
138 }
139 if a == "--pick" {
140 skip_next = true;
141 continue;
142 }
143 if !a.starts_with("--") {
144 return Some(a.as_str());
145 }
146 }
147 None
148}
149
150const HELP: &str = "\
154devscout -- fast code index for C# and TypeScript codebases.
155
156usage: devscout <command> [args]
157
158index
159 init [scope ...] register this repo, install hooks, first map
160 [--label L] [--no-hooks] [--no-map]
161 map [scope ...] [--refresh] build the index for the given scopes
162 clear --older-than <days> drop freshness rows older than N days
163 clear --session <id> drop one session's freshness rows
164
165query
166 find <query> [--resources] search the manifest by name or purpose
167 refs <symbol> references to a symbol [--out --all --no-guess --pick N --json|--compact]
168 read <symbol> decl span + inbound refs [--no-guess --pick N --json|--compact]
169 impact <file|symbol> blast radius [--hops N --no-guess --pick N --json|--compact]
170 tests <symbol> tests reaching a symbol [--no-guess --pick N --json|--compact]
171 stats index + cache summary for this repo
172
173plumbing
174 parse <file.cs> dump the parse tree
175 spans <file.cs> dump declaration spans
176 extract-dump <file.cs> dump extraction records
177 audit --semantic <refs.jsonl> score uses-member edges against a semantic oracle [--units F] [--defs F] [--json] [--assert F]
178 hook <read|bash> agent hook filters, stdin -> stdout
179 noop exit 0 (harness probe)
180
181 -C <dir> run as if devscout started in <dir>
182 -h, --help show this help
183 -V, --version show the version
184";
185
186#[allow(
188 clippy::too_many_lines,
189 reason = "one flat match over every subcommand; splitting it would scatter the dispatch table across files"
190)]
191pub fn dispatch(args: Vec<String>) {
192 let (cwd, args) = match apply_global_options(¤t_dir(), &args) {
193 Ok(v) => v,
194 Err(e) => {
195 print_out(&format!("error: {e}"));
196 process::exit(1);
197 }
198 };
199 match args.get(1).map(String::as_str) {
200 Some("noop") => process::exit(0),
201 Some("help") | Some("--help") | Some("-h") => {
202 print!("{HELP}");
203 process::exit(0);
204 }
205 Some("--version") | Some("-V") => {
206 println!("devscout {}", env!("CARGO_PKG_VERSION"));
207 process::exit(0);
208 }
209 Some("parse") => {
210 let Some(path) = args.get(2) else {
211 eprintln!("usage: devscout parse <file.cs>");
212 process::exit(1);
213 };
214 parse::run_parse(path);
215 }
216 Some("spans") => {
217 let Some(path) = args.get(2) else {
218 eprintln!("usage: devscout spans <file.cs>");
219 process::exit(1);
220 };
221 parse::run_spans(path);
222 }
223 Some("extract-dump") => {
224 let Some(path) = args.get(2) else {
225 eprintln!("usage: devscout extract-dump <file.cs>");
226 process::exit(1);
227 };
228 extract::run_extract_dump(path);
229 }
230 Some("audit") => {
231 let (code, out) = audit::cmd_audit(&cwd, &args[2..]);
232 print_out(&out);
233 process::exit(code);
234 }
235 Some("hook") => match args.get(2).map(String::as_str) {
236 Some("read") => run_hook(hookio::run_read),
237 Some("bash") => run_hook(hookio::run_bash),
238 _ => {
239 eprintln!("usage: devscout hook <read|bash>");
240 process::exit(1);
241 }
242 },
243 Some("refs") => {
244 let (code, out, note) = cmd_refs(&cwd, &args[2..]);
245 print_out(&out);
246 emit_freshness_warning(&cwd);
247 emit_zero_hit_note(code, note, &cwd, first_positional(&args[2..]));
248 process::exit(code);
249 }
250 Some("read") => {
251 let (code, out, note) = cmd_read(&cwd, &args[2..]);
252 print_out(&out);
253 emit_freshness_warning(&cwd);
254 emit_zero_hit_note(code, note, &cwd, first_positional(&args[2..]));
255 process::exit(code);
256 }
257 Some("impact") => {
258 let (code, out) = cmd_impact(&cwd, &args[2..]);
259 print_out(&out);
260 emit_freshness_warning(&cwd);
261 emit_zero_hit_note(code, Some(ZERO_HIT_IMPACT), &cwd, None);
262 process::exit(code);
263 }
264 Some("tests") => {
265 let (code, out) = cmd_tests(&cwd, &args[2..]);
266 print_out(&out);
267 emit_freshness_warning(&cwd);
268 emit_zero_hit_note(code, Some(ZERO_HIT_TESTS), &cwd, None);
269 process::exit(code);
270 }
271 Some("find") => {
272 let resources = args[2..].iter().any(|a| a == "--resources");
276 let query_str = args[2..]
277 .iter()
278 .filter(|a| a.as_str() != "--resources")
279 .cloned()
280 .collect::<Vec<_>>()
281 .join(" ");
282 let (code, out) = cmd_find(&cwd, &query_str, resources);
283 print_out(&out);
284 emit_freshness_warning(&cwd);
285 emit_zero_hit_note(code, Some(ZERO_HIT_FIND), &cwd, Some(query_str.as_str()));
286 process::exit(code);
287 }
288 Some("map") => {
289 let (code, out) = cmd_map(&cwd, &args[2..]);
290 print_out(&out);
291 process::exit(code);
292 }
293 Some("stats") => {
294 let (code, out) = cmd_stats(&cwd);
295 print_out(&out);
296 process::exit(code);
297 }
298 Some("clear") => {
299 let (code, out) = cmd_clear(&cwd, &args[2..]);
300 print_out(&out);
301 process::exit(code);
302 }
303 Some("init") => {
304 let (code, out) = initcmd::cmd_init_full(&cwd, &args[2..]);
305 print_out(&out);
306 process::exit(code);
307 }
308 _ => {
309 eprintln!("usage: devscout <noop|parse|spans|extract-dump|hook|refs|read|impact|tests|find|map|stats|clear|init> [args]");
310 process::exit(1);
311 }
312 }
313}
314
315fn run_hook(handler: fn(&[u8]) -> Vec<u8>) {
322 let mut buf = Vec::new();
323 let _ = std::io::stdin().read_to_end(&mut buf);
324 let out = handler(&buf);
325 if !out.is_empty() {
326 let _ = std::io::stdout().write_all(&out);
327 }
328 process::exit(0);
329}
330
331fn current_dir() -> PathBuf {
340 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
341}
342
343pub(crate) fn require_repo(cwd: &Path) -> Result<PathBuf, String> {
350 require_repo_for_path(cwd, None)
351}
352
353fn require_repo_for_path(cwd: &Path, arg_path: Option<&str>) -> Result<PathBuf, String> {
358 crate::repo::find_scout_root(cwd)
359 .or_else(|| crate::repo::find_repo_root(cwd))
360 .or_else(|| root_from_arg(cwd, arg_path))
361 .ok_or_else(|| {
362 "no .scout or .git ancestor; run 'devscout init' from the repo or directory root"
363 .to_string()
364 })
365}
366
367fn root_from_arg(cwd: &Path, arg_path: Option<&str>) -> Option<PathBuf> {
372 let arg_path = arg_path?;
373 if !query::looks_like_file_path(arg_path) {
374 return None;
375 }
376 let abs = crate::repo::resolve_from(cwd, Path::new(arg_path));
377 if !abs.exists() {
378 return None;
379 }
380 crate::repo::find_scout_root(&abs).or_else(|| crate::repo::find_repo_root(&abs))
381}
382
383fn repo_relative_arg(cwd: &Path, root: &Path, arg: &str) -> String {
390 if !query::looks_like_file_path(arg) {
391 return arg.to_string();
392 }
393 let abs = crate::repo::resolve_from(cwd, Path::new(arg));
394 if !abs.exists() {
395 return arg.to_string();
396 }
397 match abs.strip_prefix(root) {
398 Ok(rest) if !rest.as_os_str().is_empty() => crate::repo::rel_path(root, &abs),
399 _ => arg.to_string(),
400 }
401}
402
403fn apply_global_options(cwd: &Path, args: &[String]) -> Result<(PathBuf, Vec<String>), String> {
409 let mut cwd = cwd.to_path_buf();
410 let mut idx = 1;
411 while args.get(idx).map(String::as_str) == Some("-C") {
412 let Some(dir) = args.get(idx + 1) else {
413 return Err("no directory given for '-C' option".to_string());
414 };
415 let next = crate::repo::resolve_from(&cwd, Path::new(dir));
416 if !next.is_dir() {
417 return Err(format!("cannot change to '{dir}': no such directory"));
418 }
419 cwd = next;
420 idx += 2;
421 }
422 if idx == 1 {
423 return Ok((cwd, args.to_vec()));
424 }
425 let mut rest = vec![args[0].clone()];
426 rest.extend_from_slice(&args[idx..]);
427 Ok((cwd, rest))
428}
429
430fn ambiguous_json(q: &str, candidates: Vec<J>) -> String {
434 J::Obj(vec![
435 ("schema_version", J::UInt(query::SCHEMA_VERSION)),
436 (
437 "outcome",
438 J::Str(query::Outcome::Ambiguous.as_str().to_string()),
439 ),
440 ("query", J::Str(q.to_string())),
441 ("candidates", J::Arr(candidates)),
442 ])
443 .to_json_string()
444}
445
446fn ambiguous_candidates_out(
451 index: &query::GraphIndex,
452 q: &str,
453 ids: &[String],
454 json: bool,
455) -> (i32, String) {
456 let mut rows: Vec<(String, &String, &graph::Def)> = ids
457 .iter()
458 .map(|id| {
459 let d = index
464 .def(id)
465 .expect("ambiguous candidate id must resolve to a graph def");
466 (format!("{id} {}:{} {}", d.file, d.line, d.kind), id, d)
467 })
468 .collect();
469 rows.sort_by(|a, b| a.0.cmp(&b.0));
474 if json {
475 let candidates = rows
476 .iter()
477 .map(|(_, id, d)| {
478 J::Obj(vec![
479 ("id", J::Str((*id).clone())),
480 ("file", J::Str(d.file.clone())),
481 ("line", J::UInt(d.line as u64)),
482 ("kind", J::Str(d.kind.clone())),
483 ])
484 })
485 .collect();
486 return (1, ambiguous_json(q, candidates));
487 }
488 let mut out = vec![format!(
489 "ambiguous symbol \"{q}\" — {} candidates:",
490 rows.len()
491 )];
492 out.extend(rows.into_iter().map(|(row, _, _)| row));
493 (1, out.join("\n"))
494}
495
496fn member_ambiguous_out(
503 q: &str,
504 candidates: &[query::MemberCandidate],
505 json: bool,
506) -> (i32, String) {
507 if json {
508 let rows = candidates
509 .iter()
510 .map(|c| {
511 J::Obj(vec![
512 ("owner", J::Str(c.owner.clone())),
513 ("name", J::Str(c.name.clone())),
514 ("file", J::Str(c.file.clone())),
515 ("line", J::UInt(c.line as u64)),
516 ])
517 })
518 .collect();
519 return (1, ambiguous_json(q, rows));
520 }
521 let mut out = vec![format!(
522 "ambiguous member \"{q}\" — {} candidates:",
523 candidates.len()
524 )];
525 for c in candidates {
526 out.push(format!("{}.{} {}:{}", c.owner, c.name, c.file, c.line));
527 }
528 (1, out.join("\n"))
529}
530
531fn fallback_advised_out(q: &str, json: bool, plain: String) -> (i32, String) {
537 if json {
538 let out = J::Obj(vec![
539 ("schema_version", J::UInt(query::SCHEMA_VERSION)),
540 (
541 "outcome",
542 J::Str(query::Outcome::FallbackAdvised.as_str().to_string()),
543 ),
544 ("query", J::Str(q.to_string())),
545 ])
546 .to_json_string();
547 (EXIT_NO_RESULT, out)
548 } else {
549 (EXIT_NO_RESULT, plain)
550 }
551}
552
553fn parse_pick(args: &[String]) -> Result<Option<usize>, ()> {
561 let Some(idx) = args.iter().position(|a| a == "--pick") else {
562 return Ok(None);
563 };
564 let raw = args.get(idx + 1).map(String::as_str).unwrap_or("");
565 match raw.parse::<usize>() {
566 Ok(n) if n >= 1 => Ok(Some(n)),
567 _ => Err(()),
568 }
569}
570
571fn parse_int_js(s: &str) -> Option<i64> {
577 let s = s.trim_start();
578 let bytes = s.as_bytes();
579 let mut idx = 0;
580 if idx < bytes.len() && (bytes[idx] == b'+' || bytes[idx] == b'-') {
581 idx += 1;
582 }
583 let digits_start = idx;
584 while idx < bytes.len() && bytes[idx].is_ascii_digit() {
585 idx += 1;
586 }
587 if idx == digits_start {
588 return None;
589 }
590 s[..idx].parse::<i64>().ok()
591}
592
593fn index_options(args: &[String]) -> query::IndexOptions {
603 query::IndexOptions {
604 include_guesses: !args.iter().any(|a| a == "--no-guess"),
605 }
606}
607
608fn output_flags(verb: &str, args: &[String]) -> Result<(bool, bool), (i32, String)> {
612 let json = args.iter().any(|a| a == "--json");
613 let compact = args.iter().any(|a| a == "--compact");
614 if json && compact {
615 return Err((
616 1,
617 format!("devscout {verb}: --compact and --json are mutually exclusive"),
618 ));
619 }
620 Ok((json, compact))
621}
622
623fn require_graph(root: &Path) -> Result<graph::Graph, (i32, String)> {
627 graph::read_graph(root).ok_or_else(|| {
628 (
629 1,
630 "no graph.json for this repo — run `devscout map` on a C# scope first".to_string(),
631 )
632 })
633}
634
635fn finish_query(
639 root: &Path,
640 verb: &'static str,
641 seed: &str,
642 start: Instant,
643 answer: (i32, String, query::Outcome, usize),
644) -> (i32, String) {
645 let (code, out, outcome, candidate_count) = answer;
646 telemetry::record(
647 root,
648 &telemetry::QueryEvent {
649 verb,
650 seed,
651 outcome,
652 candidate_count,
653 },
654 start.elapsed(),
655 out.len(),
656 );
657 (code, out)
658}
659
660const REFS_USAGE: &str =
665 "usage: devscout refs <symbol> [--out] [--all] [--no-guess] [--pick N] [--json|--compact]";
666
667fn refs_model_row_count(model: &query::RefsModel) -> usize {
672 let mut n = model.inbound.inherits.rows.len()
673 + model.inbound.uses_type.rows.len()
674 + model.inbound.uses_member.rows.len();
675 if let Some(ob) = &model.outbound {
676 n += ob.inherits.rows.len()
677 + ob.uses_type.rows.len()
678 + ob.uses_member.rows.len()
679 + ob.imports.rows.len();
680 }
681 n
682}
683
684fn member_models_out(
691 q: &str,
692 json: bool,
693 compact: bool,
694 models: &[query::RefsModel],
695) -> (i32, String, query::Outcome, usize) {
696 let (code, outcome) = if models.iter().all(|m| m.inbound.uses_member.total == 0) {
697 (EXIT_NO_RESULT, query::Outcome::ZeroHit)
698 } else {
699 (0, query::Outcome::Hit)
700 };
701 let out = if json {
702 query::json::member_refs_to_json(q, models, outcome)
703 } else {
704 let render: fn(&query::RefsModel) -> String = if compact {
705 render::render_refs_compact
706 } else {
707 render::render_refs_text
708 };
709 models.iter().map(render).collect::<Vec<_>>().join("\n")
710 };
711 let count = models.iter().map(refs_model_row_count).sum();
712 (code, out, outcome, count)
713}
714
715fn cmd_refs(cwd: &Path, args: &[String]) -> (i32, String, Option<&'static str>) {
716 let start = Instant::now();
717 let (json, compact) = match output_flags("refs", args) {
718 Ok(v) => v,
719 Err((code, out)) => return (code, out, None),
720 };
721 let Ok(pick) = parse_pick(args) else {
722 return (2, REFS_USAGE.to_string(), None);
723 };
724 let Some(q) = first_positional(args) else {
725 return (2, REFS_USAGE.to_string(), None);
726 };
727 let out = args.iter().any(|a| a == "--out");
728 let all_out = args.iter().any(|a| a == "--all");
731 let root = match require_repo(cwd) {
732 Ok(r) => r,
733 Err(e) => return (1, format!("error: {e}"), None),
734 };
735 let g = match require_graph(&root) {
736 Ok(g) => g,
737 Err((code, out)) => return (code, out, None),
738 };
739 let index = query::load_graph_index_with(&g, &root, index_options(args));
740
741 let build = |seed: &str| {
742 query::build_refs_model(
743 &index,
744 seed,
745 out,
746 query::DEFAULT_CAP,
747 query::INBOUND_CAP,
748 query::OUTBOUND_CAP,
749 all_out,
750 )
751 };
752 let result = match build(q) {
758 query::RefsResult::MemberAmbiguous(candidates) => match pick {
759 Some(n) if n <= candidates.len() => build(&query::qualified_seed(&candidates[n - 1])),
760 Some(_) => return (2, REFS_USAGE.to_string(), None),
761 None => query::RefsResult::MemberAmbiguous(candidates),
762 },
763 other => other,
764 };
765
766 let answer = refs_result_out(&index, q, json, compact, result);
767 let note = (answer.2 != query::Outcome::ZeroHit).then_some(ZERO_HIT_REFS);
768 let (code, out) = finish_query(&root, "refs", q, start, answer);
769 (code, out, note)
770}
771
772fn refs_result_out(
776 index: &query::GraphIndex,
777 q: &str,
778 json: bool,
779 compact: bool,
780 result: query::RefsResult,
781) -> (i32, String, query::Outcome, usize) {
782 match result {
783 query::RefsResult::NotFound => {
784 let (code, out) = fallback_advised_out(q, json, format!("no symbol matches \"{q}\""));
785 (code, out, query::Outcome::FallbackAdvised, 0)
786 }
787 query::RefsResult::Ambiguous(ids) => {
788 let count = ids.len();
789 let (code, out) = ambiguous_candidates_out(index, q, &ids, json);
790 (code, out, query::Outcome::Ambiguous, count)
791 }
792 query::RefsResult::MemberAmbiguous(candidates) => {
793 let count = candidates.len();
794 let (code, out) = member_ambiguous_out(q, &candidates, json);
795 (code, out, query::Outcome::Ambiguous, count)
796 }
797 query::RefsResult::Members(models) => member_models_out(q, json, compact, &models),
798 query::RefsResult::Resolved(model) => {
799 let count = refs_model_row_count(&model);
800 let out = if json {
801 query::json::refs_model_to_json(&model)
802 } else if compact {
803 render::render_refs_compact(&model)
804 } else {
805 render::render_refs_text(&model)
806 };
807 (0, out, query::Outcome::Hit, count)
808 }
809 }
810}
811
812const READ_USAGE: &str = "usage: devscout read <symbol> [--no-guess] [--pick N] [--json|--compact]";
819
820fn cmd_read(cwd: &Path, args: &[String]) -> (i32, String, Option<&'static str>) {
821 let start = Instant::now();
822 let (json, compact) = match output_flags("read", args) {
823 Ok(v) => v,
824 Err((code, out)) => return (code, out, None),
825 };
826 let Ok(pick) = parse_pick(args) else {
827 return (2, READ_USAGE.to_string(), None);
828 };
829 let Some(q) = first_positional(args) else {
830 return (2, READ_USAGE.to_string(), None);
831 };
832 let root = match require_repo(cwd) {
833 Ok(r) => r,
834 Err(e) => return (1, format!("error: {e}"), None),
835 };
836 let g = match require_graph(&root) {
837 Ok(g) => g,
838 Err((code, out)) => return (code, out, None),
839 };
840 let index = query::load_graph_index_with(&g, &root, index_options(args));
841
842 let result = match query::build_read_model(&index, q) {
846 query::ReadResult::MemberAmbiguous(candidates) => match pick {
847 Some(n) if n <= candidates.len() => {
848 query::build_read_model(&index, &query::qualified_seed(&candidates[n - 1]))
849 }
850 Some(_) => return (2, READ_USAGE.to_string(), None),
851 None => query::ReadResult::MemberAmbiguous(candidates),
852 },
853 other => other,
854 };
855
856 let answer = match result {
857 query::ReadResult::NotFound => {
858 let (code, out) = fallback_advised_out(q, json, format!("no symbol matches \"{q}\""));
859 (code, out, query::Outcome::FallbackAdvised, 0)
860 }
861 query::ReadResult::Ambiguous(ids) => {
862 let count = ids.len();
863 let (code, out) = ambiguous_candidates_out(&index, q, &ids, json);
864 (code, out, query::Outcome::Ambiguous, count)
865 }
866 query::ReadResult::MemberAmbiguous(candidates) => {
867 let count = candidates.len();
868 let (code, out) = member_ambiguous_out(q, &candidates, json);
869 (code, out, query::Outcome::Ambiguous, count)
870 }
871 query::ReadResult::Members(models) => member_models_out(q, json, compact, &models),
875 query::ReadResult::Resolved(model) => {
876 let count = refs_model_row_count(&model.refs);
877 let out = if json {
878 query::json::read_model_to_json(&model)
879 } else if compact {
880 render::render_read_compact(&model)
881 } else {
882 render::render_read_text(&model)
883 };
884 (0, out, query::Outcome::Hit, count)
885 }
886 };
887 let note = (answer.2 != query::Outcome::ZeroHit).then_some(ZERO_HIT_READ);
888 let (code, out) = finish_query(&root, "read", q, start, answer);
889 (code, out, note)
890}
891
892const IMPACT_USAGE: &str = "usage: devscout impact <file|symbol> [--hops N] [--no-iface] [--no-guess] [--iface-max-fanin N] [--hub-max-indegree N] [--pick N] [--json|--compact]";
893
894struct ImpactArgs<'a> {
899 hops: u32,
900 iface_max_fanin: usize,
901 hub_max_indegree: usize,
902 pick: Option<usize>,
903 query: &'a str,
904}
905
906fn parse_impact_args(args: &[String]) -> Result<ImpactArgs<'_>, (i32, String)> {
907 let usage_err = || (2, IMPACT_USAGE.to_string());
908
909 let mut hops: u32 = query::DEFAULT_HOPS;
910 if let Some(idx) = args.iter().position(|a| a == "--hops") {
911 let raw = args.get(idx + 1).map(String::as_str).unwrap_or("");
912 match parse_int_js(raw) {
913 Some(h) if h >= 1 => hops = h as u32,
914 _ => return Err(usage_err()),
915 }
916 }
917
918 let mut iface_max_fanin: usize = query::DEFAULT_IFACE_MAX_FANIN;
922 if let Some(idx) = args.iter().position(|a| a == "--iface-max-fanin") {
923 let raw = args.get(idx + 1).map(String::as_str).unwrap_or("");
924 match parse_int_js(raw) {
925 Some(n) if n >= 0 => iface_max_fanin = n as usize,
926 _ => return Err(usage_err()),
927 }
928 }
929
930 let mut hub_max_indegree: usize = query::DEFAULT_HUB_MAX_INDEGREE;
933 if let Some(idx) = args.iter().position(|a| a == "--hub-max-indegree") {
934 let raw = args.get(idx + 1).map(String::as_str).unwrap_or("");
935 match parse_int_js(raw) {
936 Some(n) if n >= 0 => hub_max_indegree = n as usize,
937 _ => return Err(usage_err()),
938 }
939 }
940
941 let Ok(pick) = parse_pick(args) else {
942 return Err(usage_err());
943 };
944
945 let mut query: Option<&str> = None;
948 for (i, a) in args.iter().enumerate() {
949 if a.starts_with("--") {
950 continue;
951 }
952 if i > 0
953 && (args[i - 1] == "--hops"
954 || args[i - 1] == "--iface-max-fanin"
955 || args[i - 1] == "--hub-max-indegree"
956 || args[i - 1] == "--pick")
957 {
958 continue;
959 }
960 query = Some(a.as_str());
961 break;
962 }
963 let Some(query) = query else {
964 return Err(usage_err());
965 };
966
967 Ok(ImpactArgs {
968 hops,
969 iface_max_fanin,
970 hub_max_indegree,
971 pick,
972 query,
973 })
974}
975
976fn cmd_impact(cwd: &Path, args: &[String]) -> (i32, String) {
980 let start = Instant::now();
981 let (json, compact) = match output_flags("impact", args) {
982 Ok(v) => v,
983 Err(e) => return e,
984 };
985
986 let ImpactArgs {
987 hops,
988 iface_max_fanin,
989 hub_max_indegree,
990 pick,
991 query: q,
992 } = match parse_impact_args(args) {
993 Ok(a) => a,
994 Err(e) => return e,
995 };
996
997 let root = match require_repo_for_path(cwd, Some(q)) {
998 Ok(r) => r,
999 Err(e) => return (1, format!("error: {e}")),
1000 };
1001 let q = repo_relative_arg(cwd, &root, q);
1002 let q = q.as_str();
1003 let g = match require_graph(&root) {
1004 Ok(g) => g,
1005 Err(e) => return e,
1006 };
1007 let index = query::load_graph_index_with(&g, &root, index_options(args));
1008
1009 let build = |seed: &str| {
1010 query::build_impact_model(
1011 &index,
1012 seed,
1013 hops,
1014 query::DEFAULT_CAP,
1015 !args.iter().any(|a| a == "--no-iface"),
1016 iface_max_fanin,
1017 hub_max_indegree,
1018 )
1019 };
1020 let result = match build(q) {
1023 query::ImpactResult::MemberAmbiguous(candidates) => match pick {
1024 Some(n) if n <= candidates.len() => build(&query::qualified_seed(&candidates[n - 1])),
1025 Some(_) => return (2, IMPACT_USAGE.to_string()),
1026 None => query::ImpactResult::MemberAmbiguous(candidates),
1027 },
1028 other => other,
1029 };
1030
1031 let answer = match result {
1032 query::ImpactResult::NotFound { kind } => {
1033 let (code, out) = fallback_advised_out(
1034 q,
1035 json,
1036 format!("no {} match for \"{q}\"", render::seed_kind_str(kind)),
1037 );
1038 (code, out, query::Outcome::FallbackAdvised, 0)
1039 }
1040 query::ImpactResult::Ambiguous { ids, .. } => {
1041 let count = ids.len();
1042 let (code, out) = ambiguous_candidates_out(&index, q, &ids, json);
1043 (code, out, query::Outcome::Ambiguous, count)
1044 }
1045 query::ImpactResult::MemberAmbiguous(candidates) => {
1046 let count = candidates.len();
1047 let (code, out) = member_ambiguous_out(q, &candidates, json);
1048 (code, out, query::Outcome::Ambiguous, count)
1049 }
1050 query::ImpactResult::Resolved(model) => {
1051 let out = if json {
1052 query::json::impact_model_to_json(q, &model)
1053 } else if compact {
1054 render::render_impact_compact(q, &model)
1055 } else {
1056 render::render_impact_text(q, &model)
1057 };
1058 if model.rows.is_empty() {
1062 (EXIT_NO_RESULT, out, query::Outcome::ZeroHit, 0)
1063 } else {
1064 let count = model.rows.len();
1065 (0, out, query::Outcome::Hit, count)
1066 }
1067 }
1068 };
1069 finish_query(&root, "impact", q, start, answer)
1070}
1071
1072const TESTS_USAGE: &str =
1073 "usage: devscout tests <symbol> [--no-guess] [--pick N] [--json|--compact]";
1074
1075fn cmd_tests(cwd: &Path, args: &[String]) -> (i32, String) {
1078 let start = Instant::now();
1079 let (json, compact) = match output_flags("tests", args) {
1080 Ok(v) => v,
1081 Err(e) => return e,
1082 };
1083 let Ok(pick) = parse_pick(args) else {
1084 return (2, TESTS_USAGE.to_string());
1085 };
1086 let Some(q) = first_positional(args) else {
1087 return (2, TESTS_USAGE.to_string());
1088 };
1089 let root = match require_repo(cwd) {
1090 Ok(r) => r,
1091 Err(e) => return (1, format!("error: {e}")),
1092 };
1093 let g = match require_graph(&root) {
1094 Ok(g) => g,
1095 Err(e) => return e,
1096 };
1097 let index = query::load_graph_index_with(&g, &root, index_options(args));
1098
1099 let result = match query::build_tests_model(&index, q) {
1102 query::TestsResult::MemberAmbiguous(candidates) => match pick {
1103 Some(n) if n <= candidates.len() => {
1104 query::build_tests_model(&index, &query::qualified_seed(&candidates[n - 1]))
1105 }
1106 Some(_) => return (2, TESTS_USAGE.to_string()),
1107 None => query::TestsResult::MemberAmbiguous(candidates),
1108 },
1109 other => other,
1110 };
1111
1112 let answer = match result {
1113 query::TestsResult::NotFound => {
1114 let (code, out) = fallback_advised_out(q, json, format!("no symbol matches \"{q}\""));
1115 (code, out, query::Outcome::FallbackAdvised, 0)
1116 }
1117 query::TestsResult::Ambiguous(ids) => {
1118 let count = ids.len();
1119 let (code, out) = ambiguous_candidates_out(&index, q, &ids, json);
1120 (code, out, query::Outcome::Ambiguous, count)
1121 }
1122 query::TestsResult::MemberAmbiguous(candidates) => {
1123 let count = candidates.len();
1124 let (code, out) = member_ambiguous_out(q, &candidates, json);
1125 (code, out, query::Outcome::Ambiguous, count)
1126 }
1127 query::TestsResult::Resolved(model) => {
1128 let count = model.rows.len();
1129 let out = if json {
1130 query::json::tests_model_to_json(&model)
1131 } else if compact {
1132 render::render_tests_compact(&model)
1133 } else {
1134 render::render_tests_text(&model)
1135 };
1136 (0, out, query::Outcome::Hit, count)
1137 }
1138 };
1139 finish_query(&root, "tests", q, start, answer)
1140}
1141
1142const FIND_FULL_CAP: usize = 25;
1150const FIND_FALLBACK_CAP: usize = 10;
1151const FIND_NAMES_CAP: usize = 25;
1156
1157fn cmd_find(cwd: &Path, query_str: &str, resources: bool) -> (i32, String) {
1162 let start = Instant::now();
1163 let root = match require_repo(cwd) {
1164 Ok(r) => r,
1165 Err(e) => return (1, format!("error: {e}")),
1166 };
1167 if query_str.is_empty() {
1168 return (2, "usage: devscout find <query> [--resources]".to_string());
1169 }
1170 let graph = graph::read_graph(&root);
1183 let inbound_counts = graph
1188 .as_ref()
1189 .map(query::file_inbound_counts)
1190 .unwrap_or_default();
1191 let (decl_lines, resource_count): (Vec<String>, usize) = match graph.as_ref() {
1192 None => (Vec::new(), 0),
1193 Some(g) => {
1194 let named = query::find_names(g, query_str);
1195 let mut primary: Vec<&graph::GraphName> = Vec::new();
1196 let mut resource_count = 0usize;
1197 for n in named.iter() {
1198 if query::name_tier(&n.kind) <= 2 {
1199 primary.push(n);
1200 } else {
1201 resource_count += 1;
1202 }
1203 }
1204 let pool: Vec<&graph::GraphName> = if resources { named } else { primary };
1205 let mut out: Vec<String> = pool
1206 .iter()
1207 .take(FIND_NAMES_CAP)
1208 .map(|n| {
1209 let text = query::source_line(&root, &n.file, n.line);
1210 if text.is_empty() {
1211 format!("{}:{}", n.file, n.line)
1212 } else {
1213 format!("{}:{} {text}", n.file, n.line)
1214 }
1215 })
1216 .collect();
1217 if pool.len() > FIND_NAMES_CAP {
1218 out.push(format!(
1219 "… +{} more declarations (refine query)",
1220 pool.len() - FIND_NAMES_CAP
1221 ));
1222 }
1223 (out, resource_count)
1224 }
1225 };
1226 match manifest::find_in_manifest_detailed(&root, query_str, &inbound_counts) {
1227 Ok(r) => {
1228 let answer = find_result_out(
1229 graph.as_ref(),
1230 query_str,
1231 resources,
1232 resource_count,
1233 decl_lines,
1234 r,
1235 );
1236 finish_query(&root, "find", query_str, start, answer)
1237 }
1238 Err(e) => (1, format!("error: {e}")),
1239 }
1240}
1241
1242fn find_result_out(
1246 graph: Option<&graph::Graph>,
1247 query_str: &str,
1248 resources: bool,
1249 resource_count: usize,
1250 decl_lines: Vec<String>,
1251 r: manifest::FindResult,
1252) -> (i32, String, query::Outcome, usize) {
1253 if r.hits.is_empty() && decl_lines.is_empty() {
1254 let out =
1255 format!("no matches for \"{query_str}\" (run 'devscout map' if manifest is missing)");
1256 return (EXIT_NO_RESULT, out, query::Outcome::FallbackAdvised, 0);
1257 }
1258 let cap = if r.fallback {
1259 FIND_FALLBACK_CAP
1260 } else {
1261 FIND_FULL_CAP
1262 };
1263 let candidate_count = decl_lines.len().min(FIND_NAMES_CAP) + r.hits.len().min(cap);
1266 let mut lines: Vec<String> = decl_lines;
1267 if !resources && resource_count > 0 {
1268 lines.push(format!(
1269 "+{resource_count} resource-key hits, use --resources"
1270 ));
1271 }
1272 let decl_line_by_file = graph
1277 .map(query::first_decl_line_by_file)
1278 .unwrap_or_default();
1279 lines.extend(r.hits.iter().take(cap).map(|h| {
1280 let purpose = h.purpose.as_deref().unwrap_or("undefined");
1283 let agent = if h.source == "agent" { " [agent]" } else { "" };
1284 let line = decl_line_by_file.get(&h.path).copied().unwrap_or(1);
1285 format!("{}:{line}: {purpose}{agent}", h.path)
1286 }));
1287 if r.hits.len() > cap {
1288 lines.push(format!("… +{} more (refine query)", r.hits.len() - cap));
1289 }
1290 (0, lines.join("\n"), query::Outcome::Hit, candidate_count)
1291}
1292
1293fn cmd_map(cwd: &Path, args: &[String]) -> (i32, String) {
1300 let root = match require_repo(cwd) {
1301 Ok(r) => r,
1302 Err(e) => return (1, format!("error: {e}")),
1303 };
1304 let dirs: Vec<String> = args
1305 .iter()
1306 .filter(|a| a.as_str() != "--refresh")
1307 .cloned()
1308 .collect();
1309 match mapcmd::map_repo(&root, &dirs, mapcmd::MapOptions::from_env()) {
1310 Ok(report) => (0, report.summary_line()),
1311 Err(e) => (1, format!("error: {e}")),
1312 }
1313}
1314
1315fn cmd_stats(cwd: &Path) -> (i32, String) {
1321 let root = match require_repo(cwd) {
1322 Ok(r) => r,
1323 Err(e) => return (1, format!("error: {e}")),
1324 };
1325 let db = match store::open_store(&root) {
1326 Ok(c) => c,
1327 Err(e) => return (1, format!("error: {e}")),
1328 };
1329 let s = match store::stats_for(&db) {
1330 Ok(s) => s,
1331 Err(e) => return (1, format!("error: {e}")),
1332 };
1333
1334 let mut lines = vec![
1335 format!("devscout stats ({}):", root.display()),
1336 format!(" files tracked: {}", s.distinct_files),
1337 format!(" reads deduped (stubs): {}", s.total_stubs),
1338 format!(" lines saved: {}", s.lines_saved),
1339 format!(" bytes saved: {}", s.bytes_saved),
1340 format!(
1341 " est tokens saved: {}",
1342 js_math_round(s.bytes_saved as f64 / 4.0)
1343 ),
1344 ];
1345
1346 let b = match store::bash_stats_for(&db) {
1347 Ok(b) => b,
1348 Err(e) => return (1, format!("error: {e}")),
1349 };
1350 if b.commands_tracked > 0 {
1351 lines.push(format!(" bash commands tracked: {}", b.commands_tracked));
1352 lines.push(format!(" bash dedups (stubs): {}", b.total_stubs));
1353 lines.push(format!(
1354 " bash est tokens saved: {}",
1355 js_math_round(b.bytes_saved as f64 / 4.0)
1356 ));
1357 }
1358
1359 if let Ok(cs_conn) = store::open_content_store() {
1361 if let Ok(cs) = store::content_stats_for(&cs_conn) {
1362 if cs.total_stubs > 0 {
1363 lines.push(format!(
1364 " cross-repo dedups (all roots, this machine): {}",
1365 cs.total_stubs
1366 ));
1367 lines.push(format!(
1368 " cross-repo est tokens saved: {}",
1369 js_math_round(cs.bytes_saved as f64 / 4.0)
1370 ));
1371 }
1372 }
1373 }
1374
1375 let per_session = match store::session_stats(&db) {
1376 Ok(v) => v,
1377 Err(e) => return (1, format!("error: {e}")),
1378 };
1379 if !per_session.is_empty() {
1380 lines.push(String::new());
1381 lines.push(" per session:".to_string());
1382 for r in &per_session {
1383 let sid: String = r.session_id.chars().take(8).collect();
1386 lines.push(format!(
1387 " {sid:<8} files {:>4} stubs {:>4} lines saved {:>6} est tokens {}",
1388 r.files,
1389 r.stubs,
1390 r.lines_saved,
1391 js_math_round(r.bytes_saved as f64 / 4.0),
1392 ));
1393 }
1394 }
1395
1396 let top = match store::top_stubbed(&db, 5) {
1397 Ok(v) => v,
1398 Err(e) => return (1, format!("error: {e}")),
1399 };
1400 if !top.is_empty() {
1401 lines.push(String::new());
1402 lines.push(" top stubbed files:".to_string());
1403 for r in &top {
1404 let sid: String = r.session_id.chars().take(8).collect();
1405 lines.push(format!(
1406 " {}x {} ({} lines, session {sid})",
1407 r.stub_count, r.rel_path, r.lines
1408 ));
1409 }
1410 }
1411
1412 (0, lines.join("\n"))
1413}
1414
1415fn cmd_clear(cwd: &Path, args: &[String]) -> (i32, String) {
1424 let root = match require_repo(cwd) {
1425 Ok(r) => r,
1426 Err(e) => return (1, format!("error: {e}")),
1427 };
1428
1429 if let Some(idx) = args.iter().position(|a| a == "--older-than") {
1430 let raw = args.get(idx + 1).map(String::as_str).unwrap_or("");
1431 let days = match parse_int_js(raw) {
1432 Some(d) if d >= 0 => d,
1433 _ => return (2, "usage: devscout clear --older-than <days>".to_string()),
1434 };
1435 let db = match store::open_store(&root) {
1436 Ok(c) => c,
1437 Err(e) => return (1, format!("error: {e}")),
1438 };
1439 let deleted = match store::prune(&db, Some(days as f64), None) {
1440 Ok(n) => n,
1441 Err(e) => return (1, format!("error: {e}")),
1442 };
1443 let suffix = if deleted == 1 { "" } else { "s" };
1444 return (
1445 0,
1446 format!("deleted {deleted} row{suffix} older than {days}d"),
1447 );
1448 }
1449
1450 if let Some(idx) = args.iter().position(|a| a == "--session") {
1451 let prefix = args.get(idx + 1).map(String::as_str).unwrap_or("");
1452 if prefix.is_empty() {
1453 return (
1454 2,
1455 "usage: devscout clear --session <id-or-prefix>".to_string(),
1456 );
1457 }
1458 let db = match store::open_store(&root) {
1459 Ok(c) => c,
1460 Err(e) => return (1, format!("error: {e}")),
1461 };
1462 let matches = match store::session_ids_by_prefix(&db, prefix) {
1463 Ok(v) => v,
1464 Err(e) => return (1, format!("error: {e}")),
1465 };
1466 if matches.is_empty() {
1467 return (0, format!("no sessions match \"{prefix}\""));
1468 }
1469 if matches.len() > 1 {
1470 return (
1471 2,
1472 format!(
1473 "ambiguous session prefix \"{prefix}\": {}",
1474 matches.join(", ")
1475 ),
1476 );
1477 }
1478 let deleted = match store::prune(&db, None, Some(matches[0].as_str())) {
1479 Ok(n) => n,
1480 Err(e) => return (1, format!("error: {e}")),
1481 };
1482 let suffix = if deleted == 1 { "" } else { "s" };
1483 let session = &matches[0];
1484 return (
1485 0,
1486 format!("deleted {deleted} row{suffix} for session {session}"),
1487 );
1488 }
1489
1490 let db_path = crate::repo::scout_dir(&root).join("cache.db");
1491 if db_path.exists() {
1492 if let Err(e) = std::fs::remove_file(&db_path) {
1493 return (1, format!("error: {e}"));
1494 }
1495 }
1496 (0, "cache cleared".to_string())
1497}
1498
1499fn js_math_round(x: f64) -> i64 {
1504 (x + 0.5).floor() as i64
1505}
1506
1507#[cfg(test)]
1508mod tests {
1509 use super::*;
1510 use crate::query::json::*;
1515
1516 #[test]
1517 fn js_float_string_matches_node_json_stringify_on_checked_values() {
1518 assert_eq!(js_float_string(1.0), "1");
1520 assert_eq!(js_float_string(100.0), "100");
1521 assert_eq!(js_float_string(0.0), "0");
1522 assert_eq!(js_float_string(-0.0), "0");
1523 assert_eq!(js_float_string(0.15), "0.15");
1524 assert_eq!(js_float_string(0.1 + 0.2), "0.30000000000000004");
1525 assert_eq!(js_float_string(1.0 / 3.0), "0.3333333333333333");
1526 assert_eq!(js_float_string(1e21), "1e+21");
1527 assert_eq!(js_float_string(0.0000001), "1e-7");
1528 assert_eq!(js_float_string(f64::NAN), "null");
1529 assert_eq!(js_float_string(f64::INFINITY), "null");
1530 }
1531
1532 #[test]
1533 fn js_math_round_matches_node_math_round_on_checked_values() {
1534 assert_eq!(js_math_round(0.0), 0);
1536 assert_eq!(js_math_round(1.4), 1);
1537 assert_eq!(js_math_round(1.5), 2);
1538 assert_eq!(js_math_round(1.49999), 1);
1539 assert_eq!(js_math_round(2.5), 3);
1540 assert_eq!(js_math_round(100.0 / 4.0), 25);
1541 assert_eq!(js_math_round(101.0 / 4.0), 25);
1542 assert_eq!(js_math_round(103.0 / 4.0), 26);
1543 }
1544
1545 #[test]
1546 fn tests_json_appends_via_project_last_and_omits_it_for_an_attribute_row() {
1547 let attribute_row = query::TestRow {
1548 file: "tests/OrderServiceTests.cs".to_string(),
1549 test_defs: vec!["App.Orders.Tests.OrderServiceTests".to_string()],
1550 lines: vec![10],
1551 ref_count: 1,
1552 heuristic: false,
1553 tier: None,
1554 via: query::TestVia::Attribute,
1555 };
1556 let project_row = query::TestRow {
1557 file: "tests/App.Tests/FakeServer.cs".to_string(),
1558 test_defs: vec![],
1559 lines: vec![12, 34],
1560 ref_count: 2,
1561 heuristic: false,
1562 tier: None,
1563 via: query::TestVia::Project,
1564 };
1565 let model = query::TestsModel {
1566 query: "Order".to_string(),
1567 symbol: "App.Orders.Order".to_string(),
1568 def_files: vec!["src/Order.cs".to_string()],
1569 rows: vec![attribute_row, project_row],
1570 test_file_count: 2,
1571 ref_count: 3,
1572 heuristic_file_count: 0,
1573 heuristic_ref_count: 0,
1574 };
1575 let json = tests_model_to_json(&model);
1576 assert!(
1577 json.contains(
1578 r#"{"file":"tests/OrderServiceTests.cs","testDefs":["App.Orders.Tests.OrderServiceTests"],"lines":[10],"refCount":1,"why":"test-attribute"}"#
1579 ),
1580 "an attribute row carries no via key at all: {json}"
1581 );
1582 assert!(
1583 json.contains(
1584 r#"{"file":"tests/App.Tests/FakeServer.cs","testDefs":[],"lines":[12,34],"refCount":2,"via":"project","why":"test-project"}"#
1585 ),
1586 "a project row appends via LAST (no heuristic/tier on this row): {json}"
1587 );
1588 }
1589
1590 #[test]
1591 fn parse_int_js_matches_node_parseint_on_checked_values() {
1592 assert_eq!(parse_int_js("3"), Some(3));
1593 assert_eq!(parse_int_js("3abc"), Some(3));
1594 assert_eq!(parse_int_js("-5"), Some(-5));
1595 assert_eq!(parse_int_js(" 7"), Some(7));
1596 assert_eq!(parse_int_js(""), None);
1597 assert_eq!(parse_int_js("abc"), None);
1598 assert_eq!(parse_int_js("-"), None);
1599 }
1600
1601 #[test]
1602 fn j_object_and_array_serialize_with_no_extra_whitespace_like_json_stringify() {
1603 let j = J::Obj(vec![
1604 ("a", J::UInt(1)),
1605 ("b", J::Arr(vec![J::Str("x".to_string()), J::UInt(2)])),
1606 ]);
1607 assert_eq!(j.to_json_string(), r#"{"a":1,"b":["x",2]}"#);
1608 }
1609
1610 #[test]
1611 fn j_string_escapes_control_chars_and_quotes_like_json_stringify() {
1612 let j = J::Str("a\"b\\c\nd".to_string());
1613 assert_eq!(j.to_json_string(), r#""a\"b\\c\nd""#);
1614 }
1615
1616 fn json_refs_model(rows: Vec<query::InboundRow>, out: bool) -> query::RefsModel {
1624 let empty_in = || query::Table {
1625 total: 0,
1626 dropped: 0,
1627 rows: Vec::<query::InboundRow>::new(),
1628 };
1629 let empty_out = || query::Table {
1630 total: 0,
1631 dropped: 0,
1632 rows: Vec::<query::OutboundRow>::new(),
1633 };
1634 query::RefsModel {
1635 query: "Widget".to_string(),
1636 id: "App.Widget".to_string(),
1637 kind: "class".to_string(),
1638 sites: vec![query::DefSite {
1639 file: "src/Widget.cs".to_string(),
1640 line: 3,
1641 }],
1642 inbound: query::InboundTables {
1643 inherits: empty_in(),
1644 uses_type: empty_in(),
1645 uses_member: query::Table {
1646 total: rows.len(),
1647 dropped: 0,
1648 rows,
1649 },
1650 },
1651 outbound: out.then(|| query::OutboundTables {
1652 inherits: empty_out(),
1653 uses_type: empty_out(),
1654 uses_member: empty_out(),
1655 imports: query::Table {
1656 total: 0,
1657 dropped: 0,
1658 rows: Vec::new(),
1659 },
1660 }),
1661 ambiguous: query::AmbiguousTables {
1662 inbound: query::Table {
1663 total: 0,
1664 dropped: 0,
1665 rows: Vec::new(),
1666 },
1667 outbound: query::Table {
1668 total: 0,
1669 dropped: 0,
1670 rows: Vec::new(),
1671 },
1672 },
1673 manifest_gap: 0,
1674 member_refs: None,
1675 }
1676 }
1677
1678 #[test]
1679 fn refs_json_leads_with_schema_version_ahead_of_every_existing_key() {
1680 let model = json_refs_model(Vec::new(), false);
1681 let json = refs_model_to_json(&model);
1682 assert!(
1683 json.starts_with(
1684 r#"{"schema_version":1,"status":"resolved","query":"Widget","id":"App.Widget","kind":"class""#
1685 ),
1686 "schema_version is the first key and every existing key keeps its slot after it: {json}"
1687 );
1688 }
1689
1690 #[test]
1691 fn read_json_leads_with_schema_version_and_keeps_span_right_after_kind() {
1692 let model = query::ReadModel {
1693 refs: json_refs_model(Vec::new(), false),
1694 span: Some(query::ReadSpan {
1695 file: "src/Widget.cs".to_string(),
1696 start_line: 3,
1697 end_line: 5,
1698 source: "class Widget {}".to_string(),
1699 }),
1700 };
1701 let json = read_model_to_json(&model);
1702 assert!(
1703 json.starts_with(
1704 r#"{"schema_version":1,"status":"resolved","query":"Widget","id":"App.Widget","kind":"class","span":{"#
1705 ),
1706 "schema_version leads, and span still sits right after kind, unmoved: {json}"
1707 );
1708 }
1709
1710 #[test]
1711 fn refs_json_appends_heuristic_then_source_last_and_omits_each_when_it_has_no_value() {
1712 let model = json_refs_model(
1713 vec![
1714 query::InboundRow {
1715 file: "src/Fact.cs".into(),
1716 line: 4,
1717 heuristic: false,
1718 tier: None,
1719 source: String::new(),
1720 },
1721 query::InboundRow {
1722 file: "src/Guess.cs".into(),
1723 line: 9,
1724 heuristic: true,
1725 tier: None,
1726 source: "var w = new Widget();".into(),
1727 },
1728 ],
1729 true,
1730 );
1731 let json = refs_model_to_json(&model);
1732 assert!(
1733 json.contains(
1734 r#""rows":[{"file":"src/Fact.cs","line":4,"why":"uses-member-precise"},{"file":"src/Guess.cs","line":9,"heuristic":true,"source":"var w = new Widget();","why":"uses-member-guess"}]"#
1735 ),
1736 "{json}"
1737 );
1738 }
1739
1740 #[test]
1741 fn refs_json_appends_tier_after_heuristic_and_omits_it_on_a_precise_row() {
1742 let row = |file: &str, line: usize, tier: Option<graph::HeuristicTier>| query::InboundRow {
1743 file: file.into(),
1744 line,
1745 heuristic: tier.is_some(),
1746 tier,
1747 source: String::new(),
1748 };
1749 let model = json_refs_model(
1750 vec![
1751 row("src/Fact.cs", 4, None),
1752 row("src/Ext.cs", 7, Some(graph::HeuristicTier::Ext)),
1753 row("src/Guess.cs", 9, Some(graph::HeuristicTier::Guess)),
1754 ],
1755 true,
1756 );
1757 let json = refs_model_to_json(&model);
1758 assert!(
1762 json.contains(
1763 r#""rows":[{"file":"src/Fact.cs","line":4,"why":"uses-member-precise"},{"file":"src/Ext.cs","line":7,"heuristic":true,"tier":"ext","why":"uses-member-ext"},{"file":"src/Guess.cs","line":9,"heuristic":true,"tier":"guess","why":"uses-member-guess"}]"#
1764 ),
1765 "{json}"
1766 );
1767 assert_eq!(
1768 json.matches(r#""tier""#).count(),
1769 2,
1770 "a precise row carries no trace of the key: {json}"
1771 );
1772 }
1773
1774 #[test]
1775 fn refs_json_omits_the_outbound_key_entirely_without_out_and_keeps_its_slot_with_it() {
1776 let row = || {
1777 vec![query::InboundRow {
1778 file: "src/Fact.cs".into(),
1779 line: 4,
1780 heuristic: false,
1781 tier: None,
1782 source: String::new(),
1783 }]
1784 };
1785 let without = refs_model_to_json(&json_refs_model(row(), false));
1786 assert!(
1787 !without.contains(r#""outbound":{"inherits""#),
1788 "the default model must carry no outbound tables: {without}"
1789 );
1790 assert!(without.contains(r#""ambiguous":{"inbound""#), "{without}");
1791
1792 let with = refs_model_to_json(&json_refs_model(row(), true));
1793 let outbound_at = with
1794 .find(r#""outbound":{"inherits""#)
1795 .expect("--out must emit the outbound tables");
1796 let inbound_at = with
1797 .find(r#""inbound":{"inherits""#)
1798 .expect("inbound is always emitted");
1799 let ambiguous_at = with
1800 .find(r#""ambiguous":{"inbound""#)
1801 .expect("ambiguous is always emitted");
1802 assert!(
1803 inbound_at < outbound_at && outbound_at < ambiguous_at,
1804 "outbound keeps JS's key slot: {with}"
1805 );
1806 }
1807
1808 #[test]
1809 fn member_refs_json_wraps_unchanged_resolved_models_under_status_query_members() {
1810 let row = || {
1811 vec![query::InboundRow {
1812 file: "src/Fact.cs".into(),
1813 line: 4,
1814 heuristic: false,
1815 tier: None,
1816 source: String::new(),
1817 }]
1818 };
1819 let one = json_refs_model(row(), false);
1820 let json = member_refs_to_json("Widget", std::slice::from_ref(&one), query::Outcome::Hit);
1821 assert!(
1822 json.starts_with(
1823 r#"{"schema_version":1,"status":"members","query":"Widget","members":[{"schema_version":1,"status":"resolved""#
1824 ),
1825 "schema_version is the first key, ahead of status, on both the wrapper and each member: {json}"
1826 );
1827 assert!(
1828 json.ends_with(r#"}],"outcome":"hit"}"#),
1829 "the wrapper's own outcome is appended last, after the members array: {json}"
1830 );
1831 assert!(
1832 json.contains(&refs_model_to_json(&one)),
1833 "a member entry is the resolved object unchanged: {json}"
1834 );
1835 }
1836
1837 #[test]
1838 fn impact_json_leads_with_schema_version_ahead_of_query() {
1839 let model = query::ImpactModel {
1840 kind: query::SeedKind::Symbol,
1841 seed_files: vec!["src/Widget.cs".to_string()],
1842 hops: 2,
1843 total_affected: 0,
1844 rows: vec![],
1845 dropped: 0,
1846 manifest_gap: 0,
1847 heuristic_affected: 0,
1848 tests_affected: 0,
1849 braked: vec![],
1850 braked_files: vec![],
1851 };
1852 let json = impact_model_to_json("Widget", &model);
1853 assert!(
1854 json.starts_with(r#"{"schema_version":1,"query":"Widget","status":"resolved""#),
1855 "schema_version leads, ahead of the query key that used to be first: {json}"
1856 );
1857 }
1858
1859 #[test]
1860 fn tests_json_leads_with_schema_version_ahead_of_status() {
1861 let model = query::TestsModel {
1862 query: "Order".to_string(),
1863 symbol: "App.Orders.Order".to_string(),
1864 def_files: vec!["src/Order.cs".to_string()],
1865 rows: vec![],
1866 test_file_count: 0,
1867 ref_count: 0,
1868 heuristic_file_count: 0,
1869 heuristic_ref_count: 0,
1870 };
1871 let json = tests_model_to_json(&model);
1872 assert!(
1873 json.starts_with(r#"{"schema_version":1,"status":"resolved","query":"Order""#),
1874 "schema_version leads, ahead of the status key that used to be first: {json}"
1875 );
1876 }
1877
1878 #[test]
1879 fn impact_json_appends_heuristic_count_then_heuristic_after_score_and_heuristic_affected_after_manifest_gap(
1880 ) {
1881 let row = |file: &str, heuristic: bool| query::ImpactRow {
1882 file: file.to_string(),
1883 hop: 1,
1884 via_count: if heuristic { 0 } else { 1 },
1885 ambiguous_count: 0,
1886 top_symbols: vec!["Widget".to_string()],
1887 top_symbols_more: 0,
1888 score: 0.5,
1889 heuristic_count: if heuristic { 2 } else { 0 },
1890 heuristic,
1891 tier: None,
1892 iface_via: vec![],
1893 from_lines: vec![],
1894 infra: false,
1895 why: if heuristic {
1896 query::Why::UsesMemberGuess
1897 } else {
1898 query::Why::UsesMemberPrecise
1899 },
1900 };
1901 let model = query::ImpactModel {
1902 kind: query::SeedKind::Symbol,
1903 seed_files: vec!["src/Widget.cs".to_string()],
1904 hops: 2,
1905 total_affected: 1,
1906 rows: vec![row("src/Direct.cs", false), row("src/Guessed.cs", true)],
1907 dropped: 0,
1908 manifest_gap: 0,
1909 heuristic_affected: 1,
1910 tests_affected: 0,
1911 braked: vec![],
1912 braked_files: vec![],
1913 };
1914 let json = impact_model_to_json("Widget", &model);
1915 assert!(
1916 json.contains(
1917 r#"{"file":"src/Direct.cs","hop":1,"viaCount":1,"ambiguousCount":0,"topSymbols":["Widget"],"topSymbolsMore":0,"score":0.5,"why":"uses-member-precise"}"#
1918 ),
1919 "{json}"
1920 );
1921 assert!(
1922 json.contains(
1923 r#"{"file":"src/Guessed.cs","hop":1,"viaCount":0,"ambiguousCount":0,"topSymbols":["Widget"],"topSymbolsMore":0,"score":0.5,"heuristicCount":2,"heuristic":true,"why":"uses-member-guess"}"#
1924 ),
1925 "{json}"
1926 );
1927 assert!(
1928 json.ends_with(
1929 r#","dropped":0,"manifestGap":0,"heuristicAffected":1,"testsAffected":0,"outcome":"hit"}"#
1930 ),
1931 "{json}"
1932 );
1933 }
1934
1935 #[test]
1936 fn impact_json_tier_sits_between_heuristic_and_iface_via() {
1937 let row = |file: &str, tier: graph::HeuristicTier| query::ImpactRow {
1938 file: file.to_string(),
1939 hop: 1,
1940 via_count: 0,
1941 ambiguous_count: 0,
1942 top_symbols: vec!["Widget".to_string()],
1943 top_symbols_more: 0,
1944 score: 0.5,
1945 heuristic_count: 2,
1946 heuristic: true,
1947 tier: Some(tier),
1948 iface_via: vec!["IWidget".to_string()],
1949 from_lines: vec![],
1950 infra: false,
1951 why: match tier {
1952 graph::HeuristicTier::Ext => query::Why::UsesMemberExt,
1953 graph::HeuristicTier::Guess => query::Why::UsesMemberGuess,
1954 },
1955 };
1956 let model = query::ImpactModel {
1957 kind: query::SeedKind::Symbol,
1958 seed_files: vec!["src/Widget.cs".to_string()],
1959 hops: 2,
1960 total_affected: 0,
1961 rows: vec![
1962 row("src/Extended.cs", graph::HeuristicTier::Ext),
1963 row("src/Guessed.cs", graph::HeuristicTier::Guess),
1964 ],
1965 dropped: 0,
1966 manifest_gap: 0,
1967 heuristic_affected: 2,
1968 tests_affected: 0,
1969 braked: vec![],
1970 braked_files: vec![],
1971 };
1972 let json = impact_model_to_json("Widget", &model);
1973 assert!(
1977 json.contains(
1978 r#""score":0.5,"heuristicCount":2,"heuristic":true,"tier":"ext","ifaceVia":["IWidget"],"why":"uses-member-ext"}"#
1979 ),
1980 "{json}"
1981 );
1982 assert!(
1983 json.contains(
1984 r#""score":0.5,"heuristicCount":2,"heuristic":true,"tier":"guess","ifaceVia":["IWidget"],"why":"uses-member-guess"}"#
1985 ),
1986 "{json}"
1987 );
1988 }
1989
1990 fn find_cap_root(prefix: &str, entry_count: usize) -> PathBuf {
1993 let root = std::env::temp_dir().join(format!("scout-cli-{prefix}-{}", std::process::id()));
1994 let _ = std::fs::remove_dir_all(&root);
1995 let scout = root.join(".scout");
1996 std::fs::create_dir_all(&scout).unwrap();
1997 let entries: Vec<String> = (0..entry_count)
1998 .map(|i| {
1999 format!(
2000 r#""src/widget-{i:02}.cs": {{ "purpose": "widget number {i}", "mtime": {i} }}"#
2001 )
2002 })
2003 .collect();
2004 let json = format!(r#"{{ "entries": {{ {} }} }}"#, entries.join(", "));
2005 std::fs::write(scout.join("manifest.json"), json).unwrap();
2006 root
2007 }
2008
2009 #[test]
2010 fn cmd_find_caps_full_pool_at_25_with_honest_tail() {
2011 let root = find_cap_root("findcap-full", 30);
2012 let (code, out) = cmd_find(&root, "widget", false);
2013 assert_eq!(code, 0);
2014 let lines: Vec<&str> = out.split('\n').collect();
2015 assert_eq!(lines.len(), 26);
2016 assert_eq!(lines[25], "… +5 more (refine query)");
2017 assert!(lines[..25].iter().all(|l| l.contains("widget")));
2018 }
2019
2020 #[test]
2021 fn cmd_find_caps_fallback_pool_at_10_with_honest_tail() {
2022 let root = find_cap_root("findcap-fallback", 12);
2023 let (code, out) = cmd_find(&root, "widget zzz123nomatch", false);
2024 assert_eq!(code, 0);
2025 let lines: Vec<&str> = out.split('\n').collect();
2026 assert_eq!(lines.len(), 11);
2027 assert_eq!(lines[10], "… +2 more (refine query)");
2028 }
2029
2030 #[test]
2034 fn cmd_find_reports_a_zero_hit_on_its_own_exit_code_with_stdout_unchanged() {
2035 let root = find_cap_root("findcap-zero", 5);
2036 let (code, out) = cmd_find(&root, "zzz123nosuchpurpose", false);
2037 assert_eq!(code, EXIT_NO_RESULT);
2038 assert_eq!(
2039 out,
2040 "no matches for \"zzz123nosuchpurpose\" (run 'devscout map' if manifest is missing)"
2041 );
2042 }
2043
2044 #[test]
2045 fn cmd_find_below_cap_prints_no_tail() {
2046 let root = find_cap_root("findcap-small", 5);
2047 let (code, out) = cmd_find(&root, "widget", false);
2048 assert_eq!(code, 0);
2049 assert_eq!(out.split('\n').count(), 5);
2050 assert!(!out.contains("more (refine query)"));
2051 }
2052
2053 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";
2056 const PANEL_XAML: &str = "<UserControl\n\tx:Class=\"Gadgets.PanelView\">\n\t<Button x:Name=\"ShipButton\" />\n</UserControl>\n";
2057 const STRINGS_RESW: &str =
2058 "<root>\n\t<resheader name=\"resmimetype\" />\n\t<data name=\"ShipButton.Content\" xml:space=\"preserve\" />\n</root>\n";
2059
2060 fn name_index_root(prefix: &str) -> PathBuf {
2064 let root = std::env::temp_dir().join(format!("scout-cli-{prefix}-{}", std::process::id()));
2065 let _ = std::fs::remove_dir_all(&root);
2066 std::fs::create_dir_all(root.join("src")).unwrap();
2067 std::fs::write(root.join("src").join("WidgetLedger.cs"), LEDGER_CS).unwrap();
2068 std::fs::write(root.join("src").join("Panel.xaml"), PANEL_XAML).unwrap();
2069 std::fs::write(root.join("src").join("Strings.resw"), STRINGS_RESW).unwrap();
2070 std::fs::create_dir_all(root.join(".scout")).unwrap();
2071 std::fs::write(
2072 root.join(".scout").join("manifest.json"),
2073 r#"{ "entries": {} }"#,
2074 )
2075 .unwrap();
2076
2077 let fragments = vec![
2078 (
2079 "src/WidgetLedger.cs".to_string(),
2080 graph::fragment_from_extraction(&crate::extract::extract(LEDGER_CS)),
2081 ),
2082 (
2083 "src/Panel.xaml".to_string(),
2084 graph::markup_fragment(&root, "src/Panel.xaml").unwrap(),
2085 ),
2086 (
2087 "src/Strings.resw".to_string(),
2088 graph::markup_fragment(&root, "src/Strings.resw").unwrap(),
2089 ),
2090 ];
2091 let g = crate::resolve::resolve_graph(&root, &fragments);
2092 std::fs::create_dir_all(root.join(".scout").join("graph")).unwrap();
2093 std::fs::write(
2094 root.join(".scout").join("graph").join("graph.json"),
2095 serde_json::to_string(&g).unwrap(),
2096 )
2097 .unwrap();
2098 root
2099 }
2100
2101 #[test]
2102 fn cmd_find_resolves_every_code_and_markup_category_to_its_declaration_line() {
2103 let root = name_index_root("findnames");
2107 for (query, expected) in [
2108 (
2109 "PopulateSlots",
2110 "src/WidgetLedger.cs:11 private void PopulateSlots() { }",
2111 ),
2112 (
2113 "Label",
2114 "src/WidgetLedger.cs:7 public string Label { get; set; }",
2115 ),
2116 (
2117 "_entryCount",
2118 "src/WidgetLedger.cs:5 private int _entryCount;",
2119 ),
2120 (
2121 "Retired",
2122 "src/WidgetLedger.cs:9 public event EventHandler Retired;",
2123 ),
2124 (
2125 "Gadgets.PanelView",
2126 "src/Panel.xaml:2 x:Class=\"Gadgets.PanelView\">",
2127 ),
2128 ] {
2129 let (code, out) = cmd_find(&root, query, false);
2130 assert_eq!(code, 0, "{query} should be a hit");
2131 assert_eq!(out, expected, "{query}");
2132 }
2133 }
2134
2135 #[test]
2138 fn cmd_find_a_resource_key_only_query_brakes_correctly_by_default_and_is_a_hit_under_resources()
2139 {
2140 let root = name_index_root("findnames-resource-only");
2146 let (code, out) = cmd_find(&root, "ShipButton.Content", false);
2147 assert_eq!(
2148 code, EXIT_NO_RESULT,
2149 "a resource-key-only match is a zero hit by default"
2150 );
2151 assert_eq!(
2152 out,
2153 "no matches for \"ShipButton.Content\" (run 'devscout map' if manifest is missing)"
2154 );
2155
2156 let (code, out) = cmd_find(&root, "ShipButton.Content", true);
2157 assert_eq!(code, 0, "--resources lifts the demotion");
2158 assert_eq!(
2159 out,
2160 "src/Strings.resw:3 <data name=\"ShipButton.Content\" xml:space=\"preserve\" />"
2161 );
2162 }
2163
2164 #[test]
2165 fn cmd_find_default_view_hides_resource_keys_behind_a_trailer_and_resources_shows_them_inline()
2166 {
2167 let root = name_index_root("findnames-tiering");
2172 let (code, out) = cmd_find(&root, "ShipButton", false);
2173 assert_eq!(code, 0);
2174 assert_eq!(
2175 out,
2176 "src/Panel.xaml:3 <Button x:Name=\"ShipButton\" />\n+1 resource-key hits, use --resources",
2177 "tier 3 is a trailer, never an inline row, by default"
2178 );
2179
2180 let (code, out) = cmd_find(&root, "ShipButton", true);
2181 assert_eq!(code, 0);
2182 assert_eq!(
2183 out,
2184 "src/Panel.xaml:3 <Button x:Name=\"ShipButton\" />\nsrc/Strings.resw:3 <data name=\"ShipButton.Content\" xml:space=\"preserve\" />",
2185 "--resources includes the resource-key row inline and drops the trailer"
2186 );
2187 }
2188
2189 #[test]
2190 fn cmd_find_puts_the_declaration_block_above_the_manifest_block() {
2191 let root = name_index_root("findnames-order");
2192 std::fs::write(
2193 root.join(".scout").join("manifest.json"),
2194 r#"{ "entries": { "src/WidgetLedger.cs": { "purpose": "class WidgetLedger", "mtime": 1 } } }"#,
2195 )
2196 .unwrap();
2197 let (code, out) = cmd_find(&root, "WidgetLedger", false);
2198 assert_eq!(code, 0);
2199 assert_eq!(
2200 out,
2201 "src/WidgetLedger.cs:3 public class WidgetLedger\nsrc/WidgetLedger.cs:3: class WidgetLedger"
2202 );
2203 }
2204
2205 #[test]
2211 fn cmd_find_manifest_pool_row_carries_its_files_first_declaration_line() {
2212 let root = name_index_root("findnames-manifest-line");
2213 std::fs::write(
2214 root.join(".scout").join("manifest.json"),
2215 r#"{ "entries": { "src/WidgetLedger.cs": { "purpose": "class WidgetLedger; Ship", "mtime": 1 } } }"#,
2216 )
2217 .unwrap();
2218 let (code, out) = cmd_find(&root, "WidgetLedger", false);
2219 assert_eq!(code, 0);
2220 let lines: Vec<&str> = out.split('\n').collect();
2221 let manifest_row = lines
2222 .iter()
2223 .find(|l| l.starts_with("src/WidgetLedger.cs:") && l.contains("; Ship"));
2224 assert_eq!(
2225 manifest_row,
2226 Some(&"src/WidgetLedger.cs:3: class WidgetLedger; Ship"),
2227 "{out}"
2228 );
2229 }
2230
2231 #[test]
2235 fn cmd_find_manifest_pool_row_for_a_file_with_no_declaration_falls_back_to_line_1() {
2236 let root = name_index_root("findnames-manifest-no-decl");
2237 std::fs::write(root.join("src").join("Notes.md"), "# widget notes\n").unwrap();
2238 std::fs::write(
2239 root.join(".scout").join("manifest.json"),
2240 r#"{ "entries": { "src/Notes.md": { "purpose": "widget notes doc", "mtime": 1 } } }"#,
2241 )
2242 .unwrap();
2243 let (code, out) = cmd_find(&root, "widget", false);
2244 assert_eq!(code, 0);
2245 assert!(out.contains("src/Notes.md:1: widget notes doc"), "{out}");
2246 }
2247
2248 #[test]
2249 fn cmd_find_still_takes_the_zero_hit_exit_when_neither_index_matches() {
2250 let root = name_index_root("findnames-zero");
2251 let (code, out) = cmd_find(&root, "Zzzznomatch", false);
2252 assert_eq!(code, EXIT_NO_RESULT);
2253 assert_eq!(
2254 out,
2255 "no matches for \"Zzzznomatch\" (run 'devscout map' if manifest is missing)"
2256 );
2257 }
2258
2259 fn clear_root(prefix: &str) -> PathBuf {
2265 let root = std::env::temp_dir().join(format!("scout-cli-{prefix}-{}", std::process::id()));
2266 let _ = std::fs::remove_dir_all(&root);
2267 std::fs::create_dir_all(root.join(".scout")).unwrap();
2268 root
2269 }
2270
2271 fn seed_row(conn: &rusqlite::Connection, session_id: &str, rel_path: &str) {
2272 store::record_fresh(
2273 conn,
2274 &store::RecordFresh {
2275 session_id,
2276 agent_id: "",
2277 rel_path,
2278 sha256: "sha",
2279 size: 10,
2280 mtime: 1,
2281 lines: 1,
2282 delivered: true,
2283 },
2284 )
2285 .unwrap();
2286 }
2287
2288 #[test]
2289 fn cmd_clear_whole_store_removes_cache_db_and_answers_cache_cleared() {
2290 let root = clear_root("clear-whole");
2291 {
2292 let conn = store::open_store(&root).unwrap();
2293 seed_row(&conn, "s1", "a.ts");
2294 }
2295 assert!(root.join(".scout").join("cache.db").exists());
2296 let (code, out) = cmd_clear(&root, &[]);
2297 assert_eq!(code, 0);
2298 assert_eq!(out, "cache cleared");
2299 assert!(!root.join(".scout").join("cache.db").exists());
2300 }
2301
2302 #[test]
2303 fn cmd_clear_older_than_rejects_negative_and_non_numeric_with_usage_code_2() {
2304 let root = clear_root("clear-older-bad");
2305 for bad in ["-5", "abc", ""] {
2306 let (code, out) = cmd_clear(&root, &["--older-than".to_string(), bad.to_string()]);
2307 assert_eq!(code, 2, "arg {bad:?}");
2308 assert_eq!(
2309 out, "usage: devscout clear --older-than <days>",
2310 "arg {bad:?}"
2311 );
2312 }
2313 let (code, out) = cmd_clear(&root, &["--older-than".to_string()]);
2315 assert_eq!(code, 2);
2316 assert_eq!(out, "usage: devscout clear --older-than <days>");
2317 assert!(
2318 !root.join(".scout").join("cache.db").exists(),
2319 "a rejected flag must not create a store"
2320 );
2321 }
2322
2323 #[test]
2324 fn cmd_clear_session_unique_prefix_deletes_only_that_sessions_rows() {
2325 let root = clear_root("clear-session-unique");
2326 {
2327 let conn = store::open_store(&root).unwrap();
2328 seed_row(&conn, "abc-1111", "a.ts");
2329 seed_row(&conn, "abc-2222", "b.ts");
2330 }
2331 let (code, out) = cmd_clear(&root, &["--session".to_string(), "abc-1".to_string()]);
2332 assert_eq!(code, 0);
2333 assert_eq!(out, "deleted 1 row for session abc-1111");
2334
2335 let conn = store::open_store(&root).unwrap();
2336 assert!(store::lookup_read(&conn, "abc-1111", "a.ts", "")
2337 .unwrap()
2338 .is_none());
2339 assert!(store::lookup_read(&conn, "abc-2222", "b.ts", "")
2340 .unwrap()
2341 .is_some());
2342 }
2343
2344 #[test]
2348 fn cmd_clear_session_ambiguous_prefix_refuses_with_code_2_and_lists_both_ids() {
2349 let root = clear_root("clear-session-ambiguous");
2350 {
2351 let conn = store::open_store(&root).unwrap();
2352 seed_row(&conn, "abc-1111", "a.ts");
2353 seed_row(&conn, "abc-2222", "b.ts");
2354 }
2355 let (code, out) = cmd_clear(&root, &["--session".to_string(), "abc".to_string()]);
2356 assert_eq!(code, 2);
2357 assert_eq!(out, "ambiguous session prefix \"abc\": abc-1111, abc-2222");
2358
2359 let conn = store::open_store(&root).unwrap();
2361 assert!(store::lookup_read(&conn, "abc-1111", "a.ts", "")
2362 .unwrap()
2363 .is_some());
2364 assert!(store::lookup_read(&conn, "abc-2222", "b.ts", "")
2365 .unwrap()
2366 .is_some());
2367 }
2368
2369 #[test]
2370 fn cmd_clear_session_no_match_is_a_success_not_a_refusal() {
2371 let root = clear_root("clear-session-none");
2372 {
2373 let conn = store::open_store(&root).unwrap();
2374 seed_row(&conn, "abc-1111", "a.ts");
2375 }
2376 let (code, out) = cmd_clear(&root, &["--session".to_string(), "zzz".to_string()]);
2377 assert_eq!(code, 0);
2378 assert_eq!(out, "no sessions match \"zzz\"");
2379 }
2380
2381 #[test]
2382 fn cmd_clear_session_missing_value_is_a_usage_error() {
2383 let root = clear_root("clear-session-missing");
2384 let (code, out) = cmd_clear(&root, &["--session".to_string()]);
2385 assert_eq!(code, 2);
2386 assert_eq!(out, "usage: devscout clear --session <id-or-prefix>");
2387 }
2388
2389 #[test]
2390 fn cmd_clear_older_than_flag_takes_priority_over_session_flag_like_node_indexof_order() {
2391 let root = clear_root("clear-precedence");
2394 {
2395 let conn = store::open_store(&root).unwrap();
2396 seed_row(&conn, "abc-1111", "a.ts");
2397 }
2398 let (code, out) = cmd_clear(
2399 &root,
2400 &[
2401 "--session".to_string(),
2402 "abc-1111".to_string(),
2403 "--older-than".to_string(),
2404 "9999".to_string(),
2405 ],
2406 );
2407 assert_eq!(code, 0);
2408 assert_eq!(
2409 out, "deleted 0 rows older than 9999d",
2410 "the --older-than branch must win"
2411 );
2412 }
2413}