use serde::{Deserialize, Serialize};
use similar::{ChangeTag, TextDiff};
use std::path::PathBuf;
use std::sync::Arc;
use turbovault_core::prelude::*;
use turbovault_vault::VaultManager;
#[derive(Clone)]
pub struct DiffTools {
pub manager: Arc<VaultManager>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiffResult {
pub left_path: String,
pub right_path: String,
pub unified_diff: String,
pub summary: DiffSummary,
pub inline_changes: Vec<InlineChange>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiffSummary {
pub lines_added: usize,
pub lines_removed: usize,
pub lines_changed: usize,
pub lines_unchanged: usize,
pub similarity_ratio: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InlineChange {
pub line_number: usize,
pub old_text: String,
pub new_text: String,
pub changed_words: Vec<WordChange>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WordChange {
pub operation: String,
pub text: String,
}
impl DiffTools {
pub fn new(manager: Arc<VaultManager>) -> Self {
Self { manager }
}
pub async fn diff_notes(&self, left_path: &str, right_path: &str) -> Result<DiffResult> {
let left_content = self.manager.read_file(&PathBuf::from(left_path)).await?;
let right_content = self.manager.read_file(&PathBuf::from(right_path)).await?;
Ok(Self::diff_content(
&left_content,
&right_content,
left_path,
right_path,
))
}
pub fn diff_content(
left: &str,
right: &str,
left_label: &str,
right_label: &str,
) -> DiffResult {
let line_diff = TextDiff::from_lines(left, right);
let unified_diff = line_diff
.unified_diff()
.header(left_label, right_label)
.context_radius(3)
.to_string();
let mut lines_added = 0usize;
let mut lines_removed = 0usize;
let mut lines_unchanged = 0usize;
for change in line_diff.iter_all_changes() {
match change.tag() {
ChangeTag::Insert => lines_added += 1,
ChangeTag::Delete => lines_removed += 1,
ChangeTag::Equal => lines_unchanged += 1,
}
}
let similarity_ratio = f64::from(line_diff.ratio());
let mut inline_changes = compute_inline_changes(&line_diff);
let lines_changed = inline_changes.len();
inline_changes.truncate(50);
DiffResult {
left_path: left_label.to_string(),
right_path: right_label.to_string(),
unified_diff,
summary: DiffSummary {
lines_added: lines_added.saturating_sub(lines_changed),
lines_removed: lines_removed.saturating_sub(lines_changed),
lines_changed,
lines_unchanged,
similarity_ratio,
},
inline_changes,
}
}
}
fn compute_inline_changes<'a>(line_diff: &TextDiff<'a, 'a, 'a, str>) -> Vec<InlineChange> {
let mut inline_changes = Vec::new();
let changes: Vec<_> = line_diff.iter_all_changes().collect();
let mut i = 0;
let mut line_number = 0usize;
while i < changes.len() {
let change = &changes[i];
match change.tag() {
ChangeTag::Equal => {
line_number += 1;
i += 1;
}
ChangeTag::Delete => {
line_number += 1;
if i + 1 < changes.len() && changes[i + 1].tag() == ChangeTag::Insert {
let old_text = change.to_string_lossy();
let new_text = changes[i + 1].to_string_lossy();
let word_diff = TextDiff::from_words(old_text.trim_end(), new_text.trim_end());
let changed_words: Vec<WordChange> = word_diff
.iter_all_changes()
.map(|wc| WordChange {
operation: match wc.tag() {
ChangeTag::Insert => "insert".to_string(),
ChangeTag::Delete => "delete".to_string(),
ChangeTag::Equal => "equal".to_string(),
},
text: wc.to_string_lossy().to_string(),
})
.collect();
inline_changes.push(InlineChange {
line_number,
old_text: old_text.trim_end().to_string(),
new_text: new_text.trim_end().to_string(),
changed_words,
});
i += 2; } else {
i += 1; }
}
ChangeTag::Insert => {
i += 1; }
}
}
inline_changes
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_diff_identical_content() {
let content = "# Hello\n\nThis is a test note.\n";
let result = DiffTools::diff_content(content, content, "a.md", "b.md");
assert_eq!(result.summary.lines_added, 0);
assert_eq!(result.summary.lines_removed, 0);
assert_eq!(result.summary.lines_changed, 0);
assert!((result.summary.similarity_ratio - 1.0).abs() < f64::EPSILON);
assert!(result.inline_changes.is_empty());
}
#[test]
fn test_diff_completely_different() {
let left = "Hello world\n";
let right = "Goodbye universe\n";
let result = DiffTools::diff_content(left, right, "a.md", "b.md");
assert!(result.summary.similarity_ratio < 1.0);
assert!(!result.unified_diff.is_empty());
}
#[test]
fn test_diff_with_changes() {
let left = "# Title\n\nLine one\nLine two\nLine three\n";
let right = "# Title\n\nLine one\nLine modified\nLine three\n";
let result = DiffTools::diff_content(left, right, "a.md", "b.md");
assert_eq!(result.summary.lines_changed, 1);
assert_eq!(result.summary.lines_unchanged, 4); assert_eq!(result.inline_changes.len(), 1);
assert_eq!(result.inline_changes[0].old_text, "Line two");
assert_eq!(result.inline_changes[0].new_text, "Line modified");
}
#[test]
fn test_diff_additions_only() {
let left = "Line one\n";
let right = "Line one\nLine two\nLine three\n";
let result = DiffTools::diff_content(left, right, "a.md", "b.md");
assert_eq!(result.summary.lines_added, 2);
assert_eq!(result.summary.lines_removed, 0);
assert_eq!(result.summary.lines_unchanged, 1);
}
#[test]
fn test_diff_word_level_changes() {
let left = "The quick brown fox\n";
let right = "The slow brown dog\n";
let result = DiffTools::diff_content(left, right, "a.md", "b.md");
assert_eq!(result.inline_changes.len(), 1);
let change = &result.inline_changes[0];
assert!(
change
.changed_words
.iter()
.any(|w| w.operation == "delete" && w.text.contains("quick"))
);
assert!(
change
.changed_words
.iter()
.any(|w| w.operation == "insert" && w.text.contains("slow"))
);
}
#[test]
fn test_diff_empty_content() {
let result = DiffTools::diff_content("", "", "a.md", "b.md");
assert!((result.summary.similarity_ratio - 1.0).abs() < f64::EPSILON);
}
#[test]
fn test_diff_labels_in_output() {
let result = DiffTools::diff_content("a\n", "b\n", "notes/a.md", "notes/b.md");
assert!(result.left_path == "notes/a.md");
assert!(result.right_path == "notes/b.md");
}
}