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;
15use std::time::Instant;
16
17use crate::audit;
18use crate::extract;
19use crate::graph;
20use crate::hookio;
21use crate::initcmd;
22use crate::manifest;
23use crate::mapcmd;
24use crate::parse;
25use crate::query;
26use crate::query::json::J;
27use crate::render;
28use crate::store;
29use crate::suggest;
30use crate::telemetry;
31
32// Command output writer. `println!` PANICS on a broken pipe ("failed printing to
33// stdout"), which would kill `devscout find | head` noisily the moment `head`
34// closed its end. Exit silently there instead: write through `write_all` and
35// swallow the error -- on stdout that error is effectively only EPIPE, and the
36// exit code the command already computed still gets delivered by the caller's
37// `process::exit`.
38fn print_out(out: &str) {
39    use std::io::Write;
40    let stdout = std::io::stdout();
41    let mut lock = stdout.lock();
42    let _ = lock
43        .write_all(out.as_bytes())
44        .and_then(|()| lock.write_all(b"\n"));
45}
46
47// A zero-hit answer is neither success nor failure: the index was read and the
48// answer is empty. Distinct from 1 (environment, refusal) and 2 (usage) so a
49// caller can branch on it instead of rephrasing the query.
50const EXIT_NO_RESULT: i32 = 3;
51
52// These four notes go to STDERR, never stdout, so a zero hit leaves stdout
53// exactly as empty as it was -- a caller parsing stdout sees no difference, and
54// the note is advice for a human reader.
55const ZERO_HIT_FIND: &str = "devscout find: zero hits — the manifest was searched and nothing matched. Not an error; fall back to text search (rg/grep) rather than rephrasing.";
56const ZERO_HIT_REFS: &str = "devscout refs: zero hits — the graph was searched and no symbol matched. Not an error; fall back to text search (rg/grep) rather than rephrasing.";
57const ZERO_HIT_READ: &str = "devscout read: zero hits — the graph was searched and no symbol matched. Not an error; fall back to text search (rg/grep) rather than rephrasing.";
58const ZERO_HIT_IMPACT: &str = "devscout impact: zero hits — the graph was searched and no affected file came back. Not an error; fall back to text search (rg/grep) rather than rephrasing.";
59const ZERO_HIT_TESTS: &str = "devscout tests: zero hits — the graph was searched and no symbol matched. Not an error; fall back to text search (rg/grep) rather than rephrasing.";
60
61// The one place a zero-hit line is emitted. The "did you mean" candidates extend
62// that same note rather than adding a second one; `query` carries the name the
63// caller asked for on the two verbs that offer them and is `None` on the two that
64// do not. A `None` note is a verb declining the advice: its answer resolved and
65// is merely empty, so pointing at a text search would send the caller after
66// something the graph has already answered. Broken-pipe errors are swallowed for
67// the same reason `print_out` swallows them.
68fn emit_zero_hit_note(code: i32, note: Option<&str>, cwd: &Path, query: Option<&str>) {
69    let Some(note) = note else { return };
70    if code != EXIT_NO_RESULT {
71        return;
72    }
73    let mut text = String::from(note);
74    let rows = query.map(|q| nearest_names(cwd, q)).unwrap_or_default();
75    if !rows.is_empty() {
76        text.push_str("\ndid you mean:");
77        for row in rows {
78            text.push('\n');
79            text.push_str(&row);
80        }
81    }
82    let stderr = std::io::stderr();
83    let mut lock = stderr.lock();
84    let _ = lock
85        .write_all(text.as_bytes())
86        .and_then(|()| lock.write_all(b"\n"));
87}
88
89// Query-time index freshness, `find`/`refs`/`read`/`impact`/`tests` only: `map`
90// just rebuilt the index and has nothing to say about it being stale relative to
91// itself. Root resolution mirrors `require_repo`'s plain climb, never
92// `require_repo_for_path`'s argument-named-file fallback -- a query that only
93// resolved its root through an argument gets no freshness check, silently, the
94// safe default. Called BEFORE `emit_zero_hit_note` so the two lines land on
95// stderr in a fixed order when both fire for the same query. Never touches
96// stdout, never changes the exit code.
97fn emit_freshness_warning(cwd: &Path) {
98    let Some(root) = crate::repo::find_scout_root(cwd).or_else(|| crate::repo::find_repo_root(cwd))
99    else {
100        return;
101    };
102    let Some(warning) = manifest::freshness_warning(&root) else {
103        return;
104    };
105    let stderr = std::io::stderr();
106    let mut lock = stderr.lock();
107    let _ = lock
108        .write_all(warning.as_bytes())
109        .and_then(|()| lock.write_all(b"\n"));
110}
111
112// The nearest names a zero-hit `find`/`refs` offers, never substituted for the
113// query and never run. The graph is read here rather than carried out of the
114// command that just ran: this path is reached only once that command has decided
115// it has nothing to print.
116fn nearest_names(cwd: &Path, query: &str) -> Vec<String> {
117    let Ok(root) = require_repo(cwd) else {
118        return Vec::new();
119    };
120    let Some(g) = graph::read_graph(&root) else {
121        return Vec::new();
122    };
123    suggest::suggestion_lines(&g.names, query)
124}
125
126// The first non-flag argument -- the symbol `refs`/`read`/`tests` take.
127// Shared with `dispatch`, which needs the same string to build the suggestion
128// block for a query `cmd_refs`/`cmd_read` has already reported as a zero hit.
129// Skips `--pick`'s OWN value: unlike every other flag these three verbs
130// accept, `--pick N` carries a value that does not start with `--` and so
131// would otherwise be mistaken for the query itself.
132fn first_positional(args: &[String]) -> Option<&str> {
133    let mut skip_next = false;
134    for a in args {
135        if skip_next {
136            skip_next = false;
137            continue;
138        }
139        if a == "--pick" {
140            skip_next = true;
141            continue;
142        }
143        if !a.starts_with("--") {
144            return Some(a.as_str());
145        }
146    }
147    None
148}
149
150// `devscout help` / `--help`. Written to stdout with exit 0 so a shell
151// `devscout --help` is a success, unlike the usage line the unknown-command
152// arm sends to stderr.
153const HELP: &str = "\
154devscout -- fast code index for C# and TypeScript codebases.
155
156usage: devscout <command> [args]
157
158index
159  init [scope ...]           register this repo, install hooks, first map
160                             [--label L] [--no-hooks] [--no-map]
161  map [scope ...] [--refresh]  build the index for the given scopes
162  clear --older-than <days>  drop freshness rows older than N days
163  clear --session <id>       drop one session's freshness rows
164
165query
166  find <query> [--resources] search the manifest by name or purpose
167  refs <symbol>              references to a symbol   [--out --all --no-guess --pick N --json|--compact]
168  read <symbol>              decl span + inbound refs [--no-guess --pick N --json|--compact]
169  impact <file|symbol>       blast radius             [--hops N --no-guess --pick N --json|--compact]
170  tests <symbol>             tests reaching a symbol  [--no-guess --pick N --json|--compact]
171  stats                      index + cache summary for this repo
172
173plumbing
174  parse <file.cs>            dump the parse tree
175  spans <file.cs>            dump declaration spans
176  extract-dump <file.cs>     dump extraction records
177  audit --semantic <refs.jsonl>  score uses-member edges against a semantic oracle [--units F] [--defs F] [--json] [--assert F]
178  hook <read|bash>           agent hook filters, stdin -> stdout
179  noop                       exit 0 (harness probe)
180
181  -C <dir>                   run as if devscout started in <dir>
182  -h, --help                 show this help
183  -V, --version              show the version
184";
185
186/// Parses and executes a `devscout` command from its argument vector.
187#[allow(
188    clippy::too_many_lines,
189    reason = "one flat match over every subcommand; splitting it would scatter the dispatch table across files"
190)]
191pub fn dispatch(args: Vec<String>) {
192    let (cwd, args) = match apply_global_options(&current_dir(), &args) {
193        Ok(v) => v,
194        Err(e) => {
195            print_out(&format!("error: {e}"));
196            process::exit(1);
197        }
198    };
199    match args.get(1).map(String::as_str) {
200        Some("noop") => process::exit(0),
201        Some("help") | Some("--help") | Some("-h") => {
202            print!("{HELP}");
203            process::exit(0);
204        }
205        Some("--version") | Some("-V") => {
206            println!("devscout {}", env!("CARGO_PKG_VERSION"));
207            process::exit(0);
208        }
209        Some("parse") => {
210            let Some(path) = args.get(2) else {
211                eprintln!("usage: devscout parse <file.cs>");
212                process::exit(1);
213            };
214            parse::run_parse(path);
215        }
216        Some("spans") => {
217            let Some(path) = args.get(2) else {
218                eprintln!("usage: devscout spans <file.cs>");
219                process::exit(1);
220            };
221            parse::run_spans(path);
222        }
223        Some("extract-dump") => {
224            let Some(path) = args.get(2) else {
225                eprintln!("usage: devscout extract-dump <file.cs>");
226                process::exit(1);
227            };
228            extract::run_extract_dump(path);
229        }
230        Some("audit") => {
231            let (code, out) = audit::cmd_audit(&cwd, &args[2..]);
232            print_out(&out);
233            process::exit(code);
234        }
235        Some("hook") => match args.get(2).map(String::as_str) {
236            Some("read") => run_hook(hookio::run_read),
237            Some("bash") => run_hook(hookio::run_bash),
238            _ => {
239                eprintln!("usage: devscout hook <read|bash>");
240                process::exit(1);
241            }
242        },
243        Some("refs") => {
244            let (code, out, note) = cmd_refs(&cwd, &args[2..]);
245            print_out(&out);
246            emit_freshness_warning(&cwd);
247            emit_zero_hit_note(code, note, &cwd, first_positional(&args[2..]));
248            process::exit(code);
249        }
250        Some("read") => {
251            let (code, out, note) = cmd_read(&cwd, &args[2..]);
252            print_out(&out);
253            emit_freshness_warning(&cwd);
254            emit_zero_hit_note(code, note, &cwd, first_positional(&args[2..]));
255            process::exit(code);
256        }
257        Some("impact") => {
258            let (code, out) = cmd_impact(&cwd, &args[2..]);
259            print_out(&out);
260            emit_freshness_warning(&cwd);
261            emit_zero_hit_note(code, Some(ZERO_HIT_IMPACT), &cwd, None);
262            process::exit(code);
263        }
264        Some("tests") => {
265            let (code, out) = cmd_tests(&cwd, &args[2..]);
266            print_out(&out);
267            emit_freshness_warning(&cwd);
268            emit_zero_hit_note(code, Some(ZERO_HIT_TESTS), &cwd, None);
269            process::exit(code);
270        }
271        Some("find") => {
272            // `--resources` is parsed out first, then every remaining arg after
273            // the subcommand becomes one space-joined query, same as before the
274            // flag existed.
275            let resources = args[2..].iter().any(|a| a == "--resources");
276            let query_str = args[2..]
277                .iter()
278                .filter(|a| a.as_str() != "--resources")
279                .cloned()
280                .collect::<Vec<_>>()
281                .join(" ");
282            let (code, out) = cmd_find(&cwd, &query_str, resources);
283            print_out(&out);
284            emit_freshness_warning(&cwd);
285            emit_zero_hit_note(code, Some(ZERO_HIT_FIND), &cwd, Some(query_str.as_str()));
286            process::exit(code);
287        }
288        Some("map") => {
289            let (code, out) = cmd_map(&cwd, &args[2..]);
290            print_out(&out);
291            process::exit(code);
292        }
293        Some("stats") => {
294            let (code, out) = cmd_stats(&cwd);
295            print_out(&out);
296            process::exit(code);
297        }
298        Some("clear") => {
299            let (code, out) = cmd_clear(&cwd, &args[2..]);
300            print_out(&out);
301            process::exit(code);
302        }
303        Some("init") => {
304            let (code, out) = initcmd::cmd_init_full(&cwd, &args[2..]);
305            print_out(&out);
306            process::exit(code);
307        }
308        _ => {
309            eprintln!("usage: devscout <noop|parse|spans|extract-dump|hook|refs|read|impact|tests|find|map|stats|clear|init> [args]");
310            process::exit(1);
311        }
312    }
313}
314
315// Shared `hook read`/`hook bash` plumbing: read all of stdin (best-effort -- a
316// stdin read failure just means an empty/partial buffer goes into the fail-open
317// decision path, same outcome), run the hook, write stdout only if non-empty,
318// exit 0 unconditionally. Fail-open: a broken scout must never break the tool
319// call whose result it is post-processing, so this function itself must never
320// propagate a nonzero exit or a panic.
321fn run_hook(handler: fn(&[u8]) -> Vec<u8>) {
322    let mut buf = Vec::new();
323    let _ = std::io::stdin().read_to_end(&mut buf);
324    let out = handler(&buf);
325    if !out.is_empty() {
326        let _ = std::io::stdout().write_all(&out);
327    }
328    process::exit(0);
329}
330
331// ---------------------------------------------------------------------------
332// `refs`/`impact`/`find`. Each command function returns `(code, out)` directly;
333// the few points that would otherwise be a thrown error (`require_repo`'s
334// missing-root error, `find`'s corrupt-manifest error) build the same `"error:
335// {msg}"` string by hand. All command output goes to stdout -- these commands
336// never write to stderr; the dispatcher writes `out` and exits with `code`.
337// ---------------------------------------------------------------------------
338
339fn current_dir() -> PathBuf {
340    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
341}
342
343// The repo root for `cwd`: an initialized `.scout` ancestor wins; otherwise fall
344// back to the nearest `.git` ancestor. `Err` carries the message callers wrap as
345// `"error: {message}"`.
346//
347// `pub(crate)`: `audit.rs`'s `cmd_audit` resolves its repo root the same way
348// every other command here does.
349pub(crate) fn require_repo(cwd: &Path) -> Result<PathBuf, String> {
350    require_repo_for_path(cwd, None)
351}
352
353// Root resolution with a fallback to the verb's own path argument. `arg_path` is
354// consulted only after the caller's directory has come up empty, so a caller
355// sitting in a repo never has its root decided by an argument pointing outside
356// it.
357fn require_repo_for_path(cwd: &Path, arg_path: Option<&str>) -> Result<PathBuf, String> {
358    crate::repo::find_scout_root(cwd)
359        .or_else(|| crate::repo::find_repo_root(cwd))
360        .or_else(|| root_from_arg(cwd, arg_path))
361        .ok_or_else(|| {
362            "no .scout or .git ancestor; run 'devscout init' from the repo or directory root"
363                .to_string()
364        })
365}
366
367// Only a path-shaped argument naming something that is actually on disk is
368// allowed to decide a root; a symbol, or a repo-relative path that means nothing
369// from where the caller stands, resolves to a directory that has no bearing on
370// the query and is refused.
371fn root_from_arg(cwd: &Path, arg_path: Option<&str>) -> Option<PathBuf> {
372    let arg_path = arg_path?;
373    if !query::looks_like_file_path(arg_path) {
374        return None;
375    }
376    let abs = crate::repo::resolve_from(cwd, Path::new(arg_path));
377    if !abs.exists() {
378        return None;
379    }
380    crate::repo::find_scout_root(&abs).or_else(|| crate::repo::find_repo_root(&abs))
381}
382
383// The manifest and the graph key files by their exact repo-relative path, so an
384// absolute or subdirectory-relative argument is a guaranteed miss; rewriting one
385// that names a real path inside the root can only turn that miss into an answer.
386// Everything else -- a symbol, a path that is not on disk, a path outside the
387// root -- is handed back untouched, so output matches the same query run from the
388// root itself.
389fn repo_relative_arg(cwd: &Path, root: &Path, arg: &str) -> String {
390    if !query::looks_like_file_path(arg) {
391        return arg.to_string();
392    }
393    let abs = crate::repo::resolve_from(cwd, Path::new(arg));
394    if !abs.exists() {
395        return arg.to_string();
396    }
397    match abs.strip_prefix(root) {
398        Ok(rest) if !rest.as_os_str().is_empty() => crate::repo::rel_path(root, &abs),
399        _ => arg.to_string(),
400    }
401}
402
403// `-C <dir>`, git's own semantics: every root resolution and every relative path
404// argument below reads as if the process had started in `<dir>`. Consumed before
405// the subcommand, so it composes on repeat exactly as git's does. `args[0]` (the
406// program name) is carried through untouched, keeping every caller's argument
407// indices as they were.
408fn apply_global_options(cwd: &Path, args: &[String]) -> Result<(PathBuf, Vec<String>), String> {
409    let mut cwd = cwd.to_path_buf();
410    let mut idx = 1;
411    while args.get(idx).map(String::as_str) == Some("-C") {
412        let Some(dir) = args.get(idx + 1) else {
413            return Err("no directory given for '-C' option".to_string());
414        };
415        let next = crate::repo::resolve_from(&cwd, Path::new(dir));
416        if !next.is_dir() {
417            return Err(format!("cannot change to '{dir}': no such directory"));
418        }
419        cwd = next;
420        idx += 2;
421    }
422    if idx == 1 {
423        return Ok((cwd, args.to_vec()));
424    }
425    let mut rest = vec![args[0].clone()];
426    rest.extend_from_slice(&args[idx..]);
427    Ok((cwd, rest))
428}
429
430// The one JSON envelope both ambiguous answers share, so an ambiguous type and
431// an ambiguous member never drift into two shapes a consumer must tell apart:
432// only the candidate objects differ, each in its renderer's own vocabulary.
433fn ambiguous_json(q: &str, candidates: Vec<J>) -> String {
434    J::Obj(vec![
435        ("schema_version", J::UInt(query::SCHEMA_VERSION)),
436        (
437            "outcome",
438            J::Str(query::Outcome::Ambiguous.as_str().to_string()),
439        ),
440        ("query", J::Str(q.to_string())),
441        ("candidates", J::Arr(candidates)),
442    ])
443    .to_json_string()
444}
445
446// Shared by the four graph-reading verbs -- the "never guess" house rule: name
447// every candidate's `{id, def site, kind}` and exit 1. `--compact` is inert here
448// (a candidate list has no wide form to narrow); `--json` carries the same facts
449// under `member_ambiguous_out`'s own keys.
450fn ambiguous_candidates_out(
451    index: &query::GraphIndex,
452    q: &str,
453    ids: &[String],
454    json: bool,
455) -> (i32, String) {
456    let mut rows: Vec<(String, &String, &graph::Def)> = ids
457        .iter()
458        .map(|id| {
459            // Every id here was sourced from `index.by_simple_name`/
460            // `by_lower_name`, both built from `index.by_id`'s own keys during
461            // construction -- `index.def(id)` cannot miss. `.expect` fails loud
462            // if that invariant ever broke.
463            let d = index
464                .def(id)
465                .expect("ambiguous candidate id must resolve to a graph def");
466            (format!("{id}  {}:{}  {}", d.file, d.line, d.kind), id, d)
467        })
468        .collect();
469    // Sorting on the rendered row compares by UTF-8 byte order, which for the
470    // ASCII identifier/path/kind text these rows are built from is a stable,
471    // total order (the same seam resolve.rs's candidate sort documents), and
472    // gives the JSON array below the order the text list prints.
473    rows.sort_by(|a, b| a.0.cmp(&b.0));
474    if json {
475        let candidates = rows
476            .iter()
477            .map(|(_, id, d)| {
478                J::Obj(vec![
479                    ("id", J::Str((*id).clone())),
480                    ("file", J::Str(d.file.clone())),
481                    ("line", J::UInt(d.line as u64)),
482                    ("kind", J::Str(d.kind.clone())),
483                ])
484            })
485            .collect();
486        return (1, ambiguous_json(q, candidates));
487    }
488    let mut out = vec![format!(
489        "ambiguous symbol \"{q}\" — {} candidates:",
490        rows.len()
491    )];
492    out.extend(rows.into_iter().map(|(row, _, _)| row));
493    (1, out.join("\n"))
494}
495
496// Renders a member-ambiguous outcome (`RefsResult::MemberAmbiguous` and its
497// mirrors on `read`/`impact`/`tests`): one row per candidate, its declaring
498// type, file and line -- NEVER the bare `{id, def site, kind}` list
499// `ambiguous_candidates_out` renders for an ambiguous TYPE name. Both outcomes
500// answer under the same JSON keys, so a caller branching on `outcome` reads
501// either without knowing which kind of name it asked about.
502fn member_ambiguous_out(
503    q: &str,
504    candidates: &[query::MemberCandidate],
505    json: bool,
506) -> (i32, String) {
507    if json {
508        let rows = candidates
509            .iter()
510            .map(|c| {
511                J::Obj(vec![
512                    ("owner", J::Str(c.owner.clone())),
513                    ("name", J::Str(c.name.clone())),
514                    ("file", J::Str(c.file.clone())),
515                    ("line", J::UInt(c.line as u64)),
516                ])
517            })
518            .collect();
519        return (1, ambiguous_json(q, rows));
520    }
521    let mut out = vec![format!(
522        "ambiguous member \"{q}\" — {} candidates:",
523        candidates.len()
524    )];
525    for c in candidates {
526        out.push(format!("{}.{}  {}:{}", c.owner, c.name, c.file, c.line));
527    }
528    (1, out.join("\n"))
529}
530
531// Renders a fully-unresolved query (`NotFound` on every one of `refs`/`read`/
532// `impact`/`tests`): `plain` unchanged under text/`--compact` -- the exact
533// bytes these verbs always printed here -- and, only under `--json`, a small
534// object naming the outcome. No JSON shape existed for this case before, so
535// this is additive the same way `member_ambiguous_out`'s JSON arm is.
536fn fallback_advised_out(q: &str, json: bool, plain: String) -> (i32, String) {
537    if json {
538        let out = J::Obj(vec![
539            ("schema_version", J::UInt(query::SCHEMA_VERSION)),
540            (
541                "outcome",
542                J::Str(query::Outcome::FallbackAdvised.as_str().to_string()),
543            ),
544            ("query", J::Str(q.to_string())),
545        ])
546        .to_json_string();
547        (EXIT_NO_RESULT, out)
548    } else {
549        (EXIT_NO_RESULT, plain)
550    }
551}
552
553// `--pick <n>`, shared by `refs`/`read`/`impact`/`tests`: a strict positive
554// integer (unlike `--hops`'s lenient `parse_int_js` -- a candidate index is
555// never "close enough"). `Ok(None)` when the flag is absent, `Err(())` on a
556// missing, non-numeric, or zero value, which every call site turns into its
557// own usage error. Range-checking against the actual candidate count is the
558// caller's job: that count is only known once resolution has already
559// answered `MemberAmbiguous`.
560fn parse_pick(args: &[String]) -> Result<Option<usize>, ()> {
561    let Some(idx) = args.iter().position(|a| a == "--pick") else {
562        return Ok(None);
563    };
564    let raw = args.get(idx + 1).map(String::as_str).unwrap_or("");
565    match raw.parse::<usize>() {
566        Ok(n) if n >= 1 => Ok(Some(n)),
567        _ => Err(()),
568    }
569}
570
571// Lenient integer parse: skip nothing but a leading sign, take the longest
572// leading run of ASCII digits, ignore trailing garbage; no digits at all
573// (including an absent/empty string) is `None`. Used by the `--hops`,
574// `--iface-max-fanin` and `--hub-max-indegree` parsing, so a value like `"3abc"`
575// parses to `3` rather than being a usage error.
576fn parse_int_js(s: &str) -> Option<i64> {
577    let s = s.trim_start();
578    let bytes = s.as_bytes();
579    let mut idx = 0;
580    if idx < bytes.len() && (bytes[idx] == b'+' || bytes[idx] == b'-') {
581        idx += 1;
582    }
583    let digits_start = idx;
584    while idx < bytes.len() && bytes[idx].is_ascii_digit() {
585        idx += 1;
586    }
587    if idx == digits_start {
588        return None;
589    }
590    s[..idx].parse::<i64>().ok()
591}
592
593// `--no-guess`, shared by the four verbs that read the graph index. Spelled
594// once and read at INDEX BUILD rather than at each render site: a guess that
595// never entered the adjacency cannot leak back out through a consumer that
596// forgot to filter, which is the same reasoning that gives heuristic edges
597// their own buckets in the first place.
598//
599// The flag is a plain presence test like `--json`/`--out`, so it takes no
600// value and can never be mistaken for the query -- no usage-error branch of
601// its own, which is why the four call sites below just call this.
602fn index_options(args: &[String]) -> query::IndexOptions {
603    query::IndexOptions {
604        include_guesses: !args.iter().any(|a| a == "--no-guess"),
605    }
606}
607
608// `--json` and `--compact` name two different renderers, so a caller who asks
609// for both gets a refusal rather than a precedence rule each verb would have to
610// spell the same way. `verb` only names the command inside that message.
611fn output_flags(verb: &str, args: &[String]) -> Result<(bool, bool), (i32, String)> {
612    let json = args.iter().any(|a| a == "--json");
613    let compact = args.iter().any(|a| a == "--compact");
614    if json && compact {
615        return Err((
616            1,
617            format!("devscout {verb}: --compact and --json are mutually exclusive"),
618        ));
619    }
620    Ok((json, compact))
621}
622
623// The mapped graph the four graph-reading verbs need, or the single refusal all
624// four print when the repo was never mapped -- one `(code, message)` the caller
625// returns unchanged, so those refusals cannot drift apart.
626fn require_graph(root: &Path) -> Result<graph::Graph, (i32, String)> {
627    graph::read_graph(root).ok_or_else(|| {
628        (
629            1,
630            "no graph.json for this repo — run `devscout map` on a C# scope first".to_string(),
631        )
632    })
633}
634
635// The one telemetry call every query verb makes, taking the rendered answer
636// whole: the outcome and count recorded are the ones that answer carries, so a
637// record can never describe a different answer than the caller was handed.
638fn finish_query(
639    root: &Path,
640    verb: &'static str,
641    seed: &str,
642    start: Instant,
643    answer: (i32, String, query::Outcome, usize),
644) -> (i32, String) {
645    let (code, out, outcome, candidate_count) = answer;
646    telemetry::record(
647        root,
648        &telemetry::QueryEvent {
649            verb,
650            seed,
651            outcome,
652            candidate_count,
653        },
654        start.elapsed(),
655        out.len(),
656    );
657    (code, out)
658}
659
660// `refs`. Check order: `--compact`+`--json` conflict, then missing query, THEN
661// `require_repo`, THEN the graph-present check -- a query run with no repo
662// present reports the missing-repo error even if the query itself is also
663// absent-adjacent.
664const REFS_USAGE: &str =
665    "usage: devscout refs <symbol> [--out] [--all] [--no-guess] [--pick N] [--json|--compact]";
666
667// The row count `refs`/`read` telemetry reports for a resolved answer: every
668// inbound row plus, under `--out`, every outbound row -- the same rows the
669// text/JSON renderers already walk, counted here once rather than re-parsed
670// out of the rendered answer.
671fn refs_model_row_count(model: &query::RefsModel) -> usize {
672    let mut n = model.inbound.inherits.rows.len()
673        + model.inbound.uses_type.rows.len()
674        + model.inbound.uses_member.rows.len();
675    if let Some(ob) = &model.outbound {
676        n += ob.inherits.rows.len()
677            + ob.uses_type.rows.len()
678            + ob.uses_member.rows.len()
679            + ob.imports.rows.len();
680    }
681    n
682}
683
684// A bare member answers with one ordinary refs model per declaring type, so
685// each block renders through the very renderer a type uses and `--json` wraps
686// those same objects in an array rather than reshaping them. A member the
687// graph declares but no verified edge reaches is that same answer with its
688// tables empty: the seed resolved, so this is `refs`' own zero hit and not the
689// unresolved case `fallback_advised_out` renders.
690fn member_models_out(
691    q: &str,
692    json: bool,
693    compact: bool,
694    models: &[query::RefsModel],
695) -> (i32, String, query::Outcome, usize) {
696    let (code, outcome) = if models.iter().all(|m| m.inbound.uses_member.total == 0) {
697        (EXIT_NO_RESULT, query::Outcome::ZeroHit)
698    } else {
699        (0, query::Outcome::Hit)
700    };
701    let out = if json {
702        query::json::member_refs_to_json(q, models, outcome)
703    } else {
704        let render: fn(&query::RefsModel) -> String = if compact {
705            render::render_refs_compact
706        } else {
707            render::render_refs_text
708        };
709        models.iter().map(render).collect::<Vec<_>>().join("\n")
710    };
711    let count = models.iter().map(refs_model_row_count).sum();
712    (code, out, outcome, count)
713}
714
715fn cmd_refs(cwd: &Path, args: &[String]) -> (i32, String, Option<&'static str>) {
716    let start = Instant::now();
717    let (json, compact) = match output_flags("refs", args) {
718        Ok(v) => v,
719        Err((code, out)) => return (code, out, None),
720    };
721    let Ok(pick) = parse_pick(args) else {
722        return (2, REFS_USAGE.to_string(), None);
723    };
724    let Some(q) = first_positional(args) else {
725        return (2, REFS_USAGE.to_string(), None);
726    };
727    let out = args.iter().any(|a| a == "--out");
728    // `--all` lifts only `query::OUTBOUND_CAP`; it is otherwise inert without
729    // `--out`, same as `--out` is inert on the bare-member fallback path.
730    let all_out = args.iter().any(|a| a == "--all");
731    let root = match require_repo(cwd) {
732        Ok(r) => r,
733        Err(e) => return (1, format!("error: {e}"), None),
734    };
735    let g = match require_graph(&root) {
736        Ok(g) => g,
737        Err((code, out)) => return (code, out, None),
738    };
739    let index = query::load_graph_index_with(&g, &root, index_options(args));
740
741    let build = |seed: &str| {
742        query::build_refs_model(
743            &index,
744            seed,
745            out,
746            query::DEFAULT_CAP,
747            query::INBOUND_CAP,
748            query::OUTBOUND_CAP,
749            all_out,
750        )
751    };
752    // `--pick n` only ever narrows a `MemberAmbiguous` answer: it re-resolves
753    // against the SAME synthetic `Owner.Member` seed a caller could have typed
754    // by hand, rather than adding a second resolution path only `--pick`
755    // takes. An out-of-range `n` is a usage error; `--pick` is silently
756    // inert on every other outcome.
757    let result = match build(q) {
758        query::RefsResult::MemberAmbiguous(candidates) => match pick {
759            Some(n) if n <= candidates.len() => build(&query::qualified_seed(&candidates[n - 1])),
760            Some(_) => return (2, REFS_USAGE.to_string(), None),
761            None => query::RefsResult::MemberAmbiguous(candidates),
762        },
763        other => other,
764    };
765
766    let answer = refs_result_out(&index, q, json, compact, result);
767    let note = (answer.2 != query::Outcome::ZeroHit).then_some(ZERO_HIT_REFS);
768    let (code, out) = finish_query(&root, "refs", q, start, answer);
769    (code, out, note)
770}
771
772// The rendering AND telemetry facts (`Outcome`, row count) for a `refs`
773// answer, derived together at the single point the answer is rendered so the
774// telemetry line can never disagree with the bytes that went to stdout.
775fn refs_result_out(
776    index: &query::GraphIndex,
777    q: &str,
778    json: bool,
779    compact: bool,
780    result: query::RefsResult,
781) -> (i32, String, query::Outcome, usize) {
782    match result {
783        query::RefsResult::NotFound => {
784            let (code, out) = fallback_advised_out(q, json, format!("no symbol matches \"{q}\""));
785            (code, out, query::Outcome::FallbackAdvised, 0)
786        }
787        query::RefsResult::Ambiguous(ids) => {
788            let count = ids.len();
789            let (code, out) = ambiguous_candidates_out(index, q, &ids, json);
790            (code, out, query::Outcome::Ambiguous, count)
791        }
792        query::RefsResult::MemberAmbiguous(candidates) => {
793            let count = candidates.len();
794            let (code, out) = member_ambiguous_out(q, &candidates, json);
795            (code, out, query::Outcome::Ambiguous, count)
796        }
797        query::RefsResult::Members(models) => member_models_out(q, json, compact, &models),
798        query::RefsResult::Resolved(model) => {
799            let count = refs_model_row_count(&model);
800            let out = if json {
801                query::json::refs_model_to_json(&model)
802            } else if compact {
803                render::render_refs_compact(&model)
804            } else {
805                render::render_refs_text(&model)
806            };
807            (0, out, query::Outcome::Hit, count)
808        }
809    }
810}
811
812// `read`. Check order is `refs`' own: flag conflict, missing query,
813// `require_repo`, graph-present. The resolution IS refs' --
814// `build_read_model` wraps `build_refs_model` and changes nothing about how
815// a name becomes an answer -- so the ambiguity and zero-hit discipline
816// cannot drift between the two verbs; only the resolved arm grows the
817// declaration span.
818const READ_USAGE: &str = "usage: devscout read <symbol> [--no-guess] [--pick N] [--json|--compact]";
819
820fn cmd_read(cwd: &Path, args: &[String]) -> (i32, String, Option<&'static str>) {
821    let start = Instant::now();
822    let (json, compact) = match output_flags("read", args) {
823        Ok(v) => v,
824        Err((code, out)) => return (code, out, None),
825    };
826    let Ok(pick) = parse_pick(args) else {
827        return (2, READ_USAGE.to_string(), None);
828    };
829    let Some(q) = first_positional(args) else {
830        return (2, READ_USAGE.to_string(), None);
831    };
832    let root = match require_repo(cwd) {
833        Ok(r) => r,
834        Err(e) => return (1, format!("error: {e}"), None),
835    };
836    let g = match require_graph(&root) {
837        Ok(g) => g,
838        Err((code, out)) => return (code, out, None),
839    };
840    let index = query::load_graph_index_with(&g, &root, index_options(args));
841
842    // Same `--pick` narrowing rule `cmd_refs` applies: only a `MemberAmbiguous`
843    // answer is ever re-resolved, against the synthetic `Owner.Member` seed the
844    // picked candidate names.
845    let result = match query::build_read_model(&index, q) {
846        query::ReadResult::MemberAmbiguous(candidates) => match pick {
847            Some(n) if n <= candidates.len() => {
848                query::build_read_model(&index, &query::qualified_seed(&candidates[n - 1]))
849            }
850            Some(_) => return (2, READ_USAGE.to_string(), None),
851            None => query::ReadResult::MemberAmbiguous(candidates),
852        },
853        other => other,
854    };
855
856    let answer = match result {
857        query::ReadResult::NotFound => {
858            let (code, out) = fallback_advised_out(q, json, format!("no symbol matches \"{q}\""));
859            (code, out, query::Outcome::FallbackAdvised, 0)
860        }
861        query::ReadResult::Ambiguous(ids) => {
862            let count = ids.len();
863            let (code, out) = ambiguous_candidates_out(&index, q, &ids, json);
864            (code, out, query::Outcome::Ambiguous, count)
865        }
866        query::ReadResult::MemberAmbiguous(candidates) => {
867            let count = candidates.len();
868            let (code, out) = member_ambiguous_out(q, &candidates, json);
869            (code, out, query::Outcome::Ambiguous, count)
870        }
871        // `read`'s member answer IS refs' -- a member carries no
872        // declaration-span fact to add -- so it takes the same exit code and
873        // the same word, rather than a second shape to keep in step.
874        query::ReadResult::Members(models) => member_models_out(q, json, compact, &models),
875        query::ReadResult::Resolved(model) => {
876            let count = refs_model_row_count(&model.refs);
877            let out = if json {
878                query::json::read_model_to_json(&model)
879            } else if compact {
880                render::render_read_compact(&model)
881            } else {
882                render::render_read_text(&model)
883            };
884            (0, out, query::Outcome::Hit, count)
885        }
886    };
887    let note = (answer.2 != query::Outcome::ZeroHit).then_some(ZERO_HIT_READ);
888    let (code, out) = finish_query(&root, "read", q, start, answer);
889    (code, out, note)
890}
891
892const IMPACT_USAGE: &str = "usage: devscout impact <file|symbol> [--hops N] [--no-iface] [--no-guess] [--iface-max-fanin N] [--hub-max-indegree N] [--pick N] [--json|--compact]";
893
894/// The numeric/positional argument parse `cmd_impact` needs before it can
895/// touch the repo or the graph: `--hops`/`--iface-max-fanin`/
896/// `--hub-max-indegree`/`--pick`, and the query -- the first non-flag
897/// argument that is not one of those four flags' own values.
898struct ImpactArgs<'a> {
899    hops: u32,
900    iface_max_fanin: usize,
901    hub_max_indegree: usize,
902    pick: Option<usize>,
903    query: &'a str,
904}
905
906fn parse_impact_args(args: &[String]) -> Result<ImpactArgs<'_>, (i32, String)> {
907    let usage_err = || (2, IMPACT_USAGE.to_string());
908
909    let mut hops: u32 = query::DEFAULT_HOPS;
910    if let Some(idx) = args.iter().position(|a| a == "--hops") {
911        let raw = args.get(idx + 1).map(String::as_str).unwrap_or("");
912        match parse_int_js(raw) {
913            Some(h) if h >= 1 => hops = h as u32,
914            _ => return Err(usage_err()),
915        }
916    }
917
918    // `--iface-max-fanin`'s own value is never the query. `0` is a legal value
919    // and does not start with `--`, so without the guard below it would be picked
920    // up as the seed. Parsed AFTER `--hops` and BEFORE the missing-query check.
921    let mut iface_max_fanin: usize = query::DEFAULT_IFACE_MAX_FANIN;
922    if let Some(idx) = args.iter().position(|a| a == "--iface-max-fanin") {
923        let raw = args.get(idx + 1).map(String::as_str).unwrap_or("");
924        match parse_int_js(raw) {
925            Some(n) if n >= 0 => iface_max_fanin = n as usize,
926            _ => return Err(usage_err()),
927        }
928    }
929
930    // The same rule as `--iface-max-fanin`: its own value is never the query, and
931    // `0` is a legal value that does not start with `--`.
932    let mut hub_max_indegree: usize = query::DEFAULT_HUB_MAX_INDEGREE;
933    if let Some(idx) = args.iter().position(|a| a == "--hub-max-indegree") {
934        let raw = args.get(idx + 1).map(String::as_str).unwrap_or("");
935        match parse_int_js(raw) {
936            Some(n) if n >= 0 => hub_max_indegree = n as usize,
937            _ => return Err(usage_err()),
938        }
939    }
940
941    let Ok(pick) = parse_pick(args) else {
942        return Err(usage_err());
943    };
944
945    // The first non-flag argument that is not the value of `--hops`,
946    // `--iface-max-fanin`, `--hub-max-indegree`, or `--pick`.
947    let mut query: Option<&str> = None;
948    for (i, a) in args.iter().enumerate() {
949        if a.starts_with("--") {
950            continue;
951        }
952        if i > 0
953            && (args[i - 1] == "--hops"
954                || args[i - 1] == "--iface-max-fanin"
955                || args[i - 1] == "--hub-max-indegree"
956                || args[i - 1] == "--pick")
957        {
958            continue;
959        }
960        query = Some(a.as_str());
961        break;
962    }
963    let Some(query) = query else {
964        return Err(usage_err());
965    };
966
967    Ok(ImpactArgs {
968        hops,
969        iface_max_fanin,
970        hub_max_indegree,
971        pick,
972        query,
973    })
974}
975
976// `impact`. Check order: `--compact`+`--json` conflict, `--hops` parse (usage
977// error on a bad/missing value), missing query, THEN `require_repo`, THEN the
978// graph-present check.
979fn cmd_impact(cwd: &Path, args: &[String]) -> (i32, String) {
980    let start = Instant::now();
981    let (json, compact) = match output_flags("impact", args) {
982        Ok(v) => v,
983        Err(e) => return e,
984    };
985
986    let ImpactArgs {
987        hops,
988        iface_max_fanin,
989        hub_max_indegree,
990        pick,
991        query: q,
992    } = match parse_impact_args(args) {
993        Ok(a) => a,
994        Err(e) => return e,
995    };
996
997    let root = match require_repo_for_path(cwd, Some(q)) {
998        Ok(r) => r,
999        Err(e) => return (1, format!("error: {e}")),
1000    };
1001    let q = repo_relative_arg(cwd, &root, q);
1002    let q = q.as_str();
1003    let g = match require_graph(&root) {
1004        Ok(g) => g,
1005        Err(e) => return e,
1006    };
1007    let index = query::load_graph_index_with(&g, &root, index_options(args));
1008
1009    let build = |seed: &str| {
1010        query::build_impact_model(
1011            &index,
1012            seed,
1013            hops,
1014            query::DEFAULT_CAP,
1015            !args.iter().any(|a| a == "--no-iface"),
1016            iface_max_fanin,
1017            hub_max_indegree,
1018        )
1019    };
1020    // Same `--pick` narrowing rule `cmd_refs` applies: only a `MemberAmbiguous`
1021    // answer is ever re-resolved.
1022    let result = match build(q) {
1023        query::ImpactResult::MemberAmbiguous(candidates) => match pick {
1024            Some(n) if n <= candidates.len() => build(&query::qualified_seed(&candidates[n - 1])),
1025            Some(_) => return (2, IMPACT_USAGE.to_string()),
1026            None => query::ImpactResult::MemberAmbiguous(candidates),
1027        },
1028        other => other,
1029    };
1030
1031    let answer = match result {
1032        query::ImpactResult::NotFound { kind } => {
1033            let (code, out) = fallback_advised_out(
1034                q,
1035                json,
1036                format!("no {} match for \"{q}\"", render::seed_kind_str(kind)),
1037            );
1038            (code, out, query::Outcome::FallbackAdvised, 0)
1039        }
1040        query::ImpactResult::Ambiguous { ids, .. } => {
1041            let count = ids.len();
1042            let (code, out) = ambiguous_candidates_out(&index, q, &ids, json);
1043            (code, out, query::Outcome::Ambiguous, count)
1044        }
1045        query::ImpactResult::MemberAmbiguous(candidates) => {
1046            let count = candidates.len();
1047            let (code, out) = member_ambiguous_out(q, &candidates, json);
1048            (code, out, query::Outcome::Ambiguous, count)
1049        }
1050        query::ImpactResult::Resolved(model) => {
1051            let out = if json {
1052                query::json::impact_model_to_json(q, &model)
1053            } else if compact {
1054                render::render_impact_compact(q, &model)
1055            } else {
1056                render::render_impact_text(q, &model)
1057            };
1058            // A resolved seed that reaches nothing beyond its own files is the
1059            // same answer as an unresolved one -- empty -- and gets the same
1060            // signal.
1061            if model.rows.is_empty() {
1062                (EXIT_NO_RESULT, out, query::Outcome::ZeroHit, 0)
1063            } else {
1064                let count = model.rows.len();
1065                (0, out, query::Outcome::Hit, count)
1066            }
1067        }
1068    };
1069    finish_query(&root, "impact", q, start, answer)
1070}
1071
1072const TESTS_USAGE: &str =
1073    "usage: devscout tests <symbol> [--no-guess] [--pick N] [--json|--compact]";
1074
1075// `tests`. Mirrors `cmd_refs` -- same flag conflict, same missing-query usage
1076// error, same `require_repo`/graph-present order, same notfound/ambiguous exits.
1077fn cmd_tests(cwd: &Path, args: &[String]) -> (i32, String) {
1078    let start = Instant::now();
1079    let (json, compact) = match output_flags("tests", args) {
1080        Ok(v) => v,
1081        Err(e) => return e,
1082    };
1083    let Ok(pick) = parse_pick(args) else {
1084        return (2, TESTS_USAGE.to_string());
1085    };
1086    let Some(q) = first_positional(args) else {
1087        return (2, TESTS_USAGE.to_string());
1088    };
1089    let root = match require_repo(cwd) {
1090        Ok(r) => r,
1091        Err(e) => return (1, format!("error: {e}")),
1092    };
1093    let g = match require_graph(&root) {
1094        Ok(g) => g,
1095        Err(e) => return e,
1096    };
1097    let index = query::load_graph_index_with(&g, &root, index_options(args));
1098
1099    // Same `--pick` narrowing rule `cmd_refs` applies: only a `MemberAmbiguous`
1100    // answer is ever re-resolved.
1101    let result = match query::build_tests_model(&index, q) {
1102        query::TestsResult::MemberAmbiguous(candidates) => match pick {
1103            Some(n) if n <= candidates.len() => {
1104                query::build_tests_model(&index, &query::qualified_seed(&candidates[n - 1]))
1105            }
1106            Some(_) => return (2, TESTS_USAGE.to_string()),
1107            None => query::TestsResult::MemberAmbiguous(candidates),
1108        },
1109        other => other,
1110    };
1111
1112    let answer = match result {
1113        query::TestsResult::NotFound => {
1114            let (code, out) = fallback_advised_out(q, json, format!("no symbol matches \"{q}\""));
1115            (code, out, query::Outcome::FallbackAdvised, 0)
1116        }
1117        query::TestsResult::Ambiguous(ids) => {
1118            let count = ids.len();
1119            let (code, out) = ambiguous_candidates_out(&index, q, &ids, json);
1120            (code, out, query::Outcome::Ambiguous, count)
1121        }
1122        query::TestsResult::MemberAmbiguous(candidates) => {
1123            let count = candidates.len();
1124            let (code, out) = member_ambiguous_out(q, &candidates, json);
1125            (code, out, query::Outcome::Ambiguous, count)
1126        }
1127        query::TestsResult::Resolved(model) => {
1128            let count = model.rows.len();
1129            let out = if json {
1130                query::json::tests_model_to_json(&model)
1131            } else if compact {
1132                render::render_tests_compact(&model)
1133            } else {
1134                render::render_tests_text(&model)
1135            };
1136            (0, out, query::Outcome::Hit, count)
1137        }
1138    };
1139    finish_query(&root, "tests", q, start, answer)
1140}
1141
1142// Output caps: an uncapped find can dump the whole near-match pool (measured
1143// 270-450KB on broad multi-token queries against a 5k-file manifest). The
1144// full-match pool is what the user asked for, so it gets the wider cap; the
1145// OR-fallback pool is near-matches only, so it gets the tighter one. Both
1146// manifest pools are RANKED before their cap bites -- tokens matched, then the
1147// file's inbound-edge count (see `manifest::find_in_manifest_detailed`) -- so
1148// the rows a cap drops are the weakest, not just the last.
1149const FIND_FULL_CAP: usize = 25;
1150const FIND_FALLBACK_CAP: usize = 10;
1151// The declaration block gets its own cap: it is a different pool from the
1152// manifest's, and sharing one would let a name carried by 200 members swallow
1153// the file rows the same query earned. It stays in build order on purpose --
1154// ranking is the manifest pools' job, not this one's.
1155const FIND_NAMES_CAP: usize = 25;
1156
1157// `find`. Check order: `require_repo` FIRST, THEN the missing-query check -- the
1158// one query command that does NOT check its own usage before root resolution
1159// (deliberate, not a slip). Caps per pool kind; the `… +K more (refine query)`
1160// tail line keeps the true pool size honest.
1161fn cmd_find(cwd: &Path, query_str: &str, resources: bool) -> (i32, String) {
1162    let start = Instant::now();
1163    let root = match require_repo(cwd) {
1164        Ok(r) => r,
1165        Err(e) => return (1, format!("error: {e}")),
1166    };
1167    if query_str.is_empty() {
1168        return (2, "usage: devscout find <query> [--resources]".to_string());
1169    }
1170    // The declaration block leads: a caller who named a member wants the site,
1171    // and the manifest block keeps its own tail line at the bottom where it
1172    // already sat. `file:line  <source line>` -- two spaces -- degrading to bare
1173    // `file:line` when the line cannot be read. A repo with no graph.json (never
1174    // mapped, or no graph source in scope) contributes no block at all.
1175    //
1176    // Tier 1 (a code symbol) and tier 2 (a markup/binding name) are the default
1177    // pool; tier 3 (a resource key) is demoted to a one-line trailer unless
1178    // `--resources` asks for it inline. The zero-hit brake below reads
1179    // `decl_lines`, so it considers tiers 1-2 only: a query that matches nothing
1180    // but resource keys is a miss for it, correctly, even though `named` itself is
1181    // non-empty.
1182    let graph = graph::read_graph(&root);
1183    // One graph read per query: the ranking map folds off the SAME read the
1184    // declaration block uses. No graph file (never mapped) reads as an empty
1185    // map -- every entry ranks at 0 inbound and the manifest answers in its
1186    // on-disk order, exactly as it did before this existed.
1187    let inbound_counts = graph
1188        .as_ref()
1189        .map(query::file_inbound_counts)
1190        .unwrap_or_default();
1191    let (decl_lines, resource_count): (Vec<String>, usize) = match graph.as_ref() {
1192        None => (Vec::new(), 0),
1193        Some(g) => {
1194            let named = query::find_names(g, query_str);
1195            let mut primary: Vec<&graph::GraphName> = Vec::new();
1196            let mut resource_count = 0usize;
1197            for n in named.iter() {
1198                if query::name_tier(&n.kind) <= 2 {
1199                    primary.push(n);
1200                } else {
1201                    resource_count += 1;
1202                }
1203            }
1204            let pool: Vec<&graph::GraphName> = if resources { named } else { primary };
1205            let mut out: Vec<String> = pool
1206                .iter()
1207                .take(FIND_NAMES_CAP)
1208                .map(|n| {
1209                    let text = query::source_line(&root, &n.file, n.line);
1210                    if text.is_empty() {
1211                        format!("{}:{}", n.file, n.line)
1212                    } else {
1213                        format!("{}:{}  {text}", n.file, n.line)
1214                    }
1215                })
1216                .collect();
1217            if pool.len() > FIND_NAMES_CAP {
1218                out.push(format!(
1219                    "… +{} more declarations (refine query)",
1220                    pool.len() - FIND_NAMES_CAP
1221                ));
1222            }
1223            (out, resource_count)
1224        }
1225    };
1226    match manifest::find_in_manifest_detailed(&root, query_str, &inbound_counts) {
1227        Ok(r) => {
1228            let answer = find_result_out(
1229                graph.as_ref(),
1230                query_str,
1231                resources,
1232                resource_count,
1233                decl_lines,
1234                r,
1235            );
1236            finish_query(&root, "find", query_str, start, answer)
1237        }
1238        Err(e) => (1, format!("error: {e}")),
1239    }
1240}
1241
1242// The rendering AND telemetry facts for a `find` answer, derived together at
1243// the single point the answer is rendered so the telemetry line can never
1244// disagree with the bytes that went to stdout.
1245fn find_result_out(
1246    graph: Option<&graph::Graph>,
1247    query_str: &str,
1248    resources: bool,
1249    resource_count: usize,
1250    decl_lines: Vec<String>,
1251    r: manifest::FindResult,
1252) -> (i32, String, query::Outcome, usize) {
1253    if r.hits.is_empty() && decl_lines.is_empty() {
1254        let out =
1255            format!("no matches for \"{query_str}\" (run 'devscout map' if manifest is missing)");
1256        return (EXIT_NO_RESULT, out, query::Outcome::FallbackAdvised, 0);
1257    }
1258    let cap = if r.fallback {
1259        FIND_FALLBACK_CAP
1260    } else {
1261        FIND_FULL_CAP
1262    };
1263    // The rows the answer carried, not the pool they were drawn from: neither
1264    // "+N more" tail is a row, and each pool holds at most its own cap of them.
1265    let candidate_count = decl_lines.len().min(FIND_NAMES_CAP) + r.hits.len().min(cap);
1266    let mut lines: Vec<String> = decl_lines;
1267    if !resources && resource_count > 0 {
1268        lines.push(format!(
1269            "+{resource_count} resource-key hits, use --resources"
1270        ));
1271    }
1272    // Every manifest-pool row carries a line too, same as the declaration
1273    // block above it: the file's own first declaration where the name index
1274    // has one, line 1 (an always-valid "open the file" anchor) for a file
1275    // the index carries no declared symbol for at all.
1276    let decl_line_by_file = graph
1277        .map(query::first_decl_line_by_file)
1278        .unwrap_or_default();
1279    lines.extend(r.hits.iter().take(cap).map(|h| {
1280        // An absent purpose renders as the literal text "undefined" -- not
1281        // an empty string. See manifest.rs's `FindHit::purpose` doc comment.
1282        let purpose = h.purpose.as_deref().unwrap_or("undefined");
1283        let agent = if h.source == "agent" { " [agent]" } else { "" };
1284        let line = decl_line_by_file.get(&h.path).copied().unwrap_or(1);
1285        format!("{}:{line}: {purpose}{agent}", h.path)
1286    }));
1287    if r.hits.len() > cap {
1288        lines.push(format!("… +{} more (refine query)", r.hits.len() - cap));
1289    }
1290    (0, lines.join("\n"), query::Outcome::Hit, candidate_count)
1291}
1292
1293// `map`. Strips the no-op alias flag `--refresh` before anything else runs; the
1294// remaining tokens are the scope dirs, passed straight through to
1295// `mapcmd::map_repo`. This function's only job is the CLI glue: `require_repo`,
1296// flag stripping, and printing `MapReport::summary_line()`. `MapOptions::from_env()`
1297// decides the fragment-reuse mode (mapcmd.rs's own module header): content-hash
1298// reuse is the default, `SCOUT_MTIME_REUSE=1` drops back to mtime keying.
1299fn cmd_map(cwd: &Path, args: &[String]) -> (i32, String) {
1300    let root = match require_repo(cwd) {
1301        Ok(r) => r,
1302        Err(e) => return (1, format!("error: {e}")),
1303    };
1304    let dirs: Vec<String> = args
1305        .iter()
1306        .filter(|a| a.as_str() != "--refresh")
1307        .cloned()
1308        .collect();
1309    match mapcmd::map_repo(&root, &dirs, mapcmd::MapOptions::from_env()) {
1310        Ok(report) => (0, report.summary_line()),
1311        Err(e) => (1, format!("error: {e}")),
1312    }
1313}
1314
1315// `stats`. The read-cache/bash-cache/session/top-stubbed queries are NOT wrapped
1316// in error handling, so any error there aborts the whole command (an early
1317// `return` with the same `"error: {msg}"` shape every other command in this file
1318// uses); only the cross-repo content-store block IS fail-open, two chained `if
1319// let Ok(..)`s that silently produce no lines on either failure.
1320fn cmd_stats(cwd: &Path) -> (i32, String) {
1321    let root = match require_repo(cwd) {
1322        Ok(r) => r,
1323        Err(e) => return (1, format!("error: {e}")),
1324    };
1325    let db = match store::open_store(&root) {
1326        Ok(c) => c,
1327        Err(e) => return (1, format!("error: {e}")),
1328    };
1329    let s = match store::stats_for(&db) {
1330        Ok(s) => s,
1331        Err(e) => return (1, format!("error: {e}")),
1332    };
1333
1334    let mut lines = vec![
1335        format!("devscout stats ({}):", root.display()),
1336        format!("  files tracked: {}", s.distinct_files),
1337        format!("  reads deduped (stubs): {}", s.total_stubs),
1338        format!("  lines saved: {}", s.lines_saved),
1339        format!("  bytes saved: {}", s.bytes_saved),
1340        format!(
1341            "  est tokens saved: {}",
1342            js_math_round(s.bytes_saved as f64 / 4.0)
1343        ),
1344    ];
1345
1346    let b = match store::bash_stats_for(&db) {
1347        Ok(b) => b,
1348        Err(e) => return (1, format!("error: {e}")),
1349    };
1350    if b.commands_tracked > 0 {
1351        lines.push(format!("  bash commands tracked: {}", b.commands_tracked));
1352        lines.push(format!("  bash dedups (stubs): {}", b.total_stubs));
1353        lines.push(format!(
1354            "  bash est tokens saved: {}",
1355            js_math_round(b.bytes_saved as f64 / 4.0)
1356        ));
1357    }
1358
1359    // Fail open on EITHER the content-store open or the stats query.
1360    if let Ok(cs_conn) = store::open_content_store() {
1361        if let Ok(cs) = store::content_stats_for(&cs_conn) {
1362            if cs.total_stubs > 0 {
1363                lines.push(format!(
1364                    "  cross-repo dedups (all roots, this machine): {}",
1365                    cs.total_stubs
1366                ));
1367                lines.push(format!(
1368                    "  cross-repo est tokens saved: {}",
1369                    js_math_round(cs.bytes_saved as f64 / 4.0)
1370                ));
1371            }
1372        }
1373    }
1374
1375    let per_session = match store::session_stats(&db) {
1376        Ok(v) => v,
1377        Err(e) => return (1, format!("error: {e}")),
1378    };
1379    if !per_session.is_empty() {
1380        lines.push(String::new());
1381        lines.push("  per session:".to_string());
1382        for r in &per_session {
1383            // Session ids are ASCII (uuid/hash) in every real writer, so a
1384            // char-based slice of the first 8 is unambiguous.
1385            let sid: String = r.session_id.chars().take(8).collect();
1386            lines.push(format!(
1387                "    {sid:<8}  files {:>4}  stubs {:>4}  lines saved {:>6}  est tokens {}",
1388                r.files,
1389                r.stubs,
1390                r.lines_saved,
1391                js_math_round(r.bytes_saved as f64 / 4.0),
1392            ));
1393        }
1394    }
1395
1396    let top = match store::top_stubbed(&db, 5) {
1397        Ok(v) => v,
1398        Err(e) => return (1, format!("error: {e}")),
1399    };
1400    if !top.is_empty() {
1401        lines.push(String::new());
1402        lines.push("  top stubbed files:".to_string());
1403        for r in &top {
1404            let sid: String = r.session_id.chars().take(8).collect();
1405            lines.push(format!(
1406                "    {}x  {} ({} lines, session {sid})",
1407                r.stub_count, r.rel_path, r.lines
1408            ));
1409        }
1410    }
1411
1412    (0, lines.join("\n"))
1413}
1414
1415// `clear`. Three forms, checked in this exact order so the first flag present
1416// wins even if both appear: `--older-than <days>` prunes rows untouched for that
1417// many days, `--session <prefix>` prunes one session by id prefix (refusing an
1418// ambiguous one), and with neither flag the whole `cache.db` file is removed. The
1419// whole-store form never calls `open_store` -- it only builds the path and checks
1420// existence, so a repo that never wrote a cache.db is never given one just to
1421// clear it -- and the WAL/SHM sidecar files SQLite leaves beside it are
1422// deliberately NOT removed here (a plain `remove_file` on the db path).
1423fn cmd_clear(cwd: &Path, args: &[String]) -> (i32, String) {
1424    let root = match require_repo(cwd) {
1425        Ok(r) => r,
1426        Err(e) => return (1, format!("error: {e}")),
1427    };
1428
1429    if let Some(idx) = args.iter().position(|a| a == "--older-than") {
1430        let raw = args.get(idx + 1).map(String::as_str).unwrap_or("");
1431        let days = match parse_int_js(raw) {
1432            Some(d) if d >= 0 => d,
1433            _ => return (2, "usage: devscout clear --older-than <days>".to_string()),
1434        };
1435        let db = match store::open_store(&root) {
1436            Ok(c) => c,
1437            Err(e) => return (1, format!("error: {e}")),
1438        };
1439        let deleted = match store::prune(&db, Some(days as f64), None) {
1440            Ok(n) => n,
1441            Err(e) => return (1, format!("error: {e}")),
1442        };
1443        let suffix = if deleted == 1 { "" } else { "s" };
1444        return (
1445            0,
1446            format!("deleted {deleted} row{suffix} older than {days}d"),
1447        );
1448    }
1449
1450    if let Some(idx) = args.iter().position(|a| a == "--session") {
1451        let prefix = args.get(idx + 1).map(String::as_str).unwrap_or("");
1452        if prefix.is_empty() {
1453            return (
1454                2,
1455                "usage: devscout clear --session <id-or-prefix>".to_string(),
1456            );
1457        }
1458        let db = match store::open_store(&root) {
1459            Ok(c) => c,
1460            Err(e) => return (1, format!("error: {e}")),
1461        };
1462        let matches = match store::session_ids_by_prefix(&db, prefix) {
1463            Ok(v) => v,
1464            Err(e) => return (1, format!("error: {e}")),
1465        };
1466        if matches.is_empty() {
1467            return (0, format!("no sessions match \"{prefix}\""));
1468        }
1469        if matches.len() > 1 {
1470            return (
1471                2,
1472                format!(
1473                    "ambiguous session prefix \"{prefix}\": {}",
1474                    matches.join(", ")
1475                ),
1476            );
1477        }
1478        let deleted = match store::prune(&db, None, Some(matches[0].as_str())) {
1479            Ok(n) => n,
1480            Err(e) => return (1, format!("error: {e}")),
1481        };
1482        let suffix = if deleted == 1 { "" } else { "s" };
1483        let session = &matches[0];
1484        return (
1485            0,
1486            format!("deleted {deleted} row{suffix} for session {session}"),
1487        );
1488    }
1489
1490    let db_path = crate::repo::scout_dir(&root).join("cache.db");
1491    if db_path.exists() {
1492        if let Err(e) = std::fs::remove_file(&db_path) {
1493            return (1, format!("error: {e}"));
1494        }
1495    }
1496    (0, "cache cleared".to_string())
1497}
1498
1499// `Math.round`-style rounding: `floor(x + 0.5)`, NOT Rust's `f64::round` (which
1500// rounds ties away from zero -- the two agree for every non-negative input,
1501// which is all `cmd_stats` ever feeds this, but this spells out the exact rule
1502// rather than relying on that coincidence).
1503fn js_math_round(x: f64) -> i64 {
1504    (x + 0.5).floor() as i64
1505}
1506
1507#[cfg(test)]
1508mod tests {
1509    use super::*;
1510    // The `--json` builders (`J`, `refs_model_to_json`, `impact_model_to_json`,
1511    // ...) moved to `query::json` to keep this file under its size ratchet;
1512    // the tests pinning their byte shape stayed here rather than following
1513    // them, so this brings the moved names back into scope unqualified.
1514    use crate::query::json::*;
1515
1516    #[test]
1517    fn js_float_string_matches_node_json_stringify_on_checked_values() {
1518        // Expected values for JSON number formatting of each of these:
1519        assert_eq!(js_float_string(1.0), "1");
1520        assert_eq!(js_float_string(100.0), "100");
1521        assert_eq!(js_float_string(0.0), "0");
1522        assert_eq!(js_float_string(-0.0), "0");
1523        assert_eq!(js_float_string(0.15), "0.15");
1524        assert_eq!(js_float_string(0.1 + 0.2), "0.30000000000000004");
1525        assert_eq!(js_float_string(1.0 / 3.0), "0.3333333333333333");
1526        assert_eq!(js_float_string(1e21), "1e+21");
1527        assert_eq!(js_float_string(0.0000001), "1e-7");
1528        assert_eq!(js_float_string(f64::NAN), "null");
1529        assert_eq!(js_float_string(f64::INFINITY), "null");
1530    }
1531
1532    #[test]
1533    fn js_math_round_matches_node_math_round_on_checked_values() {
1534        // Expected `Math.round`-style rounding for each of these:
1535        assert_eq!(js_math_round(0.0), 0);
1536        assert_eq!(js_math_round(1.4), 1);
1537        assert_eq!(js_math_round(1.5), 2);
1538        assert_eq!(js_math_round(1.49999), 1);
1539        assert_eq!(js_math_round(2.5), 3);
1540        assert_eq!(js_math_round(100.0 / 4.0), 25);
1541        assert_eq!(js_math_round(101.0 / 4.0), 25);
1542        assert_eq!(js_math_round(103.0 / 4.0), 26);
1543    }
1544
1545    #[test]
1546    fn tests_json_appends_via_project_last_and_omits_it_for_an_attribute_row() {
1547        let attribute_row = query::TestRow {
1548            file: "tests/OrderServiceTests.cs".to_string(),
1549            test_defs: vec!["App.Orders.Tests.OrderServiceTests".to_string()],
1550            lines: vec![10],
1551            ref_count: 1,
1552            heuristic: false,
1553            tier: None,
1554            via: query::TestVia::Attribute,
1555        };
1556        let project_row = query::TestRow {
1557            file: "tests/App.Tests/FakeServer.cs".to_string(),
1558            test_defs: vec![],
1559            lines: vec![12, 34],
1560            ref_count: 2,
1561            heuristic: false,
1562            tier: None,
1563            via: query::TestVia::Project,
1564        };
1565        let model = query::TestsModel {
1566            query: "Order".to_string(),
1567            symbol: "App.Orders.Order".to_string(),
1568            def_files: vec!["src/Order.cs".to_string()],
1569            rows: vec![attribute_row, project_row],
1570            test_file_count: 2,
1571            ref_count: 3,
1572            heuristic_file_count: 0,
1573            heuristic_ref_count: 0,
1574        };
1575        let json = tests_model_to_json(&model);
1576        assert!(
1577            json.contains(
1578                r#"{"file":"tests/OrderServiceTests.cs","testDefs":["App.Orders.Tests.OrderServiceTests"],"lines":[10],"refCount":1,"why":"test-attribute"}"#
1579            ),
1580            "an attribute row carries no via key at all: {json}"
1581        );
1582        assert!(
1583            json.contains(
1584                r#"{"file":"tests/App.Tests/FakeServer.cs","testDefs":[],"lines":[12,34],"refCount":2,"via":"project","why":"test-project"}"#
1585            ),
1586            "a project row appends via LAST (no heuristic/tier on this row): {json}"
1587        );
1588    }
1589
1590    #[test]
1591    fn parse_int_js_matches_node_parseint_on_checked_values() {
1592        assert_eq!(parse_int_js("3"), Some(3));
1593        assert_eq!(parse_int_js("3abc"), Some(3));
1594        assert_eq!(parse_int_js("-5"), Some(-5));
1595        assert_eq!(parse_int_js("  7"), Some(7));
1596        assert_eq!(parse_int_js(""), None);
1597        assert_eq!(parse_int_js("abc"), None);
1598        assert_eq!(parse_int_js("-"), None);
1599    }
1600
1601    #[test]
1602    fn j_object_and_array_serialize_with_no_extra_whitespace_like_json_stringify() {
1603        let j = J::Obj(vec![
1604            ("a", J::UInt(1)),
1605            ("b", J::Arr(vec![J::Str("x".to_string()), J::UInt(2)])),
1606        ]);
1607        assert_eq!(j.to_json_string(), r#"{"a":1,"b":["x",2]}"#);
1608    }
1609
1610    #[test]
1611    fn j_string_escapes_control_chars_and_quotes_like_json_stringify() {
1612        let j = J::Str("a\"b\\c\nd".to_string());
1613        assert_eq!(j.to_json_string(), r#""a\"b\\c\nd""#);
1614    }
1615
1616    // `--json` key ORDER, which a parsed-object comparison cannot see (object key
1617    // order is not observable there). These pin three placements: a refs row's
1618    // `heuristic` appended LAST and absent when precise, an impact row's
1619    // `heuristicCount` then `heuristic` appended after `score` and both absent
1620    // when precise, and `heuristicAffected` appended after `manifestGap` on the
1621    // model itself.
1622
1623    fn json_refs_model(rows: Vec<query::InboundRow>, out: bool) -> query::RefsModel {
1624        let empty_in = || query::Table {
1625            total: 0,
1626            dropped: 0,
1627            rows: Vec::<query::InboundRow>::new(),
1628        };
1629        let empty_out = || query::Table {
1630            total: 0,
1631            dropped: 0,
1632            rows: Vec::<query::OutboundRow>::new(),
1633        };
1634        query::RefsModel {
1635            query: "Widget".to_string(),
1636            id: "App.Widget".to_string(),
1637            kind: "class".to_string(),
1638            sites: vec![query::DefSite {
1639                file: "src/Widget.cs".to_string(),
1640                line: 3,
1641            }],
1642            inbound: query::InboundTables {
1643                inherits: empty_in(),
1644                uses_type: empty_in(),
1645                uses_member: query::Table {
1646                    total: rows.len(),
1647                    dropped: 0,
1648                    rows,
1649                },
1650            },
1651            outbound: out.then(|| query::OutboundTables {
1652                inherits: empty_out(),
1653                uses_type: empty_out(),
1654                uses_member: empty_out(),
1655                imports: query::Table {
1656                    total: 0,
1657                    dropped: 0,
1658                    rows: Vec::new(),
1659                },
1660            }),
1661            ambiguous: query::AmbiguousTables {
1662                inbound: query::Table {
1663                    total: 0,
1664                    dropped: 0,
1665                    rows: Vec::new(),
1666                },
1667                outbound: query::Table {
1668                    total: 0,
1669                    dropped: 0,
1670                    rows: Vec::new(),
1671                },
1672            },
1673            manifest_gap: 0,
1674            member_refs: None,
1675        }
1676    }
1677
1678    #[test]
1679    fn refs_json_leads_with_schema_version_ahead_of_every_existing_key() {
1680        let model = json_refs_model(Vec::new(), false);
1681        let json = refs_model_to_json(&model);
1682        assert!(
1683            json.starts_with(
1684                r#"{"schema_version":1,"status":"resolved","query":"Widget","id":"App.Widget","kind":"class""#
1685            ),
1686            "schema_version is the first key and every existing key keeps its slot after it: {json}"
1687        );
1688    }
1689
1690    #[test]
1691    fn read_json_leads_with_schema_version_and_keeps_span_right_after_kind() {
1692        let model = query::ReadModel {
1693            refs: json_refs_model(Vec::new(), false),
1694            span: Some(query::ReadSpan {
1695                file: "src/Widget.cs".to_string(),
1696                start_line: 3,
1697                end_line: 5,
1698                source: "class Widget {}".to_string(),
1699            }),
1700        };
1701        let json = read_model_to_json(&model);
1702        assert!(
1703            json.starts_with(
1704                r#"{"schema_version":1,"status":"resolved","query":"Widget","id":"App.Widget","kind":"class","span":{"#
1705            ),
1706            "schema_version leads, and span still sits right after kind, unmoved: {json}"
1707        );
1708    }
1709
1710    #[test]
1711    fn refs_json_appends_heuristic_then_source_last_and_omits_each_when_it_has_no_value() {
1712        let model = json_refs_model(
1713            vec![
1714                query::InboundRow {
1715                    file: "src/Fact.cs".into(),
1716                    line: 4,
1717                    heuristic: false,
1718                    tier: None,
1719                    source: String::new(),
1720                },
1721                query::InboundRow {
1722                    file: "src/Guess.cs".into(),
1723                    line: 9,
1724                    heuristic: true,
1725                    tier: None,
1726                    source: "var w = new Widget();".into(),
1727                },
1728            ],
1729            true,
1730        );
1731        let json = refs_model_to_json(&model);
1732        assert!(
1733            json.contains(
1734                r#""rows":[{"file":"src/Fact.cs","line":4,"why":"uses-member-precise"},{"file":"src/Guess.cs","line":9,"heuristic":true,"source":"var w = new Widget();","why":"uses-member-guess"}]"#
1735            ),
1736            "{json}"
1737        );
1738    }
1739
1740    #[test]
1741    fn refs_json_appends_tier_after_heuristic_and_omits_it_on_a_precise_row() {
1742        let row = |file: &str, line: usize, tier: Option<graph::HeuristicTier>| query::InboundRow {
1743            file: file.into(),
1744            line,
1745            heuristic: tier.is_some(),
1746            tier,
1747            source: String::new(),
1748        };
1749        let model = json_refs_model(
1750            vec![
1751                row("src/Fact.cs", 4, None),
1752                row("src/Ext.cs", 7, Some(graph::HeuristicTier::Ext)),
1753                row("src/Guess.cs", 9, Some(graph::HeuristicTier::Guess)),
1754            ],
1755            true,
1756        );
1757        let json = refs_model_to_json(&model);
1758        // A precise row is byte-identical to what it was before the tier
1759        // existed; a guessed one gains exactly one key, in the slot right after
1760        // the flag it refines and still before `source`.
1761        assert!(
1762            json.contains(
1763                r#""rows":[{"file":"src/Fact.cs","line":4,"why":"uses-member-precise"},{"file":"src/Ext.cs","line":7,"heuristic":true,"tier":"ext","why":"uses-member-ext"},{"file":"src/Guess.cs","line":9,"heuristic":true,"tier":"guess","why":"uses-member-guess"}]"#
1764            ),
1765            "{json}"
1766        );
1767        assert_eq!(
1768            json.matches(r#""tier""#).count(),
1769            2,
1770            "a precise row carries no trace of the key: {json}"
1771        );
1772    }
1773
1774    #[test]
1775    fn refs_json_omits_the_outbound_key_entirely_without_out_and_keeps_its_slot_with_it() {
1776        let row = || {
1777            vec![query::InboundRow {
1778                file: "src/Fact.cs".into(),
1779                line: 4,
1780                heuristic: false,
1781                tier: None,
1782                source: String::new(),
1783            }]
1784        };
1785        let without = refs_model_to_json(&json_refs_model(row(), false));
1786        assert!(
1787            !without.contains(r#""outbound":{"inherits""#),
1788            "the default model must carry no outbound tables: {without}"
1789        );
1790        assert!(without.contains(r#""ambiguous":{"inbound""#), "{without}");
1791
1792        let with = refs_model_to_json(&json_refs_model(row(), true));
1793        let outbound_at = with
1794            .find(r#""outbound":{"inherits""#)
1795            .expect("--out must emit the outbound tables");
1796        let inbound_at = with
1797            .find(r#""inbound":{"inherits""#)
1798            .expect("inbound is always emitted");
1799        let ambiguous_at = with
1800            .find(r#""ambiguous":{"inbound""#)
1801            .expect("ambiguous is always emitted");
1802        assert!(
1803            inbound_at < outbound_at && outbound_at < ambiguous_at,
1804            "outbound keeps JS's key slot: {with}"
1805        );
1806    }
1807
1808    #[test]
1809    fn member_refs_json_wraps_unchanged_resolved_models_under_status_query_members() {
1810        let row = || {
1811            vec![query::InboundRow {
1812                file: "src/Fact.cs".into(),
1813                line: 4,
1814                heuristic: false,
1815                tier: None,
1816                source: String::new(),
1817            }]
1818        };
1819        let one = json_refs_model(row(), false);
1820        let json = member_refs_to_json("Widget", std::slice::from_ref(&one), query::Outcome::Hit);
1821        assert!(
1822            json.starts_with(
1823                r#"{"schema_version":1,"status":"members","query":"Widget","members":[{"schema_version":1,"status":"resolved""#
1824            ),
1825            "schema_version is the first key, ahead of status, on both the wrapper and each member: {json}"
1826        );
1827        assert!(
1828            json.ends_with(r#"}],"outcome":"hit"}"#),
1829            "the wrapper's own outcome is appended last, after the members array: {json}"
1830        );
1831        assert!(
1832            json.contains(&refs_model_to_json(&one)),
1833            "a member entry is the resolved object unchanged: {json}"
1834        );
1835    }
1836
1837    #[test]
1838    fn impact_json_leads_with_schema_version_ahead_of_query() {
1839        let model = query::ImpactModel {
1840            kind: query::SeedKind::Symbol,
1841            seed_files: vec!["src/Widget.cs".to_string()],
1842            hops: 2,
1843            total_affected: 0,
1844            rows: vec![],
1845            dropped: 0,
1846            manifest_gap: 0,
1847            heuristic_affected: 0,
1848            tests_affected: 0,
1849            braked: vec![],
1850            braked_files: vec![],
1851        };
1852        let json = impact_model_to_json("Widget", &model);
1853        assert!(
1854            json.starts_with(r#"{"schema_version":1,"query":"Widget","status":"resolved""#),
1855            "schema_version leads, ahead of the query key that used to be first: {json}"
1856        );
1857    }
1858
1859    #[test]
1860    fn tests_json_leads_with_schema_version_ahead_of_status() {
1861        let model = query::TestsModel {
1862            query: "Order".to_string(),
1863            symbol: "App.Orders.Order".to_string(),
1864            def_files: vec!["src/Order.cs".to_string()],
1865            rows: vec![],
1866            test_file_count: 0,
1867            ref_count: 0,
1868            heuristic_file_count: 0,
1869            heuristic_ref_count: 0,
1870        };
1871        let json = tests_model_to_json(&model);
1872        assert!(
1873            json.starts_with(r#"{"schema_version":1,"status":"resolved","query":"Order""#),
1874            "schema_version leads, ahead of the status key that used to be first: {json}"
1875        );
1876    }
1877
1878    #[test]
1879    fn impact_json_appends_heuristic_count_then_heuristic_after_score_and_heuristic_affected_after_manifest_gap(
1880    ) {
1881        let row = |file: &str, heuristic: bool| query::ImpactRow {
1882            file: file.to_string(),
1883            hop: 1,
1884            via_count: if heuristic { 0 } else { 1 },
1885            ambiguous_count: 0,
1886            top_symbols: vec!["Widget".to_string()],
1887            top_symbols_more: 0,
1888            score: 0.5,
1889            heuristic_count: if heuristic { 2 } else { 0 },
1890            heuristic,
1891            tier: None,
1892            iface_via: vec![],
1893            from_lines: vec![],
1894            infra: false,
1895            why: if heuristic {
1896                query::Why::UsesMemberGuess
1897            } else {
1898                query::Why::UsesMemberPrecise
1899            },
1900        };
1901        let model = query::ImpactModel {
1902            kind: query::SeedKind::Symbol,
1903            seed_files: vec!["src/Widget.cs".to_string()],
1904            hops: 2,
1905            total_affected: 1,
1906            rows: vec![row("src/Direct.cs", false), row("src/Guessed.cs", true)],
1907            dropped: 0,
1908            manifest_gap: 0,
1909            heuristic_affected: 1,
1910            tests_affected: 0,
1911            braked: vec![],
1912            braked_files: vec![],
1913        };
1914        let json = impact_model_to_json("Widget", &model);
1915        assert!(
1916            json.contains(
1917                r#"{"file":"src/Direct.cs","hop":1,"viaCount":1,"ambiguousCount":0,"topSymbols":["Widget"],"topSymbolsMore":0,"score":0.5,"why":"uses-member-precise"}"#
1918            ),
1919            "{json}"
1920        );
1921        assert!(
1922            json.contains(
1923                r#"{"file":"src/Guessed.cs","hop":1,"viaCount":0,"ambiguousCount":0,"topSymbols":["Widget"],"topSymbolsMore":0,"score":0.5,"heuristicCount":2,"heuristic":true,"why":"uses-member-guess"}"#
1924            ),
1925            "{json}"
1926        );
1927        assert!(
1928            json.ends_with(
1929                r#","dropped":0,"manifestGap":0,"heuristicAffected":1,"testsAffected":0,"outcome":"hit"}"#
1930            ),
1931            "{json}"
1932        );
1933    }
1934
1935    #[test]
1936    fn impact_json_tier_sits_between_heuristic_and_iface_via() {
1937        let row = |file: &str, tier: graph::HeuristicTier| query::ImpactRow {
1938            file: file.to_string(),
1939            hop: 1,
1940            via_count: 0,
1941            ambiguous_count: 0,
1942            top_symbols: vec!["Widget".to_string()],
1943            top_symbols_more: 0,
1944            score: 0.5,
1945            heuristic_count: 2,
1946            heuristic: true,
1947            tier: Some(tier),
1948            iface_via: vec!["IWidget".to_string()],
1949            from_lines: vec![],
1950            infra: false,
1951            why: match tier {
1952                graph::HeuristicTier::Ext => query::Why::UsesMemberExt,
1953                graph::HeuristicTier::Guess => query::Why::UsesMemberGuess,
1954            },
1955        };
1956        let model = query::ImpactModel {
1957            kind: query::SeedKind::Symbol,
1958            seed_files: vec!["src/Widget.cs".to_string()],
1959            hops: 2,
1960            total_affected: 0,
1961            rows: vec![
1962                row("src/Extended.cs", graph::HeuristicTier::Ext),
1963                row("src/Guessed.cs", graph::HeuristicTier::Guess),
1964            ],
1965            dropped: 0,
1966            manifest_gap: 0,
1967            heuristic_affected: 2,
1968            tests_affected: 0,
1969            braked: vec![],
1970            braked_files: vec![],
1971        };
1972        let json = impact_model_to_json("Widget", &model);
1973        // `tier` takes the slot right after the flag it refines, which on this
1974        // row shape means BEFORE `ifaceVia` -- every key that was already
1975        // appended last stays appended last.
1976        assert!(
1977            json.contains(
1978                r#""score":0.5,"heuristicCount":2,"heuristic":true,"tier":"ext","ifaceVia":["IWidget"],"why":"uses-member-ext"}"#
1979            ),
1980            "{json}"
1981        );
1982        assert!(
1983            json.contains(
1984                r#""score":0.5,"heuristicCount":2,"heuristic":true,"tier":"guess","ifaceVia":["IWidget"],"why":"uses-member-guess"}"#
1985            ),
1986            "{json}"
1987        );
1988    }
1989
1990    // The expected strings below pin the find output caps; the integration suite
1991    // also exercises the same caps end to end.
1992    fn find_cap_root(prefix: &str, entry_count: usize) -> PathBuf {
1993        let root = std::env::temp_dir().join(format!("scout-cli-{prefix}-{}", std::process::id()));
1994        let _ = std::fs::remove_dir_all(&root);
1995        let scout = root.join(".scout");
1996        std::fs::create_dir_all(&scout).unwrap();
1997        let entries: Vec<String> = (0..entry_count)
1998            .map(|i| {
1999                format!(
2000                    r#""src/widget-{i:02}.cs": {{ "purpose": "widget number {i}", "mtime": {i} }}"#
2001                )
2002            })
2003            .collect();
2004        let json = format!(r#"{{ "entries": {{ {} }} }}"#, entries.join(", "));
2005        std::fs::write(scout.join("manifest.json"), json).unwrap();
2006        root
2007    }
2008
2009    #[test]
2010    fn cmd_find_caps_full_pool_at_25_with_honest_tail() {
2011        let root = find_cap_root("findcap-full", 30);
2012        let (code, out) = cmd_find(&root, "widget", false);
2013        assert_eq!(code, 0);
2014        let lines: Vec<&str> = out.split('\n').collect();
2015        assert_eq!(lines.len(), 26);
2016        assert_eq!(lines[25], "… +5 more (refine query)");
2017        assert!(lines[..25].iter().all(|l| l.contains("widget")));
2018    }
2019
2020    #[test]
2021    fn cmd_find_caps_fallback_pool_at_10_with_honest_tail() {
2022        let root = find_cap_root("findcap-fallback", 12);
2023        let (code, out) = cmd_find(&root, "widget zzz123nomatch", false);
2024        assert_eq!(code, 0);
2025        let lines: Vec<&str> = out.split('\n').collect();
2026        assert_eq!(lines.len(), 11);
2027        assert_eq!(lines[10], "… +2 more (refine query)");
2028    }
2029
2030    // The note text and the stream it lands on are pinned in
2031    // tests/cli_zero_hit.rs, which can see both streams; what belongs here is
2032    // that the code the verb computes is EXIT_NO_RESULT and not 0.
2033    #[test]
2034    fn cmd_find_reports_a_zero_hit_on_its_own_exit_code_with_stdout_unchanged() {
2035        let root = find_cap_root("findcap-zero", 5);
2036        let (code, out) = cmd_find(&root, "zzz123nosuchpurpose", false);
2037        assert_eq!(code, EXIT_NO_RESULT);
2038        assert_eq!(
2039            out,
2040            "no matches for \"zzz123nosuchpurpose\" (run 'devscout map' if manifest is missing)"
2041        );
2042    }
2043
2044    #[test]
2045    fn cmd_find_below_cap_prints_no_tail() {
2046        let root = find_cap_root("findcap-small", 5);
2047        let (code, out) = cmd_find(&root, "widget", false);
2048        assert_eq!(code, 0);
2049        assert_eq!(out.split('\n').count(), 5);
2050        assert!(!out.contains("more (refine query)"));
2051    }
2052
2053    // --- the declaration block ----------------------------------------------
2054
2055    const LEDGER_CS: &str = "namespace Gadgets;\n\npublic class WidgetLedger\n{\n\tprivate int _entryCount;\n\n\tpublic string Label { get; set; }\n\n\tpublic event EventHandler Retired;\n\n\tprivate void PopulateSlots() { }\n}\n";
2056    const PANEL_XAML: &str = "<UserControl\n\tx:Class=\"Gadgets.PanelView\">\n\t<Button x:Name=\"ShipButton\" />\n</UserControl>\n";
2057    const STRINGS_RESW: &str =
2058        "<root>\n\t<resheader name=\"resmimetype\" />\n\t<data name=\"ShipButton.Content\" xml:space=\"preserve\" />\n</root>\n";
2059
2060    // A repo whose graph.json is the one `map` would write for these three files
2061    // -- built through the real resolver, not hand-authored JSON, so the test
2062    // cannot drift from the index the map path actually produces.
2063    fn name_index_root(prefix: &str) -> PathBuf {
2064        let root = std::env::temp_dir().join(format!("scout-cli-{prefix}-{}", std::process::id()));
2065        let _ = std::fs::remove_dir_all(&root);
2066        std::fs::create_dir_all(root.join("src")).unwrap();
2067        std::fs::write(root.join("src").join("WidgetLedger.cs"), LEDGER_CS).unwrap();
2068        std::fs::write(root.join("src").join("Panel.xaml"), PANEL_XAML).unwrap();
2069        std::fs::write(root.join("src").join("Strings.resw"), STRINGS_RESW).unwrap();
2070        std::fs::create_dir_all(root.join(".scout")).unwrap();
2071        std::fs::write(
2072            root.join(".scout").join("manifest.json"),
2073            r#"{ "entries": {} }"#,
2074        )
2075        .unwrap();
2076
2077        let fragments = vec![
2078            (
2079                "src/WidgetLedger.cs".to_string(),
2080                graph::fragment_from_extraction(&crate::extract::extract(LEDGER_CS)),
2081            ),
2082            (
2083                "src/Panel.xaml".to_string(),
2084                graph::markup_fragment(&root, "src/Panel.xaml").unwrap(),
2085            ),
2086            (
2087                "src/Strings.resw".to_string(),
2088                graph::markup_fragment(&root, "src/Strings.resw").unwrap(),
2089            ),
2090        ];
2091        let g = crate::resolve::resolve_graph(&root, &fragments);
2092        std::fs::create_dir_all(root.join(".scout").join("graph")).unwrap();
2093        std::fs::write(
2094            root.join(".scout").join("graph").join("graph.json"),
2095            serde_json::to_string(&g).unwrap(),
2096        )
2097        .unwrap();
2098        root
2099    }
2100
2101    #[test]
2102    fn cmd_find_resolves_every_code_and_markup_category_to_its_declaration_line() {
2103        // A resource key (`ShipButton.Content`) is not in this loop: it is tier
2104        // 3, hidden by default. Its own default/--resources behavior is pinned
2105        // separately below.
2106        let root = name_index_root("findnames");
2107        for (query, expected) in [
2108            (
2109                "PopulateSlots",
2110                "src/WidgetLedger.cs:11  private void PopulateSlots() { }",
2111            ),
2112            (
2113                "Label",
2114                "src/WidgetLedger.cs:7  public string Label { get; set; }",
2115            ),
2116            (
2117                "_entryCount",
2118                "src/WidgetLedger.cs:5  private int _entryCount;",
2119            ),
2120            (
2121                "Retired",
2122                "src/WidgetLedger.cs:9  public event EventHandler Retired;",
2123            ),
2124            (
2125                "Gadgets.PanelView",
2126                "src/Panel.xaml:2  x:Class=\"Gadgets.PanelView\">",
2127            ),
2128        ] {
2129            let (code, out) = cmd_find(&root, query, false);
2130            assert_eq!(code, 0, "{query} should be a hit");
2131            assert_eq!(out, expected, "{query}");
2132        }
2133    }
2134
2135    // --- kind tiering --------------------------------------------------------
2136
2137    #[test]
2138    fn cmd_find_a_resource_key_only_query_brakes_correctly_by_default_and_is_a_hit_under_resources()
2139    {
2140        // `ShipButton.Content` matches ONLY the resource-key row -- no
2141        // markup-name row contains the full stop, and the manifest is empty -- so
2142        // this is the drowned-query case: a query that would otherwise return
2143        // resource keys (here, one) and nothing else brakes, correctly, instead
2144        // of looking like a hit.
2145        let root = name_index_root("findnames-resource-only");
2146        let (code, out) = cmd_find(&root, "ShipButton.Content", false);
2147        assert_eq!(
2148            code, EXIT_NO_RESULT,
2149            "a resource-key-only match is a zero hit by default"
2150        );
2151        assert_eq!(
2152            out,
2153            "no matches for \"ShipButton.Content\" (run 'devscout map' if manifest is missing)"
2154        );
2155
2156        let (code, out) = cmd_find(&root, "ShipButton.Content", true);
2157        assert_eq!(code, 0, "--resources lifts the demotion");
2158        assert_eq!(
2159            out,
2160            "src/Strings.resw:3  <data name=\"ShipButton.Content\" xml:space=\"preserve\" />"
2161        );
2162    }
2163
2164    #[test]
2165    fn cmd_find_default_view_hides_resource_keys_behind_a_trailer_and_resources_shows_them_inline()
2166    {
2167        // `ShipButton` matches BOTH the tier-2 markup-name `ShipButton` and
2168        // the tier-3 resource key `ShipButton.Content` (substring). Default:
2169        // the tier-2 row shows, the tier-3 row is a one-line trailer. Under
2170        // `--resources`: both rows show, inline, in build order, no trailer.
2171        let root = name_index_root("findnames-tiering");
2172        let (code, out) = cmd_find(&root, "ShipButton", false);
2173        assert_eq!(code, 0);
2174        assert_eq!(
2175            out,
2176            "src/Panel.xaml:3  <Button x:Name=\"ShipButton\" />\n+1 resource-key hits, use --resources",
2177            "tier 3 is a trailer, never an inline row, by default"
2178        );
2179
2180        let (code, out) = cmd_find(&root, "ShipButton", true);
2181        assert_eq!(code, 0);
2182        assert_eq!(
2183            out,
2184            "src/Panel.xaml:3  <Button x:Name=\"ShipButton\" />\nsrc/Strings.resw:3  <data name=\"ShipButton.Content\" xml:space=\"preserve\" />",
2185            "--resources includes the resource-key row inline and drops the trailer"
2186        );
2187    }
2188
2189    #[test]
2190    fn cmd_find_puts_the_declaration_block_above_the_manifest_block() {
2191        let root = name_index_root("findnames-order");
2192        std::fs::write(
2193            root.join(".scout").join("manifest.json"),
2194            r#"{ "entries": { "src/WidgetLedger.cs": { "purpose": "class WidgetLedger", "mtime": 1 } } }"#,
2195        )
2196        .unwrap();
2197        let (code, out) = cmd_find(&root, "WidgetLedger", false);
2198        assert_eq!(code, 0);
2199        assert_eq!(
2200            out,
2201            "src/WidgetLedger.cs:3  public class WidgetLedger\nsrc/WidgetLedger.cs:3: class WidgetLedger"
2202        );
2203    }
2204
2205    // The manifest-pool row carries its own file's first declaration line, not
2206    // just the declaration block above it. `Panel.xaml` has no manifest entry in
2207    // `name_index_root`'s fixture, so this proves the line on a row that has ONE
2208    // (a real manifest hit for a file the name index also carries a declaration
2209    // for), independent of the declaration block.
2210    #[test]
2211    fn cmd_find_manifest_pool_row_carries_its_files_first_declaration_line() {
2212        let root = name_index_root("findnames-manifest-line");
2213        std::fs::write(
2214            root.join(".scout").join("manifest.json"),
2215            r#"{ "entries": { "src/WidgetLedger.cs": { "purpose": "class WidgetLedger; Ship", "mtime": 1 } } }"#,
2216        )
2217        .unwrap();
2218        let (code, out) = cmd_find(&root, "WidgetLedger", false);
2219        assert_eq!(code, 0);
2220        let lines: Vec<&str> = out.split('\n').collect();
2221        let manifest_row = lines
2222            .iter()
2223            .find(|l| l.starts_with("src/WidgetLedger.cs:") && l.contains("; Ship"));
2224        assert_eq!(
2225            manifest_row,
2226            Some(&"src/WidgetLedger.cs:3: class WidgetLedger; Ship"),
2227            "{out}"
2228        );
2229    }
2230
2231    // A manifest hit whose file the name index carries NO declaration for at all
2232    // (a plain, undeclared source file) falls back to line 1 -- an always-valid
2233    // "open the file" anchor -- rather than emitting a row with no line.
2234    #[test]
2235    fn cmd_find_manifest_pool_row_for_a_file_with_no_declaration_falls_back_to_line_1() {
2236        let root = name_index_root("findnames-manifest-no-decl");
2237        std::fs::write(root.join("src").join("Notes.md"), "# widget notes\n").unwrap();
2238        std::fs::write(
2239            root.join(".scout").join("manifest.json"),
2240            r#"{ "entries": { "src/Notes.md": { "purpose": "widget notes doc", "mtime": 1 } } }"#,
2241        )
2242        .unwrap();
2243        let (code, out) = cmd_find(&root, "widget", false);
2244        assert_eq!(code, 0);
2245        assert!(out.contains("src/Notes.md:1: widget notes doc"), "{out}");
2246    }
2247
2248    #[test]
2249    fn cmd_find_still_takes_the_zero_hit_exit_when_neither_index_matches() {
2250        let root = name_index_root("findnames-zero");
2251        let (code, out) = cmd_find(&root, "Zzzznomatch", false);
2252        assert_eq!(code, EXIT_NO_RESULT);
2253        assert_eq!(
2254            out,
2255            "no matches for \"Zzzznomatch\" (run 'devscout map' if manifest is missing)"
2256        );
2257    }
2258
2259    // --- `clear` --------------------------------------------------------------
2260    //
2261    // In-process coverage of `cmd_clear`, fast and independent of the
2262    // subprocess byte-parity gate in the integration suite.
2263
2264    fn clear_root(prefix: &str) -> PathBuf {
2265        let root = std::env::temp_dir().join(format!("scout-cli-{prefix}-{}", std::process::id()));
2266        let _ = std::fs::remove_dir_all(&root);
2267        std::fs::create_dir_all(root.join(".scout")).unwrap();
2268        root
2269    }
2270
2271    fn seed_row(conn: &rusqlite::Connection, session_id: &str, rel_path: &str) {
2272        store::record_fresh(
2273            conn,
2274            &store::RecordFresh {
2275                session_id,
2276                agent_id: "",
2277                rel_path,
2278                sha256: "sha",
2279                size: 10,
2280                mtime: 1,
2281                lines: 1,
2282                delivered: true,
2283            },
2284        )
2285        .unwrap();
2286    }
2287
2288    #[test]
2289    fn cmd_clear_whole_store_removes_cache_db_and_answers_cache_cleared() {
2290        let root = clear_root("clear-whole");
2291        {
2292            let conn = store::open_store(&root).unwrap();
2293            seed_row(&conn, "s1", "a.ts");
2294        }
2295        assert!(root.join(".scout").join("cache.db").exists());
2296        let (code, out) = cmd_clear(&root, &[]);
2297        assert_eq!(code, 0);
2298        assert_eq!(out, "cache cleared");
2299        assert!(!root.join(".scout").join("cache.db").exists());
2300    }
2301
2302    #[test]
2303    fn cmd_clear_older_than_rejects_negative_and_non_numeric_with_usage_code_2() {
2304        let root = clear_root("clear-older-bad");
2305        for bad in ["-5", "abc", ""] {
2306            let (code, out) = cmd_clear(&root, &["--older-than".to_string(), bad.to_string()]);
2307            assert_eq!(code, 2, "arg {bad:?}");
2308            assert_eq!(
2309                out, "usage: devscout clear --older-than <days>",
2310                "arg {bad:?}"
2311            );
2312        }
2313        // A bare `--older-than` with nothing after it (missing value entirely).
2314        let (code, out) = cmd_clear(&root, &["--older-than".to_string()]);
2315        assert_eq!(code, 2);
2316        assert_eq!(out, "usage: devscout clear --older-than <days>");
2317        assert!(
2318            !root.join(".scout").join("cache.db").exists(),
2319            "a rejected flag must not create a store"
2320        );
2321    }
2322
2323    #[test]
2324    fn cmd_clear_session_unique_prefix_deletes_only_that_sessions_rows() {
2325        let root = clear_root("clear-session-unique");
2326        {
2327            let conn = store::open_store(&root).unwrap();
2328            seed_row(&conn, "abc-1111", "a.ts");
2329            seed_row(&conn, "abc-2222", "b.ts");
2330        }
2331        let (code, out) = cmd_clear(&root, &["--session".to_string(), "abc-1".to_string()]);
2332        assert_eq!(code, 0);
2333        assert_eq!(out, "deleted 1 row for session abc-1111");
2334
2335        let conn = store::open_store(&root).unwrap();
2336        assert!(store::lookup_read(&conn, "abc-1111", "a.ts", "")
2337            .unwrap()
2338            .is_none());
2339        assert!(store::lookup_read(&conn, "abc-2222", "b.ts", "")
2340            .unwrap()
2341            .is_some());
2342    }
2343
2344    // The ambiguous-prefix refusal: two sessions share the "abc" prefix; asking
2345    // for exactly that prefix must refuse on code 2, name both candidates, and
2346    // delete nothing.
2347    #[test]
2348    fn cmd_clear_session_ambiguous_prefix_refuses_with_code_2_and_lists_both_ids() {
2349        let root = clear_root("clear-session-ambiguous");
2350        {
2351            let conn = store::open_store(&root).unwrap();
2352            seed_row(&conn, "abc-1111", "a.ts");
2353            seed_row(&conn, "abc-2222", "b.ts");
2354        }
2355        let (code, out) = cmd_clear(&root, &["--session".to_string(), "abc".to_string()]);
2356        assert_eq!(code, 2);
2357        assert_eq!(out, "ambiguous session prefix \"abc\": abc-1111, abc-2222");
2358
2359        // Refused, so read-only: both sessions' rows still stand.
2360        let conn = store::open_store(&root).unwrap();
2361        assert!(store::lookup_read(&conn, "abc-1111", "a.ts", "")
2362            .unwrap()
2363            .is_some());
2364        assert!(store::lookup_read(&conn, "abc-2222", "b.ts", "")
2365            .unwrap()
2366            .is_some());
2367    }
2368
2369    #[test]
2370    fn cmd_clear_session_no_match_is_a_success_not_a_refusal() {
2371        let root = clear_root("clear-session-none");
2372        {
2373            let conn = store::open_store(&root).unwrap();
2374            seed_row(&conn, "abc-1111", "a.ts");
2375        }
2376        let (code, out) = cmd_clear(&root, &["--session".to_string(), "zzz".to_string()]);
2377        assert_eq!(code, 0);
2378        assert_eq!(out, "no sessions match \"zzz\"");
2379    }
2380
2381    #[test]
2382    fn cmd_clear_session_missing_value_is_a_usage_error() {
2383        let root = clear_root("clear-session-missing");
2384        let (code, out) = cmd_clear(&root, &["--session".to_string()]);
2385        assert_eq!(code, 2);
2386        assert_eq!(out, "usage: devscout clear --session <id-or-prefix>");
2387    }
2388
2389    #[test]
2390    fn cmd_clear_older_than_flag_takes_priority_over_session_flag_like_node_indexof_order() {
2391        // `clear` checks `--older-than` before `--session`, so a call carrying
2392        // both takes the age-based branch, whatever `--session` says.
2393        let root = clear_root("clear-precedence");
2394        {
2395            let conn = store::open_store(&root).unwrap();
2396            seed_row(&conn, "abc-1111", "a.ts");
2397        }
2398        let (code, out) = cmd_clear(
2399            &root,
2400            &[
2401                "--session".to_string(),
2402                "abc-1111".to_string(),
2403                "--older-than".to_string(),
2404                "9999".to_string(),
2405            ],
2406        );
2407        assert_eq!(code, 0);
2408        assert_eq!(
2409            out, "deleted 0 rows older than 9999d",
2410            "the --older-than branch must win"
2411        );
2412    }
2413}