Skip to main content

cargo_coupling/
history.rs

1//! Time-series coupling analysis.
2//!
3//! Re-runs snapshot-equivalent balance analysis at past git revisions using
4//! disposable `git worktree`s, producing a chronological health timeline. This is
5//! the data foundation for *observing* how a codebase's coupling evolves over
6//! time, rather than inspecting a single snapshot.
7//!
8//! Each sampled revision is analyzed with AST structure, git-churn volatility,
9//! temporal coupling, and config volatility/subdomain overrides so the timeline
10//! score uses the same methodology as the snapshot report for that revision.
11
12use std::path::{Path, PathBuf};
13use std::process::Command;
14use std::sync::atomic::{AtomicUsize, Ordering};
15
16use crate::balance::grade::{HealthGrade, ProjectBalanceReport};
17use crate::balance::project::analyze_project_balance_with_thresholds;
18use crate::balance::severity::Severity;
19use crate::config::CompiledConfig;
20use crate::metrics::project::ProjectMetrics;
21use crate::{IssueThresholds, VolatilityAnalyzer, analyze_workspace_with_config};
22
23static WORKTREE_SEQ: AtomicUsize = AtomicUsize::new(0);
24
25/// One sampled point in the coupling timeline.
26#[derive(Debug, Clone)]
27pub struct HistoryPoint {
28    /// Abbreviated commit hash.
29    pub commit: String,
30    /// Committer date in ISO-8601 (e.g. `2026-05-20`).
31    pub date: String,
32    /// Overall project health grade at this revision.
33    pub grade: HealthGrade,
34    /// Average balance score (0.0 - 1.0).
35    pub average_score: f64,
36    /// Number of detected couplings.
37    pub total_couplings: usize,
38    /// Number of analyzed modules.
39    pub module_count: usize,
40    /// Critical-severity issue count.
41    pub critical: usize,
42    /// High-severity issue count.
43    pub high: usize,
44}
45
46/// A revision that was sampled but could not be analyzed.
47#[derive(Debug, Clone)]
48pub struct SkippedRevision {
49    /// Abbreviated commit hash.
50    pub commit: String,
51    /// Committer date in ISO-8601.
52    pub date: String,
53    /// Human-readable reason analysis did not complete.
54    pub reason: String,
55}
56
57/// Result of a history analysis run.
58#[derive(Debug, Default)]
59pub struct HistoryReport {
60    /// Points in chronological order (oldest first).
61    pub points: Vec<HistoryPoint>,
62    /// Revisions that were sampled but skipped, with reasons.
63    pub skipped: Vec<SkippedRevision>,
64    /// Look-back window in months that was requested.
65    pub months: usize,
66}
67
68/// Snapshot-equivalent analysis result for a single git ref.
69#[derive(Debug)]
70pub struct RefAnalysis {
71    /// The git ref that was analyzed.
72    pub git_ref: String,
73    /// Snapshot-equivalent project metrics for the ref.
74    pub metrics: ProjectMetrics,
75    /// Full balance report for the ref.
76    pub report: ProjectBalanceReport,
77    /// Number of Rust files analyzed.
78    pub total_files: usize,
79    /// Number of modules analyzed.
80    pub module_count: usize,
81    /// Number of couplings found.
82    pub total_couplings: usize,
83}
84
85impl HistoryReport {
86    /// Oldest and newest analyzed points, if any exist.
87    pub fn endpoints(&self) -> Option<(&HistoryPoint, &HistoryPoint)> {
88        match (self.points.first(), self.points.last()) {
89            (Some(first), Some(last)) => Some((first, last)),
90            _ => None,
91        }
92    }
93}
94
95/// Errors that can occur during history analysis.
96#[derive(Debug)]
97pub enum HistoryError {
98    /// The analysis path is not inside a git repository.
99    NotGitRepo,
100    /// A git command failed.
101    Git(String),
102    /// An I/O error occurred.
103    Io(std::io::Error),
104    /// No commits were found in the requested window.
105    NoCommits,
106    /// Coupling analysis failed at a git revision/ref.
107    Analysis(String),
108}
109
110impl std::fmt::Display for HistoryError {
111    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112        match self {
113            HistoryError::NotGitRepo => write!(f, "path is not inside a git repository"),
114            HistoryError::Git(msg) => write!(f, "git command failed: {}", msg),
115            HistoryError::Io(e) => write!(f, "I/O error: {}", e),
116            HistoryError::NoCommits => write!(f, "no commits found in the requested time window"),
117            HistoryError::Analysis(msg) => write!(f, "analysis failed: {}", msg),
118        }
119    }
120}
121
122impl std::error::Error for HistoryError {}
123
124impl From<std::io::Error> for HistoryError {
125    fn from(e: std::io::Error) -> Self {
126        HistoryError::Io(e)
127    }
128}
129
130/// Analyze the coupling health of a project across its recent git history.
131///
132/// Samples up to `max_points` commits evenly across the last `months` months,
133/// re-running snapshot-equivalent analysis at each via a disposable worktree.
134pub fn analyze_history(
135    path: &Path,
136    config: &CompiledConfig,
137    thresholds: &IssueThresholds,
138    months: usize,
139    max_points: usize,
140) -> Result<HistoryReport, HistoryError> {
141    let repo_root = repo_root(path)?;
142    let subpath = relative_subpath(&repo_root, path)?;
143
144    let commits = list_commits(&repo_root, months)?;
145    if commits.is_empty() {
146        return Err(HistoryError::NoCommits);
147    }
148
149    let sampled = sample_evenly(commits.len(), max_points);
150
151    let mut report = HistoryReport {
152        months,
153        ..Default::default()
154    };
155
156    for (idx, &i) in sampled.iter().enumerate() {
157        let (commit, date) = &commits[i];
158        match analyze_revision(
159            &repo_root, &subpath, commit, config, thresholds, months, idx,
160        ) {
161            Ok(point) => report.points.push(HistoryPoint {
162                date: date.clone(),
163                ..point
164            }),
165            Err(reason) => report.skipped.push(SkippedRevision {
166                commit: short_hash(commit),
167                date: date.clone(),
168                reason,
169            }),
170        }
171    }
172
173    Ok(report)
174}
175
176/// Analyze a project at an arbitrary git ref using a disposable worktree.
177///
178/// The analysis path is mapped relative to the current repository root into the
179/// worktree, then analyzed with the same structural, git-volatility, and config
180/// override methodology used for history snapshots.
181pub fn analyze_ref(
182    path: &Path,
183    config: &CompiledConfig,
184    thresholds: &IssueThresholds,
185    git_ref: &str,
186    months: usize,
187    use_git: bool,
188) -> Result<RefAnalysis, HistoryError> {
189    let repo_root = repo_root(path)?;
190    let subpath = relative_subpath(&repo_root, path)?;
191    let seq = WORKTREE_SEQ.fetch_add(1, Ordering::Relaxed);
192    analyze_ref_in_repo(
193        &repo_root,
194        &subpath,
195        git_ref,
196        RefAnalysisParams {
197            config,
198            thresholds,
199            months,
200            use_git,
201            seq,
202        },
203    )
204}
205
206/// Resolve the git repository root containing `path`.
207fn repo_root(path: &Path) -> Result<PathBuf, HistoryError> {
208    let output = Command::new("git")
209        .args(["rev-parse", "--show-toplevel"])
210        .current_dir(path)
211        .output()?;
212
213    if !output.status.success() {
214        return Err(HistoryError::NotGitRepo);
215    }
216
217    let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
218    if root.is_empty() {
219        return Err(HistoryError::NotGitRepo);
220    }
221    let root = PathBuf::from(root);
222    // Canonicalize so it matches the canonicalized analysis path in
223    // `relative_subpath` (e.g. macOS `/var` -> `/private/var`).
224    Ok(root.canonicalize().unwrap_or(root))
225}
226
227/// Compute the analysis path relative to the repo root (for use inside a worktree).
228fn relative_subpath(repo_root: &Path, path: &Path) -> Result<PathBuf, HistoryError> {
229    let abs = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
230    abs.strip_prefix(repo_root)
231        .map(|p| p.to_path_buf())
232        .map_err(|_| {
233            HistoryError::Analysis(format!(
234                "analysis path '{}' is not under git repository root '{}'",
235                abs.display(),
236                repo_root.display()
237            ))
238        })
239}
240
241/// List `(hash, iso-date)` of `.rs`-touching commits in the window, oldest first.
242fn list_commits(repo_root: &Path, months: usize) -> Result<Vec<(String, String)>, HistoryError> {
243    let output = Command::new("git")
244        .args([
245            "log",
246            "--reverse",
247            "--pretty=format:%H|%cs",
248            &format!("--since={} months ago", months),
249            "--",
250            "*.rs",
251        ])
252        .current_dir(repo_root)
253        .output()?;
254
255    if !output.status.success() {
256        return Err(HistoryError::Git(
257            String::from_utf8_lossy(&output.stderr).trim().to_string(),
258        ));
259    }
260
261    let commits = String::from_utf8_lossy(&output.stdout)
262        .lines()
263        .filter_map(|line| {
264            let (hash, date) = line.split_once('|')?;
265            if hash.is_empty() {
266                return None;
267            }
268            Some((hash.to_string(), date.to_string()))
269        })
270        .collect();
271
272    Ok(commits)
273}
274
275/// Analyze a single revision inside a disposable worktree.
276fn analyze_revision(
277    repo_root: &Path,
278    subpath: &Path,
279    commit: &str,
280    config: &CompiledConfig,
281    thresholds: &IssueThresholds,
282    months: usize,
283    seq: usize,
284) -> Result<HistoryPoint, String> {
285    let analysis = analyze_ref_in_repo(
286        repo_root,
287        subpath,
288        commit,
289        RefAnalysisParams {
290            config,
291            thresholds,
292            months,
293            use_git: true,
294            seq,
295        },
296    )
297    .map_err(|e| e.to_string())?;
298    let report = analysis.report;
299    let critical = *report
300        .issues_by_severity
301        .get(&Severity::Critical)
302        .unwrap_or(&0);
303    let high = *report.issues_by_severity.get(&Severity::High).unwrap_or(&0);
304
305    Ok(HistoryPoint {
306        commit: short_hash(commit),
307        date: String::new(), // filled in by caller
308        grade: report.health_grade,
309        average_score: report.average_score,
310        total_couplings: analysis.total_couplings,
311        module_count: analysis.module_count,
312        critical,
313        high,
314    })
315}
316
317struct RefAnalysisParams<'a> {
318    config: &'a CompiledConfig,
319    thresholds: &'a IssueThresholds,
320    months: usize,
321    use_git: bool,
322    seq: usize,
323}
324
325fn analyze_ref_in_repo(
326    repo_root: &Path,
327    subpath: &Path,
328    git_ref: &str,
329    params: RefAnalysisParams<'_>,
330) -> Result<RefAnalysis, HistoryError> {
331    let worktree = Worktree::add(repo_root, git_ref, params.seq)?;
332
333    let analysis_path = worktree.dir.join(subpath);
334    if !analysis_path.exists() {
335        return Err(HistoryError::Analysis(
336            "analysis path does not exist at this revision".to_string(),
337        ));
338    }
339
340    let mut config = rebase_config_root(params.config, repo_root, &worktree.dir);
341    let mut metrics = analyze_workspace_with_config(&analysis_path, &config)
342        .map_err(|e| HistoryError::Analysis(e.to_string()))?;
343
344    // Drift notes assert "the CURRENT config is stale". Historical revisions are
345    // analyzed with today's config against an old tree, so an unmatched pattern
346    // there says nothing about config rot — suppress to avoid false positives.
347    metrics.dead_config_patterns.clear();
348
349    if metrics.modules.is_empty() {
350        return Err(HistoryError::Analysis(
351            "no modules found at this revision".to_string(),
352        ));
353    }
354
355    if params.use_git {
356        let mut volatility = VolatilityAnalyzer::new(params.months);
357        if volatility.analyze(&analysis_path).is_ok() {
358            if let Ok(temporal) = volatility.analyze_temporal_coupling(&analysis_path) {
359                metrics.temporal_couplings = temporal;
360            }
361            metrics.file_changes = volatility.file_changes;
362            metrics.update_volatility_from_git();
363        }
364    }
365
366    if config.has_volatility_overrides() || config.has_subdomain_config() {
367        metrics.apply_config_volatility_overrides(&mut config);
368    }
369
370    let report = analyze_project_balance_with_thresholds(&metrics, params.thresholds);
371    rebase_metrics_paths(&mut metrics, &worktree.dir, repo_root);
372
373    let total_files = metrics.total_files;
374    let module_count = metrics.modules.len();
375    let total_couplings = metrics.couplings.len();
376
377    Ok(RefAnalysis {
378        git_ref: git_ref.to_string(),
379        metrics,
380        report,
381        total_files,
382        module_count,
383        total_couplings,
384    })
385}
386
387fn rebase_metrics_paths(metrics: &mut ProjectMetrics, worktree_root: &Path, repo_root: &Path) {
388    for module in metrics.modules.values_mut() {
389        if let Ok(relative) = module.path.strip_prefix(worktree_root) {
390            module.path = repo_root.join(relative);
391        }
392    }
393
394    for coupling in &mut metrics.couplings {
395        if let Some(path) = &coupling.location.file_path
396            && let Ok(relative) = path.strip_prefix(worktree_root)
397        {
398            coupling.location.file_path = Some(repo_root.join(relative));
399        }
400    }
401}
402
403fn rebase_config_root(
404    config: &CompiledConfig,
405    repo_root: &Path,
406    worktree_root: &Path,
407) -> CompiledConfig {
408    let mut config = config.clone();
409    let Some(config_root) = config.config_root() else {
410        return config;
411    };
412
413    if let Ok(relative) = config_root.strip_prefix(repo_root) {
414        config.set_config_root(Some(worktree_root.join(relative)));
415    }
416
417    config
418}
419
420/// A git worktree that is removed when dropped.
421struct Worktree {
422    repo_root: PathBuf,
423    dir: PathBuf,
424}
425
426impl Worktree {
427    fn add(repo_root: &Path, commit: &str, seq: usize) -> Result<Self, HistoryError> {
428        let dir = std::env::temp_dir().join(format!(
429            "cargo-coupling-hist-{}-{}-{}",
430            std::process::id(),
431            seq,
432            short_hash(commit)
433        ));
434
435        let output = Command::new("git")
436            .args(["worktree", "add", "--detach"])
437            .arg(&dir)
438            .arg(commit)
439            .current_dir(repo_root)
440            .output()?;
441
442        if !output.status.success() {
443            return Err(HistoryError::Git(
444                String::from_utf8_lossy(&output.stderr).trim().to_string(),
445            ));
446        }
447
448        Ok(Worktree {
449            repo_root: repo_root.to_path_buf(),
450            dir,
451        })
452    }
453}
454
455impl Drop for Worktree {
456    fn drop(&mut self) {
457        let _ = Command::new("git")
458            .args(["worktree", "remove", "--force"])
459            .arg(&self.dir)
460            .current_dir(&self.repo_root)
461            .output();
462    }
463}
464
465/// Abbreviate a commit/ref to a path-safe 7-character label.
466fn short_hash(hash: &str) -> String {
467    hash.chars()
468        .take(7)
469        .map(|c| {
470            if c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-') {
471                c
472            } else {
473                '_'
474            }
475        })
476        .collect()
477}
478
479/// Pick up to `max` evenly-spaced indices from `[0, len)`.
480///
481/// Always includes the first and last index when `len >= 2` so the endpoints
482/// of the timeline are preserved. Returns all indices when `len <= max`.
483pub(crate) fn sample_evenly(len: usize, max: usize) -> Vec<usize> {
484    // Covers the empty cases too: `len <= max` is true when `len == 0`, and the
485    // loop below produces nothing when `max == 0`, so no separate guard is needed.
486    if len <= max {
487        return (0..len).collect();
488    }
489    if max == 1 {
490        return vec![len - 1];
491    }
492
493    let mut indices = Vec::with_capacity(max);
494    for k in 0..max {
495        // Spread k across [0, len-1] inclusive.
496        let idx = k * (len - 1) / (max - 1);
497        if indices.last() != Some(&idx) {
498            indices.push(idx);
499        }
500    }
501    indices
502}
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507
508    #[test]
509    fn sample_returns_all_when_under_limit() {
510        assert_eq!(sample_evenly(3, 10), vec![0, 1, 2]);
511        assert_eq!(sample_evenly(5, 5), vec![0, 1, 2, 3, 4]);
512    }
513
514    #[test]
515    fn sample_includes_endpoints() {
516        let s = sample_evenly(100, 5);
517        assert_eq!(s.first(), Some(&0));
518        assert_eq!(s.last(), Some(&99));
519        assert!(s.len() <= 5);
520    }
521
522    #[test]
523    fn sample_is_monotonic_and_unique() {
524        let s = sample_evenly(50, 8);
525        for w in s.windows(2) {
526            assert!(w[0] < w[1], "indices must be strictly increasing: {:?}", s);
527        }
528    }
529
530    #[test]
531    fn sample_edge_cases() {
532        assert_eq!(sample_evenly(0, 5), Vec::<usize>::new());
533        assert_eq!(sample_evenly(5, 0), Vec::<usize>::new());
534        assert_eq!(sample_evenly(10, 1), vec![9]);
535        assert_eq!(sample_evenly(1, 5), vec![0]);
536    }
537
538    #[test]
539    fn short_hash_truncates() {
540        assert_eq!(short_hash("0123456789abcdef"), "0123456");
541        assert_eq!(short_hash("abc"), "abc");
542        assert_eq!(short_hash("feat/foo"), "feat_fo");
543    }
544
545    #[test]
546    fn error_display_messages() {
547        assert_eq!(
548            HistoryError::NotGitRepo.to_string(),
549            "path is not inside a git repository"
550        );
551        assert_eq!(
552            HistoryError::NoCommits.to_string(),
553            "no commits found in the requested time window"
554        );
555        assert!(
556            HistoryError::Git("boom".into())
557                .to_string()
558                .contains("boom")
559        );
560    }
561
562    #[test]
563    fn relative_subpath_strips_root() {
564        // `/repo` does not exist, so canonicalize falls back to the path as-is
565        // (`/repo/src`), and strip_prefix then yields the relative `src`.
566        let sub = relative_subpath(Path::new("/repo"), Path::new("/repo/src")).unwrap();
567        assert_eq!(sub, Path::new("src"));
568    }
569
570    #[test]
571    fn relative_subpath_errors_outside_root() {
572        let err = relative_subpath(Path::new("/repo"), Path::new("/elsewhere/src")).unwrap_err();
573        assert!(err.to_string().contains("is not under git repository root"));
574    }
575}