1#![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#[derive(Debug, Error)]
19pub enum DiffError {
20 #[error("Report not found: {0}")]
22 ReportNotFound(PathBuf),
23
24 #[error("Failed to read report '{path}': {source}")]
26 ReadError {
27 path: PathBuf,
28 #[source]
29 source: std::io::Error,
30 },
31
32 #[error("Failed to parse report '{path}': {source}")]
34 ParseError {
35 path: PathBuf,
36 #[source]
37 source: serde_json::Error,
38 },
39
40 #[error("Invalid run identifier: {0}")]
42 InvalidRunId(String),
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct DigestDiff {
48 pub run_a: String,
50 pub run_b: String,
52 pub task_diffs: Vec<TaskDiff>,
54 pub summary: DiffSummary,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct TaskDiff {
61 pub name: String,
63 pub change_type: ChangeType,
65 pub changed_files: Vec<String>,
67 pub changed_env_vars: Vec<String>,
69 pub changed_upstream: Vec<String>,
71 pub secrets_changed: bool,
73 pub cache_key_a: Option<String>,
75 pub cache_key_b: Option<String>,
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(rename_all = "snake_case")]
82pub enum ChangeType {
83 Unchanged,
85 Modified,
87 Removed,
89 Added,
91 CacheInvalidated,
93}
94
95#[derive(Debug, Clone, Default, Serialize, Deserialize)]
97pub struct DiffSummary {
98 pub total_tasks: usize,
100 pub changed_tasks: usize,
102 pub added_tasks: usize,
104 pub removed_tasks: usize,
106 pub secret_changes: usize,
108 pub file_changes: usize,
110 pub env_changes: usize,
112}
113
114pub 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
125pub 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
142pub 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#[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 #[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 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 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); }
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")); assert!(output.contains("def4567")); assert!(output.contains("Summary:"));
581 assert!(output.contains("Total tasks: 1"));
582 assert!(output.contains("~ build")); assert!(output.contains("src/lib.rs")); }
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")); assert!(output.contains("- old-task")); 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 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 assert!(output.contains("abc"));
811 assert!(output.contains("def"));
812 }
813}