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-cli-root-{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"))
}
struct Fixture {
root: PathBuf,
outside: PathBuf,
home: PathBuf,
}
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"),
(
"tests/ThingTests.cs",
"using NUnit.Framework;\n\nnamespace Shop.Tests\n{\n public class ThingTests\n {\n [Test]\n public void Reads() { var t = new Thing(); }\n }\n}\n",
),
];
impl Fixture {
fn build(prefix: &str) -> Fixture {
let base = temp_dir(prefix);
let root = base.join("repo");
let outside = base.join("outside");
let home = base.join("home");
fs::create_dir_all(&outside).expect("create outside dir");
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,
outside,
home,
};
fixture.expect_ok(&fixture.root, &["init", "--no-hooks", "--no-map"]);
fixture.expect_ok(&fixture.root, &["map", "."]);
fixture.expect_ok(&fixture.root, &["map", "."]);
fixture
}
fn run(&self, cwd: &Path, args: &[&str]) -> Output {
Command::new(rust_bin())
.args(args)
.current_dir(cwd)
.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 expect_ok(&self, cwd: &Path, args: &[&str]) -> String {
let out = self.run(cwd, args);
assert!(out.status.success(), "devscout {args:?} failed: {out:?}");
String::from_utf8(out.stdout).expect("stdout is utf-8")
}
fn root_str(&self) -> String {
self.root.to_string_lossy().into_owned()
}
}
fn stdout_of(out: &Output) -> String {
String::from_utf8(out.stdout.clone()).expect("stdout is utf-8")
}
#[test]
fn dash_c_answers_every_root_resolving_verb_byte_identically_from_an_unrelated_cwd() {
let fx = Fixture::build("dash-c");
let root = fx.root_str();
let matrix: &[&[&str]] = &[
&["find", "thing"],
&["refs", "IThing"],
&["refs", "IThing", "--compact"],
&["refs", "IThing", "--json"],
&["impact", "src/IThing.cs"],
&["impact", "IThing", "--hops", "1", "--compact"],
&["tests", "Thing"],
&["tests", "Thing", "--json"],
&["map", "."],
];
for args in matrix {
let inside = fx.run(&fx.root, args);
let mut with_flag: Vec<&str> = vec!["-C", &root];
with_flag.extend_from_slice(args);
let outside = fx.run(&fx.outside, &with_flag);
assert_eq!(
stdout_of(&inside),
stdout_of(&outside),
"stdout differs for {args:?}"
);
assert_eq!(inside.stderr, outside.stderr, "stderr differs for {args:?}");
assert_eq!(
inside.status.code(),
outside.status.code(),
"exit code differs for {args:?}"
);
assert_eq!(
inside.status.code(),
Some(0),
"{args:?} did not answer from inside the repo either"
);
}
}
#[test]
fn dash_c_reaches_stats_and_init_too() {
let fx = Fixture::build("dash-c-write");
let root = fx.root_str();
let stats = fx.expect_ok(&fx.outside, &["-C", &root, "stats"]);
assert!(
stats.starts_with(&format!("devscout stats ({root}):")),
"stats named a different root: {stats}"
);
let sub = fx.root.join("sub");
fs::create_dir_all(&sub).expect("create sub dir");
let out = fx.expect_ok(
&fx.outside,
&[
"-C",
&sub.to_string_lossy(),
"init",
"--no-hooks",
"--no-map",
],
);
assert!(
out.contains(&fx.root.join(".scout").to_string_lossy().into_owned()),
"init resolved elsewhere: {out}"
);
assert!(!sub.join(".scout").exists());
assert!(!fx.outside.join(".scout").exists());
}
#[test]
fn dash_c_composes_on_repeat_like_git() {
let fx = Fixture::build("dash-c-compose");
let parent = fx
.root
.parent()
.expect("fixture root has a parent")
.to_string_lossy()
.into_owned();
let direct = fx.expect_ok(&fx.root, &["refs", "IThing", "--compact"]);
let composed = fx.expect_ok(
&fx.outside,
&["-C", &parent, "-C", "repo", "refs", "IThing", "--compact"],
);
assert_eq!(direct, composed);
}
#[test]
fn dash_c_rejects_a_missing_or_nonexistent_directory() {
let fx = Fixture::build("dash-c-bad");
let missing = fx.run(&fx.outside, &["-C"]);
assert_eq!(missing.status.code(), Some(1));
assert_eq!(
stdout_of(&missing),
"error: no directory given for '-C' option\n"
);
let nonexistent = fx.run(&fx.outside, &["-C", "nope", "find", "thing"]);
assert_eq!(nonexistent.status.code(), Some(1));
assert_eq!(
stdout_of(&nonexistent),
"error: cannot change to 'nope': no such directory\n"
);
}
#[test]
fn impact_derives_the_root_from_an_absolute_path_argument_with_no_flag() {
let fx = Fixture::build("arg-abs");
let seed = fx.root.join("src/IThing.cs");
let inside = fx.expect_ok(&fx.root, &["impact", "src/IThing.cs", "--compact"]);
let outside = fx.expect_ok(
&fx.outside,
&["impact", &seed.to_string_lossy(), "--compact"],
);
assert_eq!(inside, outside);
}
#[test]
fn impact_reads_a_relative_path_argument_from_the_directory_it_was_given() {
let fx = Fixture::build("arg-rel");
let inside = fx.expect_ok(&fx.root, &["impact", "src/IThing.cs", "--compact"]);
let from_sub = fx.expect_ok(&fx.root.join("src"), &["impact", "IThing.cs", "--compact"]);
let from_flag = fx.expect_ok(
&fx.outside,
&["-C", &fx.root_str(), "impact", "src/IThing.cs", "--compact"],
);
assert_eq!(inside, from_sub);
assert_eq!(inside, from_flag);
}
#[test]
fn the_callers_directory_wins_over_the_argument_path_unless_dash_c_says_otherwise() {
let a = Fixture::build("two-repos-a");
let b = Fixture::build("two-repos-b");
let seed_in_b = b.root.join("src/IThing.cs");
let seed_str = seed_in_b.to_string_lossy().into_owned();
let from_a = a.run(&a.root, &["impact", &seed_str, "--compact"]);
assert_eq!(from_a.status.code(), Some(3));
assert_eq!(
stdout_of(&from_a),
format!("no file match for \"{seed_str}\"\n")
);
let inside_b = b.expect_ok(&b.root, &["impact", "src/IThing.cs", "--compact"]);
let via_flag = a.expect_ok(
&a.root,
&[
"-C",
&b.root.to_string_lossy(),
"impact",
&seed_str,
"--compact",
],
);
assert_eq!(inside_b, via_flag);
}
#[test]
fn a_symbol_argument_never_decides_the_root_and_the_error_text_is_unchanged() {
let fx = Fixture::build("no-root");
for args in [
&["refs", "IThing"][..],
&["tests", "IThing"][..],
&["impact", "IThing"][..],
&["find", "thing"][..],
] {
let out = fx.run(&fx.outside, args);
assert_eq!(out.status.code(), Some(1), "{args:?}");
assert_eq!(
stdout_of(&out),
"error: no .scout or .git ancestor; run 'devscout init' from the repo or directory root\n",
"{args:?}",
);
}
let out = fx.run(&fx.outside, &["impact", "src/IThing.cs"]);
assert_eq!(out.status.code(), Some(1));
assert_eq!(
stdout_of(&out),
"error: no .scout or .git ancestor; run 'devscout init' from the repo or directory root\n"
);
}