Skip to main content

devscout_rs/cli/
dispatch.rs

1// Top-level argument dispatch: matches the subcommand, wires each verb's
2// return value to stdout/stderr and the process exit code.
3//
4// Alongside the user-facing verbs (README.md), four dev/diagnostic subcommands
5// are wired here and nowhere else: `noop` (cold-start floor), `parse` and
6// `spans` (seed AST dumps), and `extract-dump` (full-extraction JSON).
7//
8// `init` dispatches through `initcmd::cmd_init_full`, NOT `cmd_init` directly --
9// see initcmd.rs for why that split is load-bearing rather than stylistic.
10
11use std::io::{Read, Write};
12use std::process;
13
14use crate::audit;
15use crate::extract;
16use crate::hookio;
17use crate::initcmd;
18use crate::parse;
19
20use super::admin::{cmd_clear, cmd_map, cmd_stats};
21use super::answer::{
22    emit_freshness_warning, emit_zero_hit_note, ZERO_HIT_FIND, ZERO_HIT_IMPACT, ZERO_HIT_TESTS,
23};
24use super::args::first_positional;
25use super::compiler_facts::cmd_compiler_facts;
26use super::coverage::cmd_tests;
27use super::find::cmd_find;
28use super::impact::cmd_impact;
29use super::import_edges::cmd_import_edges;
30use super::read::cmd_read;
31use super::refs::cmd_refs;
32use super::root::{apply_global_options, current_dir};
33
34// Command output writer. `println!` PANICS on a broken pipe ("failed printing to
35// stdout"), which would kill `devscout find | head` noisily the moment `head`
36// closed its end. Exit silently there instead: write through `write_all` and
37// swallow the error -- on stdout that error is effectively only EPIPE, and the
38// exit code the command already computed still gets delivered by the caller's
39// `process::exit`.
40fn print_out(out: &str) {
41    use std::io::Write;
42    let stdout = std::io::stdout();
43    let mut lock = stdout.lock();
44    let _ = lock
45        .write_all(out.as_bytes())
46        .and_then(|()| lock.write_all(b"\n"));
47}
48
49// `devscout help` / `--help`. Written to stdout with exit 0 so a shell
50// `devscout --help` is a success, unlike the usage line the unknown-command
51// arm sends to stderr.
52const HELP: &str = "\
53devscout -- fast code index for C# and TypeScript codebases.
54
55usage: devscout <command> [args]
56
57index
58  init [scope ...]           register this repo, install hooks, first map
59                             [--label L] [--no-hooks] [--no-map]
60  map [scope ...] [--refresh] [--no-semantic]  build the index for the given scopes
61  clear --older-than <days>  drop freshness rows older than N days
62  clear --session <id>       drop one session's freshness rows
63
64query
65  find <query> [--resources] search the manifest by name or purpose
66  refs <symbol>              references to a symbol   [--out --all --no-guess --no-dispatch --no-bus --pick N --json|--compact]
67  read <symbol>              decl span + inbound refs [--no-guess --no-dispatch --no-bus --pick N --json|--compact]
68  impact <file|symbol>       blast radius             [--hops N --no-guess --no-dispatch --no-bus --no-imports --pick N --json|--compact]
69  tests <symbol>             tests reaching a symbol  [--no-guess --no-dispatch --no-bus --pick N --json|--compact]
70  import-edges <file> --repo <id>  load a cross-repo edge export for `impact` to read
71  compiler-facts run|import|status  optional engine-derived facts [--target --configuration --platform]
72  stats                      index + cache summary for this repo
73
74plumbing
75  parse <file.cs>            dump the parse tree
76  spans <file.cs>            dump declaration spans
77  extract-dump <file.cs>     dump extraction records
78  audit --semantic <refs.jsonl>  score uses-member edges against a semantic oracle [--units F] [--defs F] [--json] [--assert F] [--fp-sites F]
79  hook <read|bash>           agent hook filters, stdin -> stdout
80  noop                       exit 0 (harness probe)
81
82  -C <dir>                   run as if devscout started in <dir>
83  -h, --help                 show this help
84  -V, --version              show the version
85";
86
87/// Parses and executes a `devscout` command from its argument vector.
88#[allow(
89    clippy::too_many_lines,
90    reason = "one flat match over every subcommand; splitting it would scatter the dispatch table across files"
91)]
92pub fn dispatch(args: Vec<String>) {
93    let (cwd, args) = match apply_global_options(&current_dir(), &args) {
94        Ok(v) => v,
95        Err(e) => {
96            print_out(&format!("error: {e}"));
97            process::exit(1);
98        }
99    };
100    match args.get(1).map(String::as_str) {
101        Some("noop") => process::exit(0),
102        Some("help") | Some("--help") | Some("-h") => {
103            print!("{HELP}");
104            process::exit(0);
105        }
106        Some("--version") | Some("-V") => {
107            println!("devscout {}", env!("CARGO_PKG_VERSION"));
108            process::exit(0);
109        }
110        Some("parse") => {
111            let Some(path) = args.get(2) else {
112                eprintln!("usage: devscout parse <file.cs>");
113                process::exit(1);
114            };
115            parse::run_parse(path);
116        }
117        Some("spans") => {
118            let Some(path) = args.get(2) else {
119                eprintln!("usage: devscout spans <file.cs>");
120                process::exit(1);
121            };
122            parse::run_spans(path);
123        }
124        Some("extract-dump") => {
125            let Some(path) = args.get(2) else {
126                eprintln!("usage: devscout extract-dump <file.cs>");
127                process::exit(1);
128            };
129            extract::run_extract_dump(path);
130        }
131        Some("audit") => {
132            let (code, out) = audit::cmd_audit(&cwd, &args[2..]);
133            print_out(&out);
134            process::exit(code);
135        }
136        Some("hook") => match args.get(2).map(String::as_str) {
137            Some("read") => run_hook(hookio::run_read),
138            Some("bash") => run_hook(hookio::run_bash),
139            _ => {
140                eprintln!("usage: devscout hook <read|bash>");
141                process::exit(1);
142            }
143        },
144        Some("refs") => {
145            let (code, out, note) = cmd_refs(&cwd, &args[2..]);
146            print_out(&out);
147            emit_freshness_warning(&cwd);
148            emit_zero_hit_note(code, note, &cwd, first_positional(&args[2..]));
149            process::exit(code);
150        }
151        Some("read") => {
152            let (code, out, note) = cmd_read(&cwd, &args[2..]);
153            print_out(&out);
154            emit_freshness_warning(&cwd);
155            emit_zero_hit_note(code, note, &cwd, first_positional(&args[2..]));
156            process::exit(code);
157        }
158        Some("impact") => {
159            let (code, out) = cmd_impact(&cwd, &args[2..]);
160            print_out(&out);
161            emit_freshness_warning(&cwd);
162            emit_zero_hit_note(code, Some(ZERO_HIT_IMPACT), &cwd, None);
163            process::exit(code);
164        }
165        Some("tests") => {
166            let (code, out) = cmd_tests(&cwd, &args[2..]);
167            print_out(&out);
168            emit_freshness_warning(&cwd);
169            emit_zero_hit_note(code, Some(ZERO_HIT_TESTS), &cwd, None);
170            process::exit(code);
171        }
172        Some("find") => {
173            // `--resources` is parsed out first, then every remaining arg after
174            // the subcommand becomes one space-joined query, same as before the
175            // flag existed.
176            let resources = args[2..].iter().any(|a| a == "--resources");
177            let query_str = args[2..]
178                .iter()
179                .filter(|a| a.as_str() != "--resources")
180                .cloned()
181                .collect::<Vec<_>>()
182                .join(" ");
183            let (code, out) = cmd_find(&cwd, &query_str, resources);
184            print_out(&out);
185            emit_freshness_warning(&cwd);
186            emit_zero_hit_note(code, Some(ZERO_HIT_FIND), &cwd, Some(query_str.as_str()));
187            process::exit(code);
188        }
189        Some("import-edges") => {
190            let (code, out) = cmd_import_edges(&cwd, &args[2..]);
191            print_out(&out);
192            process::exit(code);
193        }
194        Some("compiler-facts") => {
195            let (code, out) = cmd_compiler_facts(&cwd, &args[2..]);
196            print_out(&out);
197            process::exit(code);
198        }
199        Some("map") => {
200            let (code, out) = cmd_map(&cwd, &args[2..]);
201            print_out(&out);
202            process::exit(code);
203        }
204        Some("stats") => {
205            let (code, out) = cmd_stats(&cwd);
206            print_out(&out);
207            process::exit(code);
208        }
209        Some("clear") => {
210            let (code, out) = cmd_clear(&cwd, &args[2..]);
211            print_out(&out);
212            process::exit(code);
213        }
214        Some("init") => {
215            let (code, out) = initcmd::cmd_init_full(&cwd, &args[2..]);
216            print_out(&out);
217            process::exit(code);
218        }
219        _ => {
220            eprintln!("usage: devscout <noop|parse|spans|extract-dump|hook|refs|read|impact|tests|find|import-edges|compiler-facts|map|stats|clear|init> [args]");
221            process::exit(1);
222        }
223    }
224}
225
226// Shared `hook read`/`hook bash` plumbing: read all of stdin (best-effort -- a
227// stdin read failure just means an empty/partial buffer goes into the fail-open
228// decision path, same outcome), run the hook, write stdout only if non-empty,
229// exit 0 unconditionally. Fail-open: a broken scout must never break the tool
230// call whose result it is post-processing, so this function itself must never
231// propagate a nonzero exit or a panic.
232fn run_hook(handler: fn(&[u8]) -> Vec<u8>) {
233    let mut buf = Vec::new();
234    let _ = std::io::stdin().read_to_end(&mut buf);
235    let out = handler(&buf);
236    if !out.is_empty() {
237        let _ = std::io::stdout().write_all(&out);
238    }
239    process::exit(0);
240}