use codelore_lib::analyses::architecture_metrics::run_architecture_metrics;
use codelore_lib::analyses::instability::run_instability;
use codelore_lib::calibration::{
CALIBRATION_FORMAT_VERSION, CalibrationArtifact, RepoMetrics, attach_repo_metrics,
};
use codelore_lib::facts::FactsDb;
use codelore_lib::repo::GixRepo;
use codelore_lib::test_support::permissive_coupling_opts;
use std::collections::{BTreeMap, HashMap};
use std::io::Write;
use std::path::Path;
use std::process::Command;
fn git(dir: &Path, args: &[&str]) {
let status = Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.status()
.expect("spawn git");
assert!(status.success(), "git {args:?} failed");
}
fn write(root: &Path, rel: &str, content: &str) {
let p = root.join(rel);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, content).unwrap();
}
fn ingested_cycle_repo() -> (tempfile::TempDir, FactsDb, codelore_lib::Options) {
let dir = tempfile::tempdir().expect("tempdir");
let p = dir.path();
git(p, &["init", "-b", "main", "--quiet"]);
git(p, &["config", "user.email", "m@example.com"]);
git(p, &["config", "user.name", "M"]);
write(
p,
"Cargo.toml",
"[package]\nname=\"m\"\nversion=\"0.1.0\"\nedition=\"2021\"\n",
);
write(p, "src/lib.rs", "pub mod a;\npub mod b;\npub mod c;\n");
write(p, "src/a.rs", "use crate::b;\npub fn a() { b::b(); }\n");
write(p, "src/b.rs", "use crate::a;\npub fn b() { a::a(); }\n");
write(p, "src/c.rs", "use crate::a;\npub fn c() { a::a(); }\n");
git(p, &["add", "."]);
let status = Command::new("git")
.arg("-C")
.arg(p)
.args(["commit", "-m", "init", "--quiet"])
.env("GIT_AUTHOR_DATE", "2026-01-01T10:00:00Z")
.env("GIT_COMMITTER_DATE", "2026-01-01T10:00:00Z")
.status()
.expect("spawn git commit");
assert!(status.success(), "git commit failed");
let repo = GixRepo::open(p).expect("open repo");
let db = FactsDb::new_in_memory().expect("in-memory db");
let opts = permissive_coupling_opts(p.to_path_buf());
db.ingest(&repo, &opts).expect("ingest repo");
(dir, db, opts)
}
fn ingested_first_party_mix_repo() -> (tempfile::TempDir, FactsDb, codelore_lib::Options) {
let dir = tempfile::tempdir().expect("tempdir");
let p = dir.path();
git(p, &["init", "-b", "main", "--quiet"]);
git(p, &["config", "user.email", "m@example.com"]);
git(p, &["config", "user.name", "M"]);
write(
p,
"Cargo.toml",
"[package]\nname=\"m\"\nversion=\"0.1.0\"\nedition=\"2021\"\n",
);
write(p, "src/lib.rs", "pub mod a;\npub mod b;\n");
write(
p,
"src/a.rs",
"use crate::b;\nuse crate::missing;\nuse std::fmt;\npub fn a() { b::b(); }\n",
);
write(p, "src/b.rs", "use crate::a;\npub fn b() { a::a(); }\n");
git(p, &["add", "."]);
let status = Command::new("git")
.arg("-C")
.arg(p)
.args(["commit", "-m", "init", "--quiet"])
.env("GIT_AUTHOR_DATE", "2026-01-01T10:00:00Z")
.env("GIT_COMMITTER_DATE", "2026-01-01T10:00:00Z")
.status()
.expect("spawn git commit");
assert!(status.success(), "git commit failed");
let repo = GixRepo::open(p).expect("open repo");
let db = FactsDb::new_in_memory().expect("in-memory db");
let opts = permissive_coupling_opts(p.to_path_buf());
db.ingest(&repo, &opts).expect("ingest repo");
(dir, db, opts)
}
#[test]
fn instability_matches_martin_metrics() {
let (_dir, db, opts) = ingested_cycle_repo();
let rows = run_instability(&db, &opts).expect("run instability");
let by: HashMap<&str, &_> = rows.iter().map(|r| (r.path.as_str(), r)).collect();
let a = by.get("src/a.rs").expect("a present");
assert_eq!((a.ca, a.ce), (2, 1), "a: {a:?}");
assert!((a.instability - 1.0 / 3.0).abs() < 1e-9, "a I=1/3: {a:?}");
let b = by.get("src/b.rs").expect("b present");
assert_eq!((b.ca, b.ce), (1, 1), "b: {b:?}");
assert!((b.instability - 0.5).abs() < 1e-9, "b I=1/2: {b:?}");
let c = by.get("src/c.rs").expect("c present");
assert_eq!((c.ca, c.ce), (0, 1), "c: {c:?}");
assert!((c.instability - 1.0).abs() < 1e-9, "c I=1: {c:?}");
}
#[test]
fn isolated_source_file_is_a_counted_node() {
let (_dir, db, opts) = ingested_cycle_repo();
let rows = run_instability(&db, &opts).expect("run instability");
let lib = rows
.iter()
.find(|r| r.path == "src/lib.rs")
.expect("isolated src/lib.rs is a counted node");
assert_eq!(
(lib.ca, lib.ce),
(0, 0),
"isolated file imports nothing and is imported by nothing: {lib:?}"
);
assert!(
lib.instability.abs() < 1e-9,
"no coupling either way → instability 0.0: {lib:?}"
);
}
#[test]
fn architecture_metrics_report_the_cycle_and_type() {
let (_dir, db, opts) = ingested_cycle_repo();
let rows = run_architecture_metrics(&db, &opts).expect("run architecture-metrics");
let m: HashMap<&str, &str> = rows
.iter()
.map(|r| (r.metric.as_str(), r.value.as_str()))
.collect();
assert_eq!(
m.get("files"),
Some(&"4"),
"4 source files in the graph (a, b, c + the isolated lib.rs): {m:?}"
);
assert_eq!(m.get("dependency_cycles"), Some(&"1"), "one cycle: {m:?}");
assert_eq!(
m.get("largest_cycle"),
Some(&"2"),
"the a↔b tangle is size 2: {m:?}"
);
assert_eq!(
m.get("architecture_type"),
Some(&"core-periphery"),
"one dominant cycle → core-periphery: {m:?}"
);
let pc: f64 = m
.get("propagation_cost")
.expect("propagation_cost present")
.parse()
.expect("propagation_cost parses");
assert!((0.0..=1.0).contains(&pc), "propagation cost in [0,1]: {pc}");
}
#[test]
fn architecture_metrics_splits_resolution_into_first_party_signals() {
let (_dir, db, opts) = ingested_first_party_mix_repo();
let rows = run_architecture_metrics(&db, &opts).expect("run architecture-metrics");
let m: HashMap<&str, &str> = rows
.iter()
.map(|r| (r.metric.as_str(), r.value.as_str()))
.collect();
assert_eq!(
m.get("import_resolution_rate"),
Some(&"0.5000"),
"resolved 2 / total 4: {m:?}"
);
assert_eq!(
m.get("first_party_import_share"),
Some(&"0.7500"),
"first-party candidates 3 / total 4: {m:?}"
);
assert_eq!(
m.get("resolution_rate_first_party"),
Some(&"0.6667"),
"resolved 2 / first-party candidates 3: {m:?}"
);
}
fn ingested_first_party_wildcard_repo() -> (tempfile::TempDir, FactsDb, codelore_lib::Options) {
let dir = tempfile::tempdir().expect("tempdir");
let p = dir.path();
git(p, &["init", "-b", "main", "--quiet"]);
git(p, &["config", "user.email", "m@example.com"]);
git(p, &["config", "user.name", "M"]);
write(
p,
"Cargo.toml",
"[package]\nname=\"m\"\nversion=\"0.1.0\"\nedition=\"2021\"\n",
);
write(p, "src/lib.rs", "pub mod a;\npub mod b;\n");
write(
p,
"src/a.rs",
"use crate::b;\nuse crate::missing::*;\nuse std::fmt;\npub fn a() { b::b(); }\n",
);
write(p, "src/b.rs", "pub fn b() {}\n");
git(p, &["add", "."]);
let status = Command::new("git")
.arg("-C")
.arg(p)
.args(["commit", "-m", "init", "--quiet"])
.env("GIT_AUTHOR_DATE", "2026-01-01T10:00:00Z")
.env("GIT_COMMITTER_DATE", "2026-01-01T10:00:00Z")
.status()
.expect("spawn git commit");
assert!(status.success(), "git commit failed");
let repo = GixRepo::open(p).expect("open repo");
let db = FactsDb::new_in_memory().expect("in-memory db");
let opts = permissive_coupling_opts(p.to_path_buf());
db.ingest(&repo, &opts).expect("ingest repo");
(dir, db, opts)
}
#[test]
fn architecture_metrics_counts_first_party_wildcard_and_reports_wildcard_share() {
let (_dir, db, opts) = ingested_first_party_wildcard_repo();
let rows = run_architecture_metrics(&db, &opts).expect("run architecture-metrics");
let m: HashMap<&str, &str> = rows
.iter()
.map(|r| (r.metric.as_str(), r.value.as_str()))
.collect();
assert_eq!(
m.get("import_resolution_rate"),
Some(&"0.3333"),
"resolved 1 / total 3: {m:?}"
);
assert_eq!(
m.get("first_party_import_share"),
Some(&"0.6667"),
"first-party candidates 2 / total 3, including the unresolved glob: {m:?}"
);
assert_eq!(
m.get("resolution_rate_first_party"),
Some(&"0.5000"),
"resolved 1 / first-party candidates 2: {m:?}"
);
assert_eq!(
m.get("wildcard_import_share"),
Some(&"0.3333"),
"one wildcard (crate::missing::*) / total 3: {m:?}"
);
}
#[test]
fn architecture_metrics_omits_first_party_rate_when_no_candidates() {
let dir = tempfile::tempdir().expect("tempdir");
let p = dir.path();
git(p, &["init", "-b", "main", "--quiet"]);
git(p, &["config", "user.email", "m@example.com"]);
git(p, &["config", "user.name", "M"]);
write(
p,
"Cargo.toml",
"[package]\nname=\"m\"\nversion=\"0.1.0\"\nedition=\"2021\"\n",
);
write(
p,
"src/lib.rs",
"use std::fmt;\nuse std::io;\npub fn f() {}\n",
);
git(p, &["add", "."]);
let status = Command::new("git")
.arg("-C")
.arg(p)
.args(["commit", "-m", "init", "--quiet"])
.env("GIT_AUTHOR_DATE", "2026-01-01T10:00:00Z")
.env("GIT_COMMITTER_DATE", "2026-01-01T10:00:00Z")
.status()
.expect("spawn git commit");
assert!(status.success(), "git commit failed");
let repo = GixRepo::open(p).expect("open repo");
let db = FactsDb::new_in_memory().expect("in-memory db");
let opts = permissive_coupling_opts(p.to_path_buf());
db.ingest(&repo, &opts).expect("ingest repo");
let rows = run_architecture_metrics(&db, &opts).expect("run architecture-metrics");
let m: HashMap<&str, &str> = rows
.iter()
.map(|r| (r.metric.as_str(), r.value.as_str()))
.collect();
assert_eq!(m.get("import_resolution_rate"), Some(&"0.0000"), "{m:?}");
assert_eq!(m.get("first_party_import_share"), Some(&"0.0000"), "{m:?}");
assert!(
!m.contains_key("resolution_rate_first_party"),
"undefined first-party rate must be omitted, never a 0.00: {m:?}"
);
}
fn write_temp_calibration_artifact(art: &CalibrationArtifact) -> tempfile::TempPath {
let mut f = tempfile::Builder::new()
.prefix("arch-metrics-corpus")
.suffix(".calib.json")
.tempfile()
.expect("create temp artifact");
let bytes = serde_json::to_vec(art).expect("serialize artifact");
f.write_all(&bytes).expect("write artifact bytes");
f.into_temp_path()
}
#[test]
fn architecture_metrics_additivity_without_repo_metrics_pool() {
let (_dir, db, mut opts) = ingested_cycle_repo();
let artifact = CalibrationArtifact {
format_version: CALIBRATION_FORMAT_VERSION,
corpus_vintage: "test-corpus-no-pools".to_string(),
generated_at: "2026-07-14T00:00:00Z".to_string(),
repos_included: 1,
repos_attempted: 1,
languages: vec![],
repo_metrics: None,
};
let path = write_temp_calibration_artifact(&artifact);
opts.calibration = Some(path.to_path_buf());
let rows = run_architecture_metrics(&db, &opts).expect("run architecture-metrics");
let names: Vec<&str> = rows.iter().map(|r| r.metric.as_str()).collect();
assert_eq!(
names,
vec![
"propagation_cost",
"acd",
"nccd",
"dependency_cycles",
"largest_cycle",
"files",
"architecture_type",
"import_resolution_rate",
"first_party_import_share",
"resolution_rate_first_party",
"wildcard_import_share",
],
"no active repo_metrics pool -> the base rows plus the four \
import-resolution disclosure rows, no corpus rows: {names:?}"
);
}
#[test]
fn architecture_metrics_default_embedded_artifact_emits_corpus_rows() {
let (_dir, db, opts) = ingested_cycle_repo();
assert!(
opts.calibration.is_none(),
"fixture must exercise the default (embedded-artifact) path"
);
let rows = run_architecture_metrics(&db, &opts).expect("run architecture-metrics");
let m: HashMap<&str, &str> = rows
.iter()
.map(|r| (r.metric.as_str(), r.value.as_str()))
.collect();
let p: f64 = m
.get("corpus_percentile:propagation_cost")
.expect("embedded world artifact must carry a propagation_cost pool")
.parse()
.expect("percentile parses");
assert!(
(0.0..=1.0).contains(&p),
"percentile must be in [0, 1], got {p}"
);
let embedded =
codelore_lib::calibration::embedded_world().expect("embedded world artifact resolves");
let pool_len = embedded
.repo_metrics
.as_ref()
.expect("embedded world artifact must carry repo_metrics")
.values
.get("propagation_cost")
.expect("propagation_cost pool present")
.len();
assert_eq!(
m.get("corpus_n").copied(),
Some(pool_len.to_string().as_str()),
"corpus_n must state the embedded pool size"
);
let lo: f64 = m
.get("corpus_percentile:propagation_cost:ci_low")
.expect("ci_low row present alongside the percentile")
.parse()
.expect("ci_low parses");
let hi: f64 = m
.get("corpus_percentile:propagation_cost:ci_high")
.expect("ci_high row present alongside the percentile")
.parse()
.expect("ci_high parses");
assert!(
(0.0..=1.0).contains(&lo) && (0.0..=1.0).contains(&hi),
"CI bounds must be in [0, 1], got [{lo}, {hi}]"
);
assert!(
lo <= p && p <= hi,
"CI must bracket the percentile p={p}: [{lo}, {hi}]"
);
}
#[test]
fn architecture_metrics_emits_corpus_percentiles_when_pool_active() {
let (_dir, db, mut opts) = ingested_cycle_repo();
let mut artifact = CalibrationArtifact {
format_version: CALIBRATION_FORMAT_VERSION,
corpus_vintage: "test-corpus".to_string(),
generated_at: "2026-07-14T00:00:00Z".to_string(),
repos_included: 3,
repos_attempted: 3,
languages: vec![],
repo_metrics: None,
};
let mut values = BTreeMap::new();
values.insert("propagation_cost".to_string(), vec![0.1, 0.3, 0.9]);
values.insert("cycle_file_share".to_string(), vec![0.0, 0.2]);
attach_repo_metrics(&mut artifact, RepoMetrics { values });
let path = write_temp_calibration_artifact(&artifact);
opts.calibration = Some(path.to_path_buf());
let rows = run_architecture_metrics(&db, &opts).expect("run architecture-metrics");
let names: Vec<&str> = rows.iter().map(|r| r.metric.as_str()).collect();
assert_eq!(
names,
vec![
"propagation_cost",
"acd",
"nccd",
"dependency_cycles",
"largest_cycle",
"files",
"architecture_type",
"import_resolution_rate",
"first_party_import_share",
"resolution_rate_first_party",
"wildcard_import_share",
"corpus_percentile:propagation_cost",
"corpus_percentile:propagation_cost:ci_low",
"corpus_percentile:propagation_cost:ci_high",
"corpus_percentile:cycle_file_share",
"corpus_percentile:cycle_file_share:ci_low",
"corpus_percentile:cycle_file_share:ci_high",
"corpus_n",
],
"the corpus rows (percentile + its paired Wilson CI) must append, in \
order, after the base rows and the four import-resolution \
disclosure rows: {names:?}"
);
let m: HashMap<&str, &str> = rows
.iter()
.map(|r| (r.metric.as_str(), r.value.as_str()))
.collect();
assert_eq!(
m.get("corpus_percentile:propagation_cost"),
Some(&"0.67"),
"hand-computed midpoint-rank percentile: {m:?}"
);
assert_eq!(
m.get("corpus_percentile:cycle_file_share"),
Some(&"1.00"),
"hand-computed midpoint-rank percentile: {m:?}"
);
assert_eq!(
m.get("corpus_n"),
Some(&"3"),
"corpus_n uses the propagation_cost pool length: {m:?}"
);
assert_eq!(
m.get("corpus_percentile:propagation_cost:ci_low"),
Some(&"0.21"),
"Wilson lower bound for p=2/3, n=3: {m:?}"
);
assert_eq!(
m.get("corpus_percentile:propagation_cost:ci_high"),
Some(&"0.94"),
"Wilson upper bound for p=2/3, n=3: {m:?}"
);
assert_eq!(
m.get("corpus_percentile:cycle_file_share:ci_low"),
Some(&"0.34"),
"Wilson lower bound for p=1.0, n=2: {m:?}"
);
assert_eq!(
m.get("corpus_percentile:cycle_file_share:ci_high"),
Some(&"1.00"),
"Wilson upper bound clamps to 1.0 for p=1.0, n=2: {m:?}"
);
}