use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
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.";
static COUNTER: AtomicU64 = AtomicU64::new(0);
fn temp_dir(prefix: &str) -> PathBuf {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let dir = env::temp_dir().join(format!(
"scout-cli-read-{prefix}-{}-{n}",
std::process::id()
));
fs::create_dir_all(&dir).expect("create temp dir");
fs::canonicalize(&dir).expect("canonicalize temp dir")
}
fn run_git(dir: &Path, args: &[&str]) {
let status = Command::new("git")
.args(args)
.current_dir(dir)
.status()
.expect("git binary must be on PATH");
assert!(status.success(), "git {args:?} failed in {}", dir.display());
}
fn bootstrap_initial_commit(dir: &Path) {
const EMPTY_TREE: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
let output = Command::new("git")
.args(["commit-tree", EMPTY_TREE, "-m", "init"])
.env("GIT_AUTHOR_NAME", "devscout-test")
.env("GIT_AUTHOR_EMAIL", "devscout-test@example.com")
.env("GIT_COMMITTER_NAME", "devscout-test")
.env("GIT_COMMITTER_EMAIL", "devscout-test@example.com")
.current_dir(dir)
.stdin(Stdio::null())
.output()
.expect("git commit-tree must run");
assert!(
output.status.success(),
"git commit-tree failed: {output:?}"
);
let sha = String::from_utf8(output.stdout).unwrap().trim().to_string();
run_git(dir, &["update-ref", "refs/heads/master", &sha]);
run_git(dir, &["symbolic-ref", "HEAD", "refs/heads/master"]);
}
fn rust_bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_devscout"))
}
const FILES: &[(&str, &str)] = &[
("src/IThing.cs", "namespace Shop\n{\n public interface IThing\n {\n int Id { get; }\n }\n}\n"),
("src/Thing.cs", "namespace Shop\n{\n public class Thing : IThing\n {\n public int Id { get; set; }\n }\n}\n"),
("src/Consumer.cs", "using Shop;\n\nnamespace Shop.Consumers\n{\n public class Consumer\n {\n public int Get(Thing t)\n {\n return t.Id;\n }\n }\n}\n"),
("src/Ledger/Item.cs", "namespace Shop.Ledger\n{\n public class Item\n {\n }\n}\n"),
("src/Warehouse/Item.cs", "namespace Shop.Warehouse\n{\n public class Item\n {\n }\n}\n"),
("src/widget.ts", "export interface Widget {\n id: number;\n label: string;\n}\n"),
];
struct Fixture {
root: PathBuf,
home: PathBuf,
}
impl Fixture {
fn build(prefix: &str) -> Fixture {
let base = temp_dir(prefix);
let root = base.join("repo");
let home = base.join("home");
fs::create_dir_all(&home).expect("create home dir");
for (rel, body) in FILES {
let path = root.join(rel);
fs::create_dir_all(path.parent().unwrap()).expect("create fixture dir");
fs::write(&path, body).expect("write fixture file");
}
run_git(&root, &["init", "-q", "."]);
bootstrap_initial_commit(&root);
let fixture = Fixture { root, home };
let init = fixture.run(&["init", "--no-hooks", "--no-map"]);
assert!(init.status.success(), "init failed: {init:?}");
let map = fixture.run(&["map", "."]);
assert!(map.status.success(), "map failed: {map:?}");
fixture
}
fn run(&self, args: &[&str]) -> Output {
Command::new(rust_bin())
.args(args)
.current_dir(&self.root)
.env("HOME", &self.home)
.env("SCOUT_REGISTRY", self.home.join("repos.json"))
.env("SCOUT_CONTENT_DB", self.home.join("content.db"))
.output()
.expect("devscout must run")
}
fn graph_dir(&self) -> PathBuf {
self.root.join(".git").join("scout").join("graph")
}
}
fn stdout_of(out: &Output) -> String {
String::from_utf8(out.stdout.clone()).expect("stdout is utf-8")
}
fn stderr_of(out: &Output) -> String {
String::from_utf8(out.stderr.clone()).expect("stderr is utf-8")
}
#[test]
fn read_prints_the_declaration_span_with_verbatim_source_and_inbound() {
let fx = Fixture::build("span");
let out = fx.run(&["read", "IThing"]);
assert_eq!(out.status.code(), Some(0), "{out:?}");
assert_eq!(
stdout_of(&out),
concat!(
"Shop.IThing (interface)\n",
"def: src/IThing.cs:3-6\n",
" public interface IThing\n",
" {\n",
" int Id { get; }\n",
" }\n",
"inbound:\n",
" inherits (1):\n",
" src/Thing.cs:3 inherits public class Thing : IThing\n",
" uses-type (0):\n",
" uses-member (0):\n",
),
"span bounds 3-6 with the source quoted verbatim, then refs' inbound block"
);
assert_eq!(stderr_of(&out), "");
}
#[test]
fn read_json_carries_span_file_bounds_and_source() {
let fx = Fixture::build("json");
let out = fx.run(&["read", "IThing", "--json"]);
assert_eq!(out.status.code(), Some(0), "{out:?}");
let v: serde_json::Value = serde_json::from_str(stdout_of(&out).trim()).expect("valid JSON");
assert_eq!(v["status"], "resolved");
assert_eq!(v["id"], "Shop.IThing");
assert_eq!(v["span"]["file"], "src/IThing.cs");
assert_eq!(v["span"]["startLine"], 3);
assert_eq!(v["span"]["endLine"], 6);
assert_eq!(
v["span"]["source"], " public interface IThing\n {\n int Id { get; }\n }",
"the source is the whole declaration verbatim, indentation included"
);
assert_eq!(v["sites"][0]["line"], 3);
assert_eq!(
v["inbound"]["inherits"]["total"], 1,
"inbound rides on the same machinery as refs"
);
assert_eq!(v["manifestGap"], 0);
}
#[test]
fn read_json_carries_the_real_typescript_declaration_span() {
let fx = Fixture::build("ts-span");
let out = fx.run(&["read", "Widget", "--json"]);
assert!(out.status.success(), "{out:?}");
let v: serde_json::Value = serde_json::from_str(&stdout_of(&out)).unwrap();
assert_eq!(v["span"]["file"], "src/widget.ts");
assert_eq!(v["span"]["startLine"], 1);
assert_eq!(v["span"]["endLine"], 4);
assert_eq!(
v["span"]["source"],
"export interface Widget {\n id: number;\n label: string;\n}"
);
}
#[test]
fn read_compact_keeps_the_span_on_the_def_line_and_drops_the_snippets() {
let fx = Fixture::build("compact");
let out = fx.run(&["read", "IThing", "--compact"]);
assert_eq!(out.status.code(), Some(0), "{out:?}");
let stdout = stdout_of(&out);
assert!(
stdout.starts_with("Shop.IThing (interface)\ndef: src/IThing.cs:3-6\n"),
"{stdout:?}"
);
assert!(
stdout.contains("in:inherits (1):\n src/Thing.cs:3"),
"{stdout:?}"
);
assert!(
!stdout.contains("public class Thing : IThing"),
"compact carries no per-hit source: {stdout:?}"
);
}
#[test]
fn an_ambiguous_name_prints_every_candidate_and_exits_1() {
let fx = Fixture::build("ambiguous");
let out = fx.run(&["read", "Item"]);
assert_eq!(out.status.code(), Some(1), "{out:?}");
assert_eq!(
stdout_of(&out),
concat!(
"ambiguous symbol \"Item\" — 2 candidates:\n",
"Shop.Ledger.Item src/Ledger/Item.cs:3 class\n",
"Shop.Warehouse.Item src/Warehouse/Item.cs:3 class\n",
),
"every candidate printed, none picked"
);
assert_eq!(
stderr_of(&out),
"",
"an ambiguity is not a zero hit and prints no zero-hit note"
);
}
#[test]
fn a_zero_hit_exits_3_with_the_verbs_own_fixed_line_on_stderr() {
let fx = Fixture::build("zero-hit");
let out = fx.run(&["read", "ThereIsNoSuchType"]);
assert_eq!(out.status.code(), Some(3), "{out:?}");
assert_eq!(stdout_of(&out), "no symbol matches \"ThereIsNoSuchType\"\n");
assert_eq!(stderr_of(&out), format!("{ZERO_HIT_READ}\n"));
}
#[test]
fn usage_and_flag_conflicts_keep_their_own_codes_and_silence() {
let fx = Fixture::build("usage");
let missing = fx.run(&["read"]);
assert_eq!(missing.status.code(), Some(2), "{missing:?}");
assert_eq!(
stdout_of(&missing),
"usage: devscout read <symbol> [--json|--compact]\n"
);
let conflict = fx.run(&["read", "IThing", "--compact", "--json"]);
assert_eq!(conflict.status.code(), Some(1), "{conflict:?}");
assert_eq!(
stdout_of(&conflict),
"devscout read: --compact and --json are mutually exclusive\n"
);
for bad in [&missing, &conflict] {
assert_eq!(stderr_of(bad), "", "{bad:?}");
}
}
#[test]
fn a_bare_member_answers_through_refs_shape_without_claiming_a_span() {
let fx = Fixture::build("member");
let out = fx.run(&["read", "Id"]);
assert_eq!(out.status.code(), Some(0), "{out:?}");
let stdout = stdout_of(&out);
assert_eq!(
stdout,
concat!(
"Shop.Thing.Id (member)\n",
"def: src/Thing.cs:5\n",
"inbound:\n",
" inherits (0):\n",
" uses-type (0):\n",
" uses-member (1):\n",
" src/Consumer.cs:9 uses-member return t.Id;\n",
),
"the member answer carries no span range anywhere"
);
}
#[test]
fn map_rerun_reuses_fragments_and_a_missing_cache_re_extracts_with_spans() {
let fx = Fixture::build("reuse");
let rerun = fx.run(&["map", "."]);
assert!(rerun.status.success(), "{rerun:?}");
assert!(
stdout_of(&rerun).contains("0 new, 0 removed"),
"an unchanged rerun must reuse, not re-extract: {:?}",
stdout_of(&rerun)
);
let graph_dir = fx.graph_dir();
let v15 = graph_dir.join("fragments-v15.json");
assert!(v15.exists(), "the current cache generation is on disk");
fs::write(graph_dir.join("fragments-v14.json"), b"{}").unwrap();
fs::remove_file(&v15).unwrap();
fs::remove_file(graph_dir.join("fragments-index-v15.json")).unwrap();
let rebuild = fx.run(&["map", "."]);
assert!(rebuild.status.success(), "{rebuild:?}");
assert!(v15.exists(), "re-extraction rewrote the current generation");
assert!(
!graph_dir.join("fragments-v14.json").exists(),
"rename IS the invalidation: superseded generations are deleted"
);
let read = fx.run(&["read", "IThing", "--json"]);
assert_eq!(read.status.code(), Some(0), "{read:?}");
let v: serde_json::Value = serde_json::from_str(stdout_of(&read).trim()).expect("valid JSON");
assert_eq!(
v["span"]["endLine"], 6,
"the re-extracted fragment carries end lines again"
);
}