Skip to main content

turbovault_tools/
diff_tools.rs

1//! Note diff tools for comparing vault notes
2//!
3//! Provides line-level and word-level diff capabilities using the `similar` crate.
4//! Supports comparing two notes by path or comparing raw content strings
5//! (reusable by audit trail for version comparison).
6
7use serde::{Deserialize, Serialize};
8use similar::{ChangeTag, TextDiff};
9use std::path::PathBuf;
10use std::sync::Arc;
11use turbovault_core::prelude::*;
12use turbovault_vault::VaultManager;
13
14/// Diff tools for comparing notes
15#[derive(Clone)]
16pub struct DiffTools {
17    pub manager: Arc<VaultManager>,
18}
19
20/// Result of comparing two notes
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct DiffResult {
23    pub left_path: String,
24    pub right_path: String,
25    pub unified_diff: String,
26    pub summary: DiffSummary,
27    pub inline_changes: Vec<InlineChange>,
28}
29
30/// Summary statistics for a diff
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct DiffSummary {
33    pub lines_added: usize,
34    pub lines_removed: usize,
35    pub lines_changed: usize,
36    pub lines_unchanged: usize,
37    pub similarity_ratio: f64,
38}
39
40/// A changed line with word-level detail
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct InlineChange {
43    pub line_number: usize,
44    pub old_text: String,
45    pub new_text: String,
46    pub changed_words: Vec<WordChange>,
47}
48
49/// A single word-level change within a line
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct WordChange {
52    pub operation: String,
53    pub text: String,
54}
55
56impl DiffTools {
57    pub fn new(manager: Arc<VaultManager>) -> Self {
58        Self { manager }
59    }
60
61    /// Compare two notes by path
62    pub async fn diff_notes(&self, left_path: &str, right_path: &str) -> Result<DiffResult> {
63        let left_content = self.manager.read_file(&PathBuf::from(left_path)).await?;
64        let right_content = self.manager.read_file(&PathBuf::from(right_path)).await?;
65
66        Ok(Self::diff_content(
67            &left_content,
68            &right_content,
69            left_path,
70            right_path,
71        ))
72    }
73
74    /// Compare two content strings directly (reusable by audit trail)
75    pub fn diff_content(
76        left: &str,
77        right: &str,
78        left_label: &str,
79        right_label: &str,
80    ) -> DiffResult {
81        let line_diff = TextDiff::from_lines(left, right);
82
83        // Build unified diff output
84        let unified_diff = line_diff
85            .unified_diff()
86            .header(left_label, right_label)
87            .context_radius(3)
88            .to_string();
89
90        // Compute summary statistics
91        let mut lines_added = 0usize;
92        let mut lines_removed = 0usize;
93        let mut lines_unchanged = 0usize;
94
95        for change in line_diff.iter_all_changes() {
96            match change.tag() {
97                ChangeTag::Insert => lines_added += 1,
98                ChangeTag::Delete => lines_removed += 1,
99                ChangeTag::Equal => lines_unchanged += 1,
100            }
101        }
102
103        let similarity_ratio = f64::from(line_diff.ratio());
104
105        // Find changed line pairs and compute word-level diffs
106        let mut inline_changes = compute_inline_changes(&line_diff);
107        let lines_changed = inline_changes.len();
108        // Cap inline changes to avoid huge output on very different files
109        inline_changes.truncate(50);
110
111        DiffResult {
112            left_path: left_label.to_string(),
113            right_path: right_label.to_string(),
114            unified_diff,
115            summary: DiffSummary {
116                lines_added: lines_added.saturating_sub(lines_changed),
117                lines_removed: lines_removed.saturating_sub(lines_changed),
118                lines_changed,
119                lines_unchanged,
120                similarity_ratio,
121            },
122            inline_changes,
123        }
124    }
125}
126
127/// Extract paired delete/insert changes and compute word-level diffs
128fn compute_inline_changes<'a>(line_diff: &TextDiff<'a, 'a, 'a, str>) -> Vec<InlineChange> {
129    let mut inline_changes = Vec::new();
130    let changes: Vec<_> = line_diff.iter_all_changes().collect();
131
132    let mut i = 0;
133    let mut line_number = 0usize;
134
135    while i < changes.len() {
136        let change = &changes[i];
137
138        match change.tag() {
139            ChangeTag::Equal => {
140                line_number += 1;
141                i += 1;
142            }
143            ChangeTag::Delete => {
144                line_number += 1;
145                // Look ahead for a matching Insert (changed line pair)
146                if i + 1 < changes.len() && changes[i + 1].tag() == ChangeTag::Insert {
147                    let old_text = change.to_string_lossy();
148                    let new_text = changes[i + 1].to_string_lossy();
149
150                    let word_diff = TextDiff::from_words(old_text.trim_end(), new_text.trim_end());
151                    let changed_words: Vec<WordChange> = word_diff
152                        .iter_all_changes()
153                        .map(|wc| WordChange {
154                            operation: match wc.tag() {
155                                ChangeTag::Insert => "insert".to_string(),
156                                ChangeTag::Delete => "delete".to_string(),
157                                ChangeTag::Equal => "equal".to_string(),
158                            },
159                            text: wc.to_string_lossy().to_string(),
160                        })
161                        .collect();
162
163                    inline_changes.push(InlineChange {
164                        line_number,
165                        old_text: old_text.trim_end().to_string(),
166                        new_text: new_text.trim_end().to_string(),
167                        changed_words,
168                    });
169
170                    i += 2; // skip the Insert
171                } else {
172                    i += 1; // pure deletion, no pair
173                }
174            }
175            ChangeTag::Insert => {
176                i += 1; // pure insertion (no preceding delete)
177            }
178        }
179    }
180
181    inline_changes
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    #[test]
189    fn test_diff_identical_content() {
190        let content = "# Hello\n\nThis is a test note.\n";
191        let result = DiffTools::diff_content(content, content, "a.md", "b.md");
192
193        assert_eq!(result.summary.lines_added, 0);
194        assert_eq!(result.summary.lines_removed, 0);
195        assert_eq!(result.summary.lines_changed, 0);
196        assert!((result.summary.similarity_ratio - 1.0).abs() < f64::EPSILON);
197        assert!(result.inline_changes.is_empty());
198    }
199
200    #[test]
201    fn test_diff_completely_different() {
202        let left = "Hello world\n";
203        let right = "Goodbye universe\n";
204        let result = DiffTools::diff_content(left, right, "a.md", "b.md");
205
206        assert!(result.summary.similarity_ratio < 1.0);
207        assert!(!result.unified_diff.is_empty());
208    }
209
210    #[test]
211    fn test_diff_with_changes() {
212        let left = "# Title\n\nLine one\nLine two\nLine three\n";
213        let right = "# Title\n\nLine one\nLine modified\nLine three\n";
214        let result = DiffTools::diff_content(left, right, "a.md", "b.md");
215
216        assert_eq!(result.summary.lines_changed, 1);
217        assert_eq!(result.summary.lines_unchanged, 4); // title, blank, line one, line three
218        assert_eq!(result.inline_changes.len(), 1);
219        assert_eq!(result.inline_changes[0].old_text, "Line two");
220        assert_eq!(result.inline_changes[0].new_text, "Line modified");
221    }
222
223    #[test]
224    fn test_diff_additions_only() {
225        let left = "Line one\n";
226        let right = "Line one\nLine two\nLine three\n";
227        let result = DiffTools::diff_content(left, right, "a.md", "b.md");
228
229        assert_eq!(result.summary.lines_added, 2);
230        assert_eq!(result.summary.lines_removed, 0);
231        assert_eq!(result.summary.lines_unchanged, 1);
232    }
233
234    #[test]
235    fn test_diff_word_level_changes() {
236        let left = "The quick brown fox\n";
237        let right = "The slow brown dog\n";
238        let result = DiffTools::diff_content(left, right, "a.md", "b.md");
239
240        assert_eq!(result.inline_changes.len(), 1);
241        let change = &result.inline_changes[0];
242        // Should have word-level detail showing "quick" → "slow" and "fox" → "dog"
243        assert!(
244            change
245                .changed_words
246                .iter()
247                .any(|w| w.operation == "delete" && w.text.contains("quick"))
248        );
249        assert!(
250            change
251                .changed_words
252                .iter()
253                .any(|w| w.operation == "insert" && w.text.contains("slow"))
254        );
255    }
256
257    #[test]
258    fn test_diff_empty_content() {
259        let result = DiffTools::diff_content("", "", "a.md", "b.md");
260        assert!((result.summary.similarity_ratio - 1.0).abs() < f64::EPSILON);
261    }
262
263    #[test]
264    fn test_diff_labels_in_output() {
265        let result = DiffTools::diff_content("a\n", "b\n", "notes/a.md", "notes/b.md");
266        assert!(result.left_path == "notes/a.md");
267        assert!(result.right_path == "notes/b.md");
268    }
269}