use std::path::Path;
use anyhow::Result;
use tokmd_analysis_types::{EffortDeltaReport, GitReport};
#[cfg(feature = "git")]
use tokmd_analysis_types::EffortDeltaClassification;
use tokmd_types::ExportData;
#[cfg(feature = "git")]
use anyhow::Context;
#[cfg(feature = "git")]
use std::collections::{BTreeMap, BTreeSet};
#[cfg(feature = "git")]
use tokmd_git::GitRangeMode;
#[cfg(feature = "git")]
use tokmd_types::FileKind;
#[cfg(feature = "git")]
use super::cocomo81::cocomo81_baseline;
pub fn build_delta(
root: &Path,
export: &ExportData,
git: Option<&GitReport>,
base_ref: &str,
head_ref: &str,
) -> Result<EffortDeltaReport> {
let base = base_ref.trim().to_string();
let head = head_ref.trim().to_string();
if base.is_empty() || head.is_empty() {
anyhow::bail!("both base_ref and head_ref are required");
}
#[cfg(not(feature = "git"))]
{
let _ = (root, export, git);
anyhow::bail!("delta estimation requires the tokmd-git feature");
}
#[cfg(feature = "git")]
{
let repo_root = tokmd_git::repo_root(root).context("failed to locate git repository")?;
if !tokmd_git::rev_exists(&repo_root, &base) {
anyhow::bail!("effort delta skipped: could not resolve ref '{}'", base);
}
if !tokmd_git::rev_exists(&repo_root, &head) {
anyhow::bail!("effort delta skipped: could not resolve ref '{}'", head);
}
let changed = tokmd_git::get_added_lines(&repo_root, &base, &head, GitRangeMode::TwoDot)
.context("failed to compute changed files")?;
let (files_changed, changed_lines) = changed
.iter()
.fold((0usize, 0usize), |(files, lines), (_path, hunks)| {
(files + 1, lines + hunks.len())
});
let mut path_to_module_lang = BTreeMap::<&str, (&str, &str)>::new();
let mut all_modules = BTreeSet::<&str>::new();
let mut all_langs = BTreeSet::<&str>::new();
let mut changed_modules = BTreeSet::<&str>::new();
let mut changed_langs = BTreeSet::<&str>::new();
for row in &export.rows {
if row.kind != FileKind::Parent {
continue;
}
path_to_module_lang.insert(row.path.as_str(), (row.module.as_str(), row.lang.as_str()));
all_modules.insert(row.module.as_str());
all_langs.insert(row.lang.as_str());
}
for path in changed.keys() {
let key_lossy = path.to_string_lossy();
let key = key_lossy.trim_start_matches("./");
if let Some((module, lang)) = path_to_module_lang.get(key) {
changed_modules.insert(*module);
changed_langs.insert(*lang);
}
}
let _total_files = path_to_module_lang.len();
let modules_ratio = if all_modules.is_empty() {
0.0
} else {
(changed_modules.len() as f64) / (all_modules.len() as f64)
};
let _langs_ratio = if all_langs.is_empty() {
0.0
} else {
(changed_langs.len() as f64) / (all_langs.len() as f64)
};
let hotspot_files_touched = if let Some(git_report) = git {
changed
.keys()
.map(|path| path.to_string_lossy().trim_start_matches("./").to_string())
.filter(|path| git_report.hotspots.iter().any(|row| row.path == *path))
.count()
} else {
0
};
let coupling_total = git.map(|g| g.coupling.len()).unwrap_or(0) as f64;
let coupled_neighbors_touched = if let Some(git_report) = git {
let touched_modules = &changed_modules;
git_report
.coupling
.iter()
.filter(|c| {
touched_modules.contains(c.left.as_str())
|| touched_modules.contains(c.right.as_str())
})
.count() as f64
} else {
0.0
};
let _coupling_ratio = if coupling_total <= 0.0 {
0.0
} else {
coupled_neighbors_touched / coupling_total
};
let log_files = (files_changed as f64).ln_1p();
let core_boundary_crossed = if files_changed > 0 { 1.0 } else { 0.0 };
let blast_radius = (15.0 * core_boundary_crossed)
+ (3.0 * modules_ratio)
+ (1.0 * log_files)
+ (2.0 * (hotspot_files_touched as f64))
+ (2.0 * coupled_neighbors_touched);
let clamped_blast = blast_radius.clamp(0.0, 100.0);
let changed_kloc = if changed_lines > 0 {
(changed_lines as f64 / 1000.0) * (1.0 + clamped_blast / 100.0)
} else if files_changed > 0 {
(files_changed as f64 * 0.02) * (1.0 + clamped_blast / 100.0)
} else {
0.0
}
.max(0.001);
let report = cocomo81_baseline(changed_kloc);
let classification = classify_blast(clamped_blast);
Ok(EffortDeltaReport {
base,
head,
files_changed,
modules_changed: changed_modules.len(),
langs_changed: changed_langs.len(),
hotspot_files_touched,
coupled_neighbors_touched: coupled_neighbors_touched as usize,
blast_radius: clamped_blast,
classification,
effort_pm_low: report.effort_pm_low,
effort_pm_est: report.effort_pm_p50,
effort_pm_high: report.effort_pm_p80,
})
}
}
#[cfg(feature = "git")]
fn classify_blast(blast_radius: f64) -> EffortDeltaClassification {
if blast_radius < 10.0 {
EffortDeltaClassification::Low
} else if blast_radius < 20.0 {
EffortDeltaClassification::Medium
} else if blast_radius < 35.0 {
EffortDeltaClassification::High
} else {
EffortDeltaClassification::Critical
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "git")]
#[test]
fn classify_blast_boundaries() {
assert_eq!(classify_blast(0.0), EffortDeltaClassification::Low);
assert_eq!(classify_blast(9.999), EffortDeltaClassification::Low);
assert_eq!(classify_blast(10.0), EffortDeltaClassification::Medium);
assert_eq!(classify_blast(19.999), EffortDeltaClassification::Medium);
assert_eq!(classify_blast(20.0), EffortDeltaClassification::High);
assert_eq!(classify_blast(34.999), EffortDeltaClassification::High);
assert_eq!(classify_blast(35.0), EffortDeltaClassification::Critical);
assert_eq!(classify_blast(100.0), EffortDeltaClassification::Critical);
}
#[test]
fn build_delta_requires_base_and_head() {
let export = ExportData {
rows: Vec::new(),
module_roots: Vec::new(),
module_depth: 1,
children: tokmd_types::ChildIncludeMode::Separate,
};
let dir = tempfile::tempdir().unwrap();
let err = build_delta(dir.path(), &export, None, "", "HEAD").unwrap_err();
assert!(err.to_string().contains("base_ref and head_ref"));
let err = build_delta(dir.path(), &export, None, "HEAD~1", " ").unwrap_err();
assert!(err.to_string().contains("base_ref and head_ref"));
}
#[cfg(not(feature = "git"))]
#[test]
fn build_delta_without_git_feature_errors() {
let export = ExportData {
rows: Vec::new(),
module_roots: Vec::new(),
module_depth: 1,
children: tokmd_types::ChildIncludeMode::Separate,
};
let dir = tempfile::tempdir().unwrap();
let err = build_delta(dir.path(), &export, None, "HEAD~1", "HEAD").unwrap_err();
assert!(err.to_string().contains("tokmd-git feature"));
}
#[cfg(feature = "git")]
#[test]
fn build_delta_reports_unresolved_refs() {
if !tokmd_git::git_available() {
return;
}
let export = ExportData {
rows: Vec::new(),
module_roots: Vec::new(),
module_depth: 1,
children: tokmd_types::ChildIncludeMode::Separate,
};
let dir = tempfile::tempdir().unwrap();
git(&dir).arg("init").status().unwrap();
git(&dir)
.args(["config", "user.email", "tokmd@example.com"])
.status()
.unwrap();
git(&dir)
.args(["config", "user.name", "tokmd"])
.status()
.unwrap();
std::fs::write(dir.path().join("main.rs"), "fn main() {}\n").unwrap();
git(&dir).args(["add", "main.rs"]).status().unwrap();
git(&dir)
.args(["commit", "-m", "initial"])
.status()
.unwrap();
let err = build_delta(dir.path(), &export, None, "nope-xyz-123", "HEAD").unwrap_err();
assert!(
err.to_string()
.contains("could not resolve ref 'nope-xyz-123'"),
"unexpected error: {err}"
);
}
#[cfg(feature = "git")]
fn git(dir: &tempfile::TempDir) -> std::process::Command {
let mut cmd = tokmd_git::git_cmd();
cmd.arg("-C").arg(dir.path());
cmd
}
}