Skip to main content

boundary_core/
evolution.rs

1use std::io::{BufRead, Write};
2use std::path::Path;
3
4use anyhow::{Context, Result};
5use chrono::Utc;
6use serde::{Deserialize, Serialize};
7
8use crate::metrics::AnalysisResult;
9
10/// A snapshot of an analysis run, stored for evolution tracking.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct AnalysisSnapshot {
13    pub timestamp: String,
14    pub git_commit: Option<String>,
15    pub git_branch: Option<String>,
16    pub result: AnalysisResult,
17}
18
19/// Trend report comparing two snapshots.
20#[derive(Debug, Clone)]
21pub struct TrendReport {
22    pub previous_score: f64,
23    pub current_score: f64,
24    pub score_delta: f64,
25    pub previous_violations: usize,
26    pub current_violations: usize,
27    pub violation_delta: i64,
28}
29
30/// Save an analysis snapshot to `.boundary/history.ndjson`.
31pub fn save_snapshot(project_path: &Path, result: &AnalysisResult) -> Result<()> {
32    let dir = project_path.join(".boundary");
33    std::fs::create_dir_all(&dir).with_context(|| format!("failed to create {}", dir.display()))?;
34
35    let snapshot = AnalysisSnapshot {
36        timestamp: Utc::now().to_rfc3339(),
37        git_commit: get_git_commit(project_path),
38        git_branch: get_git_branch(project_path),
39        result: AnalysisResult {
40            score: result.score.clone(),
41            violations: result.violations.clone(),
42            component_count: result.component_count,
43            dependency_count: result.dependency_count,
44            metrics: result.metrics.clone(),
45        },
46    };
47
48    let line = serde_json::to_string(&snapshot).context("failed to serialize snapshot")?;
49
50    let history_path = dir.join("history.ndjson");
51    let mut file = std::fs::OpenOptions::new()
52        .create(true)
53        .append(true)
54        .open(&history_path)
55        .with_context(|| format!("failed to open {}", history_path.display()))?;
56
57    writeln!(file, "{line}").context("failed to write snapshot")?;
58
59    eprintln!("Snapshot saved to {}", history_path.display());
60
61    Ok(())
62}
63
64/// Check if the current score regresses compared to the last snapshot.
65/// Returns Some(TrendReport) if there's a regression, None otherwise.
66pub fn check_regression(
67    project_path: &Path,
68    current_result: &AnalysisResult,
69) -> Result<Option<TrendReport>> {
70    let history_path = project_path.join(".boundary/history.ndjson");
71    if !history_path.exists() {
72        return Ok(None);
73    }
74
75    let last = load_last_snapshot(&history_path)?;
76    let Some(last) = last else {
77        return Ok(None);
78    };
79
80    let trend = TrendReport {
81        previous_score: last.result.score.overall,
82        current_score: current_result.score.overall,
83        score_delta: current_result.score.overall - last.result.score.overall,
84        previous_violations: last.result.violations.len(),
85        current_violations: current_result.violations.len(),
86        violation_delta: current_result.violations.len() as i64
87            - last.result.violations.len() as i64,
88    };
89
90    if trend.score_delta < 0.0 {
91        Ok(Some(trend))
92    } else {
93        Ok(None)
94    }
95}
96
97/// Load the most recent snapshot from the NDJSON history file.
98fn load_last_snapshot(path: &Path) -> Result<Option<AnalysisSnapshot>> {
99    let file =
100        std::fs::File::open(path).with_context(|| format!("failed to open {}", path.display()))?;
101    let reader = std::io::BufReader::new(file);
102
103    let mut last: Option<AnalysisSnapshot> = None;
104    for line in reader.lines() {
105        let line = line.context("failed to read line from history")?;
106        let trimmed = line.trim();
107        if trimmed.is_empty() {
108            continue;
109        }
110        match serde_json::from_str::<AnalysisSnapshot>(trimmed) {
111            Ok(snapshot) => last = Some(snapshot),
112            Err(e) => {
113                eprintln!("Warning: skipping malformed history line: {e}");
114            }
115        }
116    }
117
118    Ok(last)
119}
120
121/// Get the current git commit hash, if available.
122fn get_git_commit(project_path: &Path) -> Option<String> {
123    std::process::Command::new("git")
124        .args(["rev-parse", "HEAD"])
125        .current_dir(project_path)
126        .output()
127        .ok()
128        .and_then(|o| {
129            if o.status.success() {
130                Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
131            } else {
132                None
133            }
134        })
135}
136
137/// Get the current git branch name, if available.
138fn get_git_branch(project_path: &Path) -> Option<String> {
139    std::process::Command::new("git")
140        .args(["rev-parse", "--abbrev-ref", "HEAD"])
141        .current_dir(project_path)
142        .output()
143        .ok()
144        .and_then(|o| {
145            if o.status.success() {
146                Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
147            } else {
148                None
149            }
150        })
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    use crate::metrics::{AnalysisResult, ArchitectureScore};
157
158    fn sample_result(score: f64) -> AnalysisResult {
159        AnalysisResult {
160            score: ArchitectureScore {
161                overall: score,
162                layer_isolation: score,
163                dependency_direction: score,
164                interface_coverage: score,
165            },
166            violations: vec![],
167            component_count: 5,
168            dependency_count: 3,
169            metrics: None,
170        }
171    }
172
173    #[test]
174    fn test_save_and_check_no_regression() {
175        let dir = tempfile::tempdir().unwrap();
176        let result = sample_result(80.0);
177        save_snapshot(dir.path(), &result).unwrap();
178
179        let better_result = sample_result(90.0);
180        let trend = check_regression(dir.path(), &better_result).unwrap();
181        assert!(trend.is_none(), "no regression when score improves");
182    }
183
184    #[test]
185    fn test_save_and_check_regression() {
186        let dir = tempfile::tempdir().unwrap();
187        let result = sample_result(90.0);
188        save_snapshot(dir.path(), &result).unwrap();
189
190        let worse_result = sample_result(70.0);
191        let trend = check_regression(dir.path(), &worse_result).unwrap();
192        assert!(trend.is_some(), "should detect regression");
193        let trend = trend.unwrap();
194        assert_eq!(trend.previous_score, 90.0);
195        assert_eq!(trend.current_score, 70.0);
196        assert_eq!(trend.score_delta, -20.0);
197    }
198
199    #[test]
200    fn test_no_history_file() {
201        let dir = tempfile::tempdir().unwrap();
202        let result = sample_result(80.0);
203        let trend = check_regression(dir.path(), &result).unwrap();
204        assert!(trend.is_none(), "no regression when no history exists");
205    }
206}