Skip to main content

cuenv_ci/
diff.rs

1//! Digest Diff Tool
2//!
3//! Compares two CI runs to identify what caused cache invalidation.
4//! Shows changed files, environment variables, and upstream outputs
5//! without exposing secret values.
6
7// Diff comparison involves complex field-by-field analysis
8#![allow(clippy::too_many_lines)]
9
10use crate::report::{PipelineReport, TaskReport};
11use serde::{Deserialize, Serialize};
12use std::collections::{HashMap, HashSet};
13use std::fs;
14use std::path::{Path, PathBuf};
15use thiserror::Error;
16
17/// Errors for diff operations
18#[derive(Debug, Error)]
19pub enum DiffError {
20    /// Report file not found
21    #[error("Report not found: {0}")]
22    ReportNotFound(PathBuf),
23
24    /// Failed to read report
25    #[error("Failed to read report '{path}': {source}")]
26    ReadError {
27        path: PathBuf,
28        #[source]
29        source: std::io::Error,
30    },
31
32    /// Failed to parse report
33    #[error("Failed to parse report '{path}': {source}")]
34    ParseError {
35        path: PathBuf,
36        #[source]
37        source: serde_json::Error,
38    },
39
40    /// Invalid run identifier
41    #[error("Invalid run identifier: {0}")]
42    InvalidRunId(String),
43}
44
45/// Result of comparing two CI runs
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct DigestDiff {
48    /// Run A identifier (typically commit SHA)
49    pub run_a: String,
50    /// Run B identifier
51    pub run_b: String,
52    /// Tasks that changed between runs
53    pub task_diffs: Vec<TaskDiff>,
54    /// Summary of changes
55    pub summary: DiffSummary,
56}
57
58/// Changes for a single task
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct TaskDiff {
61    /// Task name
62    pub name: String,
63    /// Change type
64    pub change_type: ChangeType,
65    /// Changed input files
66    pub changed_files: Vec<String>,
67    /// Changed environment variables (names only)
68    pub changed_env_vars: Vec<String>,
69    /// Changed upstream task outputs
70    pub changed_upstream: Vec<String>,
71    /// Whether secret fingerprint changed (no values exposed)
72    pub secrets_changed: bool,
73    /// Cache key in run A (if available)
74    pub cache_key_a: Option<String>,
75    /// Cache key in run B (if available)
76    pub cache_key_b: Option<String>,
77}
78
79/// Type of change for a task
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(rename_all = "snake_case")]
82pub enum ChangeType {
83    /// Task exists in both runs with same inputs
84    Unchanged,
85    /// Task inputs changed
86    Modified,
87    /// Task only exists in run A
88    Removed,
89    /// Task only exists in run B
90    Added,
91    /// Cache key changed but reason unknown
92    CacheInvalidated,
93}
94
95/// Summary statistics for the diff
96#[derive(Debug, Clone, Default, Serialize, Deserialize)]
97pub struct DiffSummary {
98    /// Total tasks compared
99    pub total_tasks: usize,
100    /// Tasks with changes
101    pub changed_tasks: usize,
102    /// Tasks added in run B
103    pub added_tasks: usize,
104    /// Tasks removed in run B
105    pub removed_tasks: usize,
106    /// Tasks with secret changes
107    pub secret_changes: usize,
108    /// Tasks with file changes
109    pub file_changes: usize,
110    /// Tasks with env var changes
111    pub env_changes: usize,
112}
113
114/// Compare two CI runs by their report files
115///
116/// # Errors
117///
118/// Returns `DiffError` if report files cannot be loaded.
119pub fn compare_runs(run_a: &Path, run_b: &Path) -> Result<DigestDiff, DiffError> {
120    let report_a = load_report(run_a)?;
121    let report_b = load_report(run_b)?;
122    compare_reports(&report_a, &report_b)
123}
124
125/// Compare two CI runs by commit SHA
126///
127/// # Errors
128///
129/// Returns `DiffError` if reports cannot be found or compared.
130pub fn compare_by_sha(
131    sha_a: &str,
132    sha_b: &str,
133    reports_dir: &Path,
134) -> Result<DigestDiff, DiffError> {
135    let dir_a = reports_dir.join(sha_a);
136    let dir_b = reports_dir.join(sha_b);
137    let report_a = find_first_report(&dir_a)?;
138    let report_b = find_first_report(&dir_b)?;
139    compare_runs(&report_a, &report_b)
140}
141
142/// Compare two pipeline reports
143///
144/// # Errors
145///
146/// Returns `DiffError` if report comparison fails.
147pub fn compare_reports(
148    report_a: &PipelineReport,
149    report_b: &PipelineReport,
150) -> Result<DigestDiff, DiffError> {
151    let mut task_diffs = Vec::new();
152    let mut summary = DiffSummary::default();
153
154    let old_tasks: HashMap<&str, &TaskReport> = report_a
155        .tasks
156        .iter()
157        .map(|t| (t.name.as_str(), t))
158        .collect();
159    let new_tasks: HashMap<&str, &TaskReport> = report_b
160        .tasks
161        .iter()
162        .map(|t| (t.name.as_str(), t))
163        .collect();
164
165    let all_tasks: HashSet<&str> = old_tasks.keys().chain(new_tasks.keys()).copied().collect();
166    summary.total_tasks = all_tasks.len();
167
168    for name in all_tasks {
169        let old_task = old_tasks.get(name);
170        let new_task = new_tasks.get(name);
171
172        let diff = match (old_task, new_task) {
173            (Some(a), Some(b)) => compare_tasks(name, a, b),
174            (Some(_), None) => TaskDiff {
175                name: name.to_string(),
176                change_type: ChangeType::Removed,
177                changed_files: vec![],
178                changed_env_vars: vec![],
179                changed_upstream: vec![],
180                secrets_changed: false,
181                cache_key_a: old_task.and_then(|t| t.cache_key.clone()),
182                cache_key_b: None,
183            },
184            (None, Some(_)) => TaskDiff {
185                name: name.to_string(),
186                change_type: ChangeType::Added,
187                changed_files: vec![],
188                changed_env_vars: vec![],
189                changed_upstream: vec![],
190                secrets_changed: false,
191                cache_key_a: None,
192                cache_key_b: new_task.and_then(|t| t.cache_key.clone()),
193            },
194            (None, None) => unreachable!(),
195        };
196
197        match diff.change_type {
198            ChangeType::Unchanged => {}
199            ChangeType::Modified | ChangeType::CacheInvalidated => summary.changed_tasks += 1,
200            ChangeType::Added => summary.added_tasks += 1,
201            ChangeType::Removed => summary.removed_tasks += 1,
202        }
203        if diff.secrets_changed {
204            summary.secret_changes += 1;
205        }
206        if !diff.changed_files.is_empty() {
207            summary.file_changes += 1;
208        }
209        if !diff.changed_env_vars.is_empty() {
210            summary.env_changes += 1;
211        }
212
213        task_diffs.push(diff);
214    }
215
216    task_diffs.sort_by(|a, b| {
217        let order = |ct: ChangeType| match ct {
218            ChangeType::Modified => 0,
219            ChangeType::CacheInvalidated => 1,
220            ChangeType::Added => 2,
221            ChangeType::Removed => 3,
222            ChangeType::Unchanged => 4,
223        };
224        order(a.change_type).cmp(&order(b.change_type))
225    });
226
227    Ok(DigestDiff {
228        run_a: report_a.context.sha.clone(),
229        run_b: report_b.context.sha.clone(),
230        task_diffs,
231        summary,
232    })
233}
234
235fn compare_tasks(name: &str, task_a: &TaskReport, task_b: &TaskReport) -> TaskDiff {
236    let mut changed_files = Vec::new();
237
238    let inputs_a: HashSet<&str> = task_a.inputs_matched.iter().map(String::as_str).collect();
239    let inputs_b: HashSet<&str> = task_b.inputs_matched.iter().map(String::as_str).collect();
240
241    for input in inputs_a.symmetric_difference(&inputs_b) {
242        changed_files.push((*input).to_string());
243    }
244
245    let secrets_changed = task_a.cache_key != task_b.cache_key
246        && changed_files.is_empty()
247        && task_a.cache_key.is_some()
248        && task_b.cache_key.is_some();
249
250    let change_type = if task_a.cache_key == task_b.cache_key {
251        ChangeType::Unchanged
252    } else if !changed_files.is_empty() {
253        ChangeType::Modified
254    } else {
255        ChangeType::CacheInvalidated
256    };
257
258    TaskDiff {
259        name: name.to_string(),
260        change_type,
261        changed_files,
262        changed_env_vars: vec![],
263        changed_upstream: vec![],
264        secrets_changed,
265        cache_key_a: task_a.cache_key.clone(),
266        cache_key_b: task_b.cache_key.clone(),
267    }
268}
269
270fn load_report(path: &Path) -> Result<PipelineReport, DiffError> {
271    if !path.exists() {
272        return Err(DiffError::ReportNotFound(path.to_path_buf()));
273    }
274    let contents = fs::read_to_string(path).map_err(|e| DiffError::ReadError {
275        path: path.to_path_buf(),
276        source: e,
277    })?;
278    serde_json::from_str(&contents).map_err(|e| DiffError::ParseError {
279        path: path.to_path_buf(),
280        source: e,
281    })
282}
283
284fn find_first_report(dir: &Path) -> Result<PathBuf, DiffError> {
285    if !dir.exists() {
286        return Err(DiffError::ReportNotFound(dir.to_path_buf()));
287    }
288    let entries = fs::read_dir(dir).map_err(|e| DiffError::ReadError {
289        path: dir.to_path_buf(),
290        source: e,
291    })?;
292    for entry in entries.flatten() {
293        let path = entry.path();
294        if path.extension().is_some_and(|ext| ext == "json") {
295            return Ok(path);
296        }
297    }
298    Err(DiffError::ReportNotFound(dir.to_path_buf()))
299}
300
301/// Format a diff for human-readable output
302#[must_use]
303pub fn format_diff(diff: &DigestDiff) -> String {
304    use std::fmt::Write;
305
306    let mut output = String::new();
307    let _ = writeln!(
308        output,
309        "Comparing runs: {} -> {}\n",
310        &diff.run_a[..7.min(diff.run_a.len())],
311        &diff.run_b[..7.min(diff.run_b.len())]
312    );
313    output.push_str("Summary:\n");
314    let _ = writeln!(output, "  Total tasks: {}", diff.summary.total_tasks);
315    let _ = writeln!(output, "  Changed: {}", diff.summary.changed_tasks);
316    let _ = writeln!(output, "  Added: {}", diff.summary.added_tasks);
317    let _ = writeln!(output, "  Removed: {}", diff.summary.removed_tasks);
318    if diff.summary.secret_changes > 0 {
319        let _ = writeln!(output, "  Secret changes: {}", diff.summary.secret_changes);
320    }
321    output.push('\n');
322
323    for task in &diff.task_diffs {
324        if task.change_type == ChangeType::Unchanged {
325            continue;
326        }
327        let symbol = match task.change_type {
328            ChangeType::Modified => "~",
329            ChangeType::CacheInvalidated => "!",
330            ChangeType::Added => "+",
331            ChangeType::Removed => "-",
332            ChangeType::Unchanged => " ",
333        };
334        let _ = writeln!(output, "{} {}", symbol, task.name);
335        if !task.changed_files.is_empty() {
336            output.push_str("  Changed files:\n");
337            for file in &task.changed_files {
338                let _ = writeln!(output, "    - {file}");
339            }
340        }
341        if task.secrets_changed {
342            output.push_str("  Secrets: changed (values hidden)\n");
343        }
344        output.push('\n');
345    }
346    output
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352    use crate::report::{ContextReport, PipelineStatus, TaskStatus};
353    use chrono::Utc;
354    use tempfile::TempDir;
355
356    fn make_report(sha: &str, tasks: Vec<TaskReport>) -> PipelineReport {
357        PipelineReport {
358            version: "1.0".to_string(),
359            project: "test".to_string(),
360            pipeline: "test-pipeline".to_string(),
361            context: ContextReport {
362                provider: "test".to_string(),
363                event: "push".to_string(),
364                ref_name: "refs/heads/main".to_string(),
365                base_ref: None,
366                sha: sha.to_string(),
367                changed_files: vec![],
368            },
369            started_at: Utc::now(),
370            completed_at: Some(Utc::now()),
371            duration_ms: Some(1000),
372            status: PipelineStatus::Success,
373            tasks,
374        }
375    }
376
377    fn make_task(name: &str, inputs: Vec<&str>, cache_key: Option<&str>) -> TaskReport {
378        TaskReport {
379            name: name.to_string(),
380            status: TaskStatus::Success,
381            duration_ms: 100,
382            exit_code: Some(0),
383            inputs_matched: inputs.into_iter().map(String::from).collect(),
384            cache_key: cache_key.map(String::from),
385            outputs: vec![],
386        }
387    }
388
389    #[test]
390    fn test_unchanged_tasks() {
391        let report_a = make_report(
392            "abc123",
393            vec![make_task("build", vec!["src/main.rs"], Some("key1"))],
394        );
395        let report_b = make_report(
396            "def456",
397            vec![make_task("build", vec!["src/main.rs"], Some("key1"))],
398        );
399        let diff = compare_reports(&report_a, &report_b).unwrap();
400        assert_eq!(diff.task_diffs[0].change_type, ChangeType::Unchanged);
401    }
402
403    #[test]
404    fn test_modified_task() {
405        let report_a = make_report(
406            "abc123",
407            vec![make_task("build", vec!["src/main.rs"], Some("key1"))],
408        );
409        let report_b = make_report(
410            "def456",
411            vec![make_task(
412                "build",
413                vec!["src/main.rs", "src/lib.rs"],
414                Some("key2"),
415            )],
416        );
417        let diff = compare_reports(&report_a, &report_b).unwrap();
418        assert_eq!(diff.task_diffs[0].change_type, ChangeType::Modified);
419        assert!(
420            diff.task_diffs[0]
421                .changed_files
422                .contains(&"src/lib.rs".to_string())
423        );
424    }
425
426    #[test]
427    fn test_secret_change_detection() {
428        let report_a = make_report(
429            "abc123",
430            vec![make_task("deploy", vec!["config.yml"], Some("key1"))],
431        );
432        let report_b = make_report(
433            "def456",
434            vec![make_task("deploy", vec!["config.yml"], Some("key2"))],
435        );
436        let diff = compare_reports(&report_a, &report_b).unwrap();
437        assert!(diff.task_diffs[0].secrets_changed);
438    }
439
440    // --- New tests for comprehensive coverage ---
441
442    #[test]
443    fn test_diff_error_report_not_found() {
444        let err = DiffError::ReportNotFound(PathBuf::from("/missing/report.json"));
445        let msg = err.to_string();
446        assert!(msg.contains("Report not found"));
447        assert!(msg.contains("/missing/report.json"));
448    }
449
450    #[test]
451    fn test_diff_error_read_error() {
452        let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
453        let err = DiffError::ReadError {
454            path: PathBuf::from("/path/to/file.json"),
455            source: io_err,
456        };
457        let msg = err.to_string();
458        assert!(msg.contains("Failed to read report"));
459        assert!(msg.contains("/path/to/file.json"));
460    }
461
462    #[test]
463    fn test_diff_error_parse_error() {
464        let json_err = serde_json::from_str::<PipelineReport>("invalid json").unwrap_err();
465        let err = DiffError::ParseError {
466            path: PathBuf::from("/path/to/file.json"),
467            source: json_err,
468        };
469        let msg = err.to_string();
470        assert!(msg.contains("Failed to parse report"));
471    }
472
473    #[test]
474    fn test_diff_error_invalid_run_id() {
475        let err = DiffError::InvalidRunId("bad-id".to_string());
476        let msg = err.to_string();
477        assert!(msg.contains("Invalid run identifier"));
478        assert!(msg.contains("bad-id"));
479    }
480
481    #[test]
482    fn test_task_added() {
483        let report_a = make_report(
484            "abc123",
485            vec![make_task("build", vec!["src/main.rs"], Some("key1"))],
486        );
487        let report_b = make_report(
488            "def456",
489            vec![
490                make_task("build", vec!["src/main.rs"], Some("key1")),
491                make_task("test", vec!["tests/test.rs"], Some("key2")),
492            ],
493        );
494        let diff = compare_reports(&report_a, &report_b).unwrap();
495
496        // Find the added task
497        let added_task = diff.task_diffs.iter().find(|t| t.name == "test").unwrap();
498        assert_eq!(added_task.change_type, ChangeType::Added);
499        assert_eq!(diff.summary.added_tasks, 1);
500    }
501
502    #[test]
503    fn test_task_removed() {
504        let report_a = make_report(
505            "abc123",
506            vec![
507                make_task("build", vec!["src/main.rs"], Some("key1")),
508                make_task("test", vec!["tests/test.rs"], Some("key2")),
509            ],
510        );
511        let report_b = make_report(
512            "def456",
513            vec![make_task("build", vec!["src/main.rs"], Some("key1"))],
514        );
515        let diff = compare_reports(&report_a, &report_b).unwrap();
516
517        // Find the removed task
518        let removed_task = diff.task_diffs.iter().find(|t| t.name == "test").unwrap();
519        assert_eq!(removed_task.change_type, ChangeType::Removed);
520        assert_eq!(diff.summary.removed_tasks, 1);
521    }
522
523    #[test]
524    fn test_cache_invalidated_no_file_changes() {
525        let report_a = make_report(
526            "abc123",
527            vec![make_task("build", vec!["src/main.rs"], Some("key1"))],
528        );
529        let report_b = make_report(
530            "def456",
531            vec![make_task("build", vec!["src/main.rs"], Some("key2"))],
532        );
533        let diff = compare_reports(&report_a, &report_b).unwrap();
534        assert_eq!(diff.task_diffs[0].change_type, ChangeType::CacheInvalidated);
535    }
536
537    #[test]
538    fn test_summary_counts() {
539        let report_a = make_report(
540            "abc123",
541            vec![
542                make_task("build", vec!["src/main.rs"], Some("key1")),
543                make_task("old-task", vec!["old.rs"], Some("old-key")),
544            ],
545        );
546        let report_b = make_report(
547            "def456",
548            vec![
549                make_task("build", vec!["src/main.rs", "src/new.rs"], Some("key2")),
550                make_task("new-task", vec!["new.rs"], Some("new-key")),
551            ],
552        );
553        let diff = compare_reports(&report_a, &report_b).unwrap();
554
555        assert_eq!(diff.summary.total_tasks, 3);
556        assert_eq!(diff.summary.added_tasks, 1);
557        assert_eq!(diff.summary.removed_tasks, 1);
558        assert_eq!(diff.summary.file_changes, 1); // build changed files
559    }
560
561    #[test]
562    fn test_format_diff_basic() {
563        let report_a = make_report(
564            "abc1234567890",
565            vec![make_task("build", vec!["src/main.rs"], Some("key1"))],
566        );
567        let report_b = make_report(
568            "def4567890abc",
569            vec![make_task(
570                "build",
571                vec!["src/main.rs", "src/lib.rs"],
572                Some("key2"),
573            )],
574        );
575        let diff = compare_reports(&report_a, &report_b).unwrap();
576        let output = format_diff(&diff);
577
578        assert!(output.contains("abc1234")); // shortened SHA
579        assert!(output.contains("def4567")); // shortened SHA
580        assert!(output.contains("Summary:"));
581        assert!(output.contains("Total tasks: 1"));
582        assert!(output.contains("~ build")); // modified task
583        assert!(output.contains("src/lib.rs")); // changed file
584    }
585
586    #[test]
587    fn test_format_diff_with_secrets() {
588        let report_a = make_report(
589            "abc123",
590            vec![make_task("deploy", vec!["config.yml"], Some("key1"))],
591        );
592        let report_b = make_report(
593            "def456",
594            vec![make_task("deploy", vec!["config.yml"], Some("key2"))],
595        );
596        let diff = compare_reports(&report_a, &report_b).unwrap();
597        let output = format_diff(&diff);
598
599        assert!(output.contains("Secrets: changed (values hidden)"));
600        assert!(output.contains("Secret changes: 1"));
601    }
602
603    #[test]
604    fn test_format_diff_added_removed() {
605        let report_a = make_report(
606            "abc123",
607            vec![make_task("old-task", vec!["old.rs"], Some("key1"))],
608        );
609        let report_b = make_report(
610            "def456",
611            vec![make_task("new-task", vec!["new.rs"], Some("key2"))],
612        );
613        let diff = compare_reports(&report_a, &report_b).unwrap();
614        let output = format_diff(&diff);
615
616        assert!(output.contains("+ new-task")); // added
617        assert!(output.contains("- old-task")); // removed
618        assert!(output.contains("Added: 1"));
619        assert!(output.contains("Removed: 1"));
620    }
621
622    #[test]
623    fn test_compare_runs_success() {
624        let temp_dir = TempDir::new().unwrap();
625        let report_a_path = temp_dir.path().join("report_a.json");
626        let report_b_path = temp_dir.path().join("report_b.json");
627
628        let report_a = make_report(
629            "abc123",
630            vec![make_task("build", vec!["src/main.rs"], Some("key1"))],
631        );
632        let report_b = make_report(
633            "def456",
634            vec![make_task("build", vec!["src/main.rs"], Some("key1"))],
635        );
636
637        std::fs::write(&report_a_path, serde_json::to_string(&report_a).unwrap()).unwrap();
638        std::fs::write(&report_b_path, serde_json::to_string(&report_b).unwrap()).unwrap();
639
640        let diff = compare_runs(&report_a_path, &report_b_path).unwrap();
641        assert_eq!(diff.run_a, "abc123");
642        assert_eq!(diff.run_b, "def456");
643    }
644
645    #[test]
646    fn test_compare_runs_file_not_found() {
647        let result = compare_runs(
648            Path::new("/nonexistent/a.json"),
649            Path::new("/nonexistent/b.json"),
650        );
651        assert!(result.is_err());
652        match result.unwrap_err() {
653            DiffError::ReportNotFound(path) => {
654                assert!(path.to_string_lossy().contains("nonexistent"));
655            }
656            _ => panic!("Expected ReportNotFound error"),
657        }
658    }
659
660    #[test]
661    fn test_load_report_invalid_json() {
662        let temp_dir = TempDir::new().unwrap();
663        let report_path = temp_dir.path().join("invalid.json");
664        std::fs::write(&report_path, "not valid json").unwrap();
665
666        let result = load_report(&report_path);
667        assert!(result.is_err());
668        match result.unwrap_err() {
669            DiffError::ParseError { path, .. } => assert_eq!(path, report_path),
670            _ => panic!("Expected ParseError"),
671        }
672    }
673
674    #[test]
675    fn test_find_first_report_success() {
676        let temp_dir = TempDir::new().unwrap();
677        let report_path = temp_dir.path().join("report.json");
678        std::fs::write(&report_path, "{}").unwrap();
679
680        let found = find_first_report(temp_dir.path()).unwrap();
681        assert_eq!(found, report_path);
682    }
683
684    #[test]
685    fn test_find_first_report_no_json() {
686        let temp_dir = TempDir::new().unwrap();
687        std::fs::write(temp_dir.path().join("file.txt"), "not json").unwrap();
688
689        let result = find_first_report(temp_dir.path());
690        assert!(result.is_err());
691    }
692
693    #[test]
694    fn test_find_first_report_dir_not_exists() {
695        let result = find_first_report(Path::new("/nonexistent/dir"));
696        assert!(result.is_err());
697    }
698
699    #[test]
700    fn test_compare_by_sha_success() {
701        let temp_dir = TempDir::new().unwrap();
702        let dir_sha_a = temp_dir.path().join("abc123");
703        let dir_sha_b = temp_dir.path().join("def456");
704        std::fs::create_dir_all(&dir_sha_a).unwrap();
705        std::fs::create_dir_all(&dir_sha_b).unwrap();
706
707        let report_a = make_report(
708            "abc123",
709            vec![make_task("build", vec!["src/main.rs"], Some("key1"))],
710        );
711        let report_b = make_report(
712            "def456",
713            vec![make_task("build", vec!["src/main.rs"], Some("key2"))],
714        );
715
716        std::fs::write(
717            dir_sha_a.join("report.json"),
718            serde_json::to_string(&report_a).unwrap(),
719        )
720        .unwrap();
721        std::fs::write(
722            dir_sha_b.join("report.json"),
723            serde_json::to_string(&report_b).unwrap(),
724        )
725        .unwrap();
726
727        let diff = compare_by_sha("abc123", "def456", temp_dir.path()).unwrap();
728        assert_eq!(diff.run_a, "abc123");
729        assert_eq!(diff.run_b, "def456");
730    }
731
732    #[test]
733    fn test_digest_diff_serialization() {
734        let diff = DigestDiff {
735            run_a: "abc123".to_string(),
736            run_b: "def456".to_string(),
737            task_diffs: vec![TaskDiff {
738                name: "build".to_string(),
739                change_type: ChangeType::Modified,
740                changed_files: vec!["src/main.rs".to_string()],
741                changed_env_vars: vec![],
742                changed_upstream: vec![],
743                secrets_changed: false,
744                cache_key_a: Some("key1".to_string()),
745                cache_key_b: Some("key2".to_string()),
746            }],
747            summary: DiffSummary {
748                total_tasks: 1,
749                changed_tasks: 1,
750                added_tasks: 0,
751                removed_tasks: 0,
752                secret_changes: 0,
753                file_changes: 1,
754                env_changes: 0,
755            },
756        };
757
758        let json = serde_json::to_string(&diff).unwrap();
759        let parsed: DigestDiff = serde_json::from_str(&json).unwrap();
760        assert_eq!(parsed.run_a, "abc123");
761        assert_eq!(parsed.task_diffs.len(), 1);
762    }
763
764    #[test]
765    fn test_change_type_serialization() {
766        let ct = ChangeType::Modified;
767        let json = serde_json::to_string(&ct).unwrap();
768        assert_eq!(json, "\"modified\"");
769
770        let ct2: ChangeType = serde_json::from_str("\"cache_invalidated\"").unwrap();
771        assert_eq!(ct2, ChangeType::CacheInvalidated);
772    }
773
774    #[test]
775    fn test_diff_summary_default() {
776        let summary = DiffSummary::default();
777        assert_eq!(summary.total_tasks, 0);
778        assert_eq!(summary.changed_tasks, 0);
779        assert_eq!(summary.added_tasks, 0);
780        assert_eq!(summary.removed_tasks, 0);
781        assert_eq!(summary.secret_changes, 0);
782        assert_eq!(summary.file_changes, 0);
783        assert_eq!(summary.env_changes, 0);
784    }
785
786    #[test]
787    fn test_task_no_cache_keys() {
788        let report_a = make_report(
789            "abc123",
790            vec![make_task("build", vec!["src/main.rs"], None)],
791        );
792        let report_b = make_report(
793            "def456",
794            vec![make_task("build", vec!["src/main.rs"], None)],
795        );
796        let diff = compare_reports(&report_a, &report_b).unwrap();
797        // Without cache keys, tasks should be unchanged
798        assert_eq!(diff.task_diffs[0].change_type, ChangeType::Unchanged);
799        assert!(!diff.task_diffs[0].secrets_changed);
800    }
801
802    #[test]
803    fn test_format_diff_short_sha() {
804        let report_a = make_report("abc", vec![]);
805        let report_b = make_report("def", vec![]);
806        let diff = compare_reports(&report_a, &report_b).unwrap();
807        let output = format_diff(&diff);
808
809        // Short SHAs should be displayed as-is
810        assert!(output.contains("abc"));
811        assert!(output.contains("def"));
812    }
813}