1use 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#[derive(Serialize, Deserialize, Debug, Clone)]
19pub struct ProjectState {
20 pub timestamp: String,
22 pub config_hash: String,
24 pub files: BTreeMap<PathBuf, FileState>,
26 pub metadata: ProjectMetadata,
28}
29
30#[derive(Serialize, Deserialize, Debug, Clone)]
32pub struct FileState {
33 pub content: String,
35 pub size: u64,
37 pub modified: SystemTime,
39 pub content_hash: String,
41}
42
43#[derive(Serialize, Deserialize, Debug, Clone)]
45pub struct ProjectMetadata {
46 pub project_name: String,
48 pub file_count: usize,
50 pub filters: Vec<String>,
52 pub ignores: Vec<String>,
54 pub line_numbers: bool,
56}
57
58#[derive(Debug, Clone)]
60pub struct StateComparison {
61 pub file_diffs: Vec<PerFileDiff>,
63 pub summary: ChangeSummary,
65}
66
67#[derive(Debug, Clone)]
69pub struct ChangeSummary {
70 pub added: Vec<PathBuf>,
72 pub removed: Vec<PathBuf>,
74 pub modified: Vec<PathBuf>,
76 pub total_changes: usize,
78}
79
80impl ProjectState {
81 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 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 .strip_prefix(base_path)
100 .or_else(|_| entry_path.strip_prefix(&cwd))
101 .map(|p| p.to_path_buf())
102 .unwrap_or_else(|_| {
103 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 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 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 pub fn compare_with(&self, previous: &ProjectState) -> StateComparison {
151 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 let file_diffs = diff_file_contents(&previous_content, ¤t_content, true, None);
166
167 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 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 fn compute_config_hash(config: &Config) -> String {
217 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, config.auto_diff, config.diff_context_lines
230 ));
231
232 let hash = xxhash_rust::xxh3::xxh3_64(config_str.as_bytes());
233 format!("{:x}", hash)
234 }
235}
236
237impl FileState {
238 pub fn from_path(path: &Path) -> std::io::Result<Self> {
240 use std::fs;
241 use std::io::ErrorKind;
242
243 let metadata = fs::metadata(path)?;
244
245 let content = match fs::read_to_string(path) {
246 Ok(content) => content,
247 Err(e) if e.kind() == ErrorKind::InvalidData => {
248 log::warn!("Skipping binary file in auto-diff mode: {}", path.display());
250 format!("<Binary file - {} bytes>", metadata.len())
251 }
252 Err(e) => return Err(e),
253 };
254
255 let content_hash = format!("{:016x}", xxhash_rust::xxh3::xxh3_64(content.as_bytes()));
257
258 Ok(FileState {
259 content,
260 size: metadata.len(),
261 modified: metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH),
262 content_hash,
263 })
264 }
265}
266
267impl ChangeSummary {
268 pub fn has_changes(&self) -> bool {
270 self.total_changes > 0
271 }
272
273 pub fn to_markdown(&self) -> String {
275 if !self.has_changes() {
276 return String::new();
277 }
278
279 let mut output = String::new();
280 output.push_str("## Change Summary\n\n");
281
282 for path in &self.added {
283 output.push_str(&format!("- Added: `{}`\n", path.display()));
284 }
285
286 for path in &self.removed {
287 output.push_str(&format!("- Removed: `{}`\n", path.display()));
288 }
289
290 for path in &self.modified {
291 output.push_str(&format!("- Modified: `{}`\n", path.display()));
292 }
293
294 output.push('\n');
295 output
296 }
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302 use std::fs;
303 use tempfile::tempdir;
304
305 #[test]
306 fn test_file_state_creation() {
307 let temp_dir = tempdir().unwrap();
308 let file_path = temp_dir.path().join("test.txt");
309 fs::write(&file_path, "Hello, world!").unwrap();
310
311 let file_state = FileState::from_path(&file_path).unwrap();
312
313 assert_eq!(file_state.content, "Hello, world!");
314 assert_eq!(file_state.size, 13);
315 assert!(!file_state.content_hash.is_empty());
316 }
317
318 #[test]
319 fn test_project_state_comparison() {
320 let temp_dir = tempdir().unwrap();
321 let base_path = temp_dir.path();
322
323 fs::write(base_path.join("file1.txt"), "content1").unwrap();
325 fs::write(base_path.join("file2.txt"), "content2").unwrap();
326
327 let mut state1_files = BTreeMap::new();
328 state1_files.insert(
329 PathBuf::from("file1.txt"),
330 FileState::from_path(&base_path.join("file1.txt")).unwrap(),
331 );
332 state1_files.insert(
333 PathBuf::from("file2.txt"),
334 FileState::from_path(&base_path.join("file2.txt")).unwrap(),
335 );
336
337 let state1 = ProjectState {
338 timestamp: "2023-01-01T00:00:00Z".to_string(),
339 config_hash: "test_hash".to_string(),
340 files: state1_files,
341 metadata: ProjectMetadata {
342 project_name: "test".to_string(),
343 file_count: 2,
344 filters: vec![],
345 ignores: vec![],
346 line_numbers: false,
347 },
348 };
349
350 fs::write(base_path.join("file1.txt"), "modified_content1").unwrap();
352 fs::write(base_path.join("file3.txt"), "content3").unwrap();
353
354 let mut state2_files = BTreeMap::new();
355 state2_files.insert(
356 PathBuf::from("file1.txt"),
357 FileState::from_path(&base_path.join("file1.txt")).unwrap(),
358 );
359 state2_files.insert(
360 PathBuf::from("file2.txt"),
361 FileState::from_path(&base_path.join("file2.txt")).unwrap(),
362 );
363 state2_files.insert(
364 PathBuf::from("file3.txt"),
365 FileState::from_path(&base_path.join("file3.txt")).unwrap(),
366 );
367
368 let state2 = ProjectState {
369 timestamp: "2023-01-01T01:00:00Z".to_string(),
370 config_hash: "test_hash".to_string(),
371 files: state2_files,
372 metadata: ProjectMetadata {
373 project_name: "test".to_string(),
374 file_count: 3,
375 filters: vec![],
376 ignores: vec![],
377 line_numbers: false,
378 },
379 };
380
381 let comparison = state2.compare_with(&state1);
382
383 assert_eq!(comparison.summary.added.len(), 1);
384 assert_eq!(comparison.summary.modified.len(), 1);
385 assert_eq!(comparison.summary.removed.len(), 0);
386 assert!(
387 comparison
388 .summary
389 .added
390 .contains(&PathBuf::from("file3.txt"))
391 );
392 assert!(
393 comparison
394 .summary
395 .modified
396 .contains(&PathBuf::from("file1.txt"))
397 );
398 }
399
400 #[test]
401 fn test_change_summary_markdown() {
402 let summary = ChangeSummary {
403 added: vec![PathBuf::from("new.txt")],
404 removed: vec![PathBuf::from("old.txt")],
405 modified: vec![PathBuf::from("changed.txt")],
406 total_changes: 3,
407 };
408
409 let markdown = summary.to_markdown();
410
411 assert!(markdown.contains("## Change Summary"));
412 assert!(markdown.contains("- Added: `new.txt`"));
413 assert!(markdown.contains("- Removed: `old.txt`"));
414 assert!(markdown.contains("- Modified: `changed.txt`"));
415 }
416
417 #[test]
418 fn test_binary_file_handling() {
419 let temp_dir = tempdir().unwrap();
420 let binary_file = temp_dir.path().join("test.bin");
421
422 let binary_data = vec![0u8, 255, 128, 42, 0, 1, 2, 3];
424 fs::write(&binary_file, &binary_data).unwrap();
425
426 let file_state = FileState::from_path(&binary_file).unwrap();
428
429 assert!(file_state.content.contains("Binary file"));
431 assert!(file_state.content.contains("8 bytes"));
432 assert_eq!(file_state.size, 8);
433 assert!(!file_state.content_hash.is_empty());
434 }
435
436 #[test]
437 fn test_has_changes_identical_states() {
438 let temp_dir = tempdir().unwrap();
439 let base_path = temp_dir.path();
440 fs::write(base_path.join("test.txt"), "content").unwrap();
441
442 let mut files = BTreeMap::new();
443 files.insert(
444 PathBuf::from("test.txt"),
445 FileState::from_path(&base_path.join("test.txt")).unwrap(),
446 );
447
448 let state1 = ProjectState {
449 timestamp: "2023-01-01T00:00:00Z".to_string(),
450 config_hash: "hash1".to_string(),
451 files: files.clone(),
452 metadata: ProjectMetadata {
453 project_name: "test".to_string(),
454 file_count: 1,
455 filters: vec![],
456 ignores: vec![],
457 line_numbers: false,
458 },
459 };
460
461 let state2 = ProjectState {
462 timestamp: "2023-01-01T01:00:00Z".to_string(),
463 config_hash: "hash1".to_string(),
464 files,
465 metadata: ProjectMetadata {
466 project_name: "test".to_string(),
467 file_count: 1,
468 filters: vec![],
469 ignores: vec![],
470 line_numbers: false,
471 },
472 };
473
474 assert!(!state1.has_changes(&state2));
475 }
476
477 #[test]
478 fn test_has_changes_different_file_count() {
479 let temp_dir = tempdir().unwrap();
480 let base_path = temp_dir.path();
481 fs::write(base_path.join("test1.txt"), "content1").unwrap();
482 fs::write(base_path.join("test2.txt"), "content2").unwrap();
483
484 let mut files1 = BTreeMap::new();
485 files1.insert(
486 PathBuf::from("test1.txt"),
487 FileState::from_path(&base_path.join("test1.txt")).unwrap(),
488 );
489
490 let mut files2 = BTreeMap::new();
491 files2.insert(
492 PathBuf::from("test1.txt"),
493 FileState::from_path(&base_path.join("test1.txt")).unwrap(),
494 );
495 files2.insert(
496 PathBuf::from("test2.txt"),
497 FileState::from_path(&base_path.join("test2.txt")).unwrap(),
498 );
499
500 let state1 = ProjectState {
501 timestamp: "2023-01-01T00:00:00Z".to_string(),
502 config_hash: "hash1".to_string(),
503 files: files1,
504 metadata: ProjectMetadata {
505 project_name: "test".to_string(),
506 file_count: 1,
507 filters: vec![],
508 ignores: vec![],
509 line_numbers: false,
510 },
511 };
512
513 let state2 = ProjectState {
514 timestamp: "2023-01-01T01:00:00Z".to_string(),
515 config_hash: "hash1".to_string(),
516 files: files2,
517 metadata: ProjectMetadata {
518 project_name: "test".to_string(),
519 file_count: 2,
520 filters: vec![],
521 ignores: vec![],
522 line_numbers: false,
523 },
524 };
525
526 assert!(state1.has_changes(&state2));
527 }
528
529 #[test]
530 fn test_has_changes_content_different() {
531 let temp_dir = tempdir().unwrap();
532 let base_path = temp_dir.path();
533 fs::write(base_path.join("test.txt"), "content1").unwrap();
534
535 let file_state1 = FileState::from_path(&base_path.join("test.txt")).unwrap();
536
537 fs::write(base_path.join("test.txt"), "content2").unwrap();
538 let file_state2 = FileState::from_path(&base_path.join("test.txt")).unwrap();
539
540 let mut files1 = BTreeMap::new();
541 files1.insert(PathBuf::from("test.txt"), file_state1);
542
543 let mut files2 = BTreeMap::new();
544 files2.insert(PathBuf::from("test.txt"), file_state2);
545
546 let state1 = ProjectState {
547 timestamp: "2023-01-01T00:00:00Z".to_string(),
548 config_hash: "hash1".to_string(),
549 files: files1,
550 metadata: ProjectMetadata {
551 project_name: "test".to_string(),
552 file_count: 1,
553 filters: vec![],
554 ignores: vec![],
555 line_numbers: false,
556 },
557 };
558
559 let state2 = ProjectState {
560 timestamp: "2023-01-01T01:00:00Z".to_string(),
561 config_hash: "hash1".to_string(),
562 files: files2,
563 metadata: ProjectMetadata {
564 project_name: "test".to_string(),
565 file_count: 1,
566 filters: vec![],
567 ignores: vec![],
568 line_numbers: false,
569 },
570 };
571
572 assert!(state1.has_changes(&state2));
573 }
574
575 #[test]
576 fn test_config_hash_generation() {
577 let config1 = Config {
578 filter: Some(vec!["rs".to_string()]),
579 ignore: Some(vec!["target".to_string()]),
580 line_numbers: Some(true),
581 auto_diff: Some(false),
582 diff_context_lines: Some(3),
583 ..Default::default()
584 };
585
586 let config2 = Config {
587 filter: Some(vec!["rs".to_string()]),
588 ignore: Some(vec!["target".to_string()]),
589 line_numbers: Some(true),
590 auto_diff: Some(false),
591 diff_context_lines: Some(3),
592 ..Default::default()
593 };
594
595 let config3 = Config {
596 filter: Some(vec!["py".to_string()]), ignore: Some(vec!["target".to_string()]),
598 line_numbers: Some(true),
599 auto_diff: Some(false),
600 diff_context_lines: Some(3),
601 ..Default::default()
602 };
603
604 let hash1 = ProjectState::compute_config_hash(&config1);
605 let hash2 = ProjectState::compute_config_hash(&config2);
606 let hash3 = ProjectState::compute_config_hash(&config3);
607
608 assert_eq!(hash1, hash2);
609 assert_ne!(hash1, hash3);
610 }
611
612 #[test]
613 fn test_change_summary_no_changes() {
614 let summary = ChangeSummary {
615 added: vec![],
616 removed: vec![],
617 modified: vec![],
618 total_changes: 0,
619 };
620
621 assert!(!summary.has_changes());
622 assert_eq!(summary.to_markdown(), "");
623 }
624
625 #[test]
626 fn test_from_files_with_config() {
627 let temp_dir = tempdir().unwrap();
628 let base_path = temp_dir.path();
629
630 fs::write(base_path.join("test.rs"), "fn main() {}").unwrap();
631 fs::write(base_path.join("README.md"), "# Test").unwrap();
632
633 let entries = vec![
634 create_mock_dir_entry(&base_path.join("test.rs")),
635 create_mock_dir_entry(&base_path.join("README.md")),
636 ];
637
638 let config = Config {
639 filter: Some(vec!["rs".to_string()]),
640 ignore: Some(vec!["target".to_string()]),
641 line_numbers: Some(true),
642 ..Default::default()
643 };
644
645 let state = ProjectState::from_files(&entries, base_path, &config, true).unwrap();
646
647 assert_eq!(state.files.len(), 2);
648 assert_eq!(state.metadata.file_count, 2);
649 assert_eq!(state.metadata.filters, vec!["rs"]);
650 assert_eq!(state.metadata.ignores, vec!["target"]);
651 assert!(state.metadata.line_numbers);
652 assert!(!state.timestamp.is_empty());
653 assert!(!state.config_hash.is_empty());
654 }
655
656 #[test]
657 fn test_from_files_absolute_path_fallback() {
658 let temp_dir = tempdir().unwrap();
659 let base_path = temp_dir.path();
660
661 fs::write(base_path.join("test.txt"), "test content").unwrap();
663 let file_path = base_path.join("test.txt");
664
665 let entry = create_mock_dir_entry(&file_path);
667
668 let different_base = PathBuf::from("/completely/different/path");
670
671 let config = Config::default();
672
673 let state = ProjectState::from_files(&[entry], &different_base, &config, false).unwrap();
674
675 assert_eq!(state.files.len(), 1);
677 assert!(state.files.contains_key(&PathBuf::from("test.txt")));
678 }
679
680 #[test]
681 fn test_change_summary_with_unchanged_files() {
682 let changes = vec![
683 PerFileDiff {
684 path: "added.txt".to_string(),
685 status: PerFileStatus::Added,
686 diff: "diff content".to_string(),
687 },
688 PerFileDiff {
689 path: "unchanged.txt".to_string(),
690 status: PerFileStatus::Unchanged,
691 diff: "".to_string(),
692 },
693 ];
694
695 let mut added = Vec::new();
697 let mut removed = Vec::new();
698 let mut modified = Vec::new();
699
700 for diff in &changes {
701 let path = PathBuf::from(&diff.path);
702 match diff.status {
703 PerFileStatus::Added => added.push(path),
704 PerFileStatus::Removed => removed.push(path),
705 PerFileStatus::Modified => modified.push(path),
706 PerFileStatus::Unchanged => {} }
708 }
709
710 let summary = ChangeSummary {
711 total_changes: added.len() + removed.len() + modified.len(),
712 added,
713 removed,
714 modified,
715 };
716
717 assert_eq!(summary.total_changes, 1); assert_eq!(summary.added.len(), 1);
719 assert_eq!(summary.removed.len(), 0);
720 assert_eq!(summary.modified.len(), 0);
721 }
722
723 #[test]
724 fn test_has_changes_with_missing_file() {
725 let temp_dir = tempdir().unwrap();
726 let base_path = temp_dir.path();
727
728 fs::write(base_path.join("file1.txt"), "content1").unwrap();
730 let entry1 = create_mock_dir_entry(&base_path.join("file1.txt"));
731
732 let config = Config::default();
733 let state1 = ProjectState::from_files(&[entry1], base_path, &config, false).unwrap();
734
735 fs::write(base_path.join("file2.txt"), "content2").unwrap();
737 let entry2 = create_mock_dir_entry(&base_path.join("file2.txt"));
738 let state2 = ProjectState::from_files(&[entry2], base_path, &config, false).unwrap();
739
740 assert!(state1.has_changes(&state2));
742 }
743
744 #[test]
745 fn test_file_state_with_invalid_data_error() {
746 let temp_dir = tempdir().unwrap();
748 let binary_file = temp_dir.path().join("binary.dat");
749
750 let binary_data = vec![0xFF, 0xFE, 0xFD, 0xFC, 0xFB, 0xFA];
752 fs::write(&binary_file, &binary_data).unwrap();
753
754 let result = FileState::from_path(&binary_file);
757 assert!(result.is_ok());
758 }
759
760 fn create_mock_dir_entry(path: &std::path::Path) -> ignore::DirEntry {
762 let walker = ignore::WalkBuilder::new(path.parent().unwrap());
765 walker
766 .build()
767 .filter_map(Result::ok)
768 .find(|entry| entry.path() == path)
769 .expect("Failed to create DirEntry for test")
770 }
771}