1use std::collections::HashMap;
2use std::io::{BufRead, Write};
3use std::path::Path;
4
5use anyhow::{Context, Result};
6use chrono::Utc;
7use serde::{Deserialize, Serialize};
8
9use crate::metrics::AnalysisResult;
10use crate::types::Violation;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct AnalysisSnapshot {
15 pub timestamp: String,
16 pub git_commit: Option<String>,
17 pub git_branch: Option<String>,
18 pub result: AnalysisResult,
19}
20
21#[derive(Debug, Clone)]
23pub struct RuleTrend {
24 pub rule_id: String,
25 pub previous_count: usize,
26 pub current_count: usize,
27 pub delta: i64,
28}
29
30#[derive(Debug, Clone)]
32pub struct TrendReport {
33 pub previous_score: f64,
34 pub current_score: f64,
35 pub score_delta: f64,
36 pub previous_violations: usize,
37 pub current_violations: usize,
38 pub violation_delta: i64,
39 pub rule_trends: Vec<RuleTrend>,
40}
41
42pub fn save_snapshot(project_path: &Path, result: &AnalysisResult) -> Result<()> {
44 let dir = project_path.join(".boundary");
45 std::fs::create_dir_all(&dir).with_context(|| format!("failed to create {}", dir.display()))?;
46
47 let snapshot = AnalysisSnapshot {
48 timestamp: Utc::now().to_rfc3339(),
49 git_commit: get_git_commit(project_path),
50 git_branch: get_git_branch(project_path),
51 result: AnalysisResult {
52 score: result.score.clone(),
53 violations: result.violations.clone(),
54 component_count: result.component_count,
55 dependency_count: result.dependency_count,
56 files_analyzed: result.files_analyzed,
57 metrics: result.metrics.clone(),
58 package_metrics: result.package_metrics.clone(),
59 pattern_detection: result.pattern_detection.clone(),
60 },
61 };
62
63 let line = serde_json::to_string(&snapshot).context("failed to serialize snapshot")?;
64
65 let history_path = dir.join("history.ndjson");
66 let mut file = std::fs::OpenOptions::new()
67 .create(true)
68 .append(true)
69 .open(&history_path)
70 .with_context(|| format!("failed to open {}", history_path.display()))?;
71
72 writeln!(file, "{line}").context("failed to write snapshot")?;
73
74 eprintln!("Snapshot saved to {}", history_path.display());
75
76 Ok(())
77}
78
79fn count_by_rule(violations: &[Violation]) -> HashMap<String, usize> {
81 let mut counts = HashMap::new();
82 for v in violations {
83 *counts.entry(v.kind.rule_id().to_string()).or_insert(0) += 1;
84 }
85 counts
86}
87
88fn build_rule_trends(
90 previous: &HashMap<String, usize>,
91 current: &HashMap<String, usize>,
92) -> Vec<RuleTrend> {
93 let mut all_rules: std::collections::BTreeSet<&String> = std::collections::BTreeSet::new();
94 all_rules.extend(previous.keys());
95 all_rules.extend(current.keys());
96
97 all_rules
98 .into_iter()
99 .map(|rule_id| {
100 let prev = *previous.get(rule_id).unwrap_or(&0);
101 let curr = *current.get(rule_id).unwrap_or(&0);
102 RuleTrend {
103 rule_id: rule_id.clone(),
104 previous_count: prev,
105 current_count: curr,
106 delta: curr as i64 - prev as i64,
107 }
108 })
109 .collect()
110}
111
112pub fn check_regression(
115 project_path: &Path,
116 current_result: &AnalysisResult,
117) -> Result<Option<TrendReport>> {
118 let history_path = project_path.join(".boundary/history.ndjson");
119 if !history_path.exists() {
120 return Ok(None);
121 }
122
123 let last = load_last_snapshot(&history_path)?;
124 let Some(last) = last else {
125 return Ok(None);
126 };
127
128 let prev_overall = last.result.score.as_ref().map(|s| s.overall).unwrap_or(0.0);
129 let curr_overall = current_result
130 .score
131 .as_ref()
132 .map(|s| s.overall)
133 .unwrap_or(0.0);
134
135 let prev_by_rule = count_by_rule(&last.result.violations);
136 let curr_by_rule = count_by_rule(¤t_result.violations);
137 let rule_trends = build_rule_trends(&prev_by_rule, &curr_by_rule);
138
139 let trend = TrendReport {
140 previous_score: prev_overall,
141 current_score: curr_overall,
142 score_delta: curr_overall - prev_overall,
143 previous_violations: last.result.violations.len(),
144 current_violations: current_result.violations.len(),
145 violation_delta: current_result.violations.len() as i64
146 - last.result.violations.len() as i64,
147 rule_trends,
148 };
149
150 if trend.score_delta < 0.0 {
151 Ok(Some(trend))
152 } else {
153 Ok(None)
154 }
155}
156
157fn load_last_snapshot(path: &Path) -> Result<Option<AnalysisSnapshot>> {
159 let file =
160 std::fs::File::open(path).with_context(|| format!("failed to open {}", path.display()))?;
161 let reader = std::io::BufReader::new(file);
162
163 let mut last: Option<AnalysisSnapshot> = None;
164 for line in reader.lines() {
165 let line = line.context("failed to read line from history")?;
166 let trimmed = line.trim();
167 if trimmed.is_empty() {
168 continue;
169 }
170 match serde_json::from_str::<AnalysisSnapshot>(trimmed) {
171 Ok(snapshot) => last = Some(snapshot),
172 Err(e) => {
173 eprintln!("Warning: skipping malformed history line: {e}");
174 }
175 }
176 }
177
178 Ok(last)
179}
180
181fn get_git_commit(project_path: &Path) -> Option<String> {
183 std::process::Command::new("git")
184 .args(["rev-parse", "HEAD"])
185 .current_dir(project_path)
186 .output()
187 .ok()
188 .and_then(|o| {
189 if o.status.success() {
190 Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
191 } else {
192 None
193 }
194 })
195}
196
197fn get_git_branch(project_path: &Path) -> Option<String> {
199 std::process::Command::new("git")
200 .args(["rev-parse", "--abbrev-ref", "HEAD"])
201 .current_dir(project_path)
202 .output()
203 .ok()
204 .and_then(|o| {
205 if o.status.success() {
206 Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
207 } else {
208 None
209 }
210 })
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216 use crate::metrics::{AnalysisResult, ArchitectureScore};
217 use crate::types::{ArchLayer, Severity, SourceLocation, ViolationKind};
218 use std::path::PathBuf;
219
220 fn sample_result(score: f64) -> AnalysisResult {
221 AnalysisResult {
222 score: Some(ArchitectureScore {
223 overall: score,
224 structural_presence: 100.0,
225 layer_conformance: score,
226 dependency_compliance: score,
227 interface_coverage: score,
228 }),
229 violations: vec![],
230 component_count: 5,
231 dependency_count: 3,
232 files_analyzed: 5,
233 metrics: None,
234 package_metrics: vec![],
235 pattern_detection: None,
236 }
237 }
238
239 fn make_violation(kind: ViolationKind) -> Violation {
240 Violation {
241 kind,
242 severity: Severity::Error,
243 location: SourceLocation {
244 file: PathBuf::from("test.go"),
245 line: 1,
246 column: 1,
247 },
248 message: "test".to_string(),
249 suggestion: None,
250 }
251 }
252
253 fn sample_result_with_violations(score: f64, kinds: Vec<ViolationKind>) -> AnalysisResult {
254 let violations = kinds.into_iter().map(make_violation).collect();
255 AnalysisResult {
256 score: Some(ArchitectureScore {
257 overall: score,
258 structural_presence: 100.0,
259 layer_conformance: score,
260 dependency_compliance: score,
261 interface_coverage: score,
262 }),
263 violations,
264 component_count: 5,
265 dependency_count: 3,
266 files_analyzed: 5,
267 metrics: None,
268 package_metrics: vec![],
269 pattern_detection: None,
270 }
271 }
272
273 #[test]
274 fn test_save_and_check_no_regression() {
275 let dir = tempfile::tempdir().unwrap();
276 let result = sample_result(80.0);
277 save_snapshot(dir.path(), &result).unwrap();
278
279 let better_result = sample_result(90.0);
280 let trend = check_regression(dir.path(), &better_result).unwrap();
281 assert!(trend.is_none(), "no regression when score improves");
282 }
283
284 #[test]
285 fn test_save_and_check_regression() {
286 let dir = tempfile::tempdir().unwrap();
287 let result = sample_result(90.0);
288 save_snapshot(dir.path(), &result).unwrap();
289
290 let worse_result = sample_result(70.0);
291 let trend = check_regression(dir.path(), &worse_result).unwrap();
292 assert!(trend.is_some(), "should detect regression");
293 let trend = trend.unwrap();
294 assert_eq!(trend.previous_score, 90.0);
295 assert_eq!(trend.current_score, 70.0);
296 assert_eq!(trend.score_delta, -20.0);
297 }
298
299 #[test]
300 fn test_no_history_file() {
301 let dir = tempfile::tempdir().unwrap();
302 let result = sample_result(80.0);
303 let trend = check_regression(dir.path(), &result).unwrap();
304 assert!(trend.is_none(), "no regression when no history exists");
305 }
306
307 #[test]
308 fn test_count_by_rule() {
309 let violations = vec![
310 make_violation(ViolationKind::LayerBoundary {
311 from_layer: ArchLayer::Domain,
312 to_layer: ArchLayer::Infrastructure,
313 }),
314 make_violation(ViolationKind::LayerBoundary {
315 from_layer: ArchLayer::Domain,
316 to_layer: ArchLayer::Infrastructure,
317 }),
318 make_violation(ViolationKind::MissingPort {
319 adapter_name: "X".into(),
320 }),
321 ];
322 let counts = count_by_rule(&violations);
323 assert_eq!(counts.get("L001"), Some(&2));
324 assert_eq!(counts.get("PA001"), Some(&1));
325 assert_eq!(counts.get("PA003"), None);
326 }
327
328 #[test]
329 fn test_build_rule_trends_new_rule_appears() {
330 let prev = HashMap::from([("L001".to_string(), 2usize)]);
331 let curr = HashMap::from([("L001".to_string(), 2usize), ("PA001".to_string(), 1usize)]);
332 let trends = build_rule_trends(&prev, &curr);
333 assert_eq!(trends.len(), 2);
334
335 let l001 = trends.iter().find(|t| t.rule_id == "L001").unwrap();
336 assert_eq!(l001.delta, 0);
337
338 let pa001 = trends.iter().find(|t| t.rule_id == "PA001").unwrap();
339 assert_eq!(pa001.previous_count, 0);
340 assert_eq!(pa001.current_count, 1);
341 assert_eq!(pa001.delta, 1);
342 }
343
344 #[test]
345 fn test_build_rule_trends_rule_disappears() {
346 let prev = HashMap::from([("L001".to_string(), 3usize), ("PA001".to_string(), 2usize)]);
347 let curr = HashMap::from([("L001".to_string(), 1usize)]);
348 let trends = build_rule_trends(&prev, &curr);
349
350 let l001 = trends.iter().find(|t| t.rule_id == "L001").unwrap();
351 assert_eq!(l001.delta, -2);
352
353 let pa001 = trends.iter().find(|t| t.rule_id == "PA001").unwrap();
354 assert_eq!(pa001.previous_count, 2);
355 assert_eq!(pa001.current_count, 0);
356 assert_eq!(pa001.delta, -2);
357 }
358
359 #[test]
360 fn test_regression_includes_rule_trends() {
361 let dir = tempfile::tempdir().unwrap();
362
363 let prev = sample_result_with_violations(
365 90.0,
366 vec![ViolationKind::MissingPort {
367 adapter_name: "X".into(),
368 }],
369 );
370 save_snapshot(dir.path(), &prev).unwrap();
371
372 let curr = sample_result_with_violations(
374 70.0,
375 vec![
376 ViolationKind::MissingPort {
377 adapter_name: "X".into(),
378 },
379 ViolationKind::LayerBoundary {
380 from_layer: ArchLayer::Domain,
381 to_layer: ArchLayer::Infrastructure,
382 },
383 ],
384 );
385
386 let trend = check_regression(dir.path(), &curr).unwrap();
387 assert!(trend.is_some(), "should detect regression");
388 let trend = trend.unwrap();
389
390 assert!(!trend.rule_trends.is_empty(), "should have rule trends");
391
392 let l001 = trend
393 .rule_trends
394 .iter()
395 .find(|t| t.rule_id == "L001")
396 .unwrap();
397 assert_eq!(l001.previous_count, 0);
398 assert_eq!(l001.current_count, 1);
399 assert_eq!(l001.delta, 1);
400
401 let pa001 = trend
402 .rule_trends
403 .iter()
404 .find(|t| t.rule_id == "PA001")
405 .unwrap();
406 assert_eq!(pa001.previous_count, 1);
407 assert_eq!(pa001.current_count, 1);
408 assert_eq!(pa001.delta, 0);
409 }
410}