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| p.file_name().and_then(|n| n.to_str()).map(|s| s.to_string()))
126 .unwrap_or_else(|| "unknown".to_string())
127 });
128
129 let metadata = ProjectMetadata {
130 project_name,
131 file_count: files.len(),
132 filters: config.filter.clone().unwrap_or_default(),
133 ignores: config.ignore.clone().unwrap_or_default(),
134 line_numbers,
135 };
136
137 Ok(ProjectState {
138 timestamp: Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(),
139 config_hash: Self::compute_config_hash(config),
140 files: file_states,
141 metadata,
142 })
143 }
144
145 pub fn compare_with(&self, previous: &ProjectState) -> StateComparison {
147 let previous_content: std::collections::HashMap<String, String> = previous
149 .files
150 .iter()
151 .map(|(path, state)| (path.to_string_lossy().to_string(), state.content.clone()))
152 .collect();
153
154 let current_content: std::collections::HashMap<String, String> = self
155 .files
156 .iter()
157 .map(|(path, state)| (path.to_string_lossy().to_string(), state.content.clone()))
158 .collect();
159
160 let file_diffs = diff_file_contents(&previous_content, ¤t_content, true, None);
162
163 let mut added = Vec::new();
165 let mut removed = Vec::new();
166 let mut modified = Vec::new();
167
168 for diff in &file_diffs {
169 let path = PathBuf::from(&diff.path);
170 match diff.status {
171 PerFileStatus::Added => added.push(path),
172 PerFileStatus::Removed => removed.push(path),
173 PerFileStatus::Modified => modified.push(path),
174 PerFileStatus::Unchanged => {}
175 }
176 }
177
178 let summary = ChangeSummary {
179 total_changes: added.len() + removed.len() + modified.len(),
180 added,
181 removed,
182 modified,
183 };
184
185 StateComparison {
186 file_diffs,
187 summary,
188 }
189 }
190
191 pub fn has_changes(&self, other: &ProjectState) -> bool {
193 if self.files.len() != other.files.len() {
194 return true;
195 }
196
197 for (path, state) in &self.files {
198 match other.files.get(path) {
199 Some(other_state) => {
200 if state.content_hash != other_state.content_hash {
201 return true;
202 }
203 }
204 None => return true,
205 }
206 }
207
208 false
209 }
210
211 fn compute_config_hash(config: &Config) -> String {
213 use std::collections::hash_map::DefaultHasher;
214 use std::hash::{Hash, Hasher};
215
216 let mut hasher = DefaultHasher::new();
217 config.filter.hash(&mut hasher);
218 config.ignore.hash(&mut hasher);
219 config.line_numbers.hash(&mut hasher);
220 config.auto_diff.hash(&mut hasher);
221 config.diff_context_lines.hash(&mut hasher);
222
223 format!("{:x}", hasher.finish())
224 }
225}
226
227impl FileState {
228 pub fn from_path(path: &Path) -> std::io::Result<Self> {
230 use std::collections::hash_map::DefaultHasher;
231 use std::fs;
232 use std::hash::{Hash, Hasher};
233 use std::io::ErrorKind;
234
235 let metadata = fs::metadata(path)?;
236
237 let content = match fs::read_to_string(path) {
238 Ok(content) => content,
239 Err(e) if e.kind() == ErrorKind::InvalidData => {
240 log::warn!("Skipping binary file in auto-diff mode: {}", path.display());
242 format!("<Binary file - {} bytes>", metadata.len())
243 }
244 Err(e) => return Err(e),
245 };
246
247 let mut hasher = DefaultHasher::new();
249 content.hash(&mut hasher);
250 let content_hash = format!("{:x}", hasher.finish());
251
252 Ok(FileState {
253 content,
254 size: metadata.len(),
255 modified: metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH),
256 content_hash,
257 })
258 }
259}
260
261impl ChangeSummary {
262 pub fn has_changes(&self) -> bool {
264 self.total_changes > 0
265 }
266
267 pub fn to_markdown(&self) -> String {
269 if !self.has_changes() {
270 return String::new();
271 }
272
273 let mut output = String::new();
274 output.push_str("## Change Summary\n\n");
275
276 for path in &self.added {
277 output.push_str(&format!("- Added: `{}`\n", path.display()));
278 }
279
280 for path in &self.removed {
281 output.push_str(&format!("- Removed: `{}`\n", path.display()));
282 }
283
284 for path in &self.modified {
285 output.push_str(&format!("- Modified: `{}`\n", path.display()));
286 }
287
288 output.push('\n');
289 output
290 }
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296 use std::fs;
297 use tempfile::tempdir;
298
299 #[test]
300 fn test_file_state_creation() {
301 let temp_dir = tempdir().unwrap();
302 let file_path = temp_dir.path().join("test.txt");
303 fs::write(&file_path, "Hello, world!").unwrap();
304
305 let file_state = FileState::from_path(&file_path).unwrap();
306
307 assert_eq!(file_state.content, "Hello, world!");
308 assert_eq!(file_state.size, 13);
309 assert!(!file_state.content_hash.is_empty());
310 }
311
312 #[test]
313 fn test_project_state_comparison() {
314 let temp_dir = tempdir().unwrap();
315 let base_path = temp_dir.path();
316
317 fs::write(base_path.join("file1.txt"), "content1").unwrap();
319 fs::write(base_path.join("file2.txt"), "content2").unwrap();
320
321 let mut state1_files = BTreeMap::new();
322 state1_files.insert(
323 PathBuf::from("file1.txt"),
324 FileState::from_path(&base_path.join("file1.txt")).unwrap(),
325 );
326 state1_files.insert(
327 PathBuf::from("file2.txt"),
328 FileState::from_path(&base_path.join("file2.txt")).unwrap(),
329 );
330
331 let state1 = ProjectState {
332 timestamp: "2023-01-01T00:00:00Z".to_string(),
333 config_hash: "test_hash".to_string(),
334 files: state1_files,
335 metadata: ProjectMetadata {
336 project_name: "test".to_string(),
337 file_count: 2,
338 filters: vec![],
339 ignores: vec![],
340 line_numbers: false,
341 },
342 };
343
344 fs::write(base_path.join("file1.txt"), "modified_content1").unwrap();
346 fs::write(base_path.join("file3.txt"), "content3").unwrap();
347
348 let mut state2_files = BTreeMap::new();
349 state2_files.insert(
350 PathBuf::from("file1.txt"),
351 FileState::from_path(&base_path.join("file1.txt")).unwrap(),
352 );
353 state2_files.insert(
354 PathBuf::from("file2.txt"),
355 FileState::from_path(&base_path.join("file2.txt")).unwrap(),
356 );
357 state2_files.insert(
358 PathBuf::from("file3.txt"),
359 FileState::from_path(&base_path.join("file3.txt")).unwrap(),
360 );
361
362 let state2 = ProjectState {
363 timestamp: "2023-01-01T01:00:00Z".to_string(),
364 config_hash: "test_hash".to_string(),
365 files: state2_files,
366 metadata: ProjectMetadata {
367 project_name: "test".to_string(),
368 file_count: 3,
369 filters: vec![],
370 ignores: vec![],
371 line_numbers: false,
372 },
373 };
374
375 let comparison = state2.compare_with(&state1);
376
377 assert_eq!(comparison.summary.added.len(), 1);
378 assert_eq!(comparison.summary.modified.len(), 1);
379 assert_eq!(comparison.summary.removed.len(), 0);
380 assert!(
381 comparison
382 .summary
383 .added
384 .contains(&PathBuf::from("file3.txt"))
385 );
386 assert!(
387 comparison
388 .summary
389 .modified
390 .contains(&PathBuf::from("file1.txt"))
391 );
392 }
393
394 #[test]
395 fn test_change_summary_markdown() {
396 let summary = ChangeSummary {
397 added: vec![PathBuf::from("new.txt")],
398 removed: vec![PathBuf::from("old.txt")],
399 modified: vec![PathBuf::from("changed.txt")],
400 total_changes: 3,
401 };
402
403 let markdown = summary.to_markdown();
404
405 assert!(markdown.contains("## Change Summary"));
406 assert!(markdown.contains("- Added: `new.txt`"));
407 assert!(markdown.contains("- Removed: `old.txt`"));
408 assert!(markdown.contains("- Modified: `changed.txt`"));
409 }
410
411 #[test]
412 fn test_binary_file_handling() {
413 let temp_dir = tempdir().unwrap();
414 let binary_file = temp_dir.path().join("test.bin");
415
416 let binary_data = vec![0u8, 255, 128, 42, 0, 1, 2, 3];
418 fs::write(&binary_file, &binary_data).unwrap();
419
420 let file_state = FileState::from_path(&binary_file).unwrap();
422
423 assert!(file_state.content.contains("Binary file"));
425 assert!(file_state.content.contains("8 bytes"));
426 assert_eq!(file_state.size, 8);
427 assert!(!file_state.content_hash.is_empty());
428 }
429
430 #[test]
431 fn test_has_changes_identical_states() {
432 let temp_dir = tempdir().unwrap();
433 let base_path = temp_dir.path();
434 fs::write(base_path.join("test.txt"), "content").unwrap();
435
436 let mut files = BTreeMap::new();
437 files.insert(
438 PathBuf::from("test.txt"),
439 FileState::from_path(&base_path.join("test.txt")).unwrap(),
440 );
441
442 let state1 = ProjectState {
443 timestamp: "2023-01-01T00:00:00Z".to_string(),
444 config_hash: "hash1".to_string(),
445 files: files.clone(),
446 metadata: ProjectMetadata {
447 project_name: "test".to_string(),
448 file_count: 1,
449 filters: vec![],
450 ignores: vec![],
451 line_numbers: false,
452 },
453 };
454
455 let state2 = ProjectState {
456 timestamp: "2023-01-01T01:00:00Z".to_string(),
457 config_hash: "hash1".to_string(),
458 files,
459 metadata: ProjectMetadata {
460 project_name: "test".to_string(),
461 file_count: 1,
462 filters: vec![],
463 ignores: vec![],
464 line_numbers: false,
465 },
466 };
467
468 assert!(!state1.has_changes(&state2));
469 }
470
471 #[test]
472 fn test_has_changes_different_file_count() {
473 let temp_dir = tempdir().unwrap();
474 let base_path = temp_dir.path();
475 fs::write(base_path.join("test1.txt"), "content1").unwrap();
476 fs::write(base_path.join("test2.txt"), "content2").unwrap();
477
478 let mut files1 = BTreeMap::new();
479 files1.insert(
480 PathBuf::from("test1.txt"),
481 FileState::from_path(&base_path.join("test1.txt")).unwrap(),
482 );
483
484 let mut files2 = BTreeMap::new();
485 files2.insert(
486 PathBuf::from("test1.txt"),
487 FileState::from_path(&base_path.join("test1.txt")).unwrap(),
488 );
489 files2.insert(
490 PathBuf::from("test2.txt"),
491 FileState::from_path(&base_path.join("test2.txt")).unwrap(),
492 );
493
494 let state1 = ProjectState {
495 timestamp: "2023-01-01T00:00:00Z".to_string(),
496 config_hash: "hash1".to_string(),
497 files: files1,
498 metadata: ProjectMetadata {
499 project_name: "test".to_string(),
500 file_count: 1,
501 filters: vec![],
502 ignores: vec![],
503 line_numbers: false,
504 },
505 };
506
507 let state2 = ProjectState {
508 timestamp: "2023-01-01T01:00:00Z".to_string(),
509 config_hash: "hash1".to_string(),
510 files: files2,
511 metadata: ProjectMetadata {
512 project_name: "test".to_string(),
513 file_count: 2,
514 filters: vec![],
515 ignores: vec![],
516 line_numbers: false,
517 },
518 };
519
520 assert!(state1.has_changes(&state2));
521 }
522
523 #[test]
524 fn test_has_changes_content_different() {
525 let temp_dir = tempdir().unwrap();
526 let base_path = temp_dir.path();
527 fs::write(base_path.join("test.txt"), "content1").unwrap();
528
529 let file_state1 = FileState::from_path(&base_path.join("test.txt")).unwrap();
530
531 fs::write(base_path.join("test.txt"), "content2").unwrap();
532 let file_state2 = FileState::from_path(&base_path.join("test.txt")).unwrap();
533
534 let mut files1 = BTreeMap::new();
535 files1.insert(PathBuf::from("test.txt"), file_state1);
536
537 let mut files2 = BTreeMap::new();
538 files2.insert(PathBuf::from("test.txt"), file_state2);
539
540 let state1 = ProjectState {
541 timestamp: "2023-01-01T00:00:00Z".to_string(),
542 config_hash: "hash1".to_string(),
543 files: files1,
544 metadata: ProjectMetadata {
545 project_name: "test".to_string(),
546 file_count: 1,
547 filters: vec![],
548 ignores: vec![],
549 line_numbers: false,
550 },
551 };
552
553 let state2 = ProjectState {
554 timestamp: "2023-01-01T01:00:00Z".to_string(),
555 config_hash: "hash1".to_string(),
556 files: files2,
557 metadata: ProjectMetadata {
558 project_name: "test".to_string(),
559 file_count: 1,
560 filters: vec![],
561 ignores: vec![],
562 line_numbers: false,
563 },
564 };
565
566 assert!(state1.has_changes(&state2));
567 }
568
569 #[test]
570 fn test_config_hash_generation() {
571 let config1 = Config {
572 filter: Some(vec!["rs".to_string()]),
573 ignore: Some(vec!["target".to_string()]),
574 line_numbers: Some(true),
575 auto_diff: Some(false),
576 diff_context_lines: Some(3),
577 ..Default::default()
578 };
579
580 let config2 = Config {
581 filter: Some(vec!["rs".to_string()]),
582 ignore: Some(vec!["target".to_string()]),
583 line_numbers: Some(true),
584 auto_diff: Some(false),
585 diff_context_lines: Some(3),
586 ..Default::default()
587 };
588
589 let config3 = Config {
590 filter: Some(vec!["py".to_string()]), ignore: Some(vec!["target".to_string()]),
592 line_numbers: Some(true),
593 auto_diff: Some(false),
594 diff_context_lines: Some(3),
595 ..Default::default()
596 };
597
598 let hash1 = ProjectState::compute_config_hash(&config1);
599 let hash2 = ProjectState::compute_config_hash(&config2);
600 let hash3 = ProjectState::compute_config_hash(&config3);
601
602 assert_eq!(hash1, hash2);
603 assert_ne!(hash1, hash3);
604 }
605
606 #[test]
607 fn test_change_summary_no_changes() {
608 let summary = ChangeSummary {
609 added: vec![],
610 removed: vec![],
611 modified: vec![],
612 total_changes: 0,
613 };
614
615 assert!(!summary.has_changes());
616 assert_eq!(summary.to_markdown(), "");
617 }
618
619 #[test]
620 fn test_from_files_with_config() {
621 let temp_dir = tempdir().unwrap();
622 let base_path = temp_dir.path();
623
624 fs::write(base_path.join("test.rs"), "fn main() {}").unwrap();
625 fs::write(base_path.join("README.md"), "# Test").unwrap();
626
627 let entries = vec![
628 create_mock_dir_entry(&base_path.join("test.rs")),
629 create_mock_dir_entry(&base_path.join("README.md")),
630 ];
631
632 let config = Config {
633 filter: Some(vec!["rs".to_string()]),
634 ignore: Some(vec!["target".to_string()]),
635 line_numbers: Some(true),
636 ..Default::default()
637 };
638
639 let state = ProjectState::from_files(&entries, base_path, &config, true).unwrap();
640
641 assert_eq!(state.files.len(), 2);
642 assert_eq!(state.metadata.file_count, 2);
643 assert_eq!(state.metadata.filters, vec!["rs"]);
644 assert_eq!(state.metadata.ignores, vec!["target"]);
645 assert!(state.metadata.line_numbers);
646 assert!(!state.timestamp.is_empty());
647 assert!(!state.config_hash.is_empty());
648 }
649
650 #[test]
651 fn test_from_files_absolute_path_fallback() {
652 let temp_dir = tempdir().unwrap();
653 let base_path = temp_dir.path();
654
655 fs::write(base_path.join("test.txt"), "test content").unwrap();
657 let file_path = base_path.join("test.txt");
658
659 let entry = create_mock_dir_entry(&file_path);
661
662 let different_base = PathBuf::from("/completely/different/path");
664
665 let config = Config::default();
666
667 let state = ProjectState::from_files(&[entry], &different_base, &config, false).unwrap();
668
669 assert_eq!(state.files.len(), 1);
671 assert!(state.files.contains_key(&PathBuf::from("test.txt")));
672 }
673
674 #[test]
675 fn test_change_summary_with_unchanged_files() {
676 let changes = vec![
677 PerFileDiff {
678 path: "added.txt".to_string(),
679 status: PerFileStatus::Added,
680 diff: "diff content".to_string(),
681 },
682 PerFileDiff {
683 path: "unchanged.txt".to_string(),
684 status: PerFileStatus::Unchanged,
685 diff: "".to_string(),
686 },
687 ];
688
689 let mut added = Vec::new();
691 let mut removed = Vec::new();
692 let mut modified = Vec::new();
693
694 for diff in &changes {
695 let path = PathBuf::from(&diff.path);
696 match diff.status {
697 PerFileStatus::Added => added.push(path),
698 PerFileStatus::Removed => removed.push(path),
699 PerFileStatus::Modified => modified.push(path),
700 PerFileStatus::Unchanged => {} }
702 }
703
704 let summary = ChangeSummary {
705 total_changes: added.len() + removed.len() + modified.len(),
706 added,
707 removed,
708 modified,
709 };
710
711 assert_eq!(summary.total_changes, 1); assert_eq!(summary.added.len(), 1);
713 assert_eq!(summary.removed.len(), 0);
714 assert_eq!(summary.modified.len(), 0);
715 }
716
717 #[test]
718 fn test_has_changes_with_missing_file() {
719 let temp_dir = tempdir().unwrap();
720 let base_path = temp_dir.path();
721
722 fs::write(base_path.join("file1.txt"), "content1").unwrap();
724 let entry1 = create_mock_dir_entry(&base_path.join("file1.txt"));
725
726 let config = Config::default();
727 let state1 = ProjectState::from_files(&[entry1], base_path, &config, false).unwrap();
728
729 fs::write(base_path.join("file2.txt"), "content2").unwrap();
731 let entry2 = create_mock_dir_entry(&base_path.join("file2.txt"));
732 let state2 = ProjectState::from_files(&[entry2], base_path, &config, false).unwrap();
733
734 assert!(state1.has_changes(&state2));
736 }
737
738 #[test]
739 fn test_file_state_with_invalid_data_error() {
740 let temp_dir = tempdir().unwrap();
742 let binary_file = temp_dir.path().join("binary.dat");
743
744 let binary_data = vec![0xFF, 0xFE, 0xFD, 0xFC, 0xFB, 0xFA];
746 fs::write(&binary_file, &binary_data).unwrap();
747
748 let result = FileState::from_path(&binary_file);
751 assert!(result.is_ok());
752 }
753
754 fn create_mock_dir_entry(path: &std::path::Path) -> ignore::DirEntry {
756 let walker = ignore::WalkBuilder::new(path.parent().unwrap());
759 walker
760 .build()
761 .filter_map(Result::ok)
762 .find(|entry| entry.path() == path)
763 .expect("Failed to create DirEntry for test")
764 }
765}