Skip to main content

cc_audit/
baseline.rs

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/// Represents a baseline snapshot for drift detection (rug pull prevention)
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct Baseline {
13    /// Version of the baseline format
14    pub version: String,
15    /// When the baseline was created
16    pub created_at: String,
17    /// Hash of each scanned file
18    pub file_hashes: HashMap<String, FileHash>,
19    /// Total number of files
20    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/// Result of drift detection
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct DriftReport {
32    /// Files that were modified since baseline
33    pub modified: Vec<DriftEntry>,
34    /// Files that were added since baseline
35    pub added: Vec<String>,
36    /// Files that were removed since baseline
37    pub removed: Vec<String>,
38    /// Whether any drift was detected
39    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    /// Create a new baseline from a directory
51    pub fn from_directory(dir: &Path) -> Result<Self> {
52        let mut file_hashes = HashMap::new();
53
54        if dir.is_file() {
55            // Single file
56            let hash = Self::hash_file(dir)?;
57            file_hashes.insert(dir.display().to_string(), hash);
58        } else if dir.is_dir() {
59            // Directory - walk and hash all relevant files
60            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    /// Check if a file is relevant for baseline (config, skill, mcp files)
84    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        // Include common configuration and skill files
96        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    /// Hash a single file
113    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    /// Save baseline to default location (dir/.cc-audit-baseline.json)
135    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    /// Save baseline to a specific file path
148    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    /// Load baseline from default location (dir/.cc-audit-baseline.json)
163    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    /// Load baseline from a specific file path
176    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    /// Check for drift against current state
193    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        // Check for modified and removed files
201        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        // Check for added files
219        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    /// Format the drift report for terminal output
238    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        // Modify the file
357        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        // Add a new file
375        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        // Remove the file
394        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                // SHA-256 hash is 64 characters, code displays first 16
428                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        // Relevant extensions
521        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        // Not relevant
531        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        // Relevant file names
539        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(), // Less than 16 chars
661                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        // Test path that is neither file nor directory
750        let result = Baseline::from_directory(Path::new("/nonexistent/path"));
751        // Should succeed with empty file_hashes since path doesn't exist
752        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        // Test file without extension that is not a known name
760        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}