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,
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 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 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 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 pub fn has_changes(&self) -> bool {
276 self.total_changes > 0
277 }
278
279 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 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 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 let binary_data = vec![0u8, 255, 128, 42, 0, 1, 2, 3];
430 fs::write(&binary_file, &binary_data).unwrap();
431
432 let file_state = FileState::from_path(&binary_file).unwrap();
434
435 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()]), 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_from_files_absolute_path_fallback() {
664 let temp_dir = tempdir().unwrap();
665 let base_path = temp_dir.path();
666
667 fs::write(base_path.join("test.txt"), "test content").unwrap();
669 let file_path = base_path.join("test.txt");
670
671 let entry = create_mock_dir_entry(&file_path);
673
674 let different_base = PathBuf::from("/completely/different/path");
676
677 let config = Config::default();
678
679 let state = ProjectState::from_files(&[entry], &different_base, &config, false).unwrap();
680
681 assert_eq!(state.files.len(), 1);
683 assert!(state.files.contains_key(&PathBuf::from("test.txt")));
684 }
685
686 #[test]
687 fn test_change_summary_with_unchanged_files() {
688 let changes = vec![
689 PerFileDiff {
690 path: "added.txt".to_string(),
691 status: PerFileStatus::Added,
692 diff: "diff content".to_string(),
693 },
694 PerFileDiff {
695 path: "unchanged.txt".to_string(),
696 status: PerFileStatus::Unchanged,
697 diff: "".to_string(),
698 },
699 ];
700
701 let mut added = Vec::new();
703 let mut removed = Vec::new();
704 let mut modified = Vec::new();
705
706 for diff in &changes {
707 let path = PathBuf::from(&diff.path);
708 match diff.status {
709 PerFileStatus::Added => added.push(path),
710 PerFileStatus::Removed => removed.push(path),
711 PerFileStatus::Modified => modified.push(path),
712 PerFileStatus::Unchanged => {} }
714 }
715
716 let summary = ChangeSummary {
717 total_changes: added.len() + removed.len() + modified.len(),
718 added,
719 removed,
720 modified,
721 };
722
723 assert_eq!(summary.total_changes, 1); assert_eq!(summary.added.len(), 1);
725 assert_eq!(summary.removed.len(), 0);
726 assert_eq!(summary.modified.len(), 0);
727 }
728
729 #[test]
730 fn test_has_changes_with_missing_file() {
731 let temp_dir = tempdir().unwrap();
732 let base_path = temp_dir.path();
733
734 fs::write(base_path.join("file1.txt"), "content1").unwrap();
736 let entry1 = create_mock_dir_entry(&base_path.join("file1.txt"));
737
738 let config = Config::default();
739 let state1 = ProjectState::from_files(&[entry1], base_path, &config, false).unwrap();
740
741 fs::write(base_path.join("file2.txt"), "content2").unwrap();
743 let entry2 = create_mock_dir_entry(&base_path.join("file2.txt"));
744 let state2 = ProjectState::from_files(&[entry2], base_path, &config, false).unwrap();
745
746 assert!(state1.has_changes(&state2));
748 }
749
750 #[test]
751 fn test_file_state_with_invalid_data_error() {
752 let temp_dir = tempdir().unwrap();
754 let binary_file = temp_dir.path().join("binary.dat");
755
756 let binary_data = vec![0xFF, 0xFE, 0xFD, 0xFC, 0xFB, 0xFA];
758 fs::write(&binary_file, &binary_data).unwrap();
759
760 let result = FileState::from_path(&binary_file);
763 assert!(result.is_ok());
764 }
765
766 fn create_mock_dir_entry(path: &std::path::Path) -> ignore::DirEntry {
768 let walker = ignore::WalkBuilder::new(path.parent().unwrap());
771 walker
772 .build()
773 .filter_map(Result::ok)
774 .find(|entry| entry.path() == path)
775 .expect("Failed to create DirEntry for test")
776 }
777}