use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
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-freshness-{prefix}-{}-{n}",
std::process::id()
));
fs::create_dir_all(&dir).expect("create temp dir");
fs::canonicalize(&dir).expect("canonicalize temp dir")
}
fn rust_bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_devscout"))
}
fn git(dir: &Path, args: &[&str]) -> String {
let output = Command::new("git")
.args(args)
.current_dir(dir)
.stdin(Stdio::null())
.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")
.output()
.expect("git binary must be on PATH");
assert!(
output.status.success(),
"git {args:?} failed in {}: {output:?}",
dir.display()
);
String::from_utf8(output.stdout).unwrap().trim().to_string()
}
const FILES: &[(&str, &str)] = &[
("src/IWidget.cs", "namespace App.Widgets\n{\n public interface IWidget\n {\n void Render();\n }\n}\n"),
(
"src/WidgetImpl.cs",
"using App.Widgets;\n\nnamespace App.Widgets.Impl\n{\n public class WidgetImpl : IWidget\n {\n public void Render() { }\n }\n}\n",
),
];
struct Fixture {
root: PathBuf,
home: PathBuf,
}
impl Fixture {
fn build(prefix: &str) -> (Fixture, String) {
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");
}
git(&root, &["init", "-q", "."]);
git(&root, &["add", "-A"]);
let tree = git(&root, &["write-tree"]);
let sha = git(&root, &["commit-tree", &tree, "-m", "init"]);
git(&root, &["update-ref", "refs/heads/master", &sha]);
git(&root, &["symbolic-ref", "HEAD", "refs/heads/master"]);
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, sha)
}
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 advance_head(&self, parent_sha: &str) -> String {
let tree = git(
&self.root,
&["rev-parse", &format!("{parent_sha}^{{tree}}")],
);
let sha = git(
&self.root,
&["commit-tree", &tree, "-p", parent_sha, "-m", "advance"],
);
git(&self.root, &["update-ref", "refs/heads/master", &sha]);
sha
}
}
fn stderr_of(out: &Output) -> String {
String::from_utf8(out.stderr.clone()).expect("stderr is utf-8")
}
#[test]
fn fresh_index_prints_no_freshness_warning_on_any_query_verb() {
let (fx, _sha) = Fixture::build("fresh");
let find = fx.run(&["find", "IWidget"]);
assert_eq!(stderr_of(&find), "", "{find:?}");
let refs = fx.run(&["refs", "IWidget"]);
assert_eq!(stderr_of(&refs), "", "{refs:?}");
let tests = fx.run(&["tests", "IWidget"]);
assert_eq!(stderr_of(&tests), "", "{tests:?}");
let impact = fx.run(&["impact", "src/IWidget.cs"]);
assert_eq!(stderr_of(&impact), "", "{impact:?}");
assert_eq!(
impact.status.code(),
Some(0),
"WidgetImpl.cs gives impact a real inbound edge to reach: {impact:?}"
);
}
#[test]
fn head_moved_since_map_prints_exactly_one_stale_index_line() {
let (fx, initial_sha) = Fixture::build("head-moved");
let new_sha = fx.advance_head(&initial_sha);
assert_ne!(new_sha, initial_sha);
let refs = fx.run(&["refs", "IWidget"]);
let want = format!(
"devscout: index for repo is stale (indexed at {}, HEAD {}; 0 changed files) — rebuild with devscout map\n",
&initial_sha[..7],
&new_sha[..7],
);
assert_eq!(stderr_of(&refs), want);
}
#[test]
fn head_moved_since_map_is_reported_by_tests_too() {
let (fx, initial_sha) = Fixture::build("head-moved-tests");
let new_sha = fx.advance_head(&initial_sha);
let tests = fx.run(&["tests", "IWidget"]);
let want = format!(
"devscout: index for repo is stale (indexed at {}, HEAD {}; 0 changed files) — rebuild with devscout map\n",
&initial_sha[..7],
&new_sha[..7],
);
assert_eq!(stderr_of(&tests), want);
}
#[test]
fn a_modified_indexed_file_head_unchanged_is_reported_as_one_changed_file() {
let (fx, _sha) = Fixture::build("modified-file");
fs::write(
fx.root.join("src").join("IWidget.cs"),
"namespace App.Widgets\n{\n public interface IWidget\n {\n void Render();\n void Resize();\n }\n}\n",
)
.unwrap();
let find = fx.run(&["find", "IWidget"]);
let stderr = stderr_of(&find);
assert!(
stderr.starts_with("devscout: index for repo is stale (indexed at "),
"{stderr:?}"
);
assert!(
stderr.contains("; 1 changed files) — rebuild with devscout map\n"),
"{stderr:?}"
);
}
#[test]
fn legacy_index_with_no_index_state_json_prints_no_warning_and_does_not_crash() {
let (fx, _sha) = Fixture::build("legacy");
let state_path = fx.root.join(".git").join("scout").join("index-state.json");
assert!(
state_path.exists(),
"map must have written the sidecar: {state_path:?}"
);
fs::remove_file(&state_path).unwrap();
let find = fx.run(&["find", "IWidget"]);
assert_eq!(find.status.code(), Some(0), "{find:?}");
assert_eq!(stderr_of(&find), "");
let refs = fx.run(&["refs", "IWidget"]);
assert_eq!(stderr_of(&refs), "");
}
fn stdout_of(out: &Output) -> String {
String::from_utf8(out.stdout.clone()).expect("stdout is utf-8")
}
#[test]
fn fresh_index_carries_freshness_fresh_on_json() {
let (fx, _sha) = Fixture::build("fresh-json");
let out = fx.run(&["refs", "IWidget", "--json"]);
let text = stdout_of(&out);
assert!(
text.trim_end()
.ends_with(r#","freshness":{"state":"fresh"}}"#),
"{text}"
);
}
#[test]
fn head_moved_since_map_carries_freshness_stale_with_both_heads_on_json() {
let (fx, initial_sha) = Fixture::build("head-moved-json");
let new_sha = fx.advance_head(&initial_sha);
let out = fx.run(&["refs", "IWidget", "--json"]);
let text = stdout_of(&out);
let want_tail = format!(
r#","freshness":{{"state":"stale","indexedHead":"{initial_sha}","currentHead":"{new_sha}","changedFiles":0}}}}"#
);
assert!(
text.trim_end().ends_with(&want_tail),
"want tail {want_tail:?} in {text}"
);
}
#[test]
fn a_modified_indexed_file_head_unchanged_carries_one_changed_file_on_json() {
let (fx, _sha) = Fixture::build("modified-file-json");
fs::write(
fx.root.join("src").join("IWidget.cs"),
"namespace App.Widgets\n{\n public interface IWidget\n {\n void Render();\n void Resize();\n }\n}\n",
)
.unwrap();
let out = fx.run(&["refs", "IWidget", "--json"]);
let text = stdout_of(&out);
assert!(text.contains(r#""freshness":{"state":"stale","#), "{text}");
assert!(text.contains(r#""changedFiles":1}"#), "{text}");
}
#[test]
fn legacy_index_with_no_index_state_json_carries_freshness_unknown_on_json() {
let (fx, _sha) = Fixture::build("legacy-json");
let state_path = fx.root.join(".git").join("scout").join("index-state.json");
fs::remove_file(&state_path).unwrap();
let out = fx.run(&["refs", "IWidget", "--json"]);
let text = stdout_of(&out);
assert!(
text.trim_end()
.ends_with(r#","freshness":{"state":"unknown","reason":"no-index-state"}}"#),
"{text}"
);
}