1use crate::error::{AuditError, Result};
2use serde::{Deserialize, Serialize};
3use sha2::{Digest, Sha256};
4use std::collections::HashMap;
5use std::fs;
6use std::path::Path;
7
8const BASELINE_FILENAME: &str = ".cc-audit-baseline.json";
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct Baseline {
13 pub version: String,
15 pub created_at: String,
17 pub file_hashes: HashMap<String, FileHash>,
19 pub file_count: usize,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct FileHash {
25 pub hash: String,
26 pub size: u64,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct DriftReport {
32 pub modified: Vec<DriftEntry>,
34 pub added: Vec<String>,
36 pub removed: Vec<String>,
38 pub has_drift: bool,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct DriftEntry {
44 pub path: String,
45 pub baseline_hash: String,
46 pub current_hash: String,
47}
48
49impl Baseline {
50 pub fn from_directory(dir: &Path) -> Result<Self> {
52 let mut file_hashes = HashMap::new();
53
54 if dir.is_file() {
55 let hash = Self::hash_file(dir)?;
57 file_hashes.insert(dir.display().to_string(), hash);
58 } else if dir.is_dir() {
59 for entry in walkdir::WalkDir::new(dir)
61 .into_iter()
62 .filter_map(|e| e.ok())
63 .filter(|e| e.file_type().is_file())
64 .filter(|e| Self::is_relevant_file(e.path()))
65 {
66 let path = entry.path();
67 let relative_path = path.strip_prefix(dir).unwrap_or(path).display().to_string();
68 let hash = Self::hash_file(path)?;
69 file_hashes.insert(relative_path, hash);
70 }
71 }
72
73 let file_count = file_hashes.len();
74
75 Ok(Self {
76 version: env!("CARGO_PKG_VERSION").to_string(),
77 created_at: chrono::Utc::now().to_rfc3339(),
78 file_hashes,
79 file_count,
80 })
81 }
82
83 fn is_relevant_file(path: &Path) -> bool {
85 let name = path
86 .file_name()
87 .and_then(|n| n.to_str())
88 .unwrap_or_default();
89 let ext = path
90 .extension()
91 .and_then(|e| e.to_str())
92 .unwrap_or_default()
93 .to_lowercase();
94
95 matches!(
97 ext.as_str(),
98 "md" | "json" | "yaml" | "yml" | "toml" | "sh" | "bash" | "zsh"
99 ) || matches!(
100 name.to_lowercase().as_str(),
101 "skill.md"
102 | "mcp.json"
103 | ".mcp.json"
104 | "settings.json"
105 | "dockerfile"
106 | "package.json"
107 | "cargo.toml"
108 | "requirements.txt"
109 )
110 }
111
112 fn hash_file(path: &Path) -> Result<FileHash> {
114 let content = fs::read(path).map_err(|e| AuditError::ReadError {
115 path: path.display().to_string(),
116 source: e,
117 })?;
118
119 let mut hasher = Sha256::new();
120 hasher.update(&content);
121 let hash = format!("{:x}", hasher.finalize());
122
123 let metadata = fs::metadata(path).map_err(|e| AuditError::ReadError {
124 path: path.display().to_string(),
125 source: e,
126 })?;
127
128 Ok(FileHash {
129 hash,
130 size: metadata.len(),
131 })
132 }
133
134 pub fn save(&self, dir: &Path) -> Result<()> {
136 let baseline_path = if dir.is_file() {
137 dir.parent()
138 .unwrap_or(Path::new("."))
139 .join(BASELINE_FILENAME)
140 } else {
141 dir.join(BASELINE_FILENAME)
142 };
143
144 self.save_to_file(&baseline_path)
145 }
146
147 pub fn save_to_file(&self, path: &Path) -> Result<()> {
149 let json = serde_json::to_string_pretty(self).map_err(|e| AuditError::ParseError {
150 path: path.display().to_string(),
151 message: e.to_string(),
152 })?;
153
154 fs::write(path, json).map_err(|e| AuditError::ReadError {
155 path: path.display().to_string(),
156 source: e,
157 })?;
158
159 Ok(())
160 }
161
162 pub fn load(dir: &Path) -> Result<Self> {
164 let baseline_path = if dir.is_file() {
165 dir.parent()
166 .unwrap_or(Path::new("."))
167 .join(BASELINE_FILENAME)
168 } else {
169 dir.join(BASELINE_FILENAME)
170 };
171
172 Self::load_from_file(&baseline_path)
173 }
174
175 pub fn load_from_file(path: &Path) -> Result<Self> {
177 if !path.exists() {
178 return Err(AuditError::FileNotFound(path.display().to_string()));
179 }
180
181 let content = fs::read_to_string(path).map_err(|e| AuditError::ReadError {
182 path: path.display().to_string(),
183 source: e,
184 })?;
185
186 serde_json::from_str(&content).map_err(|e| AuditError::ParseError {
187 path: path.display().to_string(),
188 message: e.to_string(),
189 })
190 }
191
192 pub fn check_drift(&self, dir: &Path) -> Result<DriftReport> {
194 let current = Self::from_directory(dir)?;
195
196 let mut modified = Vec::new();
197 let mut added = Vec::new();
198 let mut removed = Vec::new();
199
200 for (path, baseline_hash) in &self.file_hashes {
202 match current.file_hashes.get(path) {
203 Some(current_hash) => {
204 if baseline_hash.hash != current_hash.hash {
205 modified.push(DriftEntry {
206 path: path.clone(),
207 baseline_hash: baseline_hash.hash.clone(),
208 current_hash: current_hash.hash.clone(),
209 });
210 }
211 }
212 None => {
213 removed.push(path.clone());
214 }
215 }
216 }
217
218 for path in current.file_hashes.keys() {
220 if !self.file_hashes.contains_key(path) {
221 added.push(path.clone());
222 }
223 }
224
225 let has_drift = !modified.is_empty() || !added.is_empty() || !removed.is_empty();
226
227 Ok(DriftReport {
228 modified,
229 added,
230 removed,
231 has_drift,
232 })
233 }
234}
235
236impl DriftReport {
237 pub fn format_terminal(&self) -> String {
239 use colored::Colorize;
240
241 let mut output = String::new();
242
243 if !self.has_drift {
244 output.push_str(
245 &"No drift detected. Baseline is up to date.\n"
246 .green()
247 .to_string(),
248 );
249 return output;
250 }
251
252 output.push_str(&format!(
253 "{}\n\n",
254 "━━━ DRIFT DETECTED (Rug Pull Alert) ━━━".red().bold()
255 ));
256
257 if !self.modified.is_empty() {
258 output.push_str(&format!("{}\n", "Modified files:".yellow().bold()));
259 for entry in &self.modified {
260 output.push_str(&format!(" {} {}\n", "~".yellow(), entry.path));
261 let baseline_display = if entry.baseline_hash.len() >= 16 {
262 &entry.baseline_hash[..16]
263 } else {
264 &entry.baseline_hash
265 };
266 let current_display = if entry.current_hash.len() >= 16 {
267 &entry.current_hash[..16]
268 } else {
269 &entry.current_hash
270 };
271 output.push_str(&format!(" Baseline: {}\n", baseline_display));
272 output.push_str(&format!(" Current: {}\n", current_display));
273 }
274 output.push('\n');
275 }
276
277 if !self.added.is_empty() {
278 output.push_str(&format!("{}\n", "Added files:".green().bold()));
279 for path in &self.added {
280 output.push_str(&format!(" {} {}\n", "+".green(), path));
281 }
282 output.push('\n');
283 }
284
285 if !self.removed.is_empty() {
286 output.push_str(&format!("{}\n", "Removed files:".red().bold()));
287 for path in &self.removed {
288 output.push_str(&format!(" {} {}\n", "-".red(), path));
289 }
290 output.push('\n');
291 }
292
293 output.push_str(&format!(
294 "Summary: {} modified, {} added, {} removed\n",
295 self.modified.len(),
296 self.added.len(),
297 self.removed.len()
298 ));
299
300 output
301 }
302}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307 use tempfile::TempDir;
308
309 #[test]
310 fn test_baseline_from_directory() {
311 let temp_dir = TempDir::new().unwrap();
312 let skill_md = temp_dir.path().join("SKILL.md");
313 fs::write(&skill_md, "# Test Skill").unwrap();
314
315 let baseline = Baseline::from_directory(temp_dir.path()).unwrap();
316 assert_eq!(baseline.file_count, 1);
317 assert!(baseline.file_hashes.contains_key("SKILL.md"));
318 }
319
320 #[test]
321 fn test_baseline_save_and_load() {
322 let temp_dir = TempDir::new().unwrap();
323 let skill_md = temp_dir.path().join("SKILL.md");
324 fs::write(&skill_md, "# Test Skill").unwrap();
325
326 let baseline = Baseline::from_directory(temp_dir.path()).unwrap();
327 baseline.save(temp_dir.path()).unwrap();
328
329 let loaded = Baseline::load(temp_dir.path()).unwrap();
330 assert_eq!(baseline.file_count, loaded.file_count);
331 }
332
333 #[test]
334 fn test_drift_detection_no_changes() {
335 let temp_dir = TempDir::new().unwrap();
336 let skill_md = temp_dir.path().join("SKILL.md");
337 fs::write(&skill_md, "# Test Skill").unwrap();
338
339 let baseline = Baseline::from_directory(temp_dir.path()).unwrap();
340 let drift = baseline.check_drift(temp_dir.path()).unwrap();
341
342 assert!(!drift.has_drift);
343 assert!(drift.modified.is_empty());
344 assert!(drift.added.is_empty());
345 assert!(drift.removed.is_empty());
346 }
347
348 #[test]
349 fn test_drift_detection_modified_file() {
350 let temp_dir = TempDir::new().unwrap();
351 let skill_md = temp_dir.path().join("SKILL.md");
352 fs::write(&skill_md, "# Test Skill").unwrap();
353
354 let baseline = Baseline::from_directory(temp_dir.path()).unwrap();
355
356 fs::write(&skill_md, "# Modified Skill with malicious content").unwrap();
358
359 let drift = baseline.check_drift(temp_dir.path()).unwrap();
360
361 assert!(drift.has_drift);
362 assert_eq!(drift.modified.len(), 1);
363 assert_eq!(drift.modified[0].path, "SKILL.md");
364 }
365
366 #[test]
367 fn test_drift_detection_added_file() {
368 let temp_dir = TempDir::new().unwrap();
369 let skill_md = temp_dir.path().join("SKILL.md");
370 fs::write(&skill_md, "# Test Skill").unwrap();
371
372 let baseline = Baseline::from_directory(temp_dir.path()).unwrap();
373
374 let new_file = temp_dir.path().join("mcp.json");
376 fs::write(&new_file, "{}").unwrap();
377
378 let drift = baseline.check_drift(temp_dir.path()).unwrap();
379
380 assert!(drift.has_drift);
381 assert_eq!(drift.added.len(), 1);
382 assert!(drift.added.contains(&"mcp.json".to_string()));
383 }
384
385 #[test]
386 fn test_drift_detection_removed_file() {
387 let temp_dir = TempDir::new().unwrap();
388 let skill_md = temp_dir.path().join("SKILL.md");
389 fs::write(&skill_md, "# Test Skill").unwrap();
390
391 let baseline = Baseline::from_directory(temp_dir.path()).unwrap();
392
393 fs::remove_file(&skill_md).unwrap();
395
396 let drift = baseline.check_drift(temp_dir.path()).unwrap();
397
398 assert!(drift.has_drift);
399 assert_eq!(drift.removed.len(), 1);
400 assert!(drift.removed.contains(&"SKILL.md".to_string()));
401 }
402
403 #[test]
404 fn test_hash_consistency() {
405 let temp_dir = TempDir::new().unwrap();
406 let skill_md = temp_dir.path().join("SKILL.md");
407 fs::write(&skill_md, "# Test Skill").unwrap();
408
409 let hash1 = Baseline::hash_file(&skill_md).unwrap();
410 let hash2 = Baseline::hash_file(&skill_md).unwrap();
411
412 assert_eq!(hash1.hash, hash2.hash);
413 }
414
415 #[test]
416 fn test_baseline_load_not_found() {
417 let temp_dir = TempDir::new().unwrap();
418 let result = Baseline::load(temp_dir.path());
419 assert!(result.is_err());
420 }
421
422 #[test]
423 fn test_drift_report_format() {
424 let report = DriftReport {
425 modified: vec![DriftEntry {
426 path: "SKILL.md".to_string(),
427 baseline_hash: "abc123def456789012345678901234567890123456789012345678901234"
429 .to_string(),
430 current_hash: "def456abc123789012345678901234567890123456789012345678901234"
431 .to_string(),
432 }],
433 added: vec!["new.json".to_string()],
434 removed: vec!["old.md".to_string()],
435 has_drift: true,
436 };
437
438 let output = report.format_terminal();
439 assert!(output.contains("DRIFT DETECTED"));
440 assert!(output.contains("Modified files"));
441 assert!(output.contains("Added files"));
442 assert!(output.contains("Removed files"));
443 }
444
445 #[test]
446 fn test_drift_report_format_no_drift() {
447 let report = DriftReport {
448 modified: vec![],
449 added: vec![],
450 removed: vec![],
451 has_drift: false,
452 };
453
454 let output = report.format_terminal();
455 assert!(output.contains("No drift detected"));
456 }
457
458 #[test]
459 fn test_save_and_load_from_file() {
460 let temp_dir = TempDir::new().unwrap();
461 let skill_md = temp_dir.path().join("SKILL.md");
462 fs::write(&skill_md, "# Test Skill").unwrap();
463
464 let baseline = Baseline::from_directory(temp_dir.path()).unwrap();
465 let custom_path = temp_dir.path().join("custom-baseline.json");
466
467 baseline.save_to_file(&custom_path).unwrap();
468 let loaded = Baseline::load_from_file(&custom_path).unwrap();
469
470 assert_eq!(baseline.file_count, loaded.file_count);
471 }
472
473 #[test]
474 fn test_load_from_file_not_found() {
475 let temp_dir = TempDir::new().unwrap();
476 let nonexistent = temp_dir.path().join("does-not-exist.json");
477
478 let result = Baseline::load_from_file(&nonexistent);
479 assert!(result.is_err());
480 }
481
482 #[test]
483 fn test_baseline_from_single_file() {
484 let temp_dir = TempDir::new().unwrap();
485 let skill_md = temp_dir.path().join("SKILL.md");
486 fs::write(&skill_md, "# Test Skill").unwrap();
487
488 let baseline = Baseline::from_directory(&skill_md).unwrap();
489 assert_eq!(baseline.file_count, 1);
490 }
491
492 #[test]
493 fn test_save_baseline_for_single_file() {
494 let temp_dir = TempDir::new().unwrap();
495 let skill_md = temp_dir.path().join("SKILL.md");
496 fs::write(&skill_md, "# Test Skill").unwrap();
497
498 let baseline = Baseline::from_directory(&skill_md).unwrap();
499 baseline.save(&skill_md).unwrap();
500
501 let baseline_file = temp_dir.path().join(BASELINE_FILENAME);
502 assert!(baseline_file.exists());
503 }
504
505 #[test]
506 fn test_load_baseline_for_single_file() {
507 let temp_dir = TempDir::new().unwrap();
508 let skill_md = temp_dir.path().join("SKILL.md");
509 fs::write(&skill_md, "# Test Skill").unwrap();
510
511 let baseline = Baseline::from_directory(&skill_md).unwrap();
512 baseline.save(&skill_md).unwrap();
513
514 let loaded = Baseline::load(&skill_md).unwrap();
515 assert_eq!(baseline.file_count, loaded.file_count);
516 }
517
518 #[test]
519 fn test_is_relevant_file_extensions() {
520 assert!(Baseline::is_relevant_file(Path::new("file.md")));
522 assert!(Baseline::is_relevant_file(Path::new("file.json")));
523 assert!(Baseline::is_relevant_file(Path::new("file.yaml")));
524 assert!(Baseline::is_relevant_file(Path::new("file.yml")));
525 assert!(Baseline::is_relevant_file(Path::new("file.toml")));
526 assert!(Baseline::is_relevant_file(Path::new("file.sh")));
527 assert!(Baseline::is_relevant_file(Path::new("file.bash")));
528 assert!(Baseline::is_relevant_file(Path::new("file.zsh")));
529
530 assert!(!Baseline::is_relevant_file(Path::new("file.txt")));
532 assert!(!Baseline::is_relevant_file(Path::new("file.exe")));
533 assert!(!Baseline::is_relevant_file(Path::new("file.bin")));
534 }
535
536 #[test]
537 fn test_is_relevant_file_names() {
538 assert!(Baseline::is_relevant_file(Path::new("SKILL.md")));
540 assert!(Baseline::is_relevant_file(Path::new("skill.md")));
541 assert!(Baseline::is_relevant_file(Path::new("mcp.json")));
542 assert!(Baseline::is_relevant_file(Path::new(".mcp.json")));
543 assert!(Baseline::is_relevant_file(Path::new("settings.json")));
544 assert!(Baseline::is_relevant_file(Path::new("Dockerfile")));
545 assert!(Baseline::is_relevant_file(Path::new("dockerfile")));
546 assert!(Baseline::is_relevant_file(Path::new("package.json")));
547 assert!(Baseline::is_relevant_file(Path::new("Cargo.toml")));
548 assert!(Baseline::is_relevant_file(Path::new("requirements.txt")));
549 }
550
551 #[test]
552 fn test_baseline_debug_trait() {
553 let baseline = Baseline {
554 version: "0.1.0".to_string(),
555 created_at: "2024-01-01".to_string(),
556 file_hashes: HashMap::new(),
557 file_count: 0,
558 };
559
560 let debug_str = format!("{:?}", baseline);
561 assert!(debug_str.contains("Baseline"));
562 assert!(debug_str.contains("0.1.0"));
563 }
564
565 #[test]
566 fn test_baseline_clone_trait() {
567 let baseline = Baseline {
568 version: "0.1.0".to_string(),
569 created_at: "2024-01-01".to_string(),
570 file_hashes: HashMap::new(),
571 file_count: 0,
572 };
573
574 let cloned = baseline.clone();
575 assert_eq!(baseline.version, cloned.version);
576 assert_eq!(baseline.file_count, cloned.file_count);
577 }
578
579 #[test]
580 fn test_file_hash_debug_trait() {
581 let hash = FileHash {
582 hash: "abc123".to_string(),
583 size: 100,
584 };
585
586 let debug_str = format!("{:?}", hash);
587 assert!(debug_str.contains("FileHash"));
588 assert!(debug_str.contains("abc123"));
589 }
590
591 #[test]
592 fn test_file_hash_clone_trait() {
593 let hash = FileHash {
594 hash: "abc123".to_string(),
595 size: 100,
596 };
597
598 let cloned = hash.clone();
599 assert_eq!(hash.hash, cloned.hash);
600 assert_eq!(hash.size, cloned.size);
601 }
602
603 #[test]
604 fn test_drift_entry_debug_trait() {
605 let entry = DriftEntry {
606 path: "file.md".to_string(),
607 baseline_hash: "abc".to_string(),
608 current_hash: "def".to_string(),
609 };
610
611 let debug_str = format!("{:?}", entry);
612 assert!(debug_str.contains("DriftEntry"));
613 assert!(debug_str.contains("file.md"));
614 }
615
616 #[test]
617 fn test_drift_entry_clone_trait() {
618 let entry = DriftEntry {
619 path: "file.md".to_string(),
620 baseline_hash: "abc".to_string(),
621 current_hash: "def".to_string(),
622 };
623
624 let cloned = entry.clone();
625 assert_eq!(entry.path, cloned.path);
626 }
627
628 #[test]
629 fn test_drift_report_debug_trait() {
630 let report = DriftReport {
631 modified: vec![],
632 added: vec![],
633 removed: vec![],
634 has_drift: false,
635 };
636
637 let debug_str = format!("{:?}", report);
638 assert!(debug_str.contains("DriftReport"));
639 }
640
641 #[test]
642 fn test_drift_report_clone_trait() {
643 let report = DriftReport {
644 modified: vec![],
645 added: vec!["new.md".to_string()],
646 removed: vec![],
647 has_drift: true,
648 };
649
650 let cloned = report.clone();
651 assert_eq!(report.has_drift, cloned.has_drift);
652 assert_eq!(report.added.len(), cloned.added.len());
653 }
654
655 #[test]
656 fn test_drift_report_format_short_hash() {
657 let report = DriftReport {
658 modified: vec![DriftEntry {
659 path: "file.md".to_string(),
660 baseline_hash: "short".to_string(), current_hash: "also_short".to_string(),
662 }],
663 added: vec![],
664 removed: vec![],
665 has_drift: true,
666 };
667
668 let output = report.format_terminal();
669 assert!(output.contains("short"));
670 assert!(output.contains("also_short"));
671 }
672
673 #[test]
674 fn test_baseline_from_empty_directory() {
675 let temp_dir = TempDir::new().unwrap();
676
677 let baseline = Baseline::from_directory(temp_dir.path()).unwrap();
678 assert_eq!(baseline.file_count, 0);
679 assert!(baseline.file_hashes.is_empty());
680 }
681
682 #[test]
683 fn test_baseline_multiple_files() {
684 let temp_dir = TempDir::new().unwrap();
685 fs::write(temp_dir.path().join("SKILL.md"), "# Skill").unwrap();
686 fs::write(temp_dir.path().join("mcp.json"), "{}").unwrap();
687 fs::write(temp_dir.path().join("settings.yaml"), "key: value").unwrap();
688
689 let baseline = Baseline::from_directory(temp_dir.path()).unwrap();
690 assert_eq!(baseline.file_count, 3);
691 }
692
693 #[test]
694 fn test_baseline_serialization() {
695 let mut file_hashes = HashMap::new();
696 file_hashes.insert(
697 "test.md".to_string(),
698 FileHash {
699 hash: "abc123".to_string(),
700 size: 100,
701 },
702 );
703
704 let baseline = Baseline {
705 version: "0.1.0".to_string(),
706 created_at: "2024-01-01".to_string(),
707 file_hashes,
708 file_count: 1,
709 };
710
711 let json = serde_json::to_string(&baseline).unwrap();
712 let parsed: Baseline = serde_json::from_str(&json).unwrap();
713
714 assert_eq!(baseline.version, parsed.version);
715 assert_eq!(baseline.file_count, parsed.file_count);
716 }
717
718 #[test]
719 fn test_hash_file_nonexistent() {
720 let result = Baseline::hash_file(Path::new("/nonexistent/file/path.md"));
721 assert!(result.is_err());
722 }
723
724 #[test]
725 fn test_save_to_file_invalid_path() {
726 let baseline = Baseline {
727 version: "0.1.0".to_string(),
728 created_at: "2024-01-01".to_string(),
729 file_hashes: HashMap::new(),
730 file_count: 0,
731 };
732
733 let result = baseline.save_to_file(Path::new("/nonexistent/directory/baseline.json"));
734 assert!(result.is_err());
735 }
736
737 #[test]
738 fn test_load_from_file_invalid_json() {
739 let temp_dir = TempDir::new().unwrap();
740 let invalid_json = temp_dir.path().join("invalid.json");
741 fs::write(&invalid_json, "{ invalid json }").unwrap();
742
743 let result = Baseline::load_from_file(&invalid_json);
744 assert!(result.is_err());
745 }
746
747 #[test]
748 fn test_from_directory_nonexistent_path() {
749 let result = Baseline::from_directory(Path::new("/nonexistent/path"));
751 assert!(result.is_ok());
753 let baseline = result.unwrap();
754 assert_eq!(baseline.file_count, 0);
755 }
756
757 #[test]
758 fn test_is_relevant_file_no_extension() {
759 assert!(!Baseline::is_relevant_file(Path::new("random_file_no_ext")));
761 }
762
763 #[test]
764 fn test_drift_entry_serialization() {
765 let entry = DriftEntry {
766 path: "test.md".to_string(),
767 baseline_hash: "abc".to_string(),
768 current_hash: "def".to_string(),
769 };
770
771 let json = serde_json::to_string(&entry).unwrap();
772 let parsed: DriftEntry = serde_json::from_str(&json).unwrap();
773
774 assert_eq!(entry.path, parsed.path);
775 assert_eq!(entry.baseline_hash, parsed.baseline_hash);
776 assert_eq!(entry.current_hash, parsed.current_hash);
777 }
778
779 #[test]
780 fn test_drift_report_serialization() {
781 let report = DriftReport {
782 modified: vec![],
783 added: vec!["new.md".to_string()],
784 removed: vec![],
785 has_drift: true,
786 };
787
788 let json = serde_json::to_string(&report).unwrap();
789 let parsed: DriftReport = serde_json::from_str(&json).unwrap();
790
791 assert_eq!(report.has_drift, parsed.has_drift);
792 assert_eq!(report.added, parsed.added);
793 }
794
795 #[test]
796 fn test_file_hash_serialization() {
797 let hash = FileHash {
798 hash: "abc123".to_string(),
799 size: 42,
800 };
801
802 let json = serde_json::to_string(&hash).unwrap();
803 let parsed: FileHash = serde_json::from_str(&json).unwrap();
804
805 assert_eq!(hash.hash, parsed.hash);
806 assert_eq!(hash.size, parsed.size);
807 }
808}