use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process;
use std::time::Instant;
use crate::audit;
use crate::extract;
use crate::graph;
use crate::hookio;
use crate::initcmd;
use crate::manifest;
use crate::mapcmd;
use crate::parse;
use crate::query;
use crate::query::json::J;
use crate::render;
use crate::store;
use crate::suggest;
use crate::telemetry;
fn print_out(out: &str) {
use std::io::Write;
let stdout = std::io::stdout();
let mut lock = stdout.lock();
let _ = lock
.write_all(out.as_bytes())
.and_then(|()| lock.write_all(b"\n"));
}
const EXIT_NO_RESULT: i32 = 3;
const 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.";
const 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.";
const 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.";
const 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.";
const 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.";
fn emit_zero_hit_note(code: i32, note: Option<&str>, cwd: &Path, query: Option<&str>) {
let Some(note) = note else { return };
if code != EXIT_NO_RESULT {
return;
}
let mut text = String::from(note);
let rows = query.map(|q| nearest_names(cwd, q)).unwrap_or_default();
if !rows.is_empty() {
text.push_str("\ndid you mean:");
for row in rows {
text.push('\n');
text.push_str(&row);
}
}
let stderr = std::io::stderr();
let mut lock = stderr.lock();
let _ = lock
.write_all(text.as_bytes())
.and_then(|()| lock.write_all(b"\n"));
}
fn emit_freshness_warning(cwd: &Path) {
let Some(root) = crate::repo::find_scout_root(cwd).or_else(|| crate::repo::find_repo_root(cwd))
else {
return;
};
let Some(warning) = manifest::freshness_warning(&root) else {
return;
};
let stderr = std::io::stderr();
let mut lock = stderr.lock();
let _ = lock
.write_all(warning.as_bytes())
.and_then(|()| lock.write_all(b"\n"));
}
fn nearest_names(cwd: &Path, query: &str) -> Vec<String> {
let Ok(root) = require_repo(cwd) else {
return Vec::new();
};
let Some(g) = graph::read_graph(&root) else {
return Vec::new();
};
suggest::suggestion_lines(&g.names, query)
}
fn first_positional(args: &[String]) -> Option<&str> {
let mut skip_next = false;
for a in args {
if skip_next {
skip_next = false;
continue;
}
if a == "--pick" {
skip_next = true;
continue;
}
if !a.starts_with("--") {
return Some(a.as_str());
}
}
None
}
const HELP: &str = "\
devscout -- fast code index for C# and TypeScript codebases.
usage: devscout <command> [args]
index
init [scope ...] register this repo, install hooks, first map
[--label L] [--no-hooks] [--no-map]
map [scope ...] [--refresh] build the index for the given scopes
clear --older-than <days> drop freshness rows older than N days
clear --session <id> drop one session's freshness rows
query
find <query> [--resources] search the manifest by name or purpose
refs <symbol> references to a symbol [--out --all --no-guess --pick N --json|--compact]
read <symbol> decl span + inbound refs [--no-guess --pick N --json|--compact]
impact <file|symbol> blast radius [--hops N --no-guess --pick N --json|--compact]
tests <symbol> tests reaching a symbol [--no-guess --pick N --json|--compact]
stats index + cache summary for this repo
plumbing
parse <file.cs> dump the parse tree
spans <file.cs> dump declaration spans
extract-dump <file.cs> dump extraction records
audit --semantic <refs.jsonl> score uses-member edges against a semantic oracle [--units F] [--defs F] [--json] [--assert F]
hook <read|bash> agent hook filters, stdin -> stdout
noop exit 0 (harness probe)
-C <dir> run as if devscout started in <dir>
-h, --help show this help
-V, --version show the version
";
#[allow(
clippy::too_many_lines,
reason = "one flat match over every subcommand; splitting it would scatter the dispatch table across files"
)]
pub fn dispatch(args: Vec<String>) {
let (cwd, args) = match apply_global_options(¤t_dir(), &args) {
Ok(v) => v,
Err(e) => {
print_out(&format!("error: {e}"));
process::exit(1);
}
};
match args.get(1).map(String::as_str) {
Some("noop") => process::exit(0),
Some("help") | Some("--help") | Some("-h") => {
print!("{HELP}");
process::exit(0);
}
Some("--version") | Some("-V") => {
println!("devscout {}", env!("CARGO_PKG_VERSION"));
process::exit(0);
}
Some("parse") => {
let Some(path) = args.get(2) else {
eprintln!("usage: devscout parse <file.cs>");
process::exit(1);
};
parse::run_parse(path);
}
Some("spans") => {
let Some(path) = args.get(2) else {
eprintln!("usage: devscout spans <file.cs>");
process::exit(1);
};
parse::run_spans(path);
}
Some("extract-dump") => {
let Some(path) = args.get(2) else {
eprintln!("usage: devscout extract-dump <file.cs>");
process::exit(1);
};
extract::run_extract_dump(path);
}
Some("audit") => {
let (code, out) = audit::cmd_audit(&cwd, &args[2..]);
print_out(&out);
process::exit(code);
}
Some("hook") => match args.get(2).map(String::as_str) {
Some("read") => run_hook(hookio::run_read),
Some("bash") => run_hook(hookio::run_bash),
_ => {
eprintln!("usage: devscout hook <read|bash>");
process::exit(1);
}
},
Some("refs") => {
let (code, out, note) = cmd_refs(&cwd, &args[2..]);
print_out(&out);
emit_freshness_warning(&cwd);
emit_zero_hit_note(code, note, &cwd, first_positional(&args[2..]));
process::exit(code);
}
Some("read") => {
let (code, out, note) = cmd_read(&cwd, &args[2..]);
print_out(&out);
emit_freshness_warning(&cwd);
emit_zero_hit_note(code, note, &cwd, first_positional(&args[2..]));
process::exit(code);
}
Some("impact") => {
let (code, out) = cmd_impact(&cwd, &args[2..]);
print_out(&out);
emit_freshness_warning(&cwd);
emit_zero_hit_note(code, Some(ZERO_HIT_IMPACT), &cwd, None);
process::exit(code);
}
Some("tests") => {
let (code, out) = cmd_tests(&cwd, &args[2..]);
print_out(&out);
emit_freshness_warning(&cwd);
emit_zero_hit_note(code, Some(ZERO_HIT_TESTS), &cwd, None);
process::exit(code);
}
Some("find") => {
let resources = args[2..].iter().any(|a| a == "--resources");
let query_str = args[2..]
.iter()
.filter(|a| a.as_str() != "--resources")
.cloned()
.collect::<Vec<_>>()
.join(" ");
let (code, out) = cmd_find(&cwd, &query_str, resources);
print_out(&out);
emit_freshness_warning(&cwd);
emit_zero_hit_note(code, Some(ZERO_HIT_FIND), &cwd, Some(query_str.as_str()));
process::exit(code);
}
Some("map") => {
let (code, out) = cmd_map(&cwd, &args[2..]);
print_out(&out);
process::exit(code);
}
Some("stats") => {
let (code, out) = cmd_stats(&cwd);
print_out(&out);
process::exit(code);
}
Some("clear") => {
let (code, out) = cmd_clear(&cwd, &args[2..]);
print_out(&out);
process::exit(code);
}
Some("init") => {
let (code, out) = initcmd::cmd_init_full(&cwd, &args[2..]);
print_out(&out);
process::exit(code);
}
_ => {
eprintln!("usage: devscout <noop|parse|spans|extract-dump|hook|refs|read|impact|tests|find|map|stats|clear|init> [args]");
process::exit(1);
}
}
}
fn run_hook(handler: fn(&[u8]) -> Vec<u8>) {
let mut buf = Vec::new();
let _ = std::io::stdin().read_to_end(&mut buf);
let out = handler(&buf);
if !out.is_empty() {
let _ = std::io::stdout().write_all(&out);
}
process::exit(0);
}
fn current_dir() -> PathBuf {
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
}
pub(crate) fn require_repo(cwd: &Path) -> Result<PathBuf, String> {
require_repo_for_path(cwd, None)
}
fn require_repo_for_path(cwd: &Path, arg_path: Option<&str>) -> Result<PathBuf, String> {
crate::repo::find_scout_root(cwd)
.or_else(|| crate::repo::find_repo_root(cwd))
.or_else(|| root_from_arg(cwd, arg_path))
.ok_or_else(|| {
"no .scout or .git ancestor; run 'devscout init' from the repo or directory root"
.to_string()
})
}
fn root_from_arg(cwd: &Path, arg_path: Option<&str>) -> Option<PathBuf> {
let arg_path = arg_path?;
if !query::looks_like_file_path(arg_path) {
return None;
}
let abs = crate::repo::resolve_from(cwd, Path::new(arg_path));
if !abs.exists() {
return None;
}
crate::repo::find_scout_root(&abs).or_else(|| crate::repo::find_repo_root(&abs))
}
fn repo_relative_arg(cwd: &Path, root: &Path, arg: &str) -> String {
if !query::looks_like_file_path(arg) {
return arg.to_string();
}
let abs = crate::repo::resolve_from(cwd, Path::new(arg));
if !abs.exists() {
return arg.to_string();
}
match abs.strip_prefix(root) {
Ok(rest) if !rest.as_os_str().is_empty() => crate::repo::rel_path(root, &abs),
_ => arg.to_string(),
}
}
fn apply_global_options(cwd: &Path, args: &[String]) -> Result<(PathBuf, Vec<String>), String> {
let mut cwd = cwd.to_path_buf();
let mut idx = 1;
while args.get(idx).map(String::as_str) == Some("-C") {
let Some(dir) = args.get(idx + 1) else {
return Err("no directory given for '-C' option".to_string());
};
let next = crate::repo::resolve_from(&cwd, Path::new(dir));
if !next.is_dir() {
return Err(format!("cannot change to '{dir}': no such directory"));
}
cwd = next;
idx += 2;
}
if idx == 1 {
return Ok((cwd, args.to_vec()));
}
let mut rest = vec![args[0].clone()];
rest.extend_from_slice(&args[idx..]);
Ok((cwd, rest))
}
fn ambiguous_json(q: &str, candidates: Vec<J>) -> String {
J::Obj(vec![
("schema_version", J::UInt(query::SCHEMA_VERSION)),
(
"outcome",
J::Str(query::Outcome::Ambiguous.as_str().to_string()),
),
("query", J::Str(q.to_string())),
("candidates", J::Arr(candidates)),
])
.to_json_string()
}
fn ambiguous_candidates_out(
index: &query::GraphIndex,
q: &str,
ids: &[String],
json: bool,
) -> (i32, String) {
let mut rows: Vec<(String, &String, &graph::Def)> = ids
.iter()
.map(|id| {
let d = index
.def(id)
.expect("ambiguous candidate id must resolve to a graph def");
(format!("{id} {}:{} {}", d.file, d.line, d.kind), id, d)
})
.collect();
rows.sort_by(|a, b| a.0.cmp(&b.0));
if json {
let candidates = rows
.iter()
.map(|(_, id, d)| {
J::Obj(vec![
("id", J::Str((*id).clone())),
("file", J::Str(d.file.clone())),
("line", J::UInt(d.line as u64)),
("kind", J::Str(d.kind.clone())),
])
})
.collect();
return (1, ambiguous_json(q, candidates));
}
let mut out = vec![format!(
"ambiguous symbol \"{q}\" — {} candidates:",
rows.len()
)];
out.extend(rows.into_iter().map(|(row, _, _)| row));
(1, out.join("\n"))
}
fn member_ambiguous_out(
q: &str,
candidates: &[query::MemberCandidate],
json: bool,
) -> (i32, String) {
if json {
let rows = candidates
.iter()
.map(|c| {
J::Obj(vec![
("owner", J::Str(c.owner.clone())),
("name", J::Str(c.name.clone())),
("file", J::Str(c.file.clone())),
("line", J::UInt(c.line as u64)),
])
})
.collect();
return (1, ambiguous_json(q, rows));
}
let mut out = vec![format!(
"ambiguous member \"{q}\" — {} candidates:",
candidates.len()
)];
for c in candidates {
out.push(format!("{}.{} {}:{}", c.owner, c.name, c.file, c.line));
}
(1, out.join("\n"))
}
fn fallback_advised_out(q: &str, json: bool, plain: String) -> (i32, String) {
if json {
let out = J::Obj(vec![
("schema_version", J::UInt(query::SCHEMA_VERSION)),
(
"outcome",
J::Str(query::Outcome::FallbackAdvised.as_str().to_string()),
),
("query", J::Str(q.to_string())),
])
.to_json_string();
(EXIT_NO_RESULT, out)
} else {
(EXIT_NO_RESULT, plain)
}
}
fn parse_pick(args: &[String]) -> Result<Option<usize>, ()> {
let Some(idx) = args.iter().position(|a| a == "--pick") else {
return Ok(None);
};
let raw = args.get(idx + 1).map(String::as_str).unwrap_or("");
match raw.parse::<usize>() {
Ok(n) if n >= 1 => Ok(Some(n)),
_ => Err(()),
}
}
fn parse_int_js(s: &str) -> Option<i64> {
let s = s.trim_start();
let bytes = s.as_bytes();
let mut idx = 0;
if idx < bytes.len() && (bytes[idx] == b'+' || bytes[idx] == b'-') {
idx += 1;
}
let digits_start = idx;
while idx < bytes.len() && bytes[idx].is_ascii_digit() {
idx += 1;
}
if idx == digits_start {
return None;
}
s[..idx].parse::<i64>().ok()
}
fn index_options(args: &[String]) -> query::IndexOptions {
query::IndexOptions {
include_guesses: !args.iter().any(|a| a == "--no-guess"),
}
}
fn output_flags(verb: &str, args: &[String]) -> Result<(bool, bool), (i32, String)> {
let json = args.iter().any(|a| a == "--json");
let compact = args.iter().any(|a| a == "--compact");
if json && compact {
return Err((
1,
format!("devscout {verb}: --compact and --json are mutually exclusive"),
));
}
Ok((json, compact))
}
fn require_graph(root: &Path) -> Result<graph::Graph, (i32, String)> {
graph::read_graph(root).ok_or_else(|| {
(
1,
"no graph.json for this repo — run `devscout map` on a C# scope first".to_string(),
)
})
}
fn finish_query(
root: &Path,
verb: &'static str,
seed: &str,
start: Instant,
answer: (i32, String, query::Outcome, usize),
) -> (i32, String) {
let (code, out, outcome, candidate_count) = answer;
telemetry::record(
root,
&telemetry::QueryEvent {
verb,
seed,
outcome,
candidate_count,
},
start.elapsed(),
out.len(),
);
(code, out)
}
const REFS_USAGE: &str =
"usage: devscout refs <symbol> [--out] [--all] [--no-guess] [--pick N] [--json|--compact]";
fn refs_model_row_count(model: &query::RefsModel) -> usize {
let mut n = model.inbound.inherits.rows.len()
+ model.inbound.uses_type.rows.len()
+ model.inbound.uses_member.rows.len();
if let Some(ob) = &model.outbound {
n += ob.inherits.rows.len()
+ ob.uses_type.rows.len()
+ ob.uses_member.rows.len()
+ ob.imports.rows.len();
}
n
}
fn member_models_out(
q: &str,
json: bool,
compact: bool,
models: &[query::RefsModel],
) -> (i32, String, query::Outcome, usize) {
let (code, outcome) = if models.iter().all(|m| m.inbound.uses_member.total == 0) {
(EXIT_NO_RESULT, query::Outcome::ZeroHit)
} else {
(0, query::Outcome::Hit)
};
let out = if json {
query::json::member_refs_to_json(q, models, outcome)
} else {
let render: fn(&query::RefsModel) -> String = if compact {
render::render_refs_compact
} else {
render::render_refs_text
};
models.iter().map(render).collect::<Vec<_>>().join("\n")
};
let count = models.iter().map(refs_model_row_count).sum();
(code, out, outcome, count)
}
fn cmd_refs(cwd: &Path, args: &[String]) -> (i32, String, Option<&'static str>) {
let start = Instant::now();
let (json, compact) = match output_flags("refs", args) {
Ok(v) => v,
Err((code, out)) => return (code, out, None),
};
let Ok(pick) = parse_pick(args) else {
return (2, REFS_USAGE.to_string(), None);
};
let Some(q) = first_positional(args) else {
return (2, REFS_USAGE.to_string(), None);
};
let out = args.iter().any(|a| a == "--out");
let all_out = args.iter().any(|a| a == "--all");
let root = match require_repo(cwd) {
Ok(r) => r,
Err(e) => return (1, format!("error: {e}"), None),
};
let g = match require_graph(&root) {
Ok(g) => g,
Err((code, out)) => return (code, out, None),
};
let index = query::load_graph_index_with(&g, &root, index_options(args));
let build = |seed: &str| {
query::build_refs_model(
&index,
seed,
out,
query::DEFAULT_CAP,
query::INBOUND_CAP,
query::OUTBOUND_CAP,
all_out,
)
};
let result = match build(q) {
query::RefsResult::MemberAmbiguous(candidates) => match pick {
Some(n) if n <= candidates.len() => build(&query::qualified_seed(&candidates[n - 1])),
Some(_) => return (2, REFS_USAGE.to_string(), None),
None => query::RefsResult::MemberAmbiguous(candidates),
},
other => other,
};
let answer = refs_result_out(&index, q, json, compact, result);
let note = (answer.2 != query::Outcome::ZeroHit).then_some(ZERO_HIT_REFS);
let (code, out) = finish_query(&root, "refs", q, start, answer);
(code, out, note)
}
fn refs_result_out(
index: &query::GraphIndex,
q: &str,
json: bool,
compact: bool,
result: query::RefsResult,
) -> (i32, String, query::Outcome, usize) {
match result {
query::RefsResult::NotFound => {
let (code, out) = fallback_advised_out(q, json, format!("no symbol matches \"{q}\""));
(code, out, query::Outcome::FallbackAdvised, 0)
}
query::RefsResult::Ambiguous(ids) => {
let count = ids.len();
let (code, out) = ambiguous_candidates_out(index, q, &ids, json);
(code, out, query::Outcome::Ambiguous, count)
}
query::RefsResult::MemberAmbiguous(candidates) => {
let count = candidates.len();
let (code, out) = member_ambiguous_out(q, &candidates, json);
(code, out, query::Outcome::Ambiguous, count)
}
query::RefsResult::Members(models) => member_models_out(q, json, compact, &models),
query::RefsResult::Resolved(model) => {
let count = refs_model_row_count(&model);
let out = if json {
query::json::refs_model_to_json(&model)
} else if compact {
render::render_refs_compact(&model)
} else {
render::render_refs_text(&model)
};
(0, out, query::Outcome::Hit, count)
}
}
}
const READ_USAGE: &str = "usage: devscout read <symbol> [--no-guess] [--pick N] [--json|--compact]";
fn cmd_read(cwd: &Path, args: &[String]) -> (i32, String, Option<&'static str>) {
let start = Instant::now();
let (json, compact) = match output_flags("read", args) {
Ok(v) => v,
Err((code, out)) => return (code, out, None),
};
let Ok(pick) = parse_pick(args) else {
return (2, READ_USAGE.to_string(), None);
};
let Some(q) = first_positional(args) else {
return (2, READ_USAGE.to_string(), None);
};
let root = match require_repo(cwd) {
Ok(r) => r,
Err(e) => return (1, format!("error: {e}"), None),
};
let g = match require_graph(&root) {
Ok(g) => g,
Err((code, out)) => return (code, out, None),
};
let index = query::load_graph_index_with(&g, &root, index_options(args));
let result = match query::build_read_model(&index, q) {
query::ReadResult::MemberAmbiguous(candidates) => match pick {
Some(n) if n <= candidates.len() => {
query::build_read_model(&index, &query::qualified_seed(&candidates[n - 1]))
}
Some(_) => return (2, READ_USAGE.to_string(), None),
None => query::ReadResult::MemberAmbiguous(candidates),
},
other => other,
};
let answer = match result {
query::ReadResult::NotFound => {
let (code, out) = fallback_advised_out(q, json, format!("no symbol matches \"{q}\""));
(code, out, query::Outcome::FallbackAdvised, 0)
}
query::ReadResult::Ambiguous(ids) => {
let count = ids.len();
let (code, out) = ambiguous_candidates_out(&index, q, &ids, json);
(code, out, query::Outcome::Ambiguous, count)
}
query::ReadResult::MemberAmbiguous(candidates) => {
let count = candidates.len();
let (code, out) = member_ambiguous_out(q, &candidates, json);
(code, out, query::Outcome::Ambiguous, count)
}
query::ReadResult::Members(models) => member_models_out(q, json, compact, &models),
query::ReadResult::Resolved(model) => {
let count = refs_model_row_count(&model.refs);
let out = if json {
query::json::read_model_to_json(&model)
} else if compact {
render::render_read_compact(&model)
} else {
render::render_read_text(&model)
};
(0, out, query::Outcome::Hit, count)
}
};
let note = (answer.2 != query::Outcome::ZeroHit).then_some(ZERO_HIT_READ);
let (code, out) = finish_query(&root, "read", q, start, answer);
(code, out, note)
}
const 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]";
struct ImpactArgs<'a> {
hops: u32,
iface_max_fanin: usize,
hub_max_indegree: usize,
pick: Option<usize>,
query: &'a str,
}
fn parse_impact_args(args: &[String]) -> Result<ImpactArgs<'_>, (i32, String)> {
let usage_err = || (2, IMPACT_USAGE.to_string());
let mut hops: u32 = query::DEFAULT_HOPS;
if let Some(idx) = args.iter().position(|a| a == "--hops") {
let raw = args.get(idx + 1).map(String::as_str).unwrap_or("");
match parse_int_js(raw) {
Some(h) if h >= 1 => hops = h as u32,
_ => return Err(usage_err()),
}
}
let mut iface_max_fanin: usize = query::DEFAULT_IFACE_MAX_FANIN;
if let Some(idx) = args.iter().position(|a| a == "--iface-max-fanin") {
let raw = args.get(idx + 1).map(String::as_str).unwrap_or("");
match parse_int_js(raw) {
Some(n) if n >= 0 => iface_max_fanin = n as usize,
_ => return Err(usage_err()),
}
}
let mut hub_max_indegree: usize = query::DEFAULT_HUB_MAX_INDEGREE;
if let Some(idx) = args.iter().position(|a| a == "--hub-max-indegree") {
let raw = args.get(idx + 1).map(String::as_str).unwrap_or("");
match parse_int_js(raw) {
Some(n) if n >= 0 => hub_max_indegree = n as usize,
_ => return Err(usage_err()),
}
}
let Ok(pick) = parse_pick(args) else {
return Err(usage_err());
};
let mut query: Option<&str> = None;
for (i, a) in args.iter().enumerate() {
if a.starts_with("--") {
continue;
}
if i > 0
&& (args[i - 1] == "--hops"
|| args[i - 1] == "--iface-max-fanin"
|| args[i - 1] == "--hub-max-indegree"
|| args[i - 1] == "--pick")
{
continue;
}
query = Some(a.as_str());
break;
}
let Some(query) = query else {
return Err(usage_err());
};
Ok(ImpactArgs {
hops,
iface_max_fanin,
hub_max_indegree,
pick,
query,
})
}
fn cmd_impact(cwd: &Path, args: &[String]) -> (i32, String) {
let start = Instant::now();
let (json, compact) = match output_flags("impact", args) {
Ok(v) => v,
Err(e) => return e,
};
let ImpactArgs {
hops,
iface_max_fanin,
hub_max_indegree,
pick,
query: q,
} = match parse_impact_args(args) {
Ok(a) => a,
Err(e) => return e,
};
let root = match require_repo_for_path(cwd, Some(q)) {
Ok(r) => r,
Err(e) => return (1, format!("error: {e}")),
};
let q = repo_relative_arg(cwd, &root, q);
let q = q.as_str();
let g = match require_graph(&root) {
Ok(g) => g,
Err(e) => return e,
};
let index = query::load_graph_index_with(&g, &root, index_options(args));
let build = |seed: &str| {
query::build_impact_model(
&index,
seed,
hops,
query::DEFAULT_CAP,
!args.iter().any(|a| a == "--no-iface"),
iface_max_fanin,
hub_max_indegree,
)
};
let result = match build(q) {
query::ImpactResult::MemberAmbiguous(candidates) => match pick {
Some(n) if n <= candidates.len() => build(&query::qualified_seed(&candidates[n - 1])),
Some(_) => return (2, IMPACT_USAGE.to_string()),
None => query::ImpactResult::MemberAmbiguous(candidates),
},
other => other,
};
let answer = match result {
query::ImpactResult::NotFound { kind } => {
let (code, out) = fallback_advised_out(
q,
json,
format!("no {} match for \"{q}\"", render::seed_kind_str(kind)),
);
(code, out, query::Outcome::FallbackAdvised, 0)
}
query::ImpactResult::Ambiguous { ids, .. } => {
let count = ids.len();
let (code, out) = ambiguous_candidates_out(&index, q, &ids, json);
(code, out, query::Outcome::Ambiguous, count)
}
query::ImpactResult::MemberAmbiguous(candidates) => {
let count = candidates.len();
let (code, out) = member_ambiguous_out(q, &candidates, json);
(code, out, query::Outcome::Ambiguous, count)
}
query::ImpactResult::Resolved(model) => {
let out = if json {
query::json::impact_model_to_json(q, &model)
} else if compact {
render::render_impact_compact(q, &model)
} else {
render::render_impact_text(q, &model)
};
if model.rows.is_empty() {
(EXIT_NO_RESULT, out, query::Outcome::ZeroHit, 0)
} else {
let count = model.rows.len();
(0, out, query::Outcome::Hit, count)
}
}
};
finish_query(&root, "impact", q, start, answer)
}
const TESTS_USAGE: &str =
"usage: devscout tests <symbol> [--no-guess] [--pick N] [--json|--compact]";
fn cmd_tests(cwd: &Path, args: &[String]) -> (i32, String) {
let start = Instant::now();
let (json, compact) = match output_flags("tests", args) {
Ok(v) => v,
Err(e) => return e,
};
let Ok(pick) = parse_pick(args) else {
return (2, TESTS_USAGE.to_string());
};
let Some(q) = first_positional(args) else {
return (2, TESTS_USAGE.to_string());
};
let root = match require_repo(cwd) {
Ok(r) => r,
Err(e) => return (1, format!("error: {e}")),
};
let g = match require_graph(&root) {
Ok(g) => g,
Err(e) => return e,
};
let index = query::load_graph_index_with(&g, &root, index_options(args));
let result = match query::build_tests_model(&index, q) {
query::TestsResult::MemberAmbiguous(candidates) => match pick {
Some(n) if n <= candidates.len() => {
query::build_tests_model(&index, &query::qualified_seed(&candidates[n - 1]))
}
Some(_) => return (2, TESTS_USAGE.to_string()),
None => query::TestsResult::MemberAmbiguous(candidates),
},
other => other,
};
let answer = match result {
query::TestsResult::NotFound => {
let (code, out) = fallback_advised_out(q, json, format!("no symbol matches \"{q}\""));
(code, out, query::Outcome::FallbackAdvised, 0)
}
query::TestsResult::Ambiguous(ids) => {
let count = ids.len();
let (code, out) = ambiguous_candidates_out(&index, q, &ids, json);
(code, out, query::Outcome::Ambiguous, count)
}
query::TestsResult::MemberAmbiguous(candidates) => {
let count = candidates.len();
let (code, out) = member_ambiguous_out(q, &candidates, json);
(code, out, query::Outcome::Ambiguous, count)
}
query::TestsResult::Resolved(model) => {
let count = model.rows.len();
let out = if json {
query::json::tests_model_to_json(&model)
} else if compact {
render::render_tests_compact(&model)
} else {
render::render_tests_text(&model)
};
(0, out, query::Outcome::Hit, count)
}
};
finish_query(&root, "tests", q, start, answer)
}
const FIND_FULL_CAP: usize = 25;
const FIND_FALLBACK_CAP: usize = 10;
const FIND_NAMES_CAP: usize = 25;
fn cmd_find(cwd: &Path, query_str: &str, resources: bool) -> (i32, String) {
let start = Instant::now();
let root = match require_repo(cwd) {
Ok(r) => r,
Err(e) => return (1, format!("error: {e}")),
};
if query_str.is_empty() {
return (2, "usage: devscout find <query> [--resources]".to_string());
}
let graph = graph::read_graph(&root);
let inbound_counts = graph
.as_ref()
.map(query::file_inbound_counts)
.unwrap_or_default();
let (decl_lines, resource_count): (Vec<String>, usize) = match graph.as_ref() {
None => (Vec::new(), 0),
Some(g) => {
let named = query::find_names(g, query_str);
let mut primary: Vec<&graph::GraphName> = Vec::new();
let mut resource_count = 0usize;
for n in named.iter() {
if query::name_tier(&n.kind) <= 2 {
primary.push(n);
} else {
resource_count += 1;
}
}
let pool: Vec<&graph::GraphName> = if resources { named } else { primary };
let mut out: Vec<String> = pool
.iter()
.take(FIND_NAMES_CAP)
.map(|n| {
let text = query::source_line(&root, &n.file, n.line);
if text.is_empty() {
format!("{}:{}", n.file, n.line)
} else {
format!("{}:{} {text}", n.file, n.line)
}
})
.collect();
if pool.len() > FIND_NAMES_CAP {
out.push(format!(
"… +{} more declarations (refine query)",
pool.len() - FIND_NAMES_CAP
));
}
(out, resource_count)
}
};
match manifest::find_in_manifest_detailed(&root, query_str, &inbound_counts) {
Ok(r) => {
let answer = find_result_out(
graph.as_ref(),
query_str,
resources,
resource_count,
decl_lines,
r,
);
finish_query(&root, "find", query_str, start, answer)
}
Err(e) => (1, format!("error: {e}")),
}
}
fn find_result_out(
graph: Option<&graph::Graph>,
query_str: &str,
resources: bool,
resource_count: usize,
decl_lines: Vec<String>,
r: manifest::FindResult,
) -> (i32, String, query::Outcome, usize) {
if r.hits.is_empty() && decl_lines.is_empty() {
let out =
format!("no matches for \"{query_str}\" (run 'devscout map' if manifest is missing)");
return (EXIT_NO_RESULT, out, query::Outcome::FallbackAdvised, 0);
}
let cap = if r.fallback {
FIND_FALLBACK_CAP
} else {
FIND_FULL_CAP
};
let candidate_count = decl_lines.len().min(FIND_NAMES_CAP) + r.hits.len().min(cap);
let mut lines: Vec<String> = decl_lines;
if !resources && resource_count > 0 {
lines.push(format!(
"+{resource_count} resource-key hits, use --resources"
));
}
let decl_line_by_file = graph
.map(query::first_decl_line_by_file)
.unwrap_or_default();
lines.extend(r.hits.iter().take(cap).map(|h| {
let purpose = h.purpose.as_deref().unwrap_or("undefined");
let agent = if h.source == "agent" { " [agent]" } else { "" };
let line = decl_line_by_file.get(&h.path).copied().unwrap_or(1);
format!("{}:{line}: {purpose}{agent}", h.path)
}));
if r.hits.len() > cap {
lines.push(format!("… +{} more (refine query)", r.hits.len() - cap));
}
(0, lines.join("\n"), query::Outcome::Hit, candidate_count)
}
fn cmd_map(cwd: &Path, args: &[String]) -> (i32, String) {
let root = match require_repo(cwd) {
Ok(r) => r,
Err(e) => return (1, format!("error: {e}")),
};
let dirs: Vec<String> = args
.iter()
.filter(|a| a.as_str() != "--refresh")
.cloned()
.collect();
match mapcmd::map_repo(&root, &dirs, mapcmd::MapOptions::from_env()) {
Ok(report) => (0, report.summary_line()),
Err(e) => (1, format!("error: {e}")),
}
}
fn cmd_stats(cwd: &Path) -> (i32, String) {
let root = match require_repo(cwd) {
Ok(r) => r,
Err(e) => return (1, format!("error: {e}")),
};
let db = match store::open_store(&root) {
Ok(c) => c,
Err(e) => return (1, format!("error: {e}")),
};
let s = match store::stats_for(&db) {
Ok(s) => s,
Err(e) => return (1, format!("error: {e}")),
};
let mut lines = vec![
format!("devscout stats ({}):", root.display()),
format!(" files tracked: {}", s.distinct_files),
format!(" reads deduped (stubs): {}", s.total_stubs),
format!(" lines saved: {}", s.lines_saved),
format!(" bytes saved: {}", s.bytes_saved),
format!(
" est tokens saved: {}",
js_math_round(s.bytes_saved as f64 / 4.0)
),
];
let b = match store::bash_stats_for(&db) {
Ok(b) => b,
Err(e) => return (1, format!("error: {e}")),
};
if b.commands_tracked > 0 {
lines.push(format!(" bash commands tracked: {}", b.commands_tracked));
lines.push(format!(" bash dedups (stubs): {}", b.total_stubs));
lines.push(format!(
" bash est tokens saved: {}",
js_math_round(b.bytes_saved as f64 / 4.0)
));
}
if let Ok(cs_conn) = store::open_content_store() {
if let Ok(cs) = store::content_stats_for(&cs_conn) {
if cs.total_stubs > 0 {
lines.push(format!(
" cross-repo dedups (all roots, this machine): {}",
cs.total_stubs
));
lines.push(format!(
" cross-repo est tokens saved: {}",
js_math_round(cs.bytes_saved as f64 / 4.0)
));
}
}
}
let per_session = match store::session_stats(&db) {
Ok(v) => v,
Err(e) => return (1, format!("error: {e}")),
};
if !per_session.is_empty() {
lines.push(String::new());
lines.push(" per session:".to_string());
for r in &per_session {
let sid: String = r.session_id.chars().take(8).collect();
lines.push(format!(
" {sid:<8} files {:>4} stubs {:>4} lines saved {:>6} est tokens {}",
r.files,
r.stubs,
r.lines_saved,
js_math_round(r.bytes_saved as f64 / 4.0),
));
}
}
let top = match store::top_stubbed(&db, 5) {
Ok(v) => v,
Err(e) => return (1, format!("error: {e}")),
};
if !top.is_empty() {
lines.push(String::new());
lines.push(" top stubbed files:".to_string());
for r in &top {
let sid: String = r.session_id.chars().take(8).collect();
lines.push(format!(
" {}x {} ({} lines, session {sid})",
r.stub_count, r.rel_path, r.lines
));
}
}
(0, lines.join("\n"))
}
fn cmd_clear(cwd: &Path, args: &[String]) -> (i32, String) {
let root = match require_repo(cwd) {
Ok(r) => r,
Err(e) => return (1, format!("error: {e}")),
};
if let Some(idx) = args.iter().position(|a| a == "--older-than") {
let raw = args.get(idx + 1).map(String::as_str).unwrap_or("");
let days = match parse_int_js(raw) {
Some(d) if d >= 0 => d,
_ => return (2, "usage: devscout clear --older-than <days>".to_string()),
};
let db = match store::open_store(&root) {
Ok(c) => c,
Err(e) => return (1, format!("error: {e}")),
};
let deleted = match store::prune(&db, Some(days as f64), None) {
Ok(n) => n,
Err(e) => return (1, format!("error: {e}")),
};
let suffix = if deleted == 1 { "" } else { "s" };
return (
0,
format!("deleted {deleted} row{suffix} older than {days}d"),
);
}
if let Some(idx) = args.iter().position(|a| a == "--session") {
let prefix = args.get(idx + 1).map(String::as_str).unwrap_or("");
if prefix.is_empty() {
return (
2,
"usage: devscout clear --session <id-or-prefix>".to_string(),
);
}
let db = match store::open_store(&root) {
Ok(c) => c,
Err(e) => return (1, format!("error: {e}")),
};
let matches = match store::session_ids_by_prefix(&db, prefix) {
Ok(v) => v,
Err(e) => return (1, format!("error: {e}")),
};
if matches.is_empty() {
return (0, format!("no sessions match \"{prefix}\""));
}
if matches.len() > 1 {
return (
2,
format!(
"ambiguous session prefix \"{prefix}\": {}",
matches.join(", ")
),
);
}
let deleted = match store::prune(&db, None, Some(matches[0].as_str())) {
Ok(n) => n,
Err(e) => return (1, format!("error: {e}")),
};
let suffix = if deleted == 1 { "" } else { "s" };
let session = &matches[0];
return (
0,
format!("deleted {deleted} row{suffix} for session {session}"),
);
}
let db_path = crate::repo::scout_dir(&root).join("cache.db");
if db_path.exists() {
if let Err(e) = std::fs::remove_file(&db_path) {
return (1, format!("error: {e}"));
}
}
(0, "cache cleared".to_string())
}
fn js_math_round(x: f64) -> i64 {
(x + 0.5).floor() as i64
}
#[cfg(test)]
mod tests {
use super::*;
use crate::query::json::*;
#[test]
fn js_float_string_matches_node_json_stringify_on_checked_values() {
assert_eq!(js_float_string(1.0), "1");
assert_eq!(js_float_string(100.0), "100");
assert_eq!(js_float_string(0.0), "0");
assert_eq!(js_float_string(-0.0), "0");
assert_eq!(js_float_string(0.15), "0.15");
assert_eq!(js_float_string(0.1 + 0.2), "0.30000000000000004");
assert_eq!(js_float_string(1.0 / 3.0), "0.3333333333333333");
assert_eq!(js_float_string(1e21), "1e+21");
assert_eq!(js_float_string(0.0000001), "1e-7");
assert_eq!(js_float_string(f64::NAN), "null");
assert_eq!(js_float_string(f64::INFINITY), "null");
}
#[test]
fn js_math_round_matches_node_math_round_on_checked_values() {
assert_eq!(js_math_round(0.0), 0);
assert_eq!(js_math_round(1.4), 1);
assert_eq!(js_math_round(1.5), 2);
assert_eq!(js_math_round(1.49999), 1);
assert_eq!(js_math_round(2.5), 3);
assert_eq!(js_math_round(100.0 / 4.0), 25);
assert_eq!(js_math_round(101.0 / 4.0), 25);
assert_eq!(js_math_round(103.0 / 4.0), 26);
}
#[test]
fn tests_json_appends_via_project_last_and_omits_it_for_an_attribute_row() {
let attribute_row = query::TestRow {
file: "tests/OrderServiceTests.cs".to_string(),
test_defs: vec!["App.Orders.Tests.OrderServiceTests".to_string()],
lines: vec![10],
ref_count: 1,
heuristic: false,
tier: None,
via: query::TestVia::Attribute,
};
let project_row = query::TestRow {
file: "tests/App.Tests/FakeServer.cs".to_string(),
test_defs: vec![],
lines: vec![12, 34],
ref_count: 2,
heuristic: false,
tier: None,
via: query::TestVia::Project,
};
let model = query::TestsModel {
query: "Order".to_string(),
symbol: "App.Orders.Order".to_string(),
def_files: vec!["src/Order.cs".to_string()],
rows: vec![attribute_row, project_row],
test_file_count: 2,
ref_count: 3,
heuristic_file_count: 0,
heuristic_ref_count: 0,
};
let json = tests_model_to_json(&model);
assert!(
json.contains(
r#"{"file":"tests/OrderServiceTests.cs","testDefs":["App.Orders.Tests.OrderServiceTests"],"lines":[10],"refCount":1,"why":"test-attribute"}"#
),
"an attribute row carries no via key at all: {json}"
);
assert!(
json.contains(
r#"{"file":"tests/App.Tests/FakeServer.cs","testDefs":[],"lines":[12,34],"refCount":2,"via":"project","why":"test-project"}"#
),
"a project row appends via LAST (no heuristic/tier on this row): {json}"
);
}
#[test]
fn parse_int_js_matches_node_parseint_on_checked_values() {
assert_eq!(parse_int_js("3"), Some(3));
assert_eq!(parse_int_js("3abc"), Some(3));
assert_eq!(parse_int_js("-5"), Some(-5));
assert_eq!(parse_int_js(" 7"), Some(7));
assert_eq!(parse_int_js(""), None);
assert_eq!(parse_int_js("abc"), None);
assert_eq!(parse_int_js("-"), None);
}
#[test]
fn j_object_and_array_serialize_with_no_extra_whitespace_like_json_stringify() {
let j = J::Obj(vec![
("a", J::UInt(1)),
("b", J::Arr(vec![J::Str("x".to_string()), J::UInt(2)])),
]);
assert_eq!(j.to_json_string(), r#"{"a":1,"b":["x",2]}"#);
}
#[test]
fn j_string_escapes_control_chars_and_quotes_like_json_stringify() {
let j = J::Str("a\"b\\c\nd".to_string());
assert_eq!(j.to_json_string(), r#""a\"b\\c\nd""#);
}
fn json_refs_model(rows: Vec<query::InboundRow>, out: bool) -> query::RefsModel {
let empty_in = || query::Table {
total: 0,
dropped: 0,
rows: Vec::<query::InboundRow>::new(),
};
let empty_out = || query::Table {
total: 0,
dropped: 0,
rows: Vec::<query::OutboundRow>::new(),
};
query::RefsModel {
query: "Widget".to_string(),
id: "App.Widget".to_string(),
kind: "class".to_string(),
sites: vec![query::DefSite {
file: "src/Widget.cs".to_string(),
line: 3,
}],
inbound: query::InboundTables {
inherits: empty_in(),
uses_type: empty_in(),
uses_member: query::Table {
total: rows.len(),
dropped: 0,
rows,
},
},
outbound: out.then(|| query::OutboundTables {
inherits: empty_out(),
uses_type: empty_out(),
uses_member: empty_out(),
imports: query::Table {
total: 0,
dropped: 0,
rows: Vec::new(),
},
}),
ambiguous: query::AmbiguousTables {
inbound: query::Table {
total: 0,
dropped: 0,
rows: Vec::new(),
},
outbound: query::Table {
total: 0,
dropped: 0,
rows: Vec::new(),
},
},
manifest_gap: 0,
member_refs: None,
}
}
#[test]
fn refs_json_leads_with_schema_version_ahead_of_every_existing_key() {
let model = json_refs_model(Vec::new(), false);
let json = refs_model_to_json(&model);
assert!(
json.starts_with(
r#"{"schema_version":1,"status":"resolved","query":"Widget","id":"App.Widget","kind":"class""#
),
"schema_version is the first key and every existing key keeps its slot after it: {json}"
);
}
#[test]
fn read_json_leads_with_schema_version_and_keeps_span_right_after_kind() {
let model = query::ReadModel {
refs: json_refs_model(Vec::new(), false),
span: Some(query::ReadSpan {
file: "src/Widget.cs".to_string(),
start_line: 3,
end_line: 5,
source: "class Widget {}".to_string(),
}),
};
let json = read_model_to_json(&model);
assert!(
json.starts_with(
r#"{"schema_version":1,"status":"resolved","query":"Widget","id":"App.Widget","kind":"class","span":{"#
),
"schema_version leads, and span still sits right after kind, unmoved: {json}"
);
}
#[test]
fn refs_json_appends_heuristic_then_source_last_and_omits_each_when_it_has_no_value() {
let model = json_refs_model(
vec![
query::InboundRow {
file: "src/Fact.cs".into(),
line: 4,
heuristic: false,
tier: None,
source: String::new(),
},
query::InboundRow {
file: "src/Guess.cs".into(),
line: 9,
heuristic: true,
tier: None,
source: "var w = new Widget();".into(),
},
],
true,
);
let json = refs_model_to_json(&model);
assert!(
json.contains(
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"}]"#
),
"{json}"
);
}
#[test]
fn refs_json_appends_tier_after_heuristic_and_omits_it_on_a_precise_row() {
let row = |file: &str, line: usize, tier: Option<graph::HeuristicTier>| query::InboundRow {
file: file.into(),
line,
heuristic: tier.is_some(),
tier,
source: String::new(),
};
let model = json_refs_model(
vec![
row("src/Fact.cs", 4, None),
row("src/Ext.cs", 7, Some(graph::HeuristicTier::Ext)),
row("src/Guess.cs", 9, Some(graph::HeuristicTier::Guess)),
],
true,
);
let json = refs_model_to_json(&model);
assert!(
json.contains(
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"}]"#
),
"{json}"
);
assert_eq!(
json.matches(r#""tier""#).count(),
2,
"a precise row carries no trace of the key: {json}"
);
}
#[test]
fn refs_json_omits_the_outbound_key_entirely_without_out_and_keeps_its_slot_with_it() {
let row = || {
vec![query::InboundRow {
file: "src/Fact.cs".into(),
line: 4,
heuristic: false,
tier: None,
source: String::new(),
}]
};
let without = refs_model_to_json(&json_refs_model(row(), false));
assert!(
!without.contains(r#""outbound":{"inherits""#),
"the default model must carry no outbound tables: {without}"
);
assert!(without.contains(r#""ambiguous":{"inbound""#), "{without}");
let with = refs_model_to_json(&json_refs_model(row(), true));
let outbound_at = with
.find(r#""outbound":{"inherits""#)
.expect("--out must emit the outbound tables");
let inbound_at = with
.find(r#""inbound":{"inherits""#)
.expect("inbound is always emitted");
let ambiguous_at = with
.find(r#""ambiguous":{"inbound""#)
.expect("ambiguous is always emitted");
assert!(
inbound_at < outbound_at && outbound_at < ambiguous_at,
"outbound keeps JS's key slot: {with}"
);
}
#[test]
fn member_refs_json_wraps_unchanged_resolved_models_under_status_query_members() {
let row = || {
vec![query::InboundRow {
file: "src/Fact.cs".into(),
line: 4,
heuristic: false,
tier: None,
source: String::new(),
}]
};
let one = json_refs_model(row(), false);
let json = member_refs_to_json("Widget", std::slice::from_ref(&one), query::Outcome::Hit);
assert!(
json.starts_with(
r#"{"schema_version":1,"status":"members","query":"Widget","members":[{"schema_version":1,"status":"resolved""#
),
"schema_version is the first key, ahead of status, on both the wrapper and each member: {json}"
);
assert!(
json.ends_with(r#"}],"outcome":"hit"}"#),
"the wrapper's own outcome is appended last, after the members array: {json}"
);
assert!(
json.contains(&refs_model_to_json(&one)),
"a member entry is the resolved object unchanged: {json}"
);
}
#[test]
fn impact_json_leads_with_schema_version_ahead_of_query() {
let model = query::ImpactModel {
kind: query::SeedKind::Symbol,
seed_files: vec!["src/Widget.cs".to_string()],
hops: 2,
total_affected: 0,
rows: vec![],
dropped: 0,
manifest_gap: 0,
heuristic_affected: 0,
tests_affected: 0,
braked: vec![],
braked_files: vec![],
};
let json = impact_model_to_json("Widget", &model);
assert!(
json.starts_with(r#"{"schema_version":1,"query":"Widget","status":"resolved""#),
"schema_version leads, ahead of the query key that used to be first: {json}"
);
}
#[test]
fn tests_json_leads_with_schema_version_ahead_of_status() {
let model = query::TestsModel {
query: "Order".to_string(),
symbol: "App.Orders.Order".to_string(),
def_files: vec!["src/Order.cs".to_string()],
rows: vec![],
test_file_count: 0,
ref_count: 0,
heuristic_file_count: 0,
heuristic_ref_count: 0,
};
let json = tests_model_to_json(&model);
assert!(
json.starts_with(r#"{"schema_version":1,"status":"resolved","query":"Order""#),
"schema_version leads, ahead of the status key that used to be first: {json}"
);
}
#[test]
fn impact_json_appends_heuristic_count_then_heuristic_after_score_and_heuristic_affected_after_manifest_gap(
) {
let row = |file: &str, heuristic: bool| query::ImpactRow {
file: file.to_string(),
hop: 1,
via_count: if heuristic { 0 } else { 1 },
ambiguous_count: 0,
top_symbols: vec!["Widget".to_string()],
top_symbols_more: 0,
score: 0.5,
heuristic_count: if heuristic { 2 } else { 0 },
heuristic,
tier: None,
iface_via: vec![],
from_lines: vec![],
infra: false,
why: if heuristic {
query::Why::UsesMemberGuess
} else {
query::Why::UsesMemberPrecise
},
};
let model = query::ImpactModel {
kind: query::SeedKind::Symbol,
seed_files: vec!["src/Widget.cs".to_string()],
hops: 2,
total_affected: 1,
rows: vec![row("src/Direct.cs", false), row("src/Guessed.cs", true)],
dropped: 0,
manifest_gap: 0,
heuristic_affected: 1,
tests_affected: 0,
braked: vec![],
braked_files: vec![],
};
let json = impact_model_to_json("Widget", &model);
assert!(
json.contains(
r#"{"file":"src/Direct.cs","hop":1,"viaCount":1,"ambiguousCount":0,"topSymbols":["Widget"],"topSymbolsMore":0,"score":0.5,"why":"uses-member-precise"}"#
),
"{json}"
);
assert!(
json.contains(
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"}"#
),
"{json}"
);
assert!(
json.ends_with(
r#","dropped":0,"manifestGap":0,"heuristicAffected":1,"testsAffected":0,"outcome":"hit"}"#
),
"{json}"
);
}
#[test]
fn impact_json_tier_sits_between_heuristic_and_iface_via() {
let row = |file: &str, tier: graph::HeuristicTier| query::ImpactRow {
file: file.to_string(),
hop: 1,
via_count: 0,
ambiguous_count: 0,
top_symbols: vec!["Widget".to_string()],
top_symbols_more: 0,
score: 0.5,
heuristic_count: 2,
heuristic: true,
tier: Some(tier),
iface_via: vec!["IWidget".to_string()],
from_lines: vec![],
infra: false,
why: match tier {
graph::HeuristicTier::Ext => query::Why::UsesMemberExt,
graph::HeuristicTier::Guess => query::Why::UsesMemberGuess,
},
};
let model = query::ImpactModel {
kind: query::SeedKind::Symbol,
seed_files: vec!["src/Widget.cs".to_string()],
hops: 2,
total_affected: 0,
rows: vec![
row("src/Extended.cs", graph::HeuristicTier::Ext),
row("src/Guessed.cs", graph::HeuristicTier::Guess),
],
dropped: 0,
manifest_gap: 0,
heuristic_affected: 2,
tests_affected: 0,
braked: vec![],
braked_files: vec![],
};
let json = impact_model_to_json("Widget", &model);
assert!(
json.contains(
r#""score":0.5,"heuristicCount":2,"heuristic":true,"tier":"ext","ifaceVia":["IWidget"],"why":"uses-member-ext"}"#
),
"{json}"
);
assert!(
json.contains(
r#""score":0.5,"heuristicCount":2,"heuristic":true,"tier":"guess","ifaceVia":["IWidget"],"why":"uses-member-guess"}"#
),
"{json}"
);
}
fn find_cap_root(prefix: &str, entry_count: usize) -> PathBuf {
let root = std::env::temp_dir().join(format!("scout-cli-{prefix}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let scout = root.join(".scout");
std::fs::create_dir_all(&scout).unwrap();
let entries: Vec<String> = (0..entry_count)
.map(|i| {
format!(
r#""src/widget-{i:02}.cs": {{ "purpose": "widget number {i}", "mtime": {i} }}"#
)
})
.collect();
let json = format!(r#"{{ "entries": {{ {} }} }}"#, entries.join(", "));
std::fs::write(scout.join("manifest.json"), json).unwrap();
root
}
#[test]
fn cmd_find_caps_full_pool_at_25_with_honest_tail() {
let root = find_cap_root("findcap-full", 30);
let (code, out) = cmd_find(&root, "widget", false);
assert_eq!(code, 0);
let lines: Vec<&str> = out.split('\n').collect();
assert_eq!(lines.len(), 26);
assert_eq!(lines[25], "… +5 more (refine query)");
assert!(lines[..25].iter().all(|l| l.contains("widget")));
}
#[test]
fn cmd_find_caps_fallback_pool_at_10_with_honest_tail() {
let root = find_cap_root("findcap-fallback", 12);
let (code, out) = cmd_find(&root, "widget zzz123nomatch", false);
assert_eq!(code, 0);
let lines: Vec<&str> = out.split('\n').collect();
assert_eq!(lines.len(), 11);
assert_eq!(lines[10], "… +2 more (refine query)");
}
#[test]
fn cmd_find_reports_a_zero_hit_on_its_own_exit_code_with_stdout_unchanged() {
let root = find_cap_root("findcap-zero", 5);
let (code, out) = cmd_find(&root, "zzz123nosuchpurpose", false);
assert_eq!(code, EXIT_NO_RESULT);
assert_eq!(
out,
"no matches for \"zzz123nosuchpurpose\" (run 'devscout map' if manifest is missing)"
);
}
#[test]
fn cmd_find_below_cap_prints_no_tail() {
let root = find_cap_root("findcap-small", 5);
let (code, out) = cmd_find(&root, "widget", false);
assert_eq!(code, 0);
assert_eq!(out.split('\n').count(), 5);
assert!(!out.contains("more (refine query)"));
}
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";
const PANEL_XAML: &str = "<UserControl\n\tx:Class=\"Gadgets.PanelView\">\n\t<Button x:Name=\"ShipButton\" />\n</UserControl>\n";
const STRINGS_RESW: &str =
"<root>\n\t<resheader name=\"resmimetype\" />\n\t<data name=\"ShipButton.Content\" xml:space=\"preserve\" />\n</root>\n";
fn name_index_root(prefix: &str) -> PathBuf {
let root = std::env::temp_dir().join(format!("scout-cli-{prefix}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join("src").join("WidgetLedger.cs"), LEDGER_CS).unwrap();
std::fs::write(root.join("src").join("Panel.xaml"), PANEL_XAML).unwrap();
std::fs::write(root.join("src").join("Strings.resw"), STRINGS_RESW).unwrap();
std::fs::create_dir_all(root.join(".scout")).unwrap();
std::fs::write(
root.join(".scout").join("manifest.json"),
r#"{ "entries": {} }"#,
)
.unwrap();
let fragments = vec![
(
"src/WidgetLedger.cs".to_string(),
graph::fragment_from_extraction(&crate::extract::extract(LEDGER_CS)),
),
(
"src/Panel.xaml".to_string(),
graph::markup_fragment(&root, "src/Panel.xaml").unwrap(),
),
(
"src/Strings.resw".to_string(),
graph::markup_fragment(&root, "src/Strings.resw").unwrap(),
),
];
let g = crate::resolve::resolve_graph(&root, &fragments);
std::fs::create_dir_all(root.join(".scout").join("graph")).unwrap();
std::fs::write(
root.join(".scout").join("graph").join("graph.json"),
serde_json::to_string(&g).unwrap(),
)
.unwrap();
root
}
#[test]
fn cmd_find_resolves_every_code_and_markup_category_to_its_declaration_line() {
let root = name_index_root("findnames");
for (query, expected) in [
(
"PopulateSlots",
"src/WidgetLedger.cs:11 private void PopulateSlots() { }",
),
(
"Label",
"src/WidgetLedger.cs:7 public string Label { get; set; }",
),
(
"_entryCount",
"src/WidgetLedger.cs:5 private int _entryCount;",
),
(
"Retired",
"src/WidgetLedger.cs:9 public event EventHandler Retired;",
),
(
"Gadgets.PanelView",
"src/Panel.xaml:2 x:Class=\"Gadgets.PanelView\">",
),
] {
let (code, out) = cmd_find(&root, query, false);
assert_eq!(code, 0, "{query} should be a hit");
assert_eq!(out, expected, "{query}");
}
}
#[test]
fn cmd_find_a_resource_key_only_query_brakes_correctly_by_default_and_is_a_hit_under_resources()
{
let root = name_index_root("findnames-resource-only");
let (code, out) = cmd_find(&root, "ShipButton.Content", false);
assert_eq!(
code, EXIT_NO_RESULT,
"a resource-key-only match is a zero hit by default"
);
assert_eq!(
out,
"no matches for \"ShipButton.Content\" (run 'devscout map' if manifest is missing)"
);
let (code, out) = cmd_find(&root, "ShipButton.Content", true);
assert_eq!(code, 0, "--resources lifts the demotion");
assert_eq!(
out,
"src/Strings.resw:3 <data name=\"ShipButton.Content\" xml:space=\"preserve\" />"
);
}
#[test]
fn cmd_find_default_view_hides_resource_keys_behind_a_trailer_and_resources_shows_them_inline()
{
let root = name_index_root("findnames-tiering");
let (code, out) = cmd_find(&root, "ShipButton", false);
assert_eq!(code, 0);
assert_eq!(
out,
"src/Panel.xaml:3 <Button x:Name=\"ShipButton\" />\n+1 resource-key hits, use --resources",
"tier 3 is a trailer, never an inline row, by default"
);
let (code, out) = cmd_find(&root, "ShipButton", true);
assert_eq!(code, 0);
assert_eq!(
out,
"src/Panel.xaml:3 <Button x:Name=\"ShipButton\" />\nsrc/Strings.resw:3 <data name=\"ShipButton.Content\" xml:space=\"preserve\" />",
"--resources includes the resource-key row inline and drops the trailer"
);
}
#[test]
fn cmd_find_puts_the_declaration_block_above_the_manifest_block() {
let root = name_index_root("findnames-order");
std::fs::write(
root.join(".scout").join("manifest.json"),
r#"{ "entries": { "src/WidgetLedger.cs": { "purpose": "class WidgetLedger", "mtime": 1 } } }"#,
)
.unwrap();
let (code, out) = cmd_find(&root, "WidgetLedger", false);
assert_eq!(code, 0);
assert_eq!(
out,
"src/WidgetLedger.cs:3 public class WidgetLedger\nsrc/WidgetLedger.cs:3: class WidgetLedger"
);
}
#[test]
fn cmd_find_manifest_pool_row_carries_its_files_first_declaration_line() {
let root = name_index_root("findnames-manifest-line");
std::fs::write(
root.join(".scout").join("manifest.json"),
r#"{ "entries": { "src/WidgetLedger.cs": { "purpose": "class WidgetLedger; Ship", "mtime": 1 } } }"#,
)
.unwrap();
let (code, out) = cmd_find(&root, "WidgetLedger", false);
assert_eq!(code, 0);
let lines: Vec<&str> = out.split('\n').collect();
let manifest_row = lines
.iter()
.find(|l| l.starts_with("src/WidgetLedger.cs:") && l.contains("; Ship"));
assert_eq!(
manifest_row,
Some(&"src/WidgetLedger.cs:3: class WidgetLedger; Ship"),
"{out}"
);
}
#[test]
fn cmd_find_manifest_pool_row_for_a_file_with_no_declaration_falls_back_to_line_1() {
let root = name_index_root("findnames-manifest-no-decl");
std::fs::write(root.join("src").join("Notes.md"), "# widget notes\n").unwrap();
std::fs::write(
root.join(".scout").join("manifest.json"),
r#"{ "entries": { "src/Notes.md": { "purpose": "widget notes doc", "mtime": 1 } } }"#,
)
.unwrap();
let (code, out) = cmd_find(&root, "widget", false);
assert_eq!(code, 0);
assert!(out.contains("src/Notes.md:1: widget notes doc"), "{out}");
}
#[test]
fn cmd_find_still_takes_the_zero_hit_exit_when_neither_index_matches() {
let root = name_index_root("findnames-zero");
let (code, out) = cmd_find(&root, "Zzzznomatch", false);
assert_eq!(code, EXIT_NO_RESULT);
assert_eq!(
out,
"no matches for \"Zzzznomatch\" (run 'devscout map' if manifest is missing)"
);
}
fn clear_root(prefix: &str) -> PathBuf {
let root = std::env::temp_dir().join(format!("scout-cli-{prefix}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join(".scout")).unwrap();
root
}
fn seed_row(conn: &rusqlite::Connection, session_id: &str, rel_path: &str) {
store::record_fresh(
conn,
&store::RecordFresh {
session_id,
agent_id: "",
rel_path,
sha256: "sha",
size: 10,
mtime: 1,
lines: 1,
delivered: true,
},
)
.unwrap();
}
#[test]
fn cmd_clear_whole_store_removes_cache_db_and_answers_cache_cleared() {
let root = clear_root("clear-whole");
{
let conn = store::open_store(&root).unwrap();
seed_row(&conn, "s1", "a.ts");
}
assert!(root.join(".scout").join("cache.db").exists());
let (code, out) = cmd_clear(&root, &[]);
assert_eq!(code, 0);
assert_eq!(out, "cache cleared");
assert!(!root.join(".scout").join("cache.db").exists());
}
#[test]
fn cmd_clear_older_than_rejects_negative_and_non_numeric_with_usage_code_2() {
let root = clear_root("clear-older-bad");
for bad in ["-5", "abc", ""] {
let (code, out) = cmd_clear(&root, &["--older-than".to_string(), bad.to_string()]);
assert_eq!(code, 2, "arg {bad:?}");
assert_eq!(
out, "usage: devscout clear --older-than <days>",
"arg {bad:?}"
);
}
let (code, out) = cmd_clear(&root, &["--older-than".to_string()]);
assert_eq!(code, 2);
assert_eq!(out, "usage: devscout clear --older-than <days>");
assert!(
!root.join(".scout").join("cache.db").exists(),
"a rejected flag must not create a store"
);
}
#[test]
fn cmd_clear_session_unique_prefix_deletes_only_that_sessions_rows() {
let root = clear_root("clear-session-unique");
{
let conn = store::open_store(&root).unwrap();
seed_row(&conn, "abc-1111", "a.ts");
seed_row(&conn, "abc-2222", "b.ts");
}
let (code, out) = cmd_clear(&root, &["--session".to_string(), "abc-1".to_string()]);
assert_eq!(code, 0);
assert_eq!(out, "deleted 1 row for session abc-1111");
let conn = store::open_store(&root).unwrap();
assert!(store::lookup_read(&conn, "abc-1111", "a.ts", "")
.unwrap()
.is_none());
assert!(store::lookup_read(&conn, "abc-2222", "b.ts", "")
.unwrap()
.is_some());
}
#[test]
fn cmd_clear_session_ambiguous_prefix_refuses_with_code_2_and_lists_both_ids() {
let root = clear_root("clear-session-ambiguous");
{
let conn = store::open_store(&root).unwrap();
seed_row(&conn, "abc-1111", "a.ts");
seed_row(&conn, "abc-2222", "b.ts");
}
let (code, out) = cmd_clear(&root, &["--session".to_string(), "abc".to_string()]);
assert_eq!(code, 2);
assert_eq!(out, "ambiguous session prefix \"abc\": abc-1111, abc-2222");
let conn = store::open_store(&root).unwrap();
assert!(store::lookup_read(&conn, "abc-1111", "a.ts", "")
.unwrap()
.is_some());
assert!(store::lookup_read(&conn, "abc-2222", "b.ts", "")
.unwrap()
.is_some());
}
#[test]
fn cmd_clear_session_no_match_is_a_success_not_a_refusal() {
let root = clear_root("clear-session-none");
{
let conn = store::open_store(&root).unwrap();
seed_row(&conn, "abc-1111", "a.ts");
}
let (code, out) = cmd_clear(&root, &["--session".to_string(), "zzz".to_string()]);
assert_eq!(code, 0);
assert_eq!(out, "no sessions match \"zzz\"");
}
#[test]
fn cmd_clear_session_missing_value_is_a_usage_error() {
let root = clear_root("clear-session-missing");
let (code, out) = cmd_clear(&root, &["--session".to_string()]);
assert_eq!(code, 2);
assert_eq!(out, "usage: devscout clear --session <id-or-prefix>");
}
#[test]
fn cmd_clear_older_than_flag_takes_priority_over_session_flag_like_node_indexof_order() {
let root = clear_root("clear-precedence");
{
let conn = store::open_store(&root).unwrap();
seed_row(&conn, "abc-1111", "a.ts");
}
let (code, out) = cmd_clear(
&root,
&[
"--session".to_string(),
"abc-1111".to_string(),
"--older-than".to_string(),
"9999".to_string(),
],
);
assert_eq!(code, 0);
assert_eq!(
out, "deleted 0 rows older than 9999d",
"the --older-than branch must win"
);
}
}