1use std::collections::{BTreeSet, HashMap, HashSet};
2
3use chrono::Utc;
4use serde::Serialize;
5
6use super::graph::IssueGraph;
7use crate::model::{Comment, Dependency, Issue};
8
9const ZERO_TIME_RFC3339: &str = "0001-01-01T00:00:00Z";
10
11#[derive(Debug, Clone)]
12pub struct DiffMetadata {
13 pub from_timestamp: String,
14 pub to_timestamp: String,
15 pub from_revision: Option<String>,
16 pub to_revision: Option<String>,
17}
18
19impl Default for DiffMetadata {
20 fn default() -> Self {
21 Self {
22 from_timestamp: "0001-01-01T00:00:00Z".to_string(),
23 to_timestamp: Utc::now().to_rfc3339(),
24 from_revision: None,
25 to_revision: None,
26 }
27 }
28}
29
30#[derive(Debug, Clone, Serialize)]
31pub struct FieldChange {
32 pub field: String,
33 pub old_value: String,
34 pub new_value: String,
35}
36
37#[derive(Debug, Clone, Serialize)]
38pub struct ModifiedIssue {
39 pub issue_id: String,
40 pub title: String,
41 pub changes: Vec<FieldChange>,
42}
43
44#[derive(Debug, Clone, Serialize)]
45pub struct DiffIssue {
46 pub id: String,
47 pub title: String,
48 pub description: String,
49 pub status: String,
50 pub priority: i32,
51 pub issue_type: String,
52 #[serde(skip_serializing_if = "Option::is_none")]
53 pub assignee: Option<String>,
54 pub created_at: String,
55 pub updated_at: String,
56 #[serde(skip_serializing_if = "Option::is_none")]
57 pub closed_at: Option<String>,
58 #[serde(skip_serializing_if = "Vec::is_empty")]
59 pub labels: Vec<String>,
60 #[serde(skip_serializing_if = "Vec::is_empty")]
61 pub dependencies: Vec<DiffDependency>,
62 #[serde(skip_serializing_if = "Vec::is_empty")]
63 pub comments: Vec<DiffComment>,
64}
65
66#[derive(Debug, Clone, Serialize)]
67pub struct DiffDependency {
68 pub issue_id: String,
69 pub depends_on_id: String,
70 #[serde(rename = "type")]
71 pub dep_type: String,
72 pub created_by: String,
73 pub created_at: String,
74}
75
76#[derive(Debug, Clone, Serialize)]
77pub struct DiffComment {
78 pub id: i64,
79 pub issue_id: String,
80 pub author: String,
81 pub text: String,
82 pub created_at: String,
83}
84
85#[derive(Debug, Clone, Serialize, Default)]
86pub struct MetricDeltas {
87 pub total_issues: i64,
88 pub open_issues: i64,
89 pub closed_issues: i64,
90 pub blocked_issues: i64,
91 pub total_edges: i64,
92 pub cycle_count: i64,
93 pub component_count: i64,
94 pub avg_pagerank: f64,
95 pub avg_betweenness: f64,
96}
97
98#[derive(Debug, Clone, Serialize, Default)]
99pub struct DiffSummary {
100 pub total_changes: usize,
101 pub issues_added: usize,
102 pub issues_closed: usize,
103 pub issues_removed: usize,
104 pub issues_reopened: usize,
105 pub issues_modified: usize,
106 pub cycles_introduced: usize,
107 pub cycles_resolved: usize,
108 pub net_issue_change: i64,
109 pub health_trend: String,
110}
111
112#[derive(Debug, Clone, Serialize)]
113pub struct SnapshotDiff {
114 pub from_timestamp: String,
115 pub to_timestamp: String,
116 #[serde(skip_serializing_if = "String::is_empty")]
117 pub from_revision: String,
118 #[serde(skip_serializing_if = "String::is_empty")]
119 pub to_revision: String,
120 pub new_issues: Option<Vec<DiffIssue>>,
121 pub closed_issues: Option<Vec<DiffIssue>>,
122 pub removed_issues: Option<Vec<DiffIssue>>,
123 pub reopened_issues: Option<Vec<DiffIssue>>,
124 pub modified_issues: Option<Vec<ModifiedIssue>>,
125 pub new_cycles: Option<Vec<Vec<String>>>,
126 pub resolved_cycles: Option<Vec<Vec<String>>>,
127 pub metric_deltas: MetricDeltas,
128 pub summary: DiffSummary,
129}
130
131impl SnapshotDiff {
132 #[must_use]
133 pub fn is_empty(&self) -> bool {
134 self.summary.total_changes == 0
135 && self.summary.cycles_introduced == 0
136 && self.summary.cycles_resolved == 0
137 }
138
139 #[must_use]
140 pub fn has_significant_changes(&self) -> bool {
141 option_len(self.new_issues.as_ref()) > 0
142 || option_len(self.closed_issues.as_ref()) > 0
143 || option_len(self.reopened_issues.as_ref()) > 0
144 || option_len(self.new_cycles.as_ref()) > 0
145 || option_len(self.resolved_cycles.as_ref()) > 0
146 || self.summary.health_trend == "degrading"
147 }
148}
149
150#[must_use]
151pub fn compare_snapshots(before: &[Issue], after: &[Issue]) -> SnapshotDiff {
152 compare_snapshots_with_metadata(before, after, &DiffMetadata::default())
153}
154
155#[must_use]
156pub fn compare_snapshots_with_metadata(
157 before: &[Issue],
158 after: &[Issue],
159 metadata: &DiffMetadata,
160) -> SnapshotDiff {
161 let before_map: HashMap<&str, &Issue> = before
162 .iter()
163 .map(|issue| (issue.id.as_str(), issue))
164 .collect();
165 let after_map: HashMap<&str, &Issue> = after
166 .iter()
167 .map(|issue| (issue.id.as_str(), issue))
168 .collect();
169
170 let before_ids: HashSet<&str> = before_map.keys().copied().collect();
171 let after_ids: HashSet<&str> = after_map.keys().copied().collect();
172
173 let mut new_issues = Vec::<DiffIssue>::new();
174 let mut closed_issues = Vec::<DiffIssue>::new();
175 let mut removed_issues = Vec::<DiffIssue>::new();
176 let mut reopened_issues = Vec::<DiffIssue>::new();
177 let mut modified_issues = Vec::<ModifiedIssue>::new();
178
179 for id in after_ids.difference(&before_ids) {
180 if let Some(issue) = after_map.get(id) {
181 new_issues.push(to_diff_issue(issue));
182 }
183 }
184
185 for id in before_ids.intersection(&after_ids) {
186 let Some(before_issue) = before_map.get(id) else {
187 continue;
188 };
189 let Some(after_issue) = after_map.get(id) else {
190 continue;
191 };
192
193 let mut changes = detect_changes(before_issue, after_issue);
194 let before_closed = before_issue.is_closed_like();
195 let after_closed = after_issue.is_closed_like();
196 let mut status_transition = false;
197
198 if !before_closed && after_closed {
199 status_transition = true;
200 closed_issues.push(to_diff_issue(after_issue));
201 } else if before_closed && !after_closed {
202 status_transition = true;
203 reopened_issues.push(to_diff_issue(after_issue));
204 }
205
206 if status_transition {
207 changes.retain(|change| change.field != "status");
208 }
209
210 if !changes.is_empty() {
211 modified_issues.push(ModifiedIssue {
212 issue_id: after_issue.id.clone(),
213 title: after_issue.title.clone(),
214 changes,
215 });
216 }
217 }
218
219 for id in before_ids.difference(&after_ids) {
220 if let Some(issue) = before_map.get(id) {
221 removed_issues.push(to_diff_issue(issue));
222 }
223 }
224
225 new_issues.sort_by(|left, right| left.id.cmp(&right.id));
226 closed_issues.sort_by(|left, right| left.id.cmp(&right.id));
227 removed_issues.sort_by(|left, right| left.id.cmp(&right.id));
228 reopened_issues.sort_by(|left, right| left.id.cmp(&right.id));
229 modified_issues.sort_by(|left, right| left.issue_id.cmp(&right.issue_id));
230
231 let from_graph = IssueGraph::build(before);
232 let to_graph = IssueGraph::build(after);
233 let from_metrics = from_graph.compute_metrics();
234 let to_metrics = to_graph.compute_metrics();
235 let (new_cycles, resolved_cycles) = compare_cycles(&from_metrics.cycles, &to_metrics.cycles);
236
237 let from_component_count = from_graph.connected_open_components().len();
238 let to_component_count = to_graph.connected_open_components().len();
239
240 let metric_deltas = calculate_metric_deltas(MetricDeltaInputs {
241 before,
242 after,
243 new_cycles_count: option_len(new_cycles.as_ref()),
244 resolved_cycles_count: option_len(resolved_cycles.as_ref()),
245 from_pagerank: &from_metrics.pagerank,
246 to_pagerank: &to_metrics.pagerank,
247 from_betweenness: &from_metrics.betweenness,
248 to_betweenness: &to_metrics.betweenness,
249 from_edge_count: from_graph.edge_count(),
250 to_edge_count: to_graph.edge_count(),
251 from_component_count,
252 to_component_count,
253 });
254
255 let summary = calculate_summary(SummaryInputs {
256 issues_added: new_issues.len(),
257 issues_closed: closed_issues.len(),
258 issues_removed: removed_issues.len(),
259 issues_reopened: reopened_issues.len(),
260 issues_modified: modified_issues.len(),
261 cycles_introduced: option_len(new_cycles.as_ref()),
262 cycles_resolved: option_len(resolved_cycles.as_ref()),
263 blocked_issue_delta: metric_deltas.blocked_issues,
264 });
265
266 SnapshotDiff {
267 from_timestamp: metadata.from_timestamp.clone(),
268 to_timestamp: metadata.to_timestamp.clone(),
269 from_revision: metadata.from_revision.clone().unwrap_or_default(),
270 to_revision: metadata.to_revision.clone().unwrap_or_default(),
271 new_issues: into_option(new_issues),
272 closed_issues: into_option(closed_issues),
273 removed_issues: into_option(removed_issues),
274 reopened_issues: into_option(reopened_issues),
275 modified_issues: into_option(modified_issues),
276 new_cycles,
277 resolved_cycles,
278 metric_deltas,
279 summary,
280 }
281}
282
283pub(crate) fn detect_changes(from: &Issue, to: &Issue) -> Vec<FieldChange> {
284 let mut changes = Vec::<FieldChange>::new();
285
286 if from.title != to.title {
287 changes.push(FieldChange {
288 field: "title".to_string(),
289 old_value: from.title.clone(),
290 new_value: to.title.clone(),
291 });
292 }
293
294 if from.status != to.status {
295 changes.push(FieldChange {
296 field: "status".to_string(),
297 old_value: from.status.clone(),
298 new_value: to.status.clone(),
299 });
300 }
301
302 if from.priority != to.priority {
303 changes.push(FieldChange {
304 field: "priority".to_string(),
305 old_value: priority_string(from.priority),
306 new_value: priority_string(to.priority),
307 });
308 }
309
310 if from.assignee != to.assignee {
311 changes.push(FieldChange {
312 field: "assignee".to_string(),
313 old_value: from.assignee.clone(),
314 new_value: to.assignee.clone(),
315 });
316 }
317
318 if from.issue_type != to.issue_type {
319 changes.push(FieldChange {
320 field: "type".to_string(),
321 old_value: from.issue_type.clone(),
322 new_value: to.issue_type.clone(),
323 });
324 }
325
326 if from.description != to.description {
327 changes.push(FieldChange {
328 field: "description".to_string(),
329 old_value: "(modified)".to_string(),
330 new_value: "(modified)".to_string(),
331 });
332 }
333
334 if from.design != to.design {
335 changes.push(FieldChange {
336 field: "design".to_string(),
337 old_value: "(modified)".to_string(),
338 new_value: "(modified)".to_string(),
339 });
340 }
341
342 if from.acceptance_criteria != to.acceptance_criteria {
343 changes.push(FieldChange {
344 field: "acceptance_criteria".to_string(),
345 old_value: "(modified)".to_string(),
346 new_value: "(modified)".to_string(),
347 });
348 }
349
350 if from.notes != to.notes {
351 changes.push(FieldChange {
352 field: "notes".to_string(),
353 old_value: "(modified)".to_string(),
354 new_value: "(modified)".to_string(),
355 });
356 }
357
358 let from_deps = dependency_set(&from.dependencies);
359 let to_deps = dependency_set(&to.dependencies);
360 if from_deps != to_deps {
361 changes.push(FieldChange {
362 field: "dependencies".to_string(),
363 old_value: format_dep_set(&from_deps),
364 new_value: format_dep_set(&to_deps),
365 });
366 }
367
368 let from_labels = string_set(&from.labels);
369 let to_labels = string_set(&to.labels);
370 if from_labels != to_labels {
371 changes.push(FieldChange {
372 field: "labels".to_string(),
373 old_value: format_string_set(&from_labels),
374 new_value: format_string_set(&to_labels),
375 });
376 }
377
378 changes
379}
380
381fn dependency_set(deps: &[Dependency]) -> BTreeSet<String> {
382 let mut values = BTreeSet::<String>::new();
383 for dep in deps {
384 if dep.depends_on_id.trim().is_empty() {
385 continue;
386 }
387 values.insert(format!("{}:{}", dep.depends_on_id, dep.dep_type));
388 }
389 values
390}
391
392fn string_set(values: &[String]) -> BTreeSet<String> {
393 values.iter().cloned().collect()
394}
395
396fn format_dep_set(values: &BTreeSet<String>) -> String {
397 format_string_set(values)
398}
399
400fn format_string_set(values: &BTreeSet<String>) -> String {
401 if values.is_empty() {
402 "(none)".to_string()
403 } else {
404 values.iter().cloned().collect::<Vec<_>>().join(", ")
405 }
406}
407
408fn priority_string(priority: i32) -> String {
409 format!("P{priority}")
410}
411
412fn to_diff_issue(issue: &Issue) -> DiffIssue {
413 DiffIssue {
414 id: issue.id.clone(),
415 title: issue.title.clone(),
416 description: issue.description.clone(),
417 status: issue.status.clone(),
418 priority: issue.priority,
419 issue_type: issue.issue_type.clone(),
420 assignee: non_empty(&issue.assignee),
421 created_at: dt_or_zero(issue.created_at),
422 updated_at: dt_or_zero(issue.updated_at),
423 closed_at: issue
424 .closed_at
425 .map(|dt| dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)),
426 labels: issue.labels.clone(),
427 dependencies: issue.dependencies.iter().map(to_diff_dependency).collect(),
428 comments: issue.comments.iter().map(to_diff_comment).collect(),
429 }
430}
431
432fn to_diff_dependency(dep: &Dependency) -> DiffDependency {
433 DiffDependency {
434 issue_id: dep.issue_id.clone(),
435 depends_on_id: dep.depends_on_id.clone(),
436 dep_type: dep.dep_type.clone(),
437 created_by: dep.created_by.clone(),
438 created_at: dt_or_zero(dep.created_at),
439 }
440}
441
442fn to_diff_comment(comment: &Comment) -> DiffComment {
443 DiffComment {
444 id: comment.id,
445 issue_id: comment.issue_id.clone(),
446 author: comment.author.clone(),
447 text: comment.text.clone(),
448 created_at: dt_or_zero(comment.created_at),
449 }
450}
451
452fn dt_or_zero(dt: Option<chrono::DateTime<chrono::Utc>>) -> String {
453 dt.map_or_else(
454 || ZERO_TIME_RFC3339.to_string(),
455 |d| d.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
456 )
457}
458
459fn non_empty(value: &str) -> Option<String> {
460 let trimmed = value.trim();
461 if trimmed.is_empty() {
462 None
463 } else {
464 Some(trimmed.to_string())
465 }
466}
467
468fn option_len<T>(values: Option<&Vec<T>>) -> usize {
469 values.map_or(0, Vec::len)
470}
471
472fn into_option<T>(values: Vec<T>) -> Option<Vec<T>> {
473 if values.is_empty() {
474 None
475 } else {
476 Some(values)
477 }
478}
479
480type OptionalCycleSets = (Option<Vec<Vec<String>>>, Option<Vec<Vec<String>>>);
481
482fn compare_cycles(from_cycles: &[Vec<String>], to_cycles: &[Vec<String>]) -> OptionalCycleSets {
483 let from_cycle_set = from_cycles
484 .iter()
485 .map(|cycle| (normalize_cycle(cycle), cycle.clone()))
486 .collect::<HashMap<_, _>>();
487 let to_cycle_set = to_cycles
488 .iter()
489 .map(|cycle| (normalize_cycle(cycle), cycle.clone()))
490 .collect::<HashMap<_, _>>();
491
492 let mut new_cycles = to_cycle_set
493 .iter()
494 .filter_map(|(key, cycle)| {
495 if from_cycle_set.contains_key(key) {
496 None
497 } else {
498 Some(cycle.clone())
499 }
500 })
501 .collect::<Vec<_>>();
502
503 let mut resolved_cycles = from_cycle_set
504 .iter()
505 .filter_map(|(key, cycle)| {
506 if to_cycle_set.contains_key(key) {
507 None
508 } else {
509 Some(cycle.clone())
510 }
511 })
512 .collect::<Vec<_>>();
513
514 new_cycles.sort_by_key(|cycle| normalize_cycle(cycle));
515 resolved_cycles.sort_by_key(|cycle| normalize_cycle(cycle));
516
517 (into_option(new_cycles), into_option(resolved_cycles))
518}
519
520fn normalize_cycle(cycle: &[String]) -> String {
521 if cycle.is_empty() {
522 return String::new();
523 }
524
525 let mut min_idx = 0usize;
526 for (index, id) in cycle.iter().enumerate().skip(1) {
527 if id < &cycle[min_idx] {
528 min_idx = index;
529 }
530 }
531
532 (0..cycle.len())
533 .map(|offset| cycle[(min_idx + offset) % cycle.len()].clone())
534 .collect::<Vec<_>>()
535 .join("->")
536}
537
538struct MetricDeltaInputs<'a> {
539 before: &'a [Issue],
540 after: &'a [Issue],
541 new_cycles_count: usize,
542 resolved_cycles_count: usize,
543 from_pagerank: &'a HashMap<String, f64>,
544 to_pagerank: &'a HashMap<String, f64>,
545 from_betweenness: &'a HashMap<String, f64>,
546 to_betweenness: &'a HashMap<String, f64>,
547 from_edge_count: usize,
548 to_edge_count: usize,
549 from_component_count: usize,
550 to_component_count: usize,
551}
552
553fn saturating_i64(v: usize) -> i64 {
554 i64::try_from(v).unwrap_or(i64::MAX)
555}
556
557fn delta(after: usize, before: usize) -> i64 {
558 saturating_i64(after).saturating_sub(saturating_i64(before))
559}
560
561fn calculate_metric_deltas(inputs: MetricDeltaInputs<'_>) -> MetricDeltas {
562 let before_counts = snapshot_counts(inputs.before);
563 let after_counts = snapshot_counts(inputs.after);
564
565 MetricDeltas {
566 total_issues: delta(after_counts.total, before_counts.total),
567 open_issues: delta(after_counts.open, before_counts.open),
568 closed_issues: delta(after_counts.terminal(), before_counts.terminal()),
569 blocked_issues: delta(after_counts.blocked, before_counts.blocked),
570 total_edges: delta(inputs.to_edge_count, inputs.from_edge_count),
571 cycle_count: delta(inputs.new_cycles_count, inputs.resolved_cycles_count),
572 component_count: delta(inputs.to_component_count, inputs.from_component_count),
573 avg_pagerank: average_map_value(inputs.to_pagerank)
574 - average_map_value(inputs.from_pagerank),
575 avg_betweenness: average_map_value(inputs.to_betweenness)
576 - average_map_value(inputs.from_betweenness),
577 }
578}
579
580#[derive(Debug, Copy, Clone, Default)]
581struct SnapshotCounts {
582 total: usize,
583 open: usize,
584 closed: usize,
585 tombstone: usize,
586 blocked: usize,
587}
588
589impl SnapshotCounts {
590 fn terminal(&self) -> usize {
591 self.closed + self.tombstone
592 }
593}
594
595fn snapshot_counts(issues: &[Issue]) -> SnapshotCounts {
596 let mut counts = SnapshotCounts {
597 total: issues.len(),
598 ..SnapshotCounts::default()
599 };
600
601 for issue in issues {
602 if issue.is_tombstone() {
603 counts.tombstone = counts.tombstone.saturating_add(1);
604 } else if issue.is_closed() {
605 counts.closed = counts.closed.saturating_add(1);
606 } else {
607 counts.open = counts.open.saturating_add(1);
608 if issue.normalized_status() == "blocked" {
609 counts.blocked = counts.blocked.saturating_add(1);
610 }
611 }
612 }
613
614 counts
615}
616
617fn average_map_value(values: &HashMap<String, f64>) -> f64 {
618 if values.is_empty() {
619 return 0.0;
620 }
621
622 let mut keys = values.keys().cloned().collect::<Vec<_>>();
623 keys.sort();
624
625 let sum = keys
626 .iter()
627 .filter_map(|key| values.get(key))
628 .fold(0.0_f64, |acc, value| acc + value);
629
630 sum / (values.len() as f64)
631}
632
633struct SummaryInputs {
634 issues_added: usize,
635 issues_closed: usize,
636 issues_removed: usize,
637 issues_reopened: usize,
638 issues_modified: usize,
639 cycles_introduced: usize,
640 cycles_resolved: usize,
641 blocked_issue_delta: i64,
642}
643
644fn calculate_summary(inputs: SummaryInputs) -> DiffSummary {
645 let total_changes = inputs.issues_added
646 + inputs.issues_closed
647 + inputs.issues_removed
648 + inputs.issues_reopened
649 + inputs.issues_modified;
650
651 let mut score = 0_i64;
652 score += i64::try_from(inputs.cycles_resolved.saturating_mul(2)).unwrap_or(i64::MAX);
653 score -= i64::try_from(inputs.cycles_introduced.saturating_mul(3)).unwrap_or(i64::MAX);
654 score += i64::try_from(inputs.issues_closed).unwrap_or(i64::MAX);
655 score -= i64::try_from(inputs.issues_reopened).unwrap_or(i64::MAX);
656
657 if inputs.blocked_issue_delta < 0 {
658 score += 2;
659 } else if inputs.blocked_issue_delta > 0 {
660 score -= 1;
661 }
662
663 let health_trend = if score > 1 {
664 "improving"
665 } else if score < -1 {
666 "degrading"
667 } else {
668 "stable"
669 };
670
671 DiffSummary {
672 total_changes,
673 issues_added: inputs.issues_added,
674 issues_closed: inputs.issues_closed,
675 issues_removed: inputs.issues_removed,
676 issues_reopened: inputs.issues_reopened,
677 issues_modified: inputs.issues_modified,
678 cycles_introduced: inputs.cycles_introduced,
679 cycles_resolved: inputs.cycles_resolved,
680 net_issue_change: i64::try_from(inputs.issues_added).unwrap_or(i64::MAX)
681 - i64::try_from(inputs.issues_removed).unwrap_or(i64::MAX),
682 health_trend: health_trend.to_string(),
683 }
684}
685
686#[cfg(test)]
687mod tests {
688 use std::collections::{BTreeSet, HashMap};
689
690 use crate::model::{Dependency, Issue};
691
692 use super::{
693 SummaryInputs, average_map_value, calculate_summary, compare_cycles, compare_snapshots,
694 delta, detect_changes, format_string_set, into_option, non_empty, normalize_cycle,
695 option_len, saturating_i64, snapshot_counts,
696 };
697
698 #[test]
699 fn detects_new_closed_reopened_and_modified() {
700 let before = vec![
701 Issue {
702 id: "A".to_string(),
703 title: "A".to_string(),
704 status: "open".to_string(),
705 issue_type: "task".to_string(),
706 priority: 1,
707 ..Issue::default()
708 },
709 Issue {
710 id: "B".to_string(),
711 title: "B".to_string(),
712 status: "closed".to_string(),
713 issue_type: "task".to_string(),
714 priority: 2,
715 ..Issue::default()
716 },
717 ];
718
719 let after = vec![
720 Issue {
721 id: "A".to_string(),
722 title: "A2".to_string(),
723 status: "closed".to_string(),
724 issue_type: "task".to_string(),
725 priority: 1,
726 dependencies: vec![Dependency {
727 depends_on_id: "C".to_string(),
728 dep_type: "blocks".to_string(),
729 ..Dependency::default()
730 }],
731 ..Issue::default()
732 },
733 Issue {
734 id: "B".to_string(),
735 title: "B".to_string(),
736 status: "open".to_string(),
737 issue_type: "task".to_string(),
738 priority: 2,
739 ..Issue::default()
740 },
741 Issue {
742 id: "C".to_string(),
743 title: "C".to_string(),
744 status: "open".to_string(),
745 issue_type: "task".to_string(),
746 priority: 2,
747 ..Issue::default()
748 },
749 ];
750
751 let diff = compare_snapshots(&before, &after);
752 assert_eq!(diff.new_issues.as_ref().map_or(0, Vec::len), 1);
753 assert_eq!(diff.closed_issues.as_ref().map_or(0, Vec::len), 1);
754 assert_eq!(diff.reopened_issues.as_ref().map_or(0, Vec::len), 1);
755 assert_eq!(diff.modified_issues.as_ref().map_or(0, Vec::len), 1);
756 assert_eq!(
757 diff.modified_issues
758 .as_ref()
759 .and_then(|issues| issues.first())
760 .map(|issue| issue.issue_id.as_str()),
761 Some("A")
762 );
763 assert_eq!(diff.summary.issues_added, 1);
764 assert_eq!(diff.summary.issues_removed, 0);
765 }
766
767 #[test]
768 fn empty_before_and_after_produces_empty_diff() {
769 let diff = compare_snapshots(&[], &[]);
770 assert_eq!(diff.summary.issues_added, 0);
771 assert_eq!(diff.summary.issues_removed, 0);
772 assert_eq!(diff.summary.issues_modified, 0);
773 assert!(diff.new_issues.as_ref().is_none_or(Vec::is_empty));
774 assert!(diff.closed_issues.as_ref().is_none_or(Vec::is_empty));
775 }
776
777 #[test]
778 fn all_new_issues_detected() {
779 let after = vec![
780 Issue {
781 id: "N-1".to_string(),
782 title: "New one".to_string(),
783 status: "open".to_string(),
784 issue_type: "task".to_string(),
785 ..Issue::default()
786 },
787 Issue {
788 id: "N-2".to_string(),
789 title: "New two".to_string(),
790 status: "open".to_string(),
791 issue_type: "task".to_string(),
792 ..Issue::default()
793 },
794 ];
795 let diff = compare_snapshots(&[], &after);
796 assert_eq!(diff.new_issues.as_ref().map_or(0, Vec::len), 2);
797 assert_eq!(diff.summary.issues_added, 2);
798 }
799
800 #[test]
801 fn removed_issues_tracked() {
802 let before = vec![Issue {
803 id: "G-1".to_string(),
804 title: "Gone".to_string(),
805 status: "open".to_string(),
806 issue_type: "task".to_string(),
807 ..Issue::default()
808 }];
809 let diff = compare_snapshots(&before, &[]);
810 assert_eq!(diff.summary.issues_removed, 1);
811 }
812
813 #[test]
814 fn identical_snapshots_produce_no_changes() {
815 let issues = vec![Issue {
816 id: "S-1".to_string(),
817 title: "Stable".to_string(),
818 status: "open".to_string(),
819 issue_type: "task".to_string(),
820 priority: 1,
821 ..Issue::default()
822 }];
823 let diff = compare_snapshots(&issues, &issues);
824 assert!(diff.new_issues.as_ref().is_none_or(Vec::is_empty));
825 assert!(diff.closed_issues.as_ref().is_none_or(Vec::is_empty));
826 assert!(diff.reopened_issues.as_ref().is_none_or(Vec::is_empty));
827 assert!(diff.modified_issues.as_ref().is_none_or(Vec::is_empty));
828 assert_eq!(diff.summary.issues_added, 0);
829 assert_eq!(diff.summary.issues_removed, 0);
830 }
831
832 #[test]
833 fn priority_change_detected_as_modification() {
834 let before = vec![Issue {
835 id: "P-1".to_string(),
836 title: "Same".to_string(),
837 status: "open".to_string(),
838 issue_type: "task".to_string(),
839 priority: 1,
840 ..Issue::default()
841 }];
842 let after = vec![Issue {
843 id: "P-1".to_string(),
844 title: "Same".to_string(),
845 status: "open".to_string(),
846 issue_type: "task".to_string(),
847 priority: 3,
848 ..Issue::default()
849 }];
850 let diff = compare_snapshots(&before, &after);
851 assert_eq!(diff.modified_issues.as_ref().map_or(0, Vec::len), 1);
852 let mods = diff.modified_issues.unwrap();
853 assert!(mods[0].changes.iter().any(|c| c.field == "priority"));
854 }
855
856 #[test]
857 fn dependency_change_detected() {
858 let before = vec![Issue {
859 id: "D-1".to_string(),
860 title: "Dep change".to_string(),
861 status: "open".to_string(),
862 issue_type: "task".to_string(),
863 ..Issue::default()
864 }];
865 let after = vec![Issue {
866 id: "D-1".to_string(),
867 title: "Dep change".to_string(),
868 status: "open".to_string(),
869 issue_type: "task".to_string(),
870 dependencies: vec![Dependency {
871 depends_on_id: "D-2".to_string(),
872 dep_type: "blocks".to_string(),
873 ..Dependency::default()
874 }],
875 ..Issue::default()
876 }];
877 let diff = compare_snapshots(&before, &after);
878 assert_eq!(diff.modified_issues.as_ref().map_or(0, Vec::len), 1);
879 }
880
881 #[test]
882 fn metric_deltas_computed() {
883 let before = vec![
884 Issue {
885 id: "M-1".to_string(),
886 title: "Open".to_string(),
887 status: "open".to_string(),
888 issue_type: "task".to_string(),
889 ..Issue::default()
890 },
891 Issue {
892 id: "M-2".to_string(),
893 title: "Blocked".to_string(),
894 status: "blocked".to_string(),
895 issue_type: "task".to_string(),
896 dependencies: vec![Dependency {
897 depends_on_id: "M-1".to_string(),
898 dep_type: "blocks".to_string(),
899 ..Dependency::default()
900 }],
901 ..Issue::default()
902 },
903 ];
904 let after = vec![
905 Issue {
906 id: "M-1".to_string(),
907 title: "Open".to_string(),
908 status: "closed".to_string(),
909 issue_type: "task".to_string(),
910 ..Issue::default()
911 },
912 Issue {
913 id: "M-2".to_string(),
914 title: "Blocked".to_string(),
915 status: "open".to_string(),
916 issue_type: "task".to_string(),
917 ..Issue::default()
918 },
919 ];
920 let diff = compare_snapshots(&before, &after);
921 assert_ne!(diff.metric_deltas.open_issues, 0);
923 }
924
925 #[test]
926 fn metric_deltas_treat_review_like_status_as_open() {
927 let before = vec![Issue {
928 id: "R-1".to_string(),
929 title: "In review".to_string(),
930 status: "review".to_string(),
931 issue_type: "task".to_string(),
932 ..Issue::default()
933 }];
934 let after = Vec::<Issue>::new();
935
936 let diff = compare_snapshots(&before, &after);
937 assert_eq!(
938 diff.metric_deltas.open_issues, -1,
939 "review status should be counted as open-like in deltas"
940 );
941 }
942
943 #[test]
946 fn detect_changes_no_changes_returns_empty() {
947 let issue = Issue {
948 id: "X".to_string(),
949 title: "Same".to_string(),
950 status: "open".to_string(),
951 issue_type: "task".to_string(),
952 priority: 2,
953 assignee: "alice".to_string(),
954 ..Issue::default()
955 };
956 let changes = detect_changes(&issue, &issue);
957 assert!(changes.is_empty());
958 }
959
960 #[test]
961 fn detect_changes_title_change() {
962 let from = Issue {
963 id: "X".to_string(),
964 title: "Old title".to_string(),
965 status: "open".to_string(),
966 issue_type: "task".to_string(),
967 ..Issue::default()
968 };
969 let to = Issue {
970 title: "New title".to_string(),
971 ..from.clone()
972 };
973 let changes = detect_changes(&from, &to);
974 assert_eq!(changes.len(), 1);
975 assert_eq!(changes[0].field, "title");
976 assert_eq!(changes[0].old_value, "Old title");
977 assert_eq!(changes[0].new_value, "New title");
978 }
979
980 #[test]
981 fn detect_changes_status_change() {
982 let from = Issue {
983 id: "X".to_string(),
984 title: "T".to_string(),
985 status: "open".to_string(),
986 issue_type: "task".to_string(),
987 ..Issue::default()
988 };
989 let to = Issue {
990 status: "in_progress".to_string(),
991 ..from.clone()
992 };
993 let changes = detect_changes(&from, &to);
994 assert!(changes.iter().any(|c| c.field == "status"));
995 }
996
997 #[test]
998 fn detect_changes_priority_formats_as_p_string() {
999 let from = Issue {
1000 id: "X".to_string(),
1001 title: "T".to_string(),
1002 status: "open".to_string(),
1003 issue_type: "task".to_string(),
1004 priority: 1,
1005 ..Issue::default()
1006 };
1007 let to = Issue {
1008 priority: 3,
1009 ..from.clone()
1010 };
1011 let changes = detect_changes(&from, &to);
1012 let pchange = changes.iter().find(|c| c.field == "priority").unwrap();
1013 assert_eq!(pchange.old_value, "P1");
1014 assert_eq!(pchange.new_value, "P3");
1015 }
1016
1017 #[test]
1018 fn detect_changes_assignee_change() {
1019 let from = Issue {
1020 id: "X".to_string(),
1021 title: "T".to_string(),
1022 status: "open".to_string(),
1023 issue_type: "task".to_string(),
1024 assignee: "alice".to_string(),
1025 ..Issue::default()
1026 };
1027 let to = Issue {
1028 assignee: "bob".to_string(),
1029 ..from.clone()
1030 };
1031 let changes = detect_changes(&from, &to);
1032 let achange = changes.iter().find(|c| c.field == "assignee").unwrap();
1033 assert_eq!(achange.old_value, "alice");
1034 assert_eq!(achange.new_value, "bob");
1035 }
1036
1037 #[test]
1038 fn detect_changes_type_change() {
1039 let from = Issue {
1040 id: "X".to_string(),
1041 title: "T".to_string(),
1042 status: "open".to_string(),
1043 issue_type: "task".to_string(),
1044 ..Issue::default()
1045 };
1046 let to = Issue {
1047 issue_type: "bug".to_string(),
1048 ..from.clone()
1049 };
1050 let changes = detect_changes(&from, &to);
1051 assert!(changes.iter().any(|c| c.field == "type"));
1052 }
1053
1054 #[test]
1055 fn detect_changes_description_shows_modified_not_content() {
1056 let from = Issue {
1057 id: "X".to_string(),
1058 title: "T".to_string(),
1059 status: "open".to_string(),
1060 issue_type: "task".to_string(),
1061 description: "old desc".to_string(),
1062 ..Issue::default()
1063 };
1064 let to = Issue {
1065 description: "new desc".to_string(),
1066 ..from.clone()
1067 };
1068 let changes = detect_changes(&from, &to);
1069 let dchange = changes.iter().find(|c| c.field == "description").unwrap();
1070 assert_eq!(dchange.old_value, "(modified)");
1071 assert_eq!(dchange.new_value, "(modified)");
1072 }
1073
1074 #[test]
1075 fn detect_changes_labels_change() {
1076 let from = Issue {
1077 id: "X".to_string(),
1078 title: "T".to_string(),
1079 status: "open".to_string(),
1080 issue_type: "task".to_string(),
1081 labels: vec!["api".to_string()],
1082 ..Issue::default()
1083 };
1084 let to = Issue {
1085 labels: vec!["api".to_string(), "backend".to_string()],
1086 ..from.clone()
1087 };
1088 let changes = detect_changes(&from, &to);
1089 let lchange = changes.iter().find(|c| c.field == "labels").unwrap();
1090 assert_eq!(lchange.old_value, "api");
1091 assert_eq!(lchange.new_value, "api, backend");
1092 }
1093
1094 #[test]
1095 fn detect_changes_dependency_added() {
1096 let from = Issue {
1097 id: "X".to_string(),
1098 title: "T".to_string(),
1099 status: "open".to_string(),
1100 issue_type: "task".to_string(),
1101 ..Issue::default()
1102 };
1103 let to = Issue {
1104 dependencies: vec![Dependency {
1105 depends_on_id: "Y".to_string(),
1106 dep_type: "blocks".to_string(),
1107 ..Dependency::default()
1108 }],
1109 ..from.clone()
1110 };
1111 let changes = detect_changes(&from, &to);
1112 let dchange = changes.iter().find(|c| c.field == "dependencies").unwrap();
1113 assert_eq!(dchange.old_value, "(none)");
1114 assert!(dchange.new_value.contains("Y:blocks"));
1115 }
1116
1117 #[test]
1118 fn detect_changes_multiple_fields_at_once() {
1119 let from = Issue {
1120 id: "X".to_string(),
1121 title: "Old".to_string(),
1122 status: "open".to_string(),
1123 issue_type: "task".to_string(),
1124 priority: 1,
1125 assignee: "alice".to_string(),
1126 ..Issue::default()
1127 };
1128 let to = Issue {
1129 title: "New".to_string(),
1130 priority: 3,
1131 assignee: "bob".to_string(),
1132 ..from.clone()
1133 };
1134 let changes = detect_changes(&from, &to);
1135 assert_eq!(changes.len(), 3);
1136 let fields: Vec<&str> = changes.iter().map(|c| c.field.as_str()).collect();
1137 assert!(fields.contains(&"title"));
1138 assert!(fields.contains(&"priority"));
1139 assert!(fields.contains(&"assignee"));
1140 }
1141
1142 #[test]
1145 fn normalize_cycle_empty() {
1146 assert_eq!(normalize_cycle(&[]), "");
1147 }
1148
1149 #[test]
1150 fn normalize_cycle_single_element() {
1151 let cycle = vec!["A".to_string()];
1152 assert_eq!(normalize_cycle(&cycle), "A");
1153 }
1154
1155 #[test]
1156 fn normalize_cycle_already_starts_at_min() {
1157 let cycle = vec!["A".to_string(), "B".to_string(), "C".to_string()];
1158 assert_eq!(normalize_cycle(&cycle), "A->B->C");
1159 }
1160
1161 #[test]
1162 fn normalize_cycle_rotates_to_min() {
1163 let cycle = vec!["C".to_string(), "A".to_string(), "B".to_string()];
1164 assert_eq!(normalize_cycle(&cycle), "A->B->C");
1165 }
1166
1167 #[test]
1168 fn normalize_cycle_different_rotations_same_result() {
1169 let c1 = vec!["B".to_string(), "C".to_string(), "A".to_string()];
1170 let c2 = vec!["C".to_string(), "A".to_string(), "B".to_string()];
1171 let c3 = vec!["A".to_string(), "B".to_string(), "C".to_string()];
1172 let norm = normalize_cycle(&c3);
1173 assert_eq!(normalize_cycle(&c1), norm);
1174 assert_eq!(normalize_cycle(&c2), norm);
1175 }
1176
1177 #[test]
1180 fn compare_cycles_no_change() {
1181 let cycles = vec![vec!["A".to_string(), "B".to_string()]];
1182 let (new, resolved) = compare_cycles(&cycles, &cycles);
1183 assert!(new.is_none());
1184 assert!(resolved.is_none());
1185 }
1186
1187 #[test]
1188 fn compare_cycles_new_cycle_introduced() {
1189 let before: Vec<Vec<String>> = vec![];
1190 let after = vec![vec!["A".to_string(), "B".to_string()]];
1191 let (new, resolved) = compare_cycles(&before, &after);
1192 assert_eq!(option_len(new.as_ref()), 1);
1193 assert!(resolved.is_none());
1194 }
1195
1196 #[test]
1197 fn compare_cycles_cycle_resolved() {
1198 let before = vec![vec!["A".to_string(), "B".to_string()]];
1199 let after: Vec<Vec<String>> = vec![];
1200 let (new, resolved) = compare_cycles(&before, &after);
1201 assert!(new.is_none());
1202 assert_eq!(option_len(resolved.as_ref()), 1);
1203 }
1204
1205 #[test]
1206 fn compare_cycles_rotated_cycle_matches() {
1207 let before = vec![vec!["A".to_string(), "B".to_string(), "C".to_string()]];
1208 let after = vec![vec!["B".to_string(), "C".to_string(), "A".to_string()]];
1209 let (new, resolved) = compare_cycles(&before, &after);
1210 assert!(new.is_none(), "rotated cycle should match");
1211 assert!(resolved.is_none(), "rotated cycle should match");
1212 }
1213
1214 #[test]
1215 fn compare_cycles_mixed_new_and_resolved() {
1216 let before = vec![vec!["A".to_string(), "B".to_string()]];
1217 let after = vec![vec!["C".to_string(), "D".to_string()]];
1218 let (new, resolved) = compare_cycles(&before, &after);
1219 assert_eq!(option_len(new.as_ref()), 1);
1220 assert_eq!(option_len(resolved.as_ref()), 1);
1221 }
1222
1223 #[test]
1226 fn calculate_summary_zero_inputs() {
1227 let summary = calculate_summary(SummaryInputs {
1228 issues_added: 0,
1229 issues_closed: 0,
1230 issues_removed: 0,
1231 issues_reopened: 0,
1232 issues_modified: 0,
1233 cycles_introduced: 0,
1234 cycles_resolved: 0,
1235 blocked_issue_delta: 0,
1236 });
1237 assert_eq!(summary.total_changes, 0);
1238 assert_eq!(summary.health_trend, "stable");
1239 assert_eq!(summary.net_issue_change, 0);
1240 }
1241
1242 #[test]
1243 fn calculate_summary_improving_trend() {
1244 let summary = calculate_summary(SummaryInputs {
1246 issues_added: 0,
1247 issues_closed: 3,
1248 issues_removed: 0,
1249 issues_reopened: 0,
1250 issues_modified: 0,
1251 cycles_introduced: 0,
1252 cycles_resolved: 1,
1253 blocked_issue_delta: 0,
1254 });
1255 assert_eq!(summary.health_trend, "improving");
1256 }
1257
1258 #[test]
1259 fn calculate_summary_degrading_trend() {
1260 let summary = calculate_summary(SummaryInputs {
1262 issues_added: 0,
1263 issues_closed: 0,
1264 issues_removed: 0,
1265 issues_reopened: 1,
1266 issues_modified: 0,
1267 cycles_introduced: 1,
1268 cycles_resolved: 0,
1269 blocked_issue_delta: 0,
1270 });
1271 assert_eq!(summary.health_trend, "degrading");
1272 }
1273
1274 #[test]
1275 fn calculate_summary_stable_when_score_in_range() {
1276 let summary = calculate_summary(SummaryInputs {
1278 issues_added: 0,
1279 issues_closed: 1,
1280 issues_removed: 0,
1281 issues_reopened: 1,
1282 issues_modified: 0,
1283 cycles_introduced: 0,
1284 cycles_resolved: 0,
1285 blocked_issue_delta: 0,
1286 });
1287 assert_eq!(summary.health_trend, "stable");
1288 }
1289
1290 #[test]
1291 fn calculate_summary_blocked_delta_negative_boosts_score() {
1292 let summary = calculate_summary(SummaryInputs {
1294 issues_added: 0,
1295 issues_closed: 0,
1296 issues_removed: 0,
1297 issues_reopened: 0,
1298 issues_modified: 0,
1299 cycles_introduced: 0,
1300 cycles_resolved: 0,
1301 blocked_issue_delta: -1,
1302 });
1303 assert_eq!(summary.health_trend, "improving");
1304 }
1305
1306 #[test]
1307 fn calculate_summary_blocked_delta_positive_hurts_score() {
1308 let summary = calculate_summary(SummaryInputs {
1310 issues_added: 0,
1311 issues_closed: 0,
1312 issues_removed: 0,
1313 issues_reopened: 0,
1314 issues_modified: 0,
1315 cycles_introduced: 0,
1316 cycles_resolved: 0,
1317 blocked_issue_delta: 1,
1318 });
1319 assert_eq!(summary.health_trend, "stable");
1320 }
1321
1322 #[test]
1323 fn calculate_summary_total_changes_is_sum() {
1324 let summary = calculate_summary(SummaryInputs {
1325 issues_added: 2,
1326 issues_closed: 3,
1327 issues_removed: 1,
1328 issues_reopened: 1,
1329 issues_modified: 4,
1330 cycles_introduced: 0,
1331 cycles_resolved: 0,
1332 blocked_issue_delta: 0,
1333 });
1334 assert_eq!(summary.total_changes, 2 + 3 + 1 + 1 + 4);
1335 }
1336
1337 #[test]
1338 fn calculate_summary_net_issue_change() {
1339 let summary = calculate_summary(SummaryInputs {
1340 issues_added: 5,
1341 issues_closed: 0,
1342 issues_removed: 2,
1343 issues_reopened: 0,
1344 issues_modified: 0,
1345 cycles_introduced: 0,
1346 cycles_resolved: 0,
1347 blocked_issue_delta: 0,
1348 });
1349 assert_eq!(summary.net_issue_change, 3);
1350 }
1351
1352 #[test]
1355 fn snapshot_counts_empty() {
1356 let counts = snapshot_counts(&[]);
1357 assert_eq!(counts.total, 0);
1358 assert_eq!(counts.open, 0);
1359 assert_eq!(counts.closed, 0);
1360 assert_eq!(counts.blocked, 0);
1361 assert_eq!(counts.terminal(), 0);
1362 }
1363
1364 #[test]
1365 fn snapshot_counts_mixed_statuses() {
1366 let issues = vec![
1367 Issue {
1368 id: "1".to_string(),
1369 status: "open".to_string(),
1370 issue_type: "task".to_string(),
1371 ..Issue::default()
1372 },
1373 Issue {
1374 id: "2".to_string(),
1375 status: "closed".to_string(),
1376 issue_type: "task".to_string(),
1377 ..Issue::default()
1378 },
1379 Issue {
1380 id: "3".to_string(),
1381 status: "blocked".to_string(),
1382 issue_type: "task".to_string(),
1383 ..Issue::default()
1384 },
1385 ];
1386 let counts = snapshot_counts(&issues);
1387 assert_eq!(counts.total, 3);
1388 assert_eq!(counts.open, 2); assert_eq!(counts.closed, 1);
1390 assert_eq!(counts.blocked, 1);
1391 }
1392
1393 #[test]
1394 fn snapshot_counts_terminal_includes_tombstones() {
1395 let issues = vec![Issue {
1396 id: "1".to_string(),
1397 status: "tombstone".to_string(),
1398 issue_type: "task".to_string(),
1399 ..Issue::default()
1400 }];
1401 let counts = snapshot_counts(&issues);
1402 assert_eq!(counts.tombstone, 1);
1403 assert_eq!(counts.terminal(), 1);
1404 }
1405
1406 #[test]
1409 fn average_map_value_empty() {
1410 let map = HashMap::new();
1411 assert_eq!(average_map_value(&map), 0.0);
1412 }
1413
1414 #[test]
1415 fn average_map_value_single() {
1416 let mut map = HashMap::new();
1417 map.insert("a".to_string(), 10.0);
1418 assert!((average_map_value(&map) - 10.0).abs() < f64::EPSILON);
1419 }
1420
1421 #[test]
1422 fn average_map_value_multiple() {
1423 let mut map = HashMap::new();
1424 map.insert("a".to_string(), 2.0);
1425 map.insert("b".to_string(), 4.0);
1426 map.insert("c".to_string(), 6.0);
1427 assert!((average_map_value(&map) - 4.0).abs() < f64::EPSILON);
1428 }
1429
1430 #[test]
1433 fn format_string_set_empty_returns_none_marker() {
1434 let set = BTreeSet::new();
1435 assert_eq!(format_string_set(&set), "(none)");
1436 }
1437
1438 #[test]
1439 fn format_string_set_single() {
1440 let mut set = BTreeSet::new();
1441 set.insert("api".to_string());
1442 assert_eq!(format_string_set(&set), "api");
1443 }
1444
1445 #[test]
1446 fn format_string_set_multiple_sorted() {
1447 let mut set = BTreeSet::new();
1448 set.insert("beta".to_string());
1449 set.insert("alpha".to_string());
1450 set.insert("gamma".to_string());
1451 assert_eq!(format_string_set(&set), "alpha, beta, gamma");
1452 }
1453
1454 #[test]
1457 fn non_empty_returns_none_for_empty_string() {
1458 assert_eq!(non_empty(""), None);
1459 }
1460
1461 #[test]
1462 fn non_empty_returns_none_for_whitespace() {
1463 assert_eq!(non_empty(" "), None);
1464 }
1465
1466 #[test]
1467 fn non_empty_returns_trimmed_value() {
1468 assert_eq!(non_empty(" hello "), Some("hello".to_string()));
1469 }
1470
1471 #[test]
1474 fn into_option_empty_vec_is_none() {
1475 let v: Vec<i32> = vec![];
1476 assert!(into_option(v).is_none());
1477 }
1478
1479 #[test]
1480 fn into_option_non_empty_vec_is_some() {
1481 let v = vec![1, 2, 3];
1482 let opt = into_option(v);
1483 assert!(opt.is_some());
1484 assert_eq!(opt.unwrap().len(), 3);
1485 }
1486
1487 #[test]
1490 fn option_len_none_is_zero() {
1491 let v: Option<&Vec<i32>> = None;
1492 assert_eq!(option_len(v), 0);
1493 }
1494
1495 #[test]
1496 fn option_len_some_empty_is_zero() {
1497 let v: Vec<i32> = vec![];
1498 assert_eq!(option_len(Some(&v)), 0);
1499 }
1500
1501 #[test]
1502 fn option_len_some_with_items() {
1503 let v = vec![1, 2, 3];
1504 assert_eq!(option_len(Some(&v)), 3);
1505 }
1506
1507 #[test]
1510 fn snapshot_diff_is_empty_when_no_changes() {
1511 let diff = compare_snapshots(&[], &[]);
1512 assert!(diff.is_empty());
1513 }
1514
1515 #[test]
1516 fn snapshot_diff_is_not_empty_with_changes() {
1517 let after = vec![Issue {
1518 id: "A".to_string(),
1519 title: "New".to_string(),
1520 status: "open".to_string(),
1521 issue_type: "task".to_string(),
1522 ..Issue::default()
1523 }];
1524 let diff = compare_snapshots(&[], &after);
1525 assert!(!diff.is_empty());
1526 }
1527
1528 #[test]
1529 fn snapshot_diff_has_significant_changes_with_new_issues() {
1530 let after = vec![Issue {
1531 id: "A".to_string(),
1532 title: "New".to_string(),
1533 status: "open".to_string(),
1534 issue_type: "task".to_string(),
1535 ..Issue::default()
1536 }];
1537 let diff = compare_snapshots(&[], &after);
1538 assert!(diff.has_significant_changes());
1539 }
1540
1541 #[test]
1542 fn snapshot_diff_no_significant_changes_for_modification_only() {
1543 let before = vec![Issue {
1544 id: "A".to_string(),
1545 title: "Old".to_string(),
1546 status: "open".to_string(),
1547 issue_type: "task".to_string(),
1548 priority: 1,
1549 ..Issue::default()
1550 }];
1551 let after = vec![Issue {
1552 id: "A".to_string(),
1553 title: "New".to_string(),
1554 status: "open".to_string(),
1555 issue_type: "task".to_string(),
1556 priority: 1,
1557 ..Issue::default()
1558 }];
1559 let diff = compare_snapshots(&before, &after);
1560 assert!(!diff.has_significant_changes());
1562 }
1563
1564 #[test]
1567 fn closed_transition_strips_status_from_modified_changes() {
1568 let before = vec![Issue {
1569 id: "A".to_string(),
1570 title: "Old title".to_string(),
1571 status: "open".to_string(),
1572 issue_type: "task".to_string(),
1573 priority: 1,
1574 ..Issue::default()
1575 }];
1576 let after = vec![Issue {
1577 id: "A".to_string(),
1578 title: "New title".to_string(),
1579 status: "closed".to_string(),
1580 issue_type: "task".to_string(),
1581 priority: 1,
1582 ..Issue::default()
1583 }];
1584 let diff = compare_snapshots(&before, &after);
1585 assert_eq!(diff.closed_issues.as_ref().map_or(0, Vec::len), 1);
1586 let mods = diff.modified_issues.as_ref().unwrap();
1588 assert_eq!(mods.len(), 1);
1589 assert!(mods[0].changes.iter().any(|c| c.field == "title"));
1590 assert!(!mods[0].changes.iter().any(|c| c.field == "status"));
1591 }
1592
1593 #[test]
1594 fn reopen_transition_strips_status_from_modified_changes() {
1595 let before = vec![Issue {
1596 id: "A".to_string(),
1597 title: "Old".to_string(),
1598 status: "closed".to_string(),
1599 issue_type: "task".to_string(),
1600 priority: 1,
1601 ..Issue::default()
1602 }];
1603 let after = vec![Issue {
1604 id: "A".to_string(),
1605 title: "New".to_string(),
1606 status: "open".to_string(),
1607 issue_type: "task".to_string(),
1608 priority: 1,
1609 ..Issue::default()
1610 }];
1611 let diff = compare_snapshots(&before, &after);
1612 assert_eq!(diff.reopened_issues.as_ref().map_or(0, Vec::len), 1);
1613 let mods = diff.modified_issues.as_ref().unwrap();
1614 assert!(!mods[0].changes.iter().any(|c| c.field == "status"));
1615 }
1616
1617 #[test]
1618 fn saturating_i64_normal_values() {
1619 assert_eq!(saturating_i64(0), 0);
1620 assert_eq!(saturating_i64(42), 42);
1621 assert_eq!(saturating_i64(1_000_000), 1_000_000);
1622 }
1623
1624 #[test]
1625 fn delta_basic_subtraction() {
1626 assert_eq!(delta(10, 5), 5);
1627 assert_eq!(delta(5, 10), -5);
1628 assert_eq!(delta(0, 0), 0);
1629 assert_eq!(delta(7, 7), 0);
1630 }
1631
1632 #[test]
1633 fn delta_uses_saturating_sub_not_wrapping() {
1634 let big: usize = i64::MAX as usize;
1638 assert_eq!(delta(big, 0), i64::MAX);
1639 assert_eq!(delta(0, big), -i64::MAX);
1640 }
1641}