1use 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#[derive(Clone)]
16pub struct DiffTools {
17 pub manager: Arc<VaultManager>,
18}
19
20#[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#[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#[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#[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 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 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 let unified_diff = line_diff
85 .unified_diff()
86 .header(left_label, right_label)
87 .context_radius(3)
88 .to_string();
89
90 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 let mut inline_changes = compute_inline_changes(&line_diff);
107 let lines_changed = inline_changes.len();
108 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
127fn 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 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; } else {
172 i += 1; }
174 }
175 ChangeTag::Insert => {
176 i += 1; }
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); 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 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}