Skip to main content

boundary_core/
evolution.rs

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/// A snapshot of an analysis run, stored for evolution tracking.
13#[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/// Per-rule violation count change between two snapshots.
22#[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/// Trend report comparing two snapshots.
31#[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
42/// Save an analysis snapshot to `.boundary/history.ndjson`.
43pub 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
79/// Count violations grouped by rule ID.
80fn 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
88/// Build per-rule trend data from previous and current violation counts.
89fn 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
112/// Check if the current score regresses compared to the last snapshot.
113/// Returns Some(TrendReport) if there's a regression, None otherwise.
114pub 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(&current_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
157/// Load the most recent snapshot from the NDJSON history file.
158fn 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
181/// Get the current git commit hash, if available.
182fn 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
197/// Get the current git branch name, if available.
198fn 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        // Save a high-scoring snapshot with violations
364        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        // Current result is worse score with different violations
373        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}