use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{Context, Result, anyhow};
use codelore_lib::cli_api::Options;
use codelore_lib::cli_api::analyses::clones::{ClonesRow, run_clones};
use codelore_lib::cli_api::analyses::code_health::run_code_health;
use codelore_lib::cli_api::analyses::coupling::{
CouplingAbsence, CouplingRow, compute_coupling_absences, run_coupling,
};
use codelore_lib::cli_api::analyses::delta_health::{
DeltaHealthSection, FunctionMetricRow, compute_delta_health, run_function_metrics,
};
use codelore_lib::cli_api::analyses::hotspots::{HotspotRow, run_hotspots};
use codelore_lib::cli_api::facts::FactsDb;
use codelore_lib::cli_api::repo::GixRepo;
use serde::{Deserialize, Serialize};
use crate::args::DiffArgs;
#[derive(Debug, Default, Serialize)]
pub struct DiffOutput {
pub base_sha: String,
pub head_sha: String,
pub merge_base_used: bool,
pub hotspots: HotspotsDelta,
pub coupling_absences: Vec<CouplingAbsence>,
pub clones: ClonesDelta,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_median_code_health: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub head_median_code_health: Option<f64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub gate_violations: Vec<GateViolationOut>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gate_skip_reason: Option<String>,
#[serde(skip)]
pub gate_fail_on_skipped: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub delta_health: Option<DeltaHealthSection>,
}
#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
pub struct GateViolationOut {
pub gate: String,
pub path: String,
pub actual: String,
pub threshold: String,
}
impl From<codelore_lib::cli_api::quality_gates::GateViolation> for GateViolationOut {
fn from(v: codelore_lib::cli_api::quality_gates::GateViolation) -> Self {
Self {
gate: v.gate,
path: v.path,
actual: v.actual,
threshold: v.threshold,
}
}
}
#[derive(Debug, Default, Serialize)]
pub struct HotspotsDelta {
pub rank_entrants: Vec<HotspotRow>,
pub score_increased: Vec<ScoreDelta>,
pub pr_touched_existing: Vec<HotspotRow>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ScoreDelta {
pub path: String,
pub base_score: f64,
pub head_score: f64,
pub delta: f64,
}
#[derive(Debug, Default, Serialize)]
pub struct ClonesDelta {
pub new_families: Vec<ClonesRow>,
pub pr_touched_existing: Vec<ClonesRow>,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct RevAnalyses {
pub sha: String,
pub hotspots: Vec<HotspotRow>,
pub coupling: Vec<CouplingRow>,
pub clones: Vec<ClonesRow>,
#[serde(default)]
pub dependency_cycles: u32,
#[serde(default)]
pub functions: Vec<FunctionMetricRow>,
#[serde(default)]
pub red_files: Vec<String>,
#[serde(default)]
pub opts_digest: String,
}
pub fn parse_rev_range(repo: &Path, range: &str) -> Result<(String, String, bool)> {
if let Some((base_ref, head_ref)) = range.split_once("...") {
let base_ref = if base_ref.is_empty() {
"HEAD"
} else {
base_ref
};
let head_ref = if head_ref.is_empty() {
"HEAD"
} else {
head_ref
};
let base_sha = git_rev_parse(repo, base_ref)?;
let head_sha = git_rev_parse(repo, head_ref)?;
let mb = git_merge_base(repo, &base_sha, &head_sha)?;
return Ok((mb, head_sha, true));
}
if let Some((base_ref, head_ref)) = range.split_once("..") {
let base_ref = if base_ref.is_empty() {
"HEAD"
} else {
base_ref
};
let head_ref = if head_ref.is_empty() {
"HEAD"
} else {
head_ref
};
let base_sha = git_rev_parse(repo, base_ref)?;
let head_sha = git_rev_parse(repo, head_ref)?;
return Ok((base_sha, head_sha, false));
}
Err(anyhow!("rev range must contain '..' or '...': {range:?}"))
}
fn git_rev_parse(repo: &Path, rev: &str) -> Result<String> {
let out = Command::new("git")
.arg("-C")
.arg(repo)
.args(["rev-parse", "--verify", rev])
.output()
.with_context(|| format!("git rev-parse {rev}"))?;
if !out.status.success() {
return Err(anyhow!(
"git rev-parse failed for {rev:?}: {}",
String::from_utf8_lossy(&out.stderr).trim()
));
}
Ok(String::from_utf8(out.stdout)?.trim().to_string())
}
fn git_merge_base(repo: &Path, a: &str, b: &str) -> Result<String> {
let out = Command::new("git")
.arg("-C")
.arg(repo)
.args(["merge-base", a, b])
.output()
.with_context(|| "git merge-base")?;
if !out.status.success() {
return Err(anyhow!(
"git merge-base {a}..{b} failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
));
}
Ok(String::from_utf8(out.stdout)?.trim().to_string())
}
struct Worktree {
repo_root: PathBuf,
path: PathBuf,
}
impl Drop for Worktree {
fn drop(&mut self) {
let _ = Command::new("git")
.arg("-C")
.arg(&self.repo_root)
.args(["worktree", "remove", "--force"])
.arg(&self.path)
.output();
let _ = std::fs::remove_dir_all(&self.path);
}
}
fn add_worktree(repo: &Path, sha: &str) -> Result<Worktree> {
let cache_root = codelore_lib::cli_api::cache::default_cache_root()
.join("codelore")
.join("diff-worktrees");
std::fs::create_dir_all(&cache_root)?;
let tmp = tempfile::Builder::new()
.prefix(&format!("wt-{}-", &sha[..8.min(sha.len())]))
.tempdir_in(&cache_root)?;
let path = tmp.path().to_path_buf();
let out = Command::new("git")
.arg("-C")
.arg(repo)
.args(["worktree", "add", "--detach", "--quiet"])
.arg(&path)
.arg(sha)
.output()
.with_context(|| format!("git worktree add {sha}"))?;
if !out.status.success() {
return Err(anyhow!(
"git worktree add failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
));
}
let _ = tmp.keep();
Ok(Worktree {
repo_root: repo.to_path_buf(),
path,
})
}
fn analyze_at_rev(
repo: &Path,
sha: &str,
args: &DiffArgs,
want_red_files: bool,
) -> Result<(RevAnalyses, FactsDb, Options)> {
let wt = add_worktree(repo, sha)?;
let opts = base_rev_options(wt.path.clone(), args);
let gix = GixRepo::open(&wt.path).context("open gix repo in worktree")?;
let db = FactsDb::new_in_memory().context("open in-memory fact store")?;
db.ingest(&gix, &opts).context("ingest in worktree")?;
let hotspots = run_hotspots(&db, &opts).context("hotspots at rev")?;
let coupling = run_coupling(&db, &opts).context("coupling at rev")?;
let clones = run_clones(&opts).context("clones at rev")?;
let graph = codelore_lib::cli_api::analyses::import_graph::build_import_graph(&db)
.context("import graph at rev")?;
let dependency_cycles =
codelore_lib::cli_api::analyses::import_graph::graph_metrics(&graph).cycle_count;
let functions = run_function_metrics(&db).context("function metrics at rev")?;
let red_files: Vec<String> = if want_red_files {
run_code_health(&db, &opts)
.context("code health at rev")?
.into_iter()
.filter(|r| r.band == "red")
.map(|r| r.path)
.collect()
} else {
Vec::new()
};
drop(wt);
let analyses = RevAnalyses {
sha: sha.to_string(),
hotspots,
coupling,
clones,
dependency_cycles,
functions,
red_files,
opts_digest: base_cache_opts_digest(&base_rev_options(repo.to_path_buf(), args)),
};
Ok((analyses, db, opts))
}
fn load_base_cache(path: &Path) -> Result<RevAnalyses> {
let body = std::fs::read_to_string(path)
.with_context(|| format!("read --base-cache {}", path.display()))?;
serde_json::from_str(&body).context("parse --base-cache JSON")
}
fn write_base_cache(path: &Path, analyses: &RevAnalyses) -> Result<()> {
let body = serde_json::to_string_pretty(analyses)?;
std::fs::write(path, body).with_context(|| format!("write --base-cache {}", path.display()))?;
Ok(())
}
fn compute_hotspots_delta(
base: &[HotspotRow],
head: &[HotspotRow],
pr_files: &std::collections::HashSet<String>,
top_n: usize,
score_threshold: f64,
) -> HotspotsDelta {
use std::collections::{HashMap, HashSet};
let base_top: HashSet<&str> = base.iter().take(top_n).map(|h| h.path.as_str()).collect();
let head_top: Vec<&HotspotRow> = head.iter().take(top_n).collect();
let mut rank_entrants: Vec<HotspotRow> = Vec::new();
for h in &head_top {
if !base_top.contains(h.path.as_str()) {
rank_entrants.push((*h).clone());
}
}
let base_by_path: HashMap<&str, &HotspotRow> = base
.iter()
.take(top_n)
.map(|h| (h.path.as_str(), h))
.collect();
let mut score_increased: Vec<ScoreDelta> = Vec::new();
for h in &head_top {
if let Some(b) = base_by_path.get(h.path.as_str()) {
let delta = h.hotspot_score - b.hotspot_score;
if delta >= score_threshold {
score_increased.push(ScoreDelta {
path: h.path.clone(),
base_score: b.hotspot_score,
head_score: h.hotspot_score,
delta,
});
}
}
}
let pr_touched_existing: Vec<HotspotRow> = base
.iter()
.take(top_n)
.filter(|h| pr_files.contains(&h.path))
.cloned()
.collect();
HotspotsDelta {
rank_entrants,
score_increased,
pr_touched_existing,
}
}
fn compute_clones_delta(
base_clones: &[ClonesRow],
head_clones: &[ClonesRow],
pr_files: &std::collections::HashSet<String>,
) -> ClonesDelta {
use std::collections::HashSet;
let base_fps: HashSet<&str> = base_clones.iter().map(|c| c.fingerprint.as_str()).collect();
let new_families: Vec<ClonesRow> = head_clones
.iter()
.filter(|c| !base_fps.contains(c.fingerprint.as_str()))
.cloned()
.collect();
let pr_touched_existing: Vec<ClonesRow> = head_clones
.iter()
.filter(|c| base_fps.contains(c.fingerprint.as_str()) && pr_files.contains(&c.entity))
.cloned()
.collect();
ClonesDelta {
new_families,
pr_touched_existing,
}
}
fn list_pr_files(
repo: &Path,
base_sha: &str,
head_sha: &str,
) -> Result<std::collections::HashSet<String>> {
let out = Command::new("git")
.arg("-C")
.arg(repo)
.args(["-c", "core.quotepath=false"])
.args(["diff", "--name-only", &format!("{base_sha}..{head_sha}")])
.output()
.context("git diff --name-only")?;
if !out.status.success() {
return Err(anyhow!(
"git diff failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
));
}
Ok(String::from_utf8(out.stdout)?
.lines()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect())
}
fn base_rev_options(repo_path: PathBuf, args: &DiffArgs) -> Options {
Options {
repo_path,
min_revs: args.min_revs,
exclude_patterns: args.exclude.clone(),
..Options::default()
}
}
fn base_cache_opts_digest(opts: &Options) -> String {
format!(
"opts={}|version={}|cache_epoch={}|schema={}",
opts.canonical_json(),
env!("CARGO_PKG_VERSION"),
codelore_lib::cli_api::cache::CACHE_EPOCH,
codelore_lib::cli_api::facts::schema::CURRENT_SCHEMA_VERSION,
)
}
fn base_cache_is_fresh(cached: &RevAnalyses, base_sha: &str, expected_digest: &str) -> bool {
cached.sha == base_sha && cached.opts_digest == expected_digest
}
const STALE_WORKTREE_AGE_HOURS: u64 = 24;
fn prune_stale_worktrees(repo_root: &Path) {
let cache_root = codelore_lib::cli_api::cache::default_cache_root()
.join("codelore")
.join("diff-worktrees");
if cache_root.exists()
&& let Ok(cutoff) = std::time::SystemTime::now()
.checked_sub(std::time::Duration::from_secs(
STALE_WORKTREE_AGE_HOURS * 3600,
))
.ok_or("subtraction underflow")
&& let Ok(entries) = std::fs::read_dir(&cache_root)
{
for entry in entries.filter_map(std::result::Result::ok) {
let Ok(meta) = entry.metadata() else { continue };
if !meta.is_dir() {
continue;
}
let Ok(modified) = meta.modified() else {
continue;
};
if modified < cutoff {
let path = entry.path();
if let Err(e) = std::fs::remove_dir_all(&path) {
tracing::warn!("failed to remove stale worktree {}: {e}", path.display());
} else {
tracing::info!("pruned stale worktree directory: {}", path.display());
}
}
}
}
let prune_result = Command::new("git")
.arg("-C")
.arg(repo_root)
.args(["worktree", "prune"])
.output();
if let Err(e) = prune_result {
tracing::warn!(
"git worktree prune failed during startup cleanup: {e}; \
continuing — `git worktree add` may report 'already exists'"
);
}
}
#[allow(clippy::too_many_lines)] pub fn run_diff(args: &DiffArgs) -> Result<(DiffOutput, FactsDb, Options)> {
prune_stale_worktrees(&args.repo);
let (base_sha, head_sha, merge_base_used) = parse_rev_range(&args.repo, &args.range)?;
if base_sha == head_sha {
anyhow::bail!(
"base and head resolve to the same commit {base_sha} \
(range {:?}); nothing to diff",
args.range
);
}
let base_analyses = if let Some(cache_path) = args.base_cache.as_ref() {
let expected_digest = base_cache_opts_digest(&base_rev_options(args.repo.clone(), args));
match cache_path.exists().then(|| load_base_cache(cache_path)) {
Some(Ok(cached)) if base_cache_is_fresh(&cached, &base_sha, &expected_digest) => {
tracing::info!("loading base analysis from {}", cache_path.display());
cached
}
Some(Ok(cached)) if cached.sha == base_sha => {
tracing::warn!(
"base-cache options mismatch at {} (SHA matches {base_sha}, but the \
cache was built under different analysis options such as --min-revs \
or --exclude); discarding cache and recomputing base analysis",
cache_path.display(),
);
let (a, _db, _opts) = analyze_at_rev(&args.repo, &base_sha, args, true)?;
write_base_cache(cache_path, &a)?;
a
}
Some(Ok(cached)) => {
tracing::warn!(
"base-cache SHA mismatch at {} (cached={}, expected={}); \
discarding cache and recomputing base analysis",
cache_path.display(),
cached.sha,
base_sha
);
let (a, _db, _opts) = analyze_at_rev(&args.repo, &base_sha, args, true)?;
write_base_cache(cache_path, &a)?;
a
}
Some(Err(e)) => {
tracing::warn!(
"failed to read base-cache {}: {e:#}; recomputing base analysis",
cache_path.display()
);
let (a, _db, _opts) = analyze_at_rev(&args.repo, &base_sha, args, true)?;
write_base_cache(cache_path, &a)?;
a
}
None => {
let (a, _db, _opts) = analyze_at_rev(&args.repo, &base_sha, args, true)?;
write_base_cache(cache_path, &a)?;
tracing::info!("wrote base analysis to {}", cache_path.display());
a
}
}
} else {
let (a, _db, _opts) = analyze_at_rev(&args.repo, &base_sha, args, true)?;
a
};
let (head_analyses, head_db, head_opts) = analyze_at_rev(&args.repo, &head_sha, args, false)?;
let pr_files = list_pr_files(&args.repo, &base_sha, &head_sha)?;
let delta_health = if base_analyses.functions.is_empty()
&& !base_analyses.hotspots.is_empty()
&& !head_analyses.functions.is_empty()
{
tracing::warn!(
"base analysis has no function metrics (stale --base-cache?); \
skipping delta-health — delete the cache file to recompute"
);
None
} else {
let clone_members: std::collections::HashSet<(String, String)> = head_analyses
.clones
.iter()
.map(|c| (c.entity.clone(), c.function.clone()))
.collect();
let red: std::collections::HashSet<String> =
base_analyses.red_files.iter().cloned().collect();
Some(compute_delta_health(
&base_analyses.functions,
&head_analyses.functions,
&pr_files,
&clone_members,
&red,
))
};
let want_hotspots = args.analysis.wants_hotspots();
let want_coupling = args.analysis.wants_coupling();
let want_clones = args.analysis.wants_clones();
let hotspots = if want_hotspots {
compute_hotspots_delta(
&base_analyses.hotspots,
&head_analyses.hotspots,
&pr_files,
args.top_n as usize,
args.score_threshold,
)
} else {
HotspotsDelta::default()
};
let coupling_absences = if want_coupling {
compute_coupling_absences(
&base_analyses.coupling,
&pr_files,
args.absence_min_shared,
args.absence_fisher_p,
)
} else {
Vec::new()
};
let clones = if want_clones {
compute_clones_delta(&base_analyses.clones, &head_analyses.clones, &pr_files)
} else {
ClonesDelta::default()
};
let thresholds_opt = if let Some(path) = args.thresholds_file.as_ref() {
Some(
codelore_lib::cli_api::quality_gates::Thresholds::from_path(path)
.context("load thresholds file")?,
)
} else {
let discovered = codelore_lib::cli_api::quality_gates::Thresholds::discover(&args.repo)
.context("discover thresholds file")?;
if discovered.is_empty() {
None
} else {
Some(discovered)
}
};
let (base_median_code_health, head_median_code_health, gate_violations, gate_skip_reason) =
if let Some(t) = thresholds_opt.as_ref()
&& (t.diff.delta_code_health_min.is_some()
|| t.diff.new_hotspot_max.is_some()
|| t.diff.no_new_cycles
|| t.diff.delta_health_min.is_some()
|| t.diff.deny_degrading_verdict)
{
let base_med = median_code_health(&base_analyses.hotspots);
let head_med = median_code_health(&head_analyses.hotspots);
let delta = head_med - base_med;
let new_hotspot_count = u32::try_from(hotspots.rank_entrants.len()).unwrap_or(u32::MAX);
let violations: Vec<GateViolationOut> =
codelore_lib::cli_api::quality_gates::evaluate_diff_gate(
t,
new_hotspot_count,
delta,
base_analyses.dependency_cycles,
head_analyses.dependency_cycles,
delta_health.as_ref().and_then(|d| d.ratio),
delta_health.as_ref().map(|d| d.verdict.as_str()),
)
.into_iter()
.map(Into::into)
.collect();
let verdict = codelore_lib::cli_api::quality_gates::diff_gate_verdict(
!base_analyses.hotspots.is_empty(),
!head_analyses.hotspots.is_empty(),
violations.len(),
);
let skip_reason = (verdict == "skipped").then(|| {
"no hotspot rows measured at either revision (blind ingest — check for a \
shallow checkout / fetch-depth truncation, not a genuinely unchanged range)"
.to_string()
});
(Some(base_med), Some(head_med), violations, skip_reason)
} else {
(None, None, Vec::new(), None)
};
Ok((
DiffOutput {
base_sha,
head_sha,
merge_base_used,
hotspots,
coupling_absences,
clones,
base_median_code_health,
head_median_code_health,
gate_violations,
gate_skip_reason,
gate_fail_on_skipped: thresholds_opt
.as_ref()
.is_some_and(|t| t.gates.fail_on_skipped),
delta_health,
},
head_db,
head_opts,
))
}
fn median_code_health(rows: &[HotspotRow]) -> f64 {
if rows.is_empty() {
return 0.0;
}
let mut healths: Vec<f64> = rows.iter().map(|r| r.cognitive_health).collect();
healths.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let mid = healths.len() / 2;
if healths.len() % 2 == 1 {
healths[mid]
} else {
f64::midpoint(healths[mid - 1], healths[mid])
}
}
pub fn should_fail(args: &DiffArgs, output: &DiffOutput) -> bool {
use crate::args::DiffFailOn;
if !output.gate_violations.is_empty() {
return true;
}
if output.gate_fail_on_skipped && output.gate_skip_reason.is_some() {
return true;
}
match args.fail_on {
DiffFailOn::None => false,
DiffFailOn::RankEntrant => !output.hotspots.rank_entrants.is_empty(),
DiffFailOn::ScoreIncrease => !output.hotspots.score_increased.is_empty(),
DiffFailOn::Any => {
!output.hotspots.rank_entrants.is_empty()
|| !output.hotspots.score_increased.is_empty()
|| !output.coupling_absences.is_empty()
|| !output.clones.new_families.is_empty()
}
}
}
#[cfg(test)]
mod prune_tests {
use super::*;
fn tiny_two_commit_repo() -> (tempfile::TempDir, String, String) {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path();
let git = |args: &[&str]| {
assert!(
Command::new("git")
.arg("-C")
.arg(path)
.args(args)
.status()
.expect("spawn git")
.success(),
"git {args:?} failed"
);
};
git(&["init", "-b", "main", "--quiet"]);
git(&["config", "user.email", "x@x"]);
git(&["config", "user.name", "X"]);
std::fs::write(path.join("a.txt"), "1\n").unwrap();
git(&["add", "."]);
git(&["commit", "-m", "c1", "--quiet"]);
let sha1 = git_rev_parse(path, "HEAD").unwrap();
std::fs::write(path.join("a.txt"), "2\n").unwrap();
git(&["commit", "-am", "c2", "--quiet"]);
let sha2 = git_rev_parse(path, "HEAD").unwrap();
(dir, sha1, sha2)
}
#[test]
fn parse_rev_range_two_dot_omitted_base_defaults_to_head() {
let (dir, sha1, sha2) = tiny_two_commit_repo();
let (base, head, mb) = parse_rev_range(dir.path(), "..HEAD~1").unwrap();
assert_eq!(base, sha2, "empty base should default to HEAD");
assert_eq!(head, sha1, "head should be HEAD~1");
assert!(!mb, "two-dot form should not flag merge-base");
}
#[test]
fn parse_rev_range_two_dot_omitted_head_defaults_to_head() {
let (dir, sha1, sha2) = tiny_two_commit_repo();
let (base, head, mb) = parse_rev_range(dir.path(), "HEAD~1..").unwrap();
assert_eq!(base, sha1);
assert_eq!(head, sha2, "empty head should default to HEAD");
assert!(!mb);
}
#[test]
fn parse_rev_range_three_dot_omitted_head_defaults_to_head() {
let (dir, _sha1, sha2) = tiny_two_commit_repo();
let (_base, head, mb) = parse_rev_range(dir.path(), "HEAD~1...").unwrap();
assert_eq!(head, sha2, "empty head should default to HEAD");
assert!(mb, "three-dot form should flag merge-base");
}
#[test]
fn prune_does_not_panic_on_non_git_path() {
let tmp = tempfile::tempdir().expect("tempdir");
prune_stale_worktrees(tmp.path());
}
#[test]
fn prune_is_noop_on_missing_or_empty_cache_dir() {
let tmp = tempfile::tempdir().expect("tempdir");
prune_stale_worktrees(tmp.path()); }
#[test]
fn add_worktree_does_not_leak_tempdir_on_git_failure() {
let not_a_repo = tempfile::tempdir().expect("tempdir");
let cache_root = codelore_lib::cli_api::cache::default_cache_root()
.join("codelore")
.join("diff-worktrees");
let before: std::collections::HashSet<std::path::PathBuf> = std::fs::read_dir(&cache_root)
.map(|rd| rd.filter_map(|e| e.ok().map(|e| e.path())).collect())
.unwrap_or_default();
let result = add_worktree(not_a_repo.path(), "deadbeef");
assert!(result.is_err(), "add_worktree on a non-git path must error");
let after: std::collections::HashSet<std::path::PathBuf> = std::fs::read_dir(&cache_root)
.map(|rd| rd.filter_map(|e| e.ok().map(|e| e.path())).collect())
.unwrap_or_default();
let new_entries: Vec<_> = after.difference(&before).collect();
assert!(
new_entries.is_empty(),
"regression: add_worktree leaked a tempdir on git failure: {new_entries:?}",
);
}
#[test]
fn list_pr_files_returns_non_ascii_paths_unquoted() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path();
let git = |args: &[&str]| {
assert!(
Command::new("git")
.arg("-C")
.arg(path)
.args(args)
.status()
.expect("spawn git")
.success(),
"git {args:?} failed"
);
};
git(&["init", "-b", "main", "--quiet"]);
git(&["config", "user.email", "x@x"]);
git(&["config", "user.name", "X"]);
git(&["config", "core.quotepath", "true"]);
std::fs::write(path.join("seed.txt"), "1\n").unwrap();
git(&["add", "."]);
git(&["commit", "-m", "seed", "--quiet"]);
let base = git_rev_parse(path, "HEAD").unwrap();
std::fs::write(path.join("файл.rs"), "fn main() {}\n").unwrap();
git(&["add", "."]);
git(&["commit", "-m", "add cyrillic", "--quiet"]);
let head = git_rev_parse(path, "HEAD").unwrap();
let files = list_pr_files(path, &base, &head).unwrap();
assert!(
files.contains("файл.rs"),
"expected raw-UTF-8 path in returned set, got {files:?}"
);
}
fn digest_opts(min_revs: u32, exclude: &[String]) -> Options {
Options {
min_revs,
exclude_patterns: exclude.to_vec(),
..Options::default()
}
}
#[test]
fn base_cache_opts_digest_folds_in_min_revs() {
assert_ne!(
base_cache_opts_digest(&digest_opts(2, &[])),
base_cache_opts_digest(&digest_opts(10, &[]))
);
}
#[test]
fn base_cache_opts_digest_folds_in_exclude() {
assert_ne!(
base_cache_opts_digest(&digest_opts(2, &["src/gen/**".to_string()])),
base_cache_opts_digest(&digest_opts(2, &[]))
);
}
#[test]
fn base_cache_opts_digest_is_exclude_order_stable() {
assert_eq!(
base_cache_opts_digest(&digest_opts(2, &["a".to_string(), "b".to_string()])),
base_cache_opts_digest(&digest_opts(2, &["b".to_string(), "a".to_string()]))
);
}
#[test]
fn base_cache_opts_digest_incorporates_version_epoch_and_schema() {
let digest = base_cache_opts_digest(&digest_opts(2, &[]));
assert!(
digest.contains(env!("CARGO_PKG_VERSION")),
"digest must incorporate the binary version: {digest}"
);
assert!(
digest.contains(codelore_lib::cli_api::cache::CACHE_EPOCH),
"digest must incorporate the cache epoch: {digest}"
);
assert!(
digest.contains(codelore_lib::cli_api::facts::schema::CURRENT_SCHEMA_VERSION),
"digest must incorporate the fact-schema version: {digest}"
);
}
#[test]
fn base_cache_not_fresh_when_opts_digest_differs() {
let cached = RevAnalyses {
sha: "abc123".to_string(),
opts_digest: base_cache_opts_digest(&digest_opts(2, &[])),
..Default::default()
};
assert!(!base_cache_is_fresh(
&cached,
"abc123",
&base_cache_opts_digest(&digest_opts(10, &[]))
));
assert!(base_cache_is_fresh(
&cached,
"abc123",
&base_cache_opts_digest(&digest_opts(2, &[]))
));
assert!(!base_cache_is_fresh(
&cached,
"def456",
&base_cache_opts_digest(&digest_opts(2, &[]))
));
}
#[test]
fn legacy_base_cache_missing_opts_digest_deserialises_empty_and_is_not_served() {
let parsed: RevAnalyses =
serde_json::from_str(r#"{"sha":"abc","hotspots":[],"coupling":[],"clones":[]}"#)
.expect("legacy base-cache JSON should deserialise");
assert_eq!(parsed.opts_digest, "");
assert!(!base_cache_is_fresh(
&parsed,
"abc",
&base_cache_opts_digest(&digest_opts(2, &[]))
));
}
}
#[cfg(test)]
mod median_code_health_tests {
use super::*;
fn row(path: &str, cognitive_health: f64) -> HotspotRow {
HotspotRow {
path: path.to_string(),
revisions: 1,
cognitive: 999.0,
cognitive_health,
hotspot_score: 0.0,
mi: None,
mi_rank: None,
ai_pct: None,
hotspot_score_anchored: None,
}
}
#[test]
#[allow(clippy::float_cmp)] fn median_code_health_reads_cognitive_health_field() {
let rows = vec![row("a.rs", 60.0), row("b.rs", 70.0), row("c.rs", 100.0)];
assert_eq!(median_code_health(&rows), 70.0);
}
#[test]
#[allow(clippy::float_cmp)] fn median_code_health_even_count_averages_the_middle_pair() {
let rows = vec![
row("a.rs", 60.0),
row("b.rs", 70.0),
row("c.rs", 80.0),
row("d.rs", 100.0),
];
assert_eq!(median_code_health(&rows), 75.0);
}
}