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#[derive(Debug, Clone)]
14pub struct DriftThresholds {
15 pub density_warning_pct: f64,
17 pub density_info_pct: f64,
19 pub blocked_increase_threshold: i64,
21 pub actionable_decrease_pct: f64,
23 pub structure_change_pct: f64,
25 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#[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 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 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 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#[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
228pub 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 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 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 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 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 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 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 check_metric_drift(
393 "pagerank_change",
394 &baseline.top_metrics.pagerank,
395 ¤t.top_metrics.pagerank,
396 &mut alerts,
397 );
398
399 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 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, }
491}
492
493fn chrono_now() -> String {
494 let secs = std::time::SystemTime::now()
496 .duration_since(std::time::UNIX_EPOCH)
497 .map_or(0, |d| d.as_secs());
498
499 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 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#[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 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, ¤t_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; 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 #[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 #[test]
738 fn is_leap_common_year() {
739 assert!(!is_leap(2023));
740 assert!(!is_leap(1900)); }
742
743 #[test]
744 fn is_leap_leap_year() {
745 assert!(is_leap(2024));
746 assert!(is_leap(2000)); }
748
749 #[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 assert_eq!(days_to_date(10957), (2000, 1, 1));
760 }
761
762 #[test]
763 fn days_to_date_end_of_year() {
764 assert_eq!(days_to_date(364), (1970, 12, 31));
766 }
767
768 #[test]
769 fn days_to_date_leap_day() {
770 assert_eq!(days_to_date(789), (1972, 2, 29));
774 }
775
776 #[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 #[test]
820 fn severity_rank_unknown_defaults_to_info() {
821 assert_eq!(severity_rank("info"), severity_rank("bogus"));
822 }
823
824 #[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 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, ¤t, &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 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, ¤t, &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, ¤t, &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 #[test]
1006 fn compute_drift_density_growth_warning() {
1007 let baseline = make_baseline(0, 0, 5, 0.1, 10, 5);
1008 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 assert!(result.exit_code <= 2);
1050 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 assert!(
1063 !result
1064 .alerts
1065 .iter()
1066 .any(|a| a.alert_type == "density_growth")
1067 );
1068 }
1069
1070 #[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 #[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 #[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 #[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 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()]]; 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 #[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}