Skip to main content

bvr/analysis/
diff.rs

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        // Closing M-1 should change open_issues delta
922        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    // --- detect_changes tests ---
944
945    #[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    // --- normalize_cycle tests ---
1143
1144    #[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    // --- compare_cycles tests ---
1178
1179    #[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    // --- calculate_summary tests ---
1224
1225    #[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        // score: +2 (cycles resolved * 2) + 3 (issues closed) = 5 > 1 → improving
1245        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        // score: -3 (cycle introduced * 3) - 1 (reopened) = -4 < -1 → degrading
1261        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        // score: +1 (closed) - 1 (reopened) = 0 → stable
1277        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        // score: 0 + 2 (blocked decreased) = 2 > 1 → improving
1293        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        // score: 0 - 1 (blocked increased) = -1, which is NOT < -1 → stable
1309        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    // --- snapshot_counts tests ---
1353
1354    #[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); // open + blocked are both non-closed
1389        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    // --- average_map_value tests ---
1407
1408    #[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    // --- format_string_set tests ---
1431
1432    #[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    // --- non_empty tests ---
1455
1456    #[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    // --- into_option tests ---
1472
1473    #[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    // --- option_len tests ---
1488
1489    #[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    // --- SnapshotDiff::is_empty / has_significant_changes tests ---
1508
1509    #[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        // Only a title modification — no new/closed/reopened/cycles
1561        assert!(!diff.has_significant_changes());
1562    }
1563
1564    // --- status transition stripping test ---
1565
1566    #[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        // modified_issues should have title change but NOT status change
1587        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        // Both values at i64::MAX would yield 0 with plain subtraction,
1635        // but saturating_sub also yields 0 — the key difference is when
1636        // only one overflows: the delta should saturate rather than wrap.
1637        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}