Skip to main content

aaai_core/diff/
entry.rs

1//! Core diff vocabulary: [`DiffType`] and [`DiffEntry`] (Phase 4: binary support + stats).
2
3use serde::{Deserialize, Serialize};
4
5/// The kind of change detected for a path.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
7pub enum DiffType {
8    Added,
9    Removed,
10    Modified,
11    Unchanged,
12    TypeChanged,
13    Unreadable,
14    Incomparable,
15}
16
17impl std::fmt::Display for DiffType {
18    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
19        let s = match self {
20            DiffType::Added        => "Added",
21            DiffType::Removed      => "Removed",
22            DiffType::Modified     => "Modified",
23            DiffType::Unchanged    => "Unchanged",
24            DiffType::TypeChanged  => "TypeChanged",
25            DiffType::Unreadable   => "Unreadable",
26            DiffType::Incomparable => "Incomparable",
27        };
28        write!(f, "{s}")
29    }
30}
31
32impl DiffType {
33    pub fn is_changed(self) -> bool {
34        !matches!(self, DiffType::Unchanged)
35    }
36    pub fn is_error(self) -> bool {
37        matches!(self, DiffType::Unreadable | DiffType::Incomparable)
38    }
39}
40
41/// Line-level diff statistics for text files.
42#[derive(Debug, Clone, Default)]
43pub struct DiffStats {
44    pub lines_added:     usize,
45    pub lines_removed:   usize,
46    pub lines_unchanged: usize,
47}
48
49impl DiffStats {
50    pub fn lines_changed(&self) -> usize {
51        self.lines_added + self.lines_removed
52    }
53
54    /// Compute from before/after text using the similar crate.
55    pub fn compute(before: &str, after: &str) -> Self {
56        use similar::{ChangeTag, TextDiff};
57        let td = TextDiff::from_lines(before, after);
58        let mut stats = DiffStats::default();
59        for change in td.iter_all_changes() {
60            match change.tag() {
61                ChangeTag::Insert => stats.lines_added     += 1,
62                ChangeTag::Delete => stats.lines_removed   += 1,
63                ChangeTag::Equal  => stats.lines_unchanged += 1,
64            }
65        }
66        stats
67    }
68}
69
70/// One entry in the diff result.
71#[derive(Debug, Clone)]
72pub struct DiffEntry {
73    /// Root-relative path with forward slashes.
74    pub path: String,
75    pub diff_type: DiffType,
76    pub is_dir: bool,
77
78    // ── Text content (None for binary or missing files) ───────────────
79    pub before_text: Option<String>,
80    pub after_text:  Option<String>,
81
82    // ── Binary / size tracking (Phase 4) ─────────────────────────────
83    /// True when the file cannot be decoded as UTF-8.
84    pub is_binary: bool,
85    /// File size in bytes of the before-file, if available.
86    pub before_size: Option<u64>,
87    /// File size in bytes of the after-file, if available.
88    pub after_size: Option<u64>,
89
90    // ── Hashes ────────────────────────────────────────────────────────
91    /// SHA-256 hex digest of the before-file.
92    pub before_sha256: Option<String>,
93    /// SHA-256 hex digest of the after-file.
94    pub after_sha256: Option<String>,
95
96    // ── Line statistics (Phase 4) ─────────────────────────────────────
97    /// Set for Modified text files; None for binary / non-Modified entries.
98    pub stats: Option<DiffStats>,
99
100    pub error_detail: Option<String>,
101}
102
103impl DiffEntry {
104    pub fn has_text_diff(&self) -> bool {
105        !self.is_binary && (self.before_text.is_some() || self.after_text.is_some())
106    }
107
108    /// Human-readable size change description, e.g. "12 KB → 15 KB".
109    pub fn size_change_label(&self) -> Option<String> {
110        match (self.before_size, self.after_size) {
111            (Some(b), Some(a)) => Some(format!("{} → {}", fmt_size(b), fmt_size(a))),
112            (None,    Some(a)) => Some(format!("(new) {}", fmt_size(a))),
113            (Some(b), None)    => Some(format!("{} (removed)", fmt_size(b))),
114            (None, None)       => None,
115        }
116    }
117}
118
119/// Format bytes as human-readable string.
120pub fn fmt_size(bytes: u64) -> String {
121    if bytes < 1_024 {
122        format!("{bytes} B")
123    } else if bytes < 1_024 * 1_024 {
124        format!("{:.1} KB", bytes as f64 / 1_024.0)
125    } else if bytes < 1_024 * 1_024 * 1_024 {
126        format!("{:.1} MB", bytes as f64 / (1_024.0 * 1_024.0))
127    } else {
128        format!("{:.2} GB", bytes as f64 / (1_024.0 * 1_024.0 * 1_024.0))
129    }
130}
131
132/// Threshold above which Exact / LineMatch strategies warn about file size.
133pub const LARGE_FILE_THRESHOLD: u64 = 1_024 * 1_024; // 1 MB