Skip to main content

context_builder/
state.rs

1//! Project state representation for context-builder.
2//!
3//! This module provides structured data types to represent the state of a project
4//! at a point in time. This replaces the previous approach of caching generated
5//! markdown and enables more robust diff generation.
6
7use chrono::Utc;
8use ignore::DirEntry;
9use serde::{Deserialize, Serialize};
10use std::collections::BTreeMap;
11use std::path::{Path, PathBuf};
12use std::time::SystemTime;
13
14use crate::config::Config;
15use crate::diff::{PerFileDiff, PerFileStatus, diff_file_contents};
16
17/// Complete state representation of a project at a point in time
18#[derive(Serialize, Deserialize, Debug, Clone)]
19pub struct ProjectState {
20    /// Timestamp when this state was captured
21    pub timestamp: String,
22    /// Hash of the configuration used to generate this state
23    pub config_hash: String,
24    /// Map of file paths to their state information
25    pub files: BTreeMap<PathBuf, FileState>,
26    /// Project metadata
27    pub metadata: ProjectMetadata,
28}
29
30/// State information for a single file
31#[derive(Serialize, Deserialize, Debug, Clone)]
32pub struct FileState {
33    /// Raw file content as string
34    pub content: String,
35    /// File size in bytes
36    pub size: u64,
37    /// Last modified time
38    pub modified: SystemTime,
39    /// Content hash for quick comparison
40    pub content_hash: String,
41}
42
43/// Metadata about the project
44#[derive(Serialize, Deserialize, Debug, Clone)]
45pub struct ProjectMetadata {
46    /// Project directory name
47    pub project_name: String,
48    /// Total number of files processed
49    pub file_count: usize,
50    /// Filters applied during processing
51    pub filters: Vec<String>,
52    /// Ignore patterns applied
53    pub ignores: Vec<String>,
54    /// Whether line numbers were enabled
55    pub line_numbers: bool,
56}
57
58/// Result of comparing two project states
59#[derive(Debug, Clone)]
60pub struct StateComparison {
61    /// Per-file differences
62    pub file_diffs: Vec<PerFileDiff>,
63    /// Summary of changes
64    pub summary: ChangeSummary,
65}
66
67/// Summary of changes between two states
68#[derive(Debug, Clone)]
69pub struct ChangeSummary {
70    /// Files that were added
71    pub added: Vec<PathBuf>,
72    /// Files that were removed
73    pub removed: Vec<PathBuf>,
74    /// Files that were modified
75    pub modified: Vec<PathBuf>,
76    /// Total number of changed files
77    pub total_changes: usize,
78}
79
80impl ProjectState {
81    /// Create a new project state from collected files
82    pub fn from_files(
83        files: &[DirEntry],
84        base_path: &Path,
85        config: &Config,
86        line_numbers: bool,
87    ) -> std::io::Result<Self> {
88        let mut file_states = BTreeMap::new();
89
90        // Ensure paths stored in the state are *always* relative (never absolute).
91        // This keeps cache stable across different launch contexts and matches
92        // test expectations. We attempt a few strategies to derive a relative path.
93        let cwd = std::env::current_dir().unwrap_or_else(|_| base_path.to_path_buf());
94        for entry in files {
95            let entry_path = entry.path();
96
97            let relative_path = entry_path
98                // Preferred: relative to provided base_path (common case when input is absolute)
99                .strip_prefix(base_path)
100                .or_else(|_| entry_path.strip_prefix(&cwd))
101                .map(|p| p.to_path_buf())
102                .unwrap_or_else(|_| {
103                    // Fallback: last component (file name) to avoid leaking absolute paths
104                    entry_path
105                        .file_name()
106                        .map(PathBuf::from)
107                        .unwrap_or_else(|| entry_path.to_path_buf())
108                });
109
110            let file_state = FileState::from_path(entry_path)?;
111            file_states.insert(relative_path, file_state);
112        }
113
114        // Resolve project name robustly: canonicalize to handle "." and relative paths
115        let canonical = base_path.canonicalize().ok();
116        let resolved = canonical.as_deref().unwrap_or(base_path);
117        let project_name = resolved
118            .file_name()
119            .and_then(|n| n.to_str())
120            .map(|s| s.to_string())
121            .unwrap_or_else(|| {
122                // Fallback: try CWD if base_path has no file_name (e.g., root path)
123                std::env::current_dir()
124                    .ok()
125                    .and_then(|p| {
126                        p.file_name()
127                            .and_then(|n| n.to_str())
128                            .map(|s| s.to_string())
129                    })
130                    .unwrap_or_else(|| "unknown".to_string())
131            });
132
133        let metadata = ProjectMetadata {
134            project_name,
135            file_count: files.len(),
136            filters: config.filter.clone().unwrap_or_default(),
137            ignores: config.ignore.clone().unwrap_or_default(),
138            line_numbers,
139        };
140
141        Ok(ProjectState {
142            timestamp: Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(),
143            config_hash: Self::compute_config_hash(config),
144            files: file_states,
145            metadata,
146        })
147    }
148
149    /// Compare this state with a previous state
150    pub fn compare_with(&self, previous: &ProjectState) -> StateComparison {
151        // Convert file states to content maps for diff_file_contents
152        let previous_content: std::collections::HashMap<String, String> = previous
153            .files
154            .iter()
155            .map(|(path, state)| (path.to_string_lossy().to_string(), state.content.clone()))
156            .collect();
157
158        let current_content: std::collections::HashMap<String, String> = self
159            .files
160            .iter()
161            .map(|(path, state)| (path.to_string_lossy().to_string(), state.content.clone()))
162            .collect();
163
164        // Generate per-file diffs
165        let file_diffs = diff_file_contents(&previous_content, &current_content, true, None);
166
167        // Generate summary
168        let mut added = Vec::new();
169        let mut removed = Vec::new();
170        let mut modified = Vec::new();
171
172        for diff in &file_diffs {
173            let path = PathBuf::from(&diff.path);
174            match diff.status {
175                PerFileStatus::Added => added.push(path),
176                PerFileStatus::Removed => removed.push(path),
177                PerFileStatus::Modified => modified.push(path),
178                PerFileStatus::Unchanged => {}
179            }
180        }
181
182        let summary = ChangeSummary {
183            total_changes: added.len() + removed.len() + modified.len(),
184            added,
185            removed,
186            modified,
187        };
188
189        StateComparison {
190            file_diffs,
191            summary,
192        }
193    }
194
195    /// Check if this state has any content changes compared to another
196    pub fn has_changes(&self, other: &ProjectState) -> bool {
197        if self.files.len() != other.files.len() {
198            return true;
199        }
200
201        for (path, state) in &self.files {
202            match other.files.get(path) {
203                Some(other_state) => {
204                    if state.content_hash != other_state.content_hash {
205                        return true;
206                    }
207                }
208                None => return true,
209            }
210        }
211
212        false
213    }
214
215    /// Generate a configuration hash for cache validation
216    fn compute_config_hash(config: &Config) -> String {
217        // Build a stable string representation for hashing
218        let mut config_str = String::new();
219        if let Some(ref filters) = config.filter {
220            config_str.push_str(&filters.join(","));
221        }
222        config_str.push('|');
223        if let Some(ref ignores) = config.ignore {
224            config_str.push_str(&ignores.join(","));
225        }
226        config_str.push('|');
227        config_str.push_str(&format!(
228            "{:?}|{:?}|{:?}|{:?}|{:?}|{:?}|{:?}",
229            config.line_numbers,
230            config.auto_diff,
231            config.diff_context_lines,
232            config.signatures,
233            config.structure,
234            config.truncate,
235            config.visibility,
236        ));
237
238        let hash = xxhash_rust::xxh3::xxh3_64(config_str.as_bytes());
239        format!("{:x}", hash)
240    }
241}
242
243impl FileState {
244    /// Create a file state from a file path
245    pub fn from_path(path: &Path) -> std::io::Result<Self> {
246        use std::fs;
247        use std::io::ErrorKind;
248
249        let metadata = fs::metadata(path)?;
250
251        let content = match fs::read_to_string(path) {
252            Ok(content) => content,
253            Err(e) if e.kind() == ErrorKind::InvalidData => {
254                // Handle binary files gracefully
255                log::warn!("Skipping binary file in auto-diff mode: {}", path.display());
256                format!("<Binary file - {} bytes>", metadata.len())
257            }
258            Err(e) => return Err(e),
259        };
260
261        // Compute content hash using stable xxh3
262        let content_hash = format!("{:016x}", xxhash_rust::xxh3::xxh3_64(content.as_bytes()));
263
264        Ok(FileState {
265            content,
266            size: metadata.len(),
267            modified: metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH),
268            content_hash,
269        })
270    }
271}
272
273impl ChangeSummary {
274    /// Check if there are any changes
275    pub fn has_changes(&self) -> bool {
276        self.total_changes > 0
277    }
278
279    /// Generate markdown representation of the change summary
280    pub fn to_markdown(&self) -> String {
281        if !self.has_changes() {
282            return String::new();
283        }
284
285        let mut output = String::new();
286        output.push_str("## Change Summary\n\n");
287
288        for path in &self.added {
289            output.push_str(&format!("- Added: `{}`\n", path.display()));
290        }
291
292        for path in &self.removed {
293            output.push_str(&format!("- Removed: `{}`\n", path.display()));
294        }
295
296        for path in &self.modified {
297            output.push_str(&format!("- Modified: `{}`\n", path.display()));
298        }
299
300        output.push('\n');
301        output
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308    use std::fs;
309    use tempfile::tempdir;
310
311    #[test]
312    fn test_file_state_creation() {
313        let temp_dir = tempdir().unwrap();
314        let file_path = temp_dir.path().join("test.txt");
315        fs::write(&file_path, "Hello, world!").unwrap();
316
317        let file_state = FileState::from_path(&file_path).unwrap();
318
319        assert_eq!(file_state.content, "Hello, world!");
320        assert_eq!(file_state.size, 13);
321        assert!(!file_state.content_hash.is_empty());
322    }
323
324    #[test]
325    fn test_project_state_comparison() {
326        let temp_dir = tempdir().unwrap();
327        let base_path = temp_dir.path();
328
329        // Create initial files
330        fs::write(base_path.join("file1.txt"), "content1").unwrap();
331        fs::write(base_path.join("file2.txt"), "content2").unwrap();
332
333        let mut state1_files = BTreeMap::new();
334        state1_files.insert(
335            PathBuf::from("file1.txt"),
336            FileState::from_path(&base_path.join("file1.txt")).unwrap(),
337        );
338        state1_files.insert(
339            PathBuf::from("file2.txt"),
340            FileState::from_path(&base_path.join("file2.txt")).unwrap(),
341        );
342
343        let state1 = ProjectState {
344            timestamp: "2023-01-01T00:00:00Z".to_string(),
345            config_hash: "test_hash".to_string(),
346            files: state1_files,
347            metadata: ProjectMetadata {
348                project_name: "test".to_string(),
349                file_count: 2,
350                filters: vec![],
351                ignores: vec![],
352                line_numbers: false,
353            },
354        };
355
356        // Modify and create new state
357        fs::write(base_path.join("file1.txt"), "modified_content1").unwrap();
358        fs::write(base_path.join("file3.txt"), "content3").unwrap();
359
360        let mut state2_files = BTreeMap::new();
361        state2_files.insert(
362            PathBuf::from("file1.txt"),
363            FileState::from_path(&base_path.join("file1.txt")).unwrap(),
364        );
365        state2_files.insert(
366            PathBuf::from("file2.txt"),
367            FileState::from_path(&base_path.join("file2.txt")).unwrap(),
368        );
369        state2_files.insert(
370            PathBuf::from("file3.txt"),
371            FileState::from_path(&base_path.join("file3.txt")).unwrap(),
372        );
373
374        let state2 = ProjectState {
375            timestamp: "2023-01-01T01:00:00Z".to_string(),
376            config_hash: "test_hash".to_string(),
377            files: state2_files,
378            metadata: ProjectMetadata {
379                project_name: "test".to_string(),
380                file_count: 3,
381                filters: vec![],
382                ignores: vec![],
383                line_numbers: false,
384            },
385        };
386
387        let comparison = state2.compare_with(&state1);
388
389        assert_eq!(comparison.summary.added.len(), 1);
390        assert_eq!(comparison.summary.modified.len(), 1);
391        assert_eq!(comparison.summary.removed.len(), 0);
392        assert!(
393            comparison
394                .summary
395                .added
396                .contains(&PathBuf::from("file3.txt"))
397        );
398        assert!(
399            comparison
400                .summary
401                .modified
402                .contains(&PathBuf::from("file1.txt"))
403        );
404    }
405
406    #[test]
407    fn test_change_summary_markdown() {
408        let summary = ChangeSummary {
409            added: vec![PathBuf::from("new.txt")],
410            removed: vec![PathBuf::from("old.txt")],
411            modified: vec![PathBuf::from("changed.txt")],
412            total_changes: 3,
413        };
414
415        let markdown = summary.to_markdown();
416
417        assert!(markdown.contains("## Change Summary"));
418        assert!(markdown.contains("- Added: `new.txt`"));
419        assert!(markdown.contains("- Removed: `old.txt`"));
420        assert!(markdown.contains("- Modified: `changed.txt`"));
421    }
422
423    #[test]
424    fn test_binary_file_handling() {
425        let temp_dir = tempdir().unwrap();
426        let binary_file = temp_dir.path().join("test.bin");
427
428        // Write binary data (non-UTF8)
429        let binary_data = vec![0u8, 255, 128, 42, 0, 1, 2, 3];
430        fs::write(&binary_file, &binary_data).unwrap();
431
432        // Should not crash and should handle gracefully
433        let file_state = FileState::from_path(&binary_file).unwrap();
434
435        // Content should be a placeholder for binary files
436        assert!(file_state.content.contains("Binary file"));
437        assert!(file_state.content.contains("8 bytes"));
438        assert_eq!(file_state.size, 8);
439        assert!(!file_state.content_hash.is_empty());
440    }
441
442    #[test]
443    fn test_has_changes_identical_states() {
444        let temp_dir = tempdir().unwrap();
445        let base_path = temp_dir.path();
446        fs::write(base_path.join("test.txt"), "content").unwrap();
447
448        let mut files = BTreeMap::new();
449        files.insert(
450            PathBuf::from("test.txt"),
451            FileState::from_path(&base_path.join("test.txt")).unwrap(),
452        );
453
454        let state1 = ProjectState {
455            timestamp: "2023-01-01T00:00:00Z".to_string(),
456            config_hash: "hash1".to_string(),
457            files: files.clone(),
458            metadata: ProjectMetadata {
459                project_name: "test".to_string(),
460                file_count: 1,
461                filters: vec![],
462                ignores: vec![],
463                line_numbers: false,
464            },
465        };
466
467        let state2 = ProjectState {
468            timestamp: "2023-01-01T01:00:00Z".to_string(),
469            config_hash: "hash1".to_string(),
470            files,
471            metadata: ProjectMetadata {
472                project_name: "test".to_string(),
473                file_count: 1,
474                filters: vec![],
475                ignores: vec![],
476                line_numbers: false,
477            },
478        };
479
480        assert!(!state1.has_changes(&state2));
481    }
482
483    #[test]
484    fn test_has_changes_different_file_count() {
485        let temp_dir = tempdir().unwrap();
486        let base_path = temp_dir.path();
487        fs::write(base_path.join("test1.txt"), "content1").unwrap();
488        fs::write(base_path.join("test2.txt"), "content2").unwrap();
489
490        let mut files1 = BTreeMap::new();
491        files1.insert(
492            PathBuf::from("test1.txt"),
493            FileState::from_path(&base_path.join("test1.txt")).unwrap(),
494        );
495
496        let mut files2 = BTreeMap::new();
497        files2.insert(
498            PathBuf::from("test1.txt"),
499            FileState::from_path(&base_path.join("test1.txt")).unwrap(),
500        );
501        files2.insert(
502            PathBuf::from("test2.txt"),
503            FileState::from_path(&base_path.join("test2.txt")).unwrap(),
504        );
505
506        let state1 = ProjectState {
507            timestamp: "2023-01-01T00:00:00Z".to_string(),
508            config_hash: "hash1".to_string(),
509            files: files1,
510            metadata: ProjectMetadata {
511                project_name: "test".to_string(),
512                file_count: 1,
513                filters: vec![],
514                ignores: vec![],
515                line_numbers: false,
516            },
517        };
518
519        let state2 = ProjectState {
520            timestamp: "2023-01-01T01:00:00Z".to_string(),
521            config_hash: "hash1".to_string(),
522            files: files2,
523            metadata: ProjectMetadata {
524                project_name: "test".to_string(),
525                file_count: 2,
526                filters: vec![],
527                ignores: vec![],
528                line_numbers: false,
529            },
530        };
531
532        assert!(state1.has_changes(&state2));
533    }
534
535    #[test]
536    fn test_has_changes_content_different() {
537        let temp_dir = tempdir().unwrap();
538        let base_path = temp_dir.path();
539        fs::write(base_path.join("test.txt"), "content1").unwrap();
540
541        let file_state1 = FileState::from_path(&base_path.join("test.txt")).unwrap();
542
543        fs::write(base_path.join("test.txt"), "content2").unwrap();
544        let file_state2 = FileState::from_path(&base_path.join("test.txt")).unwrap();
545
546        let mut files1 = BTreeMap::new();
547        files1.insert(PathBuf::from("test.txt"), file_state1);
548
549        let mut files2 = BTreeMap::new();
550        files2.insert(PathBuf::from("test.txt"), file_state2);
551
552        let state1 = ProjectState {
553            timestamp: "2023-01-01T00:00:00Z".to_string(),
554            config_hash: "hash1".to_string(),
555            files: files1,
556            metadata: ProjectMetadata {
557                project_name: "test".to_string(),
558                file_count: 1,
559                filters: vec![],
560                ignores: vec![],
561                line_numbers: false,
562            },
563        };
564
565        let state2 = ProjectState {
566            timestamp: "2023-01-01T01:00:00Z".to_string(),
567            config_hash: "hash1".to_string(),
568            files: files2,
569            metadata: ProjectMetadata {
570                project_name: "test".to_string(),
571                file_count: 1,
572                filters: vec![],
573                ignores: vec![],
574                line_numbers: false,
575            },
576        };
577
578        assert!(state1.has_changes(&state2));
579    }
580
581    #[test]
582    fn test_config_hash_generation() {
583        let config1 = Config {
584            filter: Some(vec!["rs".to_string()]),
585            ignore: Some(vec!["target".to_string()]),
586            line_numbers: Some(true),
587            auto_diff: Some(false),
588            diff_context_lines: Some(3),
589            ..Default::default()
590        };
591
592        let config2 = Config {
593            filter: Some(vec!["rs".to_string()]),
594            ignore: Some(vec!["target".to_string()]),
595            line_numbers: Some(true),
596            auto_diff: Some(false),
597            diff_context_lines: Some(3),
598            ..Default::default()
599        };
600
601        let config3 = Config {
602            filter: Some(vec!["py".to_string()]), // Different filter
603            ignore: Some(vec!["target".to_string()]),
604            line_numbers: Some(true),
605            auto_diff: Some(false),
606            diff_context_lines: Some(3),
607            ..Default::default()
608        };
609
610        let hash1 = ProjectState::compute_config_hash(&config1);
611        let hash2 = ProjectState::compute_config_hash(&config2);
612        let hash3 = ProjectState::compute_config_hash(&config3);
613
614        assert_eq!(hash1, hash2);
615        assert_ne!(hash1, hash3);
616    }
617
618    #[test]
619    fn test_change_summary_no_changes() {
620        let summary = ChangeSummary {
621            added: vec![],
622            removed: vec![],
623            modified: vec![],
624            total_changes: 0,
625        };
626
627        assert!(!summary.has_changes());
628        assert_eq!(summary.to_markdown(), "");
629    }
630
631    #[test]
632    fn test_from_files_with_config() {
633        let temp_dir = tempdir().unwrap();
634        let base_path = temp_dir.path();
635
636        fs::write(base_path.join("test.rs"), "fn main() {}").unwrap();
637        fs::write(base_path.join("README.md"), "# Test").unwrap();
638
639        let entries = vec![
640            create_mock_dir_entry(&base_path.join("test.rs")),
641            create_mock_dir_entry(&base_path.join("README.md")),
642        ];
643
644        let config = Config {
645            filter: Some(vec!["rs".to_string()]),
646            ignore: Some(vec!["target".to_string()]),
647            line_numbers: Some(true),
648            ..Default::default()
649        };
650
651        let state = ProjectState::from_files(&entries, base_path, &config, true).unwrap();
652
653        assert_eq!(state.files.len(), 2);
654        assert_eq!(state.metadata.file_count, 2);
655        assert_eq!(state.metadata.filters, vec!["rs"]);
656        assert_eq!(state.metadata.ignores, vec!["target"]);
657        assert!(state.metadata.line_numbers);
658        assert!(!state.timestamp.is_empty());
659        assert!(!state.config_hash.is_empty());
660    }
661
662    #[test]
663    fn test_file_state_from_path_missing_file() {
664        let nonexistent = Path::new("/nonexistent/path/file.txt");
665        let result = FileState::from_path(nonexistent);
666        assert!(result.is_err());
667    }
668
669    #[test]
670    fn test_project_state_timestamp_format() {
671        let temp_dir = tempdir().unwrap();
672        let base_path = temp_dir.path();
673        fs::write(base_path.join("test.txt"), "content").unwrap();
674
675        let entries = vec![create_mock_dir_entry(&base_path.join("test.txt"))];
676        let config = Config::default();
677        let state = ProjectState::from_files(&entries, base_path, &config, false).unwrap();
678
679        assert!(state.timestamp.contains("UTC"));
680        let year = chrono::Utc::now().format("%Y").to_string();
681        assert!(state.timestamp.contains(&year));
682    }
683
684    #[test]
685    fn test_project_metadata_with_filters_and_ignores() {
686        let temp_dir = tempdir().unwrap();
687        let base_path = temp_dir.path();
688        fs::write(base_path.join("main.rs"), "fn main() {}").unwrap();
689        fs::write(base_path.join("lib.rs"), "pub fn lib() {}").unwrap();
690
691        let entries = vec![
692            create_mock_dir_entry(&base_path.join("main.rs")),
693            create_mock_dir_entry(&base_path.join("lib.rs")),
694        ];
695
696        let config = Config {
697            filter: Some(vec!["rs".to_string()]),
698            ignore: Some(vec!["target".to_string()]),
699            line_numbers: Some(true),
700            ..Default::default()
701        };
702
703        let state = ProjectState::from_files(&entries, base_path, &config, true).unwrap();
704
705        assert_eq!(state.metadata.filters, vec!["rs"]);
706        assert_eq!(state.metadata.ignores, vec!["target"]);
707        assert!(state.metadata.line_numbers);
708        assert_eq!(state.metadata.file_count, 2);
709    }
710
711    #[test]
712    fn test_project_name_extraction_from_path() {
713        let temp_dir = tempdir().unwrap();
714        let base_path = temp_dir.path();
715        fs::write(base_path.join("test.txt"), "content").unwrap();
716
717        let entries = vec![create_mock_dir_entry(&base_path.join("test.txt"))];
718        let config = Config::default();
719        let state = ProjectState::from_files(&entries, base_path, &config, false).unwrap();
720
721        assert!(!state.metadata.project_name.is_empty());
722        assert_ne!(state.metadata.project_name, "unknown");
723    }
724
725    #[test]
726    fn test_change_summary_empty_outputs_nothing() {
727        let summary = ChangeSummary {
728            added: vec![],
729            removed: vec![],
730            modified: vec![],
731            total_changes: 0,
732        };
733
734        let markdown = summary.to_markdown();
735        assert!(markdown.is_empty());
736    }
737
738    #[test]
739    fn test_state_comparison_with_removed_files() {
740        let temp_dir = tempdir().unwrap();
741        let base_path = temp_dir.path();
742
743        fs::write(base_path.join("file1.txt"), "content1").unwrap();
744        fs::write(base_path.join("file2.txt"), "content2").unwrap();
745
746        let state1_files = BTreeMap::from([
747            (
748                PathBuf::from("file1.txt"),
749                FileState::from_path(&base_path.join("file1.txt")).unwrap(),
750            ),
751            (
752                PathBuf::from("file2.txt"),
753                FileState::from_path(&base_path.join("file2.txt")).unwrap(),
754            ),
755        ]);
756
757        let state1 = ProjectState {
758            timestamp: "2023-01-01T00:00:00Z".to_string(),
759            config_hash: "hash".to_string(),
760            files: state1_files,
761            metadata: ProjectMetadata {
762                project_name: "test".to_string(),
763                file_count: 2,
764                filters: vec![],
765                ignores: vec![],
766                line_numbers: false,
767            },
768        };
769
770        std::fs::remove_file(base_path.join("file2.txt")).unwrap();
771
772        let state2_files = BTreeMap::from([(
773            PathBuf::from("file1.txt"),
774            FileState::from_path(&base_path.join("file1.txt")).unwrap(),
775        )]);
776
777        let state2 = ProjectState {
778            timestamp: "2023-01-01T01:00:00Z".to_string(),
779            config_hash: "hash".to_string(),
780            files: state2_files,
781            metadata: ProjectMetadata {
782                project_name: "test".to_string(),
783                file_count: 1,
784                filters: vec![],
785                ignores: vec![],
786                line_numbers: false,
787            },
788        };
789
790        let comparison = state2.compare_with(&state1);
791        assert_eq!(comparison.summary.removed.len(), 1);
792    }
793
794    #[test]
795    fn test_compute_config_hash_with_all_options() {
796        let config = Config {
797            filter: Some(vec!["rs".to_string(), "toml".to_string()]),
798            ignore: Some(vec!["target".to_string()]),
799            line_numbers: Some(true),
800            auto_diff: Some(true),
801            diff_context_lines: Some(5),
802            signatures: Some(true),
803            structure: Some(true),
804            truncate: Some("smart".to_string()),
805            visibility: Some("public".to_string()),
806            ..Default::default()
807        };
808
809        let hash1 = ProjectState::compute_config_hash(&config);
810        let hash2 = ProjectState::compute_config_hash(&config);
811
812        assert_eq!(hash1, hash2);
813        assert!(!hash1.is_empty());
814    }
815
816    #[test]
817    fn test_compute_config_hash_differences() {
818        let config1 = Config {
819            filter: None,
820            ignore: None,
821            ..Default::default()
822        };
823
824        let config2 = Config {
825            filter: Some(vec!["rs".to_string()]),
826            ignore: None,
827            ..Default::default()
828        };
829
830        let hash1 = ProjectState::compute_config_hash(&config1);
831        let hash2 = ProjectState::compute_config_hash(&config2);
832
833        assert_ne!(hash1, hash2);
834    }
835
836    // Helper function to create a mock DirEntry for testing
837    fn create_mock_dir_entry(path: &std::path::Path) -> ignore::DirEntry {
838        let walker = ignore::WalkBuilder::new(path.parent().unwrap());
839        walker
840            .build()
841            .filter_map(Result::ok)
842            .find(|entry| entry.path() == path)
843            .expect("Failed to create DirEntry for test")
844    }
845}