Skip to main content

bvr/analysis/
drift.rs

1use std::collections::HashMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use serde::{Deserialize, Serialize};
6
7use super::graph::{GraphMetrics, IssueGraph};
8use crate::model::Issue;
9
10/// Tunable thresholds for drift detection severity levels.
11///
12/// All percentage values are positive (e.g., 50.0 = 50%).
13#[derive(Debug, Clone)]
14pub struct DriftThresholds {
15    /// Density growth >= this % triggers a warning.
16    pub density_warning_pct: f64,
17    /// Density growth >= this % triggers an info alert.
18    pub density_info_pct: f64,
19    /// Blocked count increase >= this count triggers a warning.
20    pub blocked_increase_threshold: i64,
21    /// Actionable count decrease >= this % triggers a warning.
22    pub actionable_decrease_pct: f64,
23    /// Node/edge count change >= this % triggers a warning.
24    pub structure_change_pct: f64,
25    /// Top-N ranking items changed >= this count triggers a warning.
26    pub ranking_change_threshold: usize,
27}
28
29impl Default for DriftThresholds {
30    fn default() -> Self {
31        Self {
32            density_warning_pct: 50.0,
33            density_info_pct: 20.0,
34            blocked_increase_threshold: 5,
35            actionable_decrease_pct: 30.0,
36            structure_change_pct: 25.0,
37            ranking_change_threshold: 3,
38        }
39    }
40}
41
42// ---------------------------------------------------------------------------
43// Baseline
44// ---------------------------------------------------------------------------
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct BaselineMetricItem {
48    pub id: String,
49    pub value: f64,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct BaselineTopMetrics {
54    #[serde(default)]
55    pub pagerank: Vec<BaselineMetricItem>,
56    #[serde(default)]
57    pub betweenness: Vec<BaselineMetricItem>,
58    #[serde(default)]
59    pub hubs: Vec<BaselineMetricItem>,
60    #[serde(default)]
61    pub authorities: Vec<BaselineMetricItem>,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct BaselineGraphStats {
66    pub node_count: usize,
67    pub edge_count: usize,
68    pub density: f64,
69    pub open_count: usize,
70    pub closed_count: usize,
71    pub blocked_count: usize,
72    pub cycle_count: usize,
73    pub actionable_count: usize,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct Baseline {
78    pub version: u32,
79    pub created_at: String,
80    #[serde(default, skip_serializing_if = "String::is_empty")]
81    pub description: String,
82    pub stats: BaselineGraphStats,
83    pub top_metrics: BaselineTopMetrics,
84    pub cycles: Vec<Vec<String>>,
85}
86
87impl Baseline {
88    /// Build a baseline snapshot from current issues, graph, and metrics.
89    pub fn from_current(
90        issues: &[Issue],
91        graph: &IssueGraph,
92        metrics: &GraphMetrics,
93        description: &str,
94    ) -> Self {
95        let open_count = issues.iter().filter(|i| i.is_open_like()).count();
96        let closed_count = issues.len() - open_count;
97        let blocked_count = issues
98            .iter()
99            .filter(|i| i.is_open_like() && !graph.open_blockers(&i.id).is_empty())
100            .count();
101        let actionable_count = graph.actionable_ids().len();
102
103        let n = graph.node_count();
104        let e = graph.edge_count();
105        let density = if n > 1 {
106            e as f64 / (n as f64 * (n as f64 - 1.0))
107        } else {
108            0.0
109        };
110
111        let top_n = 10;
112
113        Self {
114            version: 1,
115            created_at: chrono_now(),
116            description: description.to_string(),
117            stats: BaselineGraphStats {
118                node_count: n,
119                edge_count: e,
120                density,
121                open_count,
122                closed_count,
123                blocked_count,
124                cycle_count: metrics.cycles.len(),
125                actionable_count,
126            },
127            top_metrics: BaselineTopMetrics {
128                pagerank: top_metric_items(&metrics.pagerank, top_n),
129                betweenness: top_metric_items(&metrics.betweenness, top_n),
130                hubs: top_metric_items(&metrics.hubs, top_n),
131                authorities: top_metric_items(&metrics.authorities, top_n),
132            },
133            cycles: metrics.cycles.clone(),
134        }
135    }
136
137    /// Save baseline to `.bv/baseline.json` under the given project directory.
138    pub fn save(&self, project_dir: &Path) -> Result<PathBuf, String> {
139        let dir = project_dir.join(".bv");
140        fs::create_dir_all(&dir).map_err(|e| format!("failed to create .bv dir: {e}"))?;
141
142        let path = dir.join("baseline.json");
143        let json = serde_json::to_string_pretty(self)
144            .map_err(|e| format!("failed to serialize baseline: {e}"))?;
145        fs::write(&path, json).map_err(|e| format!("failed to write baseline: {e}"))?;
146        Ok(path)
147    }
148
149    /// Load baseline from `.bv/baseline.json` under the given project directory.
150    pub fn load(project_dir: &Path) -> Result<Self, String> {
151        let path = project_dir.join(".bv").join("baseline.json");
152        let content =
153            fs::read_to_string(&path).map_err(|e| format!("failed to read baseline: {e}"))?;
154        serde_json::from_str(&content).map_err(|e| format!("failed to parse baseline: {e}"))
155    }
156}
157
158fn top_metric_items(values: &HashMap<String, f64>, limit: usize) -> Vec<BaselineMetricItem> {
159    let mut items: Vec<BaselineMetricItem> = values
160        .iter()
161        .map(|(id, value)| BaselineMetricItem {
162            id: id.clone(),
163            value: *value,
164        })
165        .collect();
166
167    items.sort_by(|a, b| b.value.total_cmp(&a.value).then_with(|| a.id.cmp(&b.id)));
168    items.truncate(limit);
169    items
170}
171
172// ---------------------------------------------------------------------------
173// Drift Detection
174// ---------------------------------------------------------------------------
175
176#[derive(Debug, Clone, Serialize)]
177pub struct DriftAlert {
178    #[serde(rename = "type")]
179    pub alert_type: String,
180    pub severity: String,
181    pub message: String,
182    pub baseline_value: f64,
183    pub current_value: f64,
184    pub delta: f64,
185    #[serde(skip_serializing_if = "Vec::is_empty")]
186    pub details: Vec<String>,
187}
188
189#[derive(Debug, Clone, Serialize)]
190pub struct DriftSummary {
191    pub critical: usize,
192    pub warning: usize,
193    pub info: usize,
194}
195
196#[derive(Debug, Clone, Serialize)]
197pub struct DriftBaselineInfo {
198    pub created_at: String,
199    #[serde(skip_serializing_if = "String::is_empty")]
200    pub description: String,
201}
202
203#[derive(Debug, Clone, Serialize)]
204pub struct DriftResult {
205    pub has_drift: bool,
206    pub exit_code: u8,
207    pub summary: DriftSummary,
208    pub alerts: Vec<DriftAlert>,
209    pub baseline: DriftBaselineInfo,
210}
211
212#[derive(Debug, Serialize)]
213pub struct RobotDriftOutput {
214    #[serde(flatten)]
215    pub envelope: crate::robot::RobotEnvelope,
216    #[serde(flatten)]
217    pub result: DriftResult,
218}
219
220fn signed_usize_delta(current: usize, baseline: usize) -> i64 {
221    if current >= baseline {
222        i64::try_from(current - baseline).unwrap_or(i64::MAX)
223    } else {
224        -i64::try_from(baseline - current).unwrap_or(i64::MAX)
225    }
226}
227
228/// Compare current state against a saved baseline and produce drift alerts.
229pub fn compute_drift(
230    baseline: &Baseline,
231    issues: &[Issue],
232    graph: &IssueGraph,
233    metrics: &GraphMetrics,
234) -> DriftResult {
235    let current = Baseline::from_current(issues, graph, metrics, "");
236    let mut alerts = Vec::new();
237
238    // 1. New cycles (CRITICAL)
239    let new_cycles = current
240        .stats
241        .cycle_count
242        .saturating_sub(baseline.stats.cycle_count);
243    if new_cycles > 0 {
244        let details: Vec<String> = current
245            .cycles
246            .iter()
247            .skip(baseline.cycles.len())
248            .map(|cycle| cycle.join(" -> "))
249            .collect();
250        alerts.push(DriftAlert {
251            alert_type: "new_cycle".to_string(),
252            severity: "critical".to_string(),
253            message: format!("{new_cycles} new cycle(s) detected"),
254            baseline_value: baseline.stats.cycle_count as f64,
255            current_value: current.stats.cycle_count as f64,
256            delta: new_cycles as f64,
257            details,
258        });
259    }
260
261    // 2. Density growth (WARNING if >= 50% increase)
262    if baseline.stats.density > 0.0 {
263        let pct_change =
264            ((current.stats.density - baseline.stats.density) / baseline.stats.density) * 100.0;
265        if pct_change >= 50.0 {
266            alerts.push(DriftAlert {
267                alert_type: "density_growth".to_string(),
268                severity: "warning".to_string(),
269                message: format!("Graph density increased by {pct_change:.0}%"),
270                baseline_value: baseline.stats.density,
271                current_value: current.stats.density,
272                delta: pct_change,
273                details: Vec::new(),
274            });
275        } else if pct_change >= 20.0 {
276            alerts.push(DriftAlert {
277                alert_type: "density_growth".to_string(),
278                severity: "info".to_string(),
279                message: format!("Graph density increased by {pct_change:.0}%"),
280                baseline_value: baseline.stats.density,
281                current_value: current.stats.density,
282                delta: pct_change,
283                details: Vec::new(),
284            });
285        }
286    }
287
288    // 3. Blocked count increase (WARNING if delta >= 5)
289    let blocked_delta =
290        signed_usize_delta(current.stats.blocked_count, baseline.stats.blocked_count);
291    if blocked_delta >= 5 {
292        alerts.push(DriftAlert {
293            alert_type: "blocked_increase".to_string(),
294            severity: "warning".to_string(),
295            message: format!(
296                "Blocked issues increased by {blocked_delta} ({} -> {})",
297                baseline.stats.blocked_count, current.stats.blocked_count
298            ),
299            baseline_value: baseline.stats.blocked_count as f64,
300            current_value: current.stats.blocked_count as f64,
301            delta: blocked_delta as f64,
302            details: Vec::new(),
303        });
304    }
305
306    // 4. Actionable count changes (WARNING if decrease >= 30%)
307    if baseline.stats.actionable_count > 0 {
308        let pct_change = ((current.stats.actionable_count as f64
309            - baseline.stats.actionable_count as f64)
310            / baseline.stats.actionable_count as f64)
311            * 100.0;
312        if pct_change <= -30.0 {
313            alerts.push(DriftAlert {
314                alert_type: "actionable_change".to_string(),
315                severity: "warning".to_string(),
316                message: format!(
317                    "Actionable issues decreased by {:.0}% ({} -> {})",
318                    -pct_change, baseline.stats.actionable_count, current.stats.actionable_count
319                ),
320                baseline_value: baseline.stats.actionable_count as f64,
321                current_value: current.stats.actionable_count as f64,
322                delta: pct_change,
323                details: Vec::new(),
324            });
325        } else if pct_change >= 20.0 {
326            alerts.push(DriftAlert {
327                alert_type: "actionable_change".to_string(),
328                severity: "info".to_string(),
329                message: format!(
330                    "Actionable issues increased by {pct_change:.0}% ({} -> {})",
331                    baseline.stats.actionable_count, current.stats.actionable_count
332                ),
333                baseline_value: baseline.stats.actionable_count as f64,
334                current_value: current.stats.actionable_count as f64,
335                delta: pct_change,
336                details: Vec::new(),
337            });
338        }
339    }
340
341    // 5. Node count change (INFO)
342    let node_delta = signed_usize_delta(current.stats.node_count, baseline.stats.node_count);
343    if node_delta != 0 && baseline.stats.node_count > 0 {
344        let pct = (node_delta.unsigned_abs() as f64 / baseline.stats.node_count as f64) * 100.0;
345        if pct >= 25.0 {
346            let direction = if node_delta > 0 {
347                "increased"
348            } else {
349                "decreased"
350            };
351            alerts.push(DriftAlert {
352                alert_type: "node_count_change".to_string(),
353                severity: "info".to_string(),
354                message: format!(
355                    "Issue count {direction} by {pct:.0}% ({} -> {})",
356                    baseline.stats.node_count, current.stats.node_count
357                ),
358                baseline_value: baseline.stats.node_count as f64,
359                current_value: current.stats.node_count as f64,
360                delta: node_delta as f64,
361                details: Vec::new(),
362            });
363        }
364    }
365
366    // 6. Edge count change (INFO)
367    let edge_delta = signed_usize_delta(current.stats.edge_count, baseline.stats.edge_count);
368    if edge_delta != 0 && baseline.stats.edge_count > 0 {
369        let pct = (edge_delta.unsigned_abs() as f64 / baseline.stats.edge_count as f64) * 100.0;
370        if pct >= 25.0 {
371            let direction = if edge_delta > 0 {
372                "increased"
373            } else {
374                "decreased"
375            };
376            alerts.push(DriftAlert {
377                alert_type: "edge_count_change".to_string(),
378                severity: "info".to_string(),
379                message: format!(
380                    "Dependency count {direction} by {pct:.0}% ({} -> {})",
381                    baseline.stats.edge_count, current.stats.edge_count
382                ),
383                baseline_value: baseline.stats.edge_count as f64,
384                current_value: current.stats.edge_count as f64,
385                delta: edge_delta as f64,
386                details: Vec::new(),
387            });
388        }
389    }
390
391    // 7. PageRank ranking shift (WARNING if top IDs changed significantly)
392    check_metric_drift(
393        "pagerank_change",
394        &baseline.top_metrics.pagerank,
395        &current.top_metrics.pagerank,
396        &mut alerts,
397    );
398
399    // Sort alerts by severity (critical > warning > info), then by type
400    alerts.sort_by(|a, b| {
401        severity_rank(&a.severity)
402            .cmp(&severity_rank(&b.severity))
403            .then_with(|| a.alert_type.cmp(&b.alert_type))
404    });
405
406    let critical = alerts.iter().filter(|a| a.severity == "critical").count();
407    let warning = alerts.iter().filter(|a| a.severity == "warning").count();
408    let info = alerts.iter().filter(|a| a.severity == "info").count();
409
410    let has_drift = critical > 0 || warning > 0;
411    let exit_code = if critical > 0 {
412        1
413    } else if warning > 0 {
414        2
415    } else {
416        0
417    };
418
419    DriftResult {
420        has_drift,
421        exit_code,
422        summary: DriftSummary {
423            critical,
424            warning,
425            info,
426        },
427        alerts,
428        baseline: DriftBaselineInfo {
429            created_at: baseline.created_at.clone(),
430            description: baseline.description.clone(),
431        },
432    }
433}
434
435fn check_metric_drift(
436    alert_type: &str,
437    baseline_items: &[BaselineMetricItem],
438    current_items: &[BaselineMetricItem],
439    alerts: &mut Vec<DriftAlert>,
440) {
441    if baseline_items.is_empty() || current_items.is_empty() {
442        return;
443    }
444
445    let compare_count = baseline_items.len().min(current_items.len()).min(5);
446    if compare_count == 0 {
447        return;
448    }
449
450    // Compare the top-N IDs for the available items, capped at five.
451    let baseline_top5: Vec<&str> = baseline_items
452        .iter()
453        .take(compare_count)
454        .map(|i| i.id.as_str())
455        .collect();
456    let current_top5: Vec<&str> = current_items
457        .iter()
458        .take(compare_count)
459        .map(|i| i.id.as_str())
460        .collect();
461
462    let changed = baseline_top5
463        .iter()
464        .filter(|id| !current_top5.contains(id))
465        .count();
466
467    if changed >= 3 {
468        let details: Vec<String> = baseline_top5
469            .iter()
470            .filter(|id| !current_top5.contains(id))
471            .map(|id| format!("{id} dropped from top-{compare_count}"))
472            .collect();
473        alerts.push(DriftAlert {
474            alert_type: alert_type.to_string(),
475            severity: "warning".to_string(),
476            message: format!("{changed} of top-{compare_count} rankings changed"),
477            baseline_value: compare_count as f64,
478            current_value: (compare_count - changed) as f64,
479            delta: changed as f64,
480            details,
481        });
482    }
483}
484
485const fn severity_rank(severity: &str) -> u8 {
486    match severity.as_bytes() {
487        b"critical" => 0,
488        b"warning" => 1,
489        _ => 2, // info
490    }
491}
492
493fn chrono_now() -> String {
494    // Simple UTC timestamp without chrono dependency
495    let secs = std::time::SystemTime::now()
496        .duration_since(std::time::UNIX_EPOCH)
497        .map_or(0, |d| d.as_secs());
498
499    // Convert epoch seconds to ISO-8601
500    const SECS_PER_DAY: u64 = 86_400;
501    let days = secs / SECS_PER_DAY;
502    let time_secs = secs % SECS_PER_DAY;
503    let hours = time_secs / 3600;
504    let minutes = (time_secs % 3600) / 60;
505    let seconds = time_secs % 60;
506
507    // Calculate year/month/day from days since epoch
508    let (year, month, day) = days_to_date(days);
509
510    format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}Z")
511}
512
513fn days_to_date(days_since_epoch: u64) -> (u64, u64, u64) {
514    let mut remaining = days_since_epoch;
515    let mut year = 1970;
516
517    loop {
518        let days_in_year = if is_leap(year) { 366 } else { 365 };
519        if remaining < days_in_year {
520            break;
521        }
522        remaining -= days_in_year;
523        year += 1;
524    }
525
526    let month_days = if is_leap(year) {
527        [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
528    } else {
529        [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
530    };
531
532    let mut month = 1u64;
533    for days in &month_days {
534        if remaining < *days {
535            break;
536        }
537        remaining -= days;
538        month += 1;
539    }
540
541    (year, month, remaining + 1)
542}
543
544const fn is_leap(year: u64) -> bool {
545    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
546}
547
548// ---------------------------------------------------------------------------
549// Tests
550// ---------------------------------------------------------------------------
551
552#[cfg(test)]
553mod tests {
554    use super::*;
555
556    fn make_baseline(
557        cycle_count: usize,
558        blocked_count: usize,
559        actionable_count: usize,
560        density: f64,
561        node_count: usize,
562        edge_count: usize,
563    ) -> Baseline {
564        Baseline {
565            version: 1,
566            created_at: "2025-01-01T00:00:00Z".to_string(),
567            description: "test baseline".to_string(),
568            stats: BaselineGraphStats {
569                node_count,
570                edge_count,
571                density,
572                open_count: node_count,
573                closed_count: 0,
574                blocked_count,
575                cycle_count,
576                actionable_count,
577            },
578            top_metrics: BaselineTopMetrics {
579                pagerank: vec![
580                    BaselineMetricItem {
581                        id: "A".to_string(),
582                        value: 0.5,
583                    },
584                    BaselineMetricItem {
585                        id: "B".to_string(),
586                        value: 0.3,
587                    },
588                ],
589                betweenness: Vec::new(),
590                hubs: Vec::new(),
591                authorities: Vec::new(),
592            },
593            cycles: Vec::new(),
594        }
595    }
596
597    fn make_issues_and_graph(count: usize) -> (Vec<Issue>, IssueGraph, GraphMetrics) {
598        let issues: Vec<Issue> = (0..count)
599            .map(|i| Issue {
600                id: format!("I-{i}"),
601                title: format!("Issue {i}"),
602                status: "open".to_string(),
603                priority: 1,
604                ..Issue::default()
605            })
606            .collect();
607        let graph = IssueGraph::build(&issues);
608        let metrics = graph.compute_metrics();
609        (issues, graph, metrics)
610    }
611
612    #[test]
613    fn no_drift_when_identical() {
614        let (issues, graph, metrics) = make_issues_and_graph(5);
615        let baseline = Baseline::from_current(&issues, &graph, &metrics, "test");
616        let result = compute_drift(&baseline, &issues, &graph, &metrics);
617
618        assert!(!result.has_drift);
619        assert_eq!(result.exit_code, 0);
620        assert!(result.alerts.is_empty());
621    }
622
623    #[test]
624    fn detects_new_cycles() {
625        let (issues, graph, metrics) = make_issues_and_graph(3);
626        let mut baseline = Baseline::from_current(&issues, &graph, &metrics, "test");
627        baseline.stats.cycle_count = 0;
628        baseline.cycles.clear();
629
630        // Simulate current having a cycle
631        let mut current_metrics = metrics;
632        current_metrics.cycles = vec![vec!["A".to_string(), "B".to_string(), "A".to_string()]];
633
634        let result = compute_drift(&baseline, &issues, &graph, &current_metrics);
635        assert!(result.has_drift);
636        assert_eq!(result.exit_code, 1);
637        assert!(result.alerts.iter().any(|a| a.alert_type == "new_cycle"));
638    }
639
640    #[test]
641    fn detects_blocked_increase() {
642        let (issues, graph, metrics) = make_issues_and_graph(10);
643        let mut baseline = Baseline::from_current(&issues, &graph, &metrics, "test");
644        baseline.stats.blocked_count = 0; // Pretend no blockers in baseline
645
646        // Create current with 6 blocked (delta = 6 >= 5 threshold)
647        let issues_with_blockers: Vec<Issue> = (0..10)
648            .map(|i| {
649                let mut issue = Issue {
650                    id: format!("I-{i}"),
651                    title: format!("Issue {i}"),
652                    status: if i < 6 { "blocked" } else { "open" }.to_string(),
653                    priority: 1,
654                    ..Issue::default()
655                };
656                if i < 6 {
657                    issue.dependencies = vec![crate::model::Dependency {
658                        issue_id: format!("I-{i}"),
659                        depends_on_id: format!("I-{}", i + 4),
660                        dep_type: "blocks".to_string(),
661                        ..crate::model::Dependency::default()
662                    }];
663                }
664                issue
665            })
666            .collect();
667        let graph2 = IssueGraph::build(&issues_with_blockers);
668        let metrics2 = graph2.compute_metrics();
669
670        let result = compute_drift(&baseline, &issues_with_blockers, &graph2, &metrics2);
671        assert!(
672            result
673                .alerts
674                .iter()
675                .any(|a| a.alert_type == "blocked_increase"),
676            "Expected blocked_increase alert"
677        );
678    }
679
680    #[test]
681    fn severity_ordering() {
682        assert!(severity_rank("critical") < severity_rank("warning"));
683        assert!(severity_rank("warning") < severity_rank("info"));
684    }
685
686    #[test]
687    fn baseline_serialization_roundtrip() {
688        let baseline = make_baseline(0, 2, 5, 0.1, 10, 8);
689        let json = serde_json::to_string_pretty(&baseline).unwrap();
690        let restored: Baseline = serde_json::from_str(&json).unwrap();
691
692        assert_eq!(restored.version, 1);
693        assert_eq!(restored.stats.node_count, 10);
694        assert_eq!(restored.stats.blocked_count, 2);
695        assert_eq!(restored.top_metrics.pagerank.len(), 2);
696    }
697
698    #[test]
699    fn chrono_now_format() {
700        let now = chrono_now();
701        assert!(now.contains('T'));
702        assert!(now.ends_with('Z'));
703        assert_eq!(now.len(), 20);
704    }
705
706    #[test]
707    fn baseline_from_current_captures_stats() {
708        let (issues, graph, metrics) = make_issues_and_graph(5);
709        let baseline = Baseline::from_current(&issues, &graph, &metrics, "snapshot");
710
711        assert_eq!(baseline.version, 1);
712        assert_eq!(baseline.stats.node_count, 5);
713        assert_eq!(baseline.stats.open_count, 5);
714        assert_eq!(baseline.stats.closed_count, 0);
715        assert_eq!(baseline.description, "snapshot");
716    }
717
718    // --- signed_usize_delta tests ---
719
720    #[test]
721    fn signed_usize_delta_positive() {
722        assert_eq!(signed_usize_delta(10, 3), 7);
723    }
724
725    #[test]
726    fn signed_usize_delta_negative() {
727        assert_eq!(signed_usize_delta(3, 10), -7);
728    }
729
730    #[test]
731    fn signed_usize_delta_zero() {
732        assert_eq!(signed_usize_delta(5, 5), 0);
733    }
734
735    // --- is_leap tests ---
736
737    #[test]
738    fn is_leap_common_year() {
739        assert!(!is_leap(2023));
740        assert!(!is_leap(1900)); // divisible by 100 but not 400
741    }
742
743    #[test]
744    fn is_leap_leap_year() {
745        assert!(is_leap(2024));
746        assert!(is_leap(2000)); // divisible by 400
747    }
748
749    // --- days_to_date tests ---
750
751    #[test]
752    fn days_to_date_epoch() {
753        assert_eq!(days_to_date(0), (1970, 1, 1));
754    }
755
756    #[test]
757    fn days_to_date_known_date() {
758        // 2000-01-01 is day 10957 from epoch
759        assert_eq!(days_to_date(10957), (2000, 1, 1));
760    }
761
762    #[test]
763    fn days_to_date_end_of_year() {
764        // 1970-12-31 is day 364
765        assert_eq!(days_to_date(364), (1970, 12, 31));
766    }
767
768    #[test]
769    fn days_to_date_leap_day() {
770        // 1972 is leap, 1972-02-29 is day 789 (365 + 366 + 31 + 28 = 790... let me compute)
771        // 1970: 365, 1971: 365 = 730 days
772        // 1972: Jan=31 + Feb 29 = 60 → day 730+59 = 789
773        assert_eq!(days_to_date(789), (1972, 2, 29));
774    }
775
776    // --- top_metric_items tests ---
777
778    #[test]
779    fn top_metric_items_sorts_descending_by_value() {
780        let mut map = HashMap::new();
781        map.insert("low".to_string(), 0.1);
782        map.insert("high".to_string(), 0.9);
783        map.insert("mid".to_string(), 0.5);
784        let items = top_metric_items(&map, 10);
785        assert_eq!(items[0].id, "high");
786        assert_eq!(items[1].id, "mid");
787        assert_eq!(items[2].id, "low");
788    }
789
790    #[test]
791    fn top_metric_items_truncates_to_limit() {
792        let mut map = HashMap::new();
793        for i in 0..20 {
794            map.insert(format!("i-{i}"), i as f64);
795        }
796        let items = top_metric_items(&map, 5);
797        assert_eq!(items.len(), 5);
798    }
799
800    #[test]
801    fn top_metric_items_empty_map() {
802        let map = HashMap::new();
803        let items = top_metric_items(&map, 10);
804        assert!(items.is_empty());
805    }
806
807    #[test]
808    fn top_metric_items_tiebreaks_by_id() {
809        let mut map = HashMap::new();
810        map.insert("B".to_string(), 1.0);
811        map.insert("A".to_string(), 1.0);
812        let items = top_metric_items(&map, 10);
813        assert_eq!(items[0].id, "A");
814        assert_eq!(items[1].id, "B");
815    }
816
817    // --- severity_rank tests ---
818
819    #[test]
820    fn severity_rank_unknown_defaults_to_info() {
821        assert_eq!(severity_rank("info"), severity_rank("bogus"));
822    }
823
824    // --- check_metric_drift tests ---
825
826    #[test]
827    fn check_metric_drift_empty_baseline_no_alert() {
828        let mut alerts = Vec::new();
829        check_metric_drift(
830            "test",
831            &[],
832            &[BaselineMetricItem {
833                id: "A".to_string(),
834                value: 1.0,
835            }],
836            &mut alerts,
837        );
838        assert!(alerts.is_empty());
839    }
840
841    #[test]
842    fn check_metric_drift_empty_current_no_alert() {
843        let mut alerts = Vec::new();
844        check_metric_drift(
845            "test",
846            &[BaselineMetricItem {
847                id: "A".to_string(),
848                value: 1.0,
849            }],
850            &[],
851            &mut alerts,
852        );
853        assert!(alerts.is_empty());
854    }
855
856    #[test]
857    fn check_metric_drift_fewer_than_3_changes_no_alert() {
858        let baseline = vec![
859            BaselineMetricItem {
860                id: "A".to_string(),
861                value: 5.0,
862            },
863            BaselineMetricItem {
864                id: "B".to_string(),
865                value: 4.0,
866            },
867            BaselineMetricItem {
868                id: "C".to_string(),
869                value: 3.0,
870            },
871            BaselineMetricItem {
872                id: "D".to_string(),
873                value: 2.0,
874            },
875            BaselineMetricItem {
876                id: "E".to_string(),
877                value: 1.0,
878            },
879        ];
880        // Only change 2 of top-5
881        let current = vec![
882            BaselineMetricItem {
883                id: "A".to_string(),
884                value: 5.0,
885            },
886            BaselineMetricItem {
887                id: "B".to_string(),
888                value: 4.0,
889            },
890            BaselineMetricItem {
891                id: "C".to_string(),
892                value: 3.0,
893            },
894            BaselineMetricItem {
895                id: "X".to_string(),
896                value: 2.0,
897            },
898            BaselineMetricItem {
899                id: "Y".to_string(),
900                value: 1.0,
901            },
902        ];
903        let mut alerts = Vec::new();
904        check_metric_drift("pr", &baseline, &current, &mut alerts);
905        assert!(alerts.is_empty());
906    }
907
908    #[test]
909    fn check_metric_drift_3_or_more_changes_triggers_alert() {
910        let baseline = vec![
911            BaselineMetricItem {
912                id: "A".to_string(),
913                value: 5.0,
914            },
915            BaselineMetricItem {
916                id: "B".to_string(),
917                value: 4.0,
918            },
919            BaselineMetricItem {
920                id: "C".to_string(),
921                value: 3.0,
922            },
923            BaselineMetricItem {
924                id: "D".to_string(),
925                value: 2.0,
926            },
927            BaselineMetricItem {
928                id: "E".to_string(),
929                value: 1.0,
930            },
931        ];
932        // Change 3 of top-5
933        let current = vec![
934            BaselineMetricItem {
935                id: "A".to_string(),
936                value: 5.0,
937            },
938            BaselineMetricItem {
939                id: "B".to_string(),
940                value: 4.0,
941            },
942            BaselineMetricItem {
943                id: "X".to_string(),
944                value: 3.0,
945            },
946            BaselineMetricItem {
947                id: "Y".to_string(),
948                value: 2.0,
949            },
950            BaselineMetricItem {
951                id: "Z".to_string(),
952                value: 1.0,
953            },
954        ];
955        let mut alerts = Vec::new();
956        check_metric_drift("pr", &baseline, &current, &mut alerts);
957        assert_eq!(alerts.len(), 1);
958        assert_eq!(alerts[0].alert_type, "pr");
959        assert_eq!(alerts[0].severity, "warning");
960    }
961
962    #[test]
963    fn check_metric_drift_reports_actual_compared_count_when_under_five() {
964        let baseline = vec![
965            BaselineMetricItem {
966                id: "A".to_string(),
967                value: 3.0,
968            },
969            BaselineMetricItem {
970                id: "B".to_string(),
971                value: 2.0,
972            },
973            BaselineMetricItem {
974                id: "C".to_string(),
975                value: 1.0,
976            },
977        ];
978        let current = vec![
979            BaselineMetricItem {
980                id: "X".to_string(),
981                value: 3.0,
982            },
983            BaselineMetricItem {
984                id: "Y".to_string(),
985                value: 2.0,
986            },
987            BaselineMetricItem {
988                id: "Z".to_string(),
989                value: 1.0,
990            },
991        ];
992
993        let mut alerts = Vec::new();
994        check_metric_drift("pr", &baseline, &current, &mut alerts);
995
996        assert_eq!(alerts.len(), 1);
997        assert_eq!(alerts[0].message, "3 of top-3 rankings changed");
998        assert_eq!(alerts[0].baseline_value, 3.0);
999        assert_eq!(alerts[0].current_value, 0.0);
1000        assert_eq!(alerts[0].delta, 3.0);
1001    }
1002
1003    // --- compute_drift density tests ---
1004
1005    #[test]
1006    fn compute_drift_density_growth_warning() {
1007        let baseline = make_baseline(0, 0, 5, 0.1, 10, 5);
1008        // Need current density >= 0.15 (50% increase from 0.1)
1009        // With 10 nodes and many edges, density goes up
1010        let issues: Vec<Issue> = (0..10)
1011            .map(|i| Issue {
1012                id: format!("I-{i}"),
1013                title: format!("Issue {i}"),
1014                status: "open".to_string(),
1015                issue_type: "task".to_string(),
1016                priority: 1,
1017                dependencies: if i > 0 {
1018                    vec![
1019                        crate::model::Dependency {
1020                            issue_id: format!("I-{i}"),
1021                            depends_on_id: format!("I-{}", i - 1),
1022                            dep_type: "blocks".to_string(),
1023                            ..crate::model::Dependency::default()
1024                        },
1025                        crate::model::Dependency {
1026                            issue_id: format!("I-{i}"),
1027                            depends_on_id: format!("I-{}", (i + 2) % 10),
1028                            dep_type: "blocks".to_string(),
1029                            ..crate::model::Dependency::default()
1030                        },
1031                    ]
1032                } else {
1033                    vec![]
1034                },
1035                ..Issue::default()
1036            })
1037            .collect();
1038        let graph = IssueGraph::build(&issues);
1039        let metrics = graph.compute_metrics();
1040
1041        let result = compute_drift(&baseline, &issues, &graph, &metrics);
1042        let density_alerts: Vec<_> = result
1043            .alerts
1044            .iter()
1045            .filter(|a| a.alert_type == "density_growth")
1046            .collect();
1047        // We may or may not hit 50% threshold depending on exact edge count,
1048        // but we should at least not panic
1049        assert!(result.exit_code <= 2);
1050        // Verify alert structure if present
1051        for alert in &density_alerts {
1052            assert!(alert.severity == "warning" || alert.severity == "info");
1053        }
1054    }
1055
1056    #[test]
1057    fn compute_drift_no_alerts_when_density_baseline_zero() {
1058        let baseline = make_baseline(0, 0, 5, 0.0, 10, 0);
1059        let (issues, graph, metrics) = make_issues_and_graph(10);
1060        let result = compute_drift(&baseline, &issues, &graph, &metrics);
1061        // density baseline is 0, so density check is skipped
1062        assert!(
1063            !result
1064                .alerts
1065                .iter()
1066                .any(|a| a.alert_type == "density_growth")
1067        );
1068    }
1069
1070    // --- compute_drift exit code tests ---
1071
1072    #[test]
1073    fn compute_drift_exit_code_0_when_clean() {
1074        let (issues, graph, metrics) = make_issues_and_graph(5);
1075        let baseline = Baseline::from_current(&issues, &graph, &metrics, "");
1076        let result = compute_drift(&baseline, &issues, &graph, &metrics);
1077        assert_eq!(result.exit_code, 0);
1078        assert!(!result.has_drift);
1079    }
1080
1081    #[test]
1082    fn compute_drift_exit_code_1_for_critical() {
1083        let (issues, graph, metrics) = make_issues_and_graph(3);
1084        let mut baseline = Baseline::from_current(&issues, &graph, &metrics, "");
1085        baseline.stats.cycle_count = 0;
1086        baseline.cycles.clear();
1087
1088        let mut metrics_with_cycle = metrics;
1089        metrics_with_cycle.cycles = vec![vec!["X".to_string(), "Y".to_string()]];
1090
1091        let result = compute_drift(&baseline, &issues, &graph, &metrics_with_cycle);
1092        assert_eq!(result.exit_code, 1);
1093        assert!(result.has_drift);
1094        assert!(result.summary.critical > 0);
1095    }
1096
1097    // --- Baseline save/load roundtrip ---
1098
1099    #[test]
1100    fn baseline_save_load_roundtrip() {
1101        let baseline = make_baseline(2, 3, 8, 0.15, 20, 12);
1102        let dir = tempfile::tempdir().unwrap();
1103        let path = baseline.save(dir.path()).unwrap();
1104        assert!(path.exists());
1105
1106        let loaded = Baseline::load(dir.path()).unwrap();
1107        assert_eq!(loaded.version, baseline.version);
1108        assert_eq!(loaded.stats.node_count, 20);
1109        assert_eq!(loaded.stats.edge_count, 12);
1110        assert_eq!(loaded.stats.blocked_count, 3);
1111        assert_eq!(loaded.stats.cycle_count, 2);
1112    }
1113
1114    #[test]
1115    fn baseline_load_missing_file_returns_error() {
1116        let dir = tempfile::tempdir().unwrap();
1117        let result = Baseline::load(dir.path());
1118        assert!(result.is_err());
1119    }
1120
1121    // --- DriftSummary counts ---
1122
1123    #[test]
1124    fn drift_summary_counts_by_severity() {
1125        let (issues, graph, metrics) = make_issues_and_graph(3);
1126        let baseline = Baseline::from_current(&issues, &graph, &metrics, "");
1127        let result = compute_drift(&baseline, &issues, &graph, &metrics);
1128        assert_eq!(
1129            result.summary.critical + result.summary.warning + result.summary.info,
1130            result.alerts.len()
1131        );
1132    }
1133
1134    // --- alert sorting ---
1135
1136    #[test]
1137    fn alerts_sorted_critical_before_warning_before_info() {
1138        let mut baseline = make_baseline(0, 0, 10, 0.1, 10, 5);
1139        baseline.stats.cycle_count = 0;
1140        baseline.cycles.clear();
1141
1142        // Create issues that trigger multiple alert types
1143        let issues: Vec<Issue> = (0..10)
1144            .map(|i| Issue {
1145                id: format!("I-{i}"),
1146                title: format!("Issue {i}"),
1147                status: "open".to_string(),
1148                issue_type: "task".to_string(),
1149                priority: 1,
1150                ..Issue::default()
1151            })
1152            .collect();
1153        let graph = IssueGraph::build(&issues);
1154        let mut metrics = graph.compute_metrics();
1155        metrics.cycles = vec![vec!["A".to_string(), "B".to_string()]]; // trigger critical
1156
1157        let result = compute_drift(&baseline, &issues, &graph, &metrics);
1158        if result.alerts.len() >= 2 {
1159            for window in result.alerts.windows(2) {
1160                assert!(
1161                    severity_rank(&window[0].severity) <= severity_rank(&window[1].severity),
1162                    "alerts should be sorted by severity"
1163                );
1164            }
1165        }
1166    }
1167
1168    // --- Baseline density calculation ---
1169
1170    #[test]
1171    fn baseline_from_current_density_zero_for_single_node() {
1172        let (issues, graph, metrics) = make_issues_and_graph(1);
1173        let baseline = Baseline::from_current(&issues, &graph, &metrics, "");
1174        assert_eq!(baseline.stats.density, 0.0);
1175    }
1176
1177    #[test]
1178    fn baseline_from_current_counts_closed() {
1179        let issues = vec![
1180            Issue {
1181                id: "A".to_string(),
1182                title: "Open".to_string(),
1183                status: "open".to_string(),
1184                issue_type: "task".to_string(),
1185                ..Issue::default()
1186            },
1187            Issue {
1188                id: "B".to_string(),
1189                title: "Closed".to_string(),
1190                status: "closed".to_string(),
1191                issue_type: "task".to_string(),
1192                ..Issue::default()
1193            },
1194        ];
1195        let graph = IssueGraph::build(&issues);
1196        let metrics = graph.compute_metrics();
1197        let baseline = Baseline::from_current(&issues, &graph, &metrics, "");
1198        assert_eq!(baseline.stats.open_count, 1);
1199        assert_eq!(baseline.stats.closed_count, 1);
1200    }
1201}