Skip to main content

devscout_rs/
cli.rs

1// Arg parsing + dispatch for the subcommand set. Dispatch is hand-rolled; no
2// flag-parsing dependency is pulled in until a subcommand needs real flag
3// parsing.
4//
5// Alongside the user-facing verbs (README.md), four dev/diagnostic subcommands
6// are wired here and nowhere else: `noop` (cold-start floor), `parse` and
7// `spans` (seed AST dumps), and `extract-dump` (full-extraction JSON).
8//
9// `init` dispatches through `initcmd::cmd_init_full`, NOT `cmd_init` directly --
10// see initcmd.rs for why that split is load-bearing rather than stylistic.
11
12use 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
28// Command output writer. `println!` PANICS on a broken pipe ("failed printing to
29// stdout"), which would kill `devscout find | head` noisily the moment `head`
30// closed its end. Exit silently there instead: write through `write_all` and
31// swallow the error -- on stdout that error is effectively only EPIPE, and the
32// exit code the command already computed still gets delivered by the caller's
33// `process::exit`.
34fn 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
43// A zero-hit answer is neither success nor failure: the index was read and the
44// answer is empty. Distinct from 1 (environment, refusal) and 2 (usage) so a
45// caller can branch on it instead of rephrasing the query.
46const EXIT_NO_RESULT: i32 = 3;
47
48// These four notes go to STDERR, never stdout, so a zero hit leaves stdout
49// exactly as empty as it was -- a caller parsing stdout sees no difference, and
50// the note is advice for a human reader.
51const 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
57// The one place a zero-hit line is emitted. The "did you mean" candidates extend
58// that same note rather than adding a second one; `query` carries the name the
59// caller asked for on the two verbs that offer them and is `None` on the two that
60// do not. Broken-pipe errors are swallowed for the same reason `print_out`
61// swallows them.
62fn 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
82// Query-time index freshness, `find`/`refs`/`impact` only: `map` just rebuilt
83// the index and has nothing to say about it being stale relative to itself. Root
84// resolution mirrors `require_repo`'s plain climb, never
85// `require_repo_for_path`'s argument-named-file fallback -- a query that only
86// resolved its root through an argument gets no freshness check, silently, the
87// safe default. Called BEFORE `emit_zero_hit_note` so the two lines land on
88// stderr in a fixed order when both fire for the same query. Never touches
89// stdout, never changes the exit code.
90fn 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
105// The nearest names a zero-hit `find`/`refs` offers, never substituted for the
106// query and never run. The graph is read here rather than carried out of the
107// command that just ran: this path is reached only once that command has decided
108// it has nothing to print.
109fn 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
119// The first non-flag argument -- the symbol `refs` takes. Shared with
120// `dispatch`, which needs the same string to build the suggestion block for a
121// query `cmd_refs` has already reported as a zero hit.
122fn first_positional(args: &[String]) -> Option<&str> {
123    args.iter()
124        .find(|a| !a.starts_with("--"))
125        .map(String::as_str)
126}
127
128// `devscout help` / `--help`. Written to stdout with exit 0 so a shell
129// `devscout --help` is a success, unlike the usage line the unknown-command
130// arm sends to stderr.
131const 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
163/// Parses and executes a `devscout` command from its argument vector.
164pub fn dispatch(args: Vec<String>) {
165    let (cwd, args) = match apply_global_options(&current_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            // `--resources` is parsed out first, then every remaining arg after
240            // the subcommand becomes one space-joined query, same as before the
241            // flag existed.
242            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
282// Shared `hook read`/`hook bash` plumbing: read all of stdin (best-effort -- a
283// stdin read failure just means an empty/partial buffer goes into the fail-open
284// decision path, same outcome), run the hook, write stdout only if non-empty,
285// exit 0 unconditionally. Fail-open: a broken scout must never break the tool
286// call whose result it is post-processing, so this function itself must never
287// propagate a nonzero exit or a panic.
288fn 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
298// ---------------------------------------------------------------------------
299// `refs`/`impact`/`find`. Each command function returns `(code, out)` directly;
300// the few points that would otherwise be a thrown error (`require_repo`'s
301// missing-root error, `find`'s corrupt-manifest error) build the same `"error:
302// {msg}"` string by hand. All command output goes to stdout -- these commands
303// never write to stderr; the dispatcher writes `out` and exits with `code`.
304// ---------------------------------------------------------------------------
305
306fn current_dir() -> PathBuf {
307    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
308}
309
310// The repo root for `cwd`: an initialized `.scout` ancestor wins; otherwise fall
311// back to the nearest `.git` ancestor. `Err` carries the message callers wrap as
312// `"error: {message}"`.
313fn require_repo(cwd: &Path) -> Result<PathBuf, String> {
314    require_repo_for_path(cwd, None)
315}
316
317// Root resolution with a fallback to the verb's own path argument. `arg_path` is
318// consulted only after the caller's directory has come up empty, so a caller
319// sitting in a repo never has its root decided by an argument pointing outside
320// it.
321fn 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
331// Only a path-shaped argument naming something that is actually on disk is
332// allowed to decide a root; a symbol, or a repo-relative path that means nothing
333// from where the caller stands, resolves to a directory that has no bearing on
334// the query and is refused.
335fn 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
347// The manifest and the graph key files by their exact repo-relative path, so an
348// absolute or subdirectory-relative argument is a guaranteed miss; rewriting one
349// that names a real path inside the root can only turn that miss into an answer.
350// Everything else -- a symbol, a path that is not on disk, a path outside the
351// root -- is handed back untouched, so output matches the same query run from the
352// root itself.
353fn 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
367// `-C <dir>`, git's own semantics: every root resolution and every relative path
368// argument below reads as if the process had started in `<dir>`. Consumed before
369// the subcommand, so it composes on repeat exactly as git's does. `args[0]` (the
370// program name) is carried through untouched, keeping every caller's argument
371// indices as they were.
372fn 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
394// Shared by `cmd_refs`/`cmd_impact` -- the "never guess" house rule: print every
395// candidate's `{id, def site, kind}` and exit 1, regardless of
396// `--compact`/`--json` (see `cmd_refs`/`cmd_impact`: this is reached BEFORE
397// either flag is consulted).
398fn 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            // Every id here was sourced from `index.by_simple_name`/
403            // `by_lower_name`, both built from `index.by_id`'s own keys during
404            // construction -- `index.def(id)` cannot miss. `.expect` fails loud
405            // if that invariant ever broke.
406            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    // `Vec<String>::sort` compares by UTF-8 byte order, which for the ASCII
413    // identifier/path/kind text these rows are built from is a stable, total
414    // order (the same seam resolve.rs's candidate sort documents).
415    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
424// Lenient integer parse: skip nothing but a leading sign, take the longest
425// leading run of ASCII digits, ignore trailing garbage; no digits at all
426// (including an absent/empty string) is `None`. Used by the `--hops`,
427// `--iface-max-fanin` and `--hub-max-indegree` parsing, so a value like `"3abc"`
428// parses to `3` rather than being a usage error.
429fn 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
446// `refs`. Check order: `--compact`+`--json` conflict, then missing query, THEN
447// `require_repo`, THEN the graph-present check -- a query run with no repo
448// present reports the missing-repo error even if the query itself is also
449// absent-adjacent.
450fn 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    // `--all` lifts only `query::OUTBOUND_CAP`; it is otherwise inert without
467    // `--out`, same as `--out` is inert on the bare-member fallback path.
468    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        // A bare member answers with one ordinary refs model per declaring type,
493        // so each block renders through the very renderer a type uses and
494        // `--json` wraps those same objects in an array rather than reshaping
495        // them.
496        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
531// `read`. Check order is `refs`' own: flag conflict, missing query,
532// `require_repo`, graph-present. The resolution IS refs' --
533// `build_read_model` wraps `build_refs_model` and changes nothing about how
534// a name becomes an answer -- so the ambiguity and zero-hit discipline
535// cannot drift between the two verbs; only the resolved arm grows the
536// declaration span.
537fn 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        // A bare member answers through refs' own member rendering in all
568        // three forms: the member answer carries no declaration-span fact to
569        // add, so reshaping it here would only create a second shape to keep
570        // in step.
571        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
606// `impact`. Check order: `--compact`+`--json` conflict, `--hops` parse (usage
607// error on a bad/missing value), missing query, THEN `require_repo`, THEN the
608// graph-present check.
609fn 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    // `--iface-max-fanin`'s own value is never the query. `0` is a legal value
629    // and does not start with `--`, so without the guard below it would be picked
630    // up as the seed. Parsed AFTER `--hops` and BEFORE the missing-query check.
631    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    // The same rule as `--iface-max-fanin`: its own value is never the query, and
641    // `0` is a legal value that does not start with `--`.
642    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    // The first non-flag argument that is not the value of `--hops`,
652    // `--iface-max-fanin`, or `--hub-max-indegree`.
653    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            // A resolved seed that reaches nothing beyond its own files is the
709            // same answer as an unresolved one -- empty -- and gets the same
710            // signal.
711            if model.rows.is_empty() {
712                (EXIT_NO_RESULT, out)
713            } else {
714                (0, out)
715            }
716        }
717    }
718}
719
720// `tests`. Mirrors `cmd_refs` -- same flag conflict, same missing-query usage
721// error, same `require_repo`/graph-present order, same notfound/ambiguous exits.
722fn 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
764// Output caps: an uncapped find can dump the whole near-match pool (measured
765// 270-450KB on broad multi-token queries against a 5k-file manifest). The
766// full-match pool is what the user asked for, so it gets the wider cap; the
767// OR-fallback pool is near-matches only, so it gets the tighter one. Both
768// manifest pools are RANKED before their cap bites -- tokens matched, then the
769// file's inbound-edge count (see `manifest::find_in_manifest_detailed`) -- so
770// the rows a cap drops are the weakest, not just the last.
771const FIND_FULL_CAP: usize = 25;
772const FIND_FALLBACK_CAP: usize = 10;
773// The declaration block gets its own cap: it is a different pool from the
774// manifest's, and sharing one would let a name carried by 200 members swallow
775// the file rows the same query earned. It stays in build order on purpose --
776// ranking is the manifest pools' job, not this one's.
777const FIND_NAMES_CAP: usize = 25;
778
779// `find`. Check order: `require_repo` FIRST, THEN the missing-query check -- the
780// one query command that does NOT check its own usage before root resolution
781// (deliberate, not a slip). Caps per pool kind; the `… +K more (refine query)`
782// tail line keeps the true pool size honest.
783fn 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    // The declaration block leads: a caller who named a member wants the site,
792    // and the manifest block keeps its own tail line at the bottom where it
793    // already sat. `file:line  <source line>` -- two spaces -- degrading to bare
794    // `file:line` when the line cannot be read. A repo with no graph.json (never
795    // mapped, or no graph source in scope) contributes no block at all.
796    //
797    // Tier 1 (a code symbol) and tier 2 (a markup/binding name) are the default
798    // pool; tier 3 (a resource key) is demoted to a one-line trailer unless
799    // `--resources` asks for it inline. The zero-hit brake below reads
800    // `decl_lines`, so it considers tiers 1-2 only: a query that matches nothing
801    // but resource keys is a miss for it, correctly, even though `named` itself is
802    // non-empty.
803    let graph = graph::read_graph(&root);
804    // One graph read per query: the ranking map folds off the SAME read the
805    // declaration block uses. No graph file (never mapped) reads as an empty
806    // map -- every entry ranks at 0 inbound and the manifest answers in its
807    // on-disk order, exactly as it did before this existed.
808    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            // Every manifest-pool row carries a line too, same as the declaration
865            // block above it: the file's own first declaration where the name
866            // index has one, line 1 (an always-valid "open the file" anchor) for a
867            // file the index carries no declared symbol for at all.
868            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                // An absent purpose renders as the literal text "undefined"
874                // -- not an empty string. See manifest.rs's `FindHit::purpose`
875                // doc comment.
876                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
890// `map`. Strips the no-op alias flag `--refresh` before anything else runs; the
891// remaining tokens are the scope dirs, passed straight through to
892// `mapcmd::map_repo`. This function's only job is the CLI glue: `require_repo`,
893// flag stripping, and printing `MapReport::summary_line()`. `MapOptions::from_env()`
894// decides the fragment-reuse mode (mapcmd.rs's own module header): content-hash
895// reuse is the default, `SCOUT_MTIME_REUSE=1` drops back to mtime keying.
896fn 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
912// `stats`. The read-cache/bash-cache/session/top-stubbed queries are NOT wrapped
913// in error handling, so any error there aborts the whole command (an early
914// `return` with the same `"error: {msg}"` shape every other command in this file
915// uses); only the cross-repo content-store block IS fail-open, two chained `if
916// let Ok(..)`s that silently produce no lines on either failure.
917fn 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    // Fail open on EITHER the content-store open or the stats query.
957    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            // Session ids are ASCII (uuid/hash) in every real writer, so a
981            // char-based slice of the first 8 is unambiguous.
982            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
1012// `clear`. Three forms, checked in this exact order so the first flag present
1013// wins even if both appear: `--older-than <days>` prunes rows untouched for that
1014// many days, `--session <prefix>` prunes one session by id prefix (refusing an
1015// ambiguous one), and with neither flag the whole `cache.db` file is removed. The
1016// whole-store form never calls `open_store` -- it only builds the path and checks
1017// existence, so a repo that never wrote a cache.db is never given one just to
1018// clear it -- and the WAL/SHM sidecar files SQLite leaves beside it are
1019// deliberately NOT removed here (a plain `remove_file` on the db path).
1020fn 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
1096// `Math.round`-style rounding: `floor(x + 0.5)`, NOT Rust's `f64::round` (which
1097// rounds ties away from zero -- the two agree for every non-negative input,
1098// which is all `cmd_stats` ever feeds this, but this spells out the exact rule
1099// rather than relying on that coincidence).
1100fn js_math_round(x: f64) -> i64 {
1101    (x + 0.5).floor() as i64
1102}
1103
1104// ---------------------------------------------------------------------------
1105// `--json` output shaping. query.rs's model types deliberately derive no
1106// `Serialize` (its own module header) -- the byte shape is built here, by hand,
1107// key order included.
1108//
1109// This does NOT reuse `manifest::Value` (the crate's other order-preserving JSON
1110// value): its `Number` variant serializes floats through serde_json's own
1111// `Number::serialize`, which always keeps a decimal point (`1.0`, `100.0`) where
1112// the target JSON shape drops it for integral values (`1`, `100`). `score` (a
1113// `personalized_page_rank` output) is the only float anywhere in this output, so
1114// a tiny local ordered-value type with a pre-formatted-number escape hatch
1115// (`J::RawNum`) is used instead. String/key fields still delegate to
1116// `serde_json::to_string` for escaping (control chars, `"`, `\`).
1117// ---------------------------------------------------------------------------
1118
1119enum J {
1120    Str(String),
1121    UInt(u64),
1122    RawNum(String),
1123    // Only ever built as `true`: `heuristic: true` is written and the key is
1124    // omitted entirely otherwise, so a `false` never reaches this encoder.
1125    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
1171// ECMA-262 `JSON.stringify` number formatting for a finite `f64`. Digit selection
1172// is serde_json's shortest-round-trip float formatter, which agrees with the
1173// ECMA-262 shortest-round-trip representation. Two adjustments bring it fully in
1174// line: the decimal point is dropped for an integral value in plain notation
1175// (`1`, not `1.0`), and zero is never signed (`-0` -> `"0"`); serde_json's
1176// `Number` keeps a `.0` and would emit `-0`. Exponential notation already matches
1177// (`1e+21`, `1e-7`) and passes through unchanged. `score` is always finite in
1178// practice; the non-finite branch coerces `NaN`/`Infinity` to `null` (as
1179// `JSON.stringify` does) as cheap insurance, not because personalized_page_rank
1180// can produce one.
1181fn 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
1206// `heuristic: true` is appended LAST on a guessed row and the key is ABSENT on a
1207// precise one -- the flag is added only in the heuristic branch, so a precise
1208// row's JSON carries no trace of it at all.
1209fn 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    // `source` is appended after `heuristic` and omitted when the line could not
1222    // be read -- an absent key, never an empty string.
1223    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    // `source` is appended after `heuristic`, the same append-last/omit-when-empty
1237    // rule `j_inbound_row` follows.
1238    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
1264// The resolved `refs` JSON shape (`build_refs_model`'s resolved return):
1265// `{status, query, id, kind, sites, inbound, [outbound], ambiguous,
1266// manifestGap}`, in that key order. `outbound` sits between `inbound` and
1267// `ambiguous` only under `--out`; the key is either in that slot or absent
1268// entirely.
1269fn refs_model_to_json(model: &query::RefsModel) -> String {
1270    refs_model_j(model).to_json_string()
1271}
1272
1273// The resolved `read` JSON shape: exactly the refs shape with ONE key
1274// inserted -- `"span"` sits between `kind` and `sites`, carrying `{file,
1275// startLine, endLine, source}`. The key is ABSENT when no span is on record
1276// (a def whose end line was never extracted), the same honest-absence rule
1277// `outbound` follows; a caller cannot mistake a start-only answer for a
1278// span.
1279fn read_model_to_json(model: &query::ReadModel) -> String {
1280    let mut fields = refs_model_fields(&model.refs);
1281    // `split_off(4)` lifts everything after the first four keys
1282    // (status/query/id/kind) so `span` can take their place in line.
1283    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
1299// The bare-member `refs` JSON: `{status:'members', query, members}`, where each
1300// member is `refs_model_j`'s object unchanged -- the bare-member answer reshapes
1301// nothing, it only says how many declaring types answered.
1302fn 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    // Appended LAST, after `manifestGap`, and only for an enum with member-level
1377    // references; an absent key keeps every other symbol's `--json` bytes
1378    // unchanged.
1379    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    // `heuristicCount` then `heuristic`, both appended after `score` and both
1420    // present only on a heuristic-only row (JS assigns them inside the same
1421    // `if`).
1422    if r.heuristic {
1423        fields.push(("heuristicCount", J::UInt(r.heuristic_count as u64)));
1424        fields.push(("heuristic", J::Bool(true)));
1425    }
1426    // Appended LAST, present only on a row the interface hop actually reached.
1427    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    // Appended after `ifaceVia`, present only when at least one edge kind could
1434    // attribute a line to this row. Key order inside the object is the walk's own
1435    // kind declaration order, fixed in `from_lines_of`, never a map iteration.
1436    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    // Appended LAST and only on a hub file, so every other row keeps the key
1448    // order it had.
1449    if r.infra {
1450        fields.push(("class", J::Str("infra".to_string())));
1451    }
1452    J::Obj(fields)
1453}
1454
1455// The resolved `impact` JSON shape (`build_impact_model`'s resolved return),
1456// with the query key first: `{query, status, kind, seedFiles, hops,
1457// totalAffected, rows, dropped, manifestGap, heuristicAffected}`, in that key
1458// order.
1459fn 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            // Appended LAST after `manifestGap` -- always present, unlike the
1481            // per-row flags.
1482            (
1483                "heuristicAffected",
1484                J::UInt(model.heuristic_affected as u64),
1485            ),
1486            // Test-coverage stage, appended after it -- also always present.
1487            ("testsAffected", J::UInt(model.tests_affected as u64)),
1488        ]
1489        .into_iter()
1490        // Appended LAST and only when the brake actually fired, so every answer it
1491        // never touched keeps the exact key order it had before. The file entries
1492        // ride in the SAME array, after every interface entry, rather than in a
1493        // second top-level key: a consumer already reading `braked` sees both brakes
1494        // without a schema change.
1495        .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
1527// The resolved `tests` JSON shape (`build_tests_model`'s resolved return):
1528// `{status, query, symbol, defFiles, rows, testFileCount, refCount,
1529// heuristicFileCount, heuristicRefCount}`, in that key order, with the heuristic
1530// pair LAST.
1531fn 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        // Expected values for JSON number formatting of each of these:
1586        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        // Expected `Math.round`-style rounding for each of these:
1602        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    // `--json` key ORDER, which a parsed-object comparison cannot see (object key
1639    // order is not observable there). These pin three placements: a refs row's
1640    // `heuristic` appended LAST and absent when precise, an impact row's
1641    // `heuristicCount` then `heuristic` appended after `score` and both absent
1642    // when precise, and `heuristicAffected` appended after `manifestGap` on the
1643    // model itself.
1644
1645    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    // The expected strings below pin the find output caps; the integration suite
1838    // also exercises the same caps end to end.
1839    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    // The note text and the stream it lands on are pinned in
1878    // tests/cli_zero_hit.rs, which can see both streams; what belongs here is
1879    // that the code the verb computes is EXIT_NO_RESULT and not 0.
1880    #[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    // --- the declaration block ----------------------------------------------
1901
1902    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    // A repo whose graph.json is the one `map` would write for these three files
1908    // -- built through the real resolver, not hand-authored JSON, so the test
1909    // cannot drift from the index the map path actually produces.
1910    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        // A resource key (`ShipButton.Content`) is not in this loop: it is tier
1951        // 3, hidden by default. Its own default/--resources behavior is pinned
1952        // separately below.
1953        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    // --- kind tiering --------------------------------------------------------
1983
1984    #[test]
1985    fn cmd_find_a_resource_key_only_query_brakes_correctly_by_default_and_is_a_hit_under_resources()
1986    {
1987        // `ShipButton.Content` matches ONLY the resource-key row -- no
1988        // markup-name row contains the full stop, and the manifest is empty -- so
1989        // this is the drowned-query case: a query that would otherwise return
1990        // resource keys (here, one) and nothing else brakes, correctly, instead
1991        // of looking like a hit.
1992        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        // `ShipButton` matches BOTH the tier-2 markup-name `ShipButton` and
2015        // the tier-3 resource key `ShipButton.Content` (substring). Default:
2016        // the tier-2 row shows, the tier-3 row is a one-line trailer. Under
2017        // `--resources`: both rows show, inline, in build order, no trailer.
2018        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    // The manifest-pool row carries its own file's first declaration line, not
2053    // just the declaration block above it. `Panel.xaml` has no manifest entry in
2054    // `name_index_root`'s fixture, so this proves the line on a row that has ONE
2055    // (a real manifest hit for a file the name index also carries a declaration
2056    // for), independent of the declaration block.
2057    #[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    // A manifest hit whose file the name index carries NO declaration for at all
2079    // (a plain, undeclared source file) falls back to line 1 -- an always-valid
2080    // "open the file" anchor -- rather than emitting a row with no line.
2081    #[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    // --- `clear` --------------------------------------------------------------
2107    //
2108    // In-process coverage of `cmd_clear`, fast and independent of the
2109    // subprocess byte-parity gate in the integration suite.
2110
2111    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        // A bare `--older-than` with nothing after it (missing value entirely).
2161        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    // The ambiguous-prefix refusal: two sessions share the "abc" prefix; asking
2192    // for exactly that prefix must refuse on code 2, name both candidates, and
2193    // delete nothing.
2194    #[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        // Refused, so read-only: both sessions' rows still stand.
2207        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        // `clear` checks `--older-than` before `--session`, so a call carrying
2239        // both takes the age-based branch, whatever `--session` says.
2240        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}