boundary_core/
evolution.rs1use 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#[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#[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
30pub 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
64pub 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
97fn 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
121fn 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
137fn 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}