use crate::block::Block;
use crate::document::Document;
use crate::error::{BlocksError, Result};
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq)]
pub enum Change {
Added { block: Block, index: usize },
Removed { block: Block, index: usize },
Modified {
old_block: Block,
new_block: Block,
index: usize,
},
Moved {
block_id: Uuid,
old_index: usize,
new_index: usize,
},
TitleChanged {
old_title: String,
new_title: String,
},
MetadataChanged {
key: String,
old_value: Option<String>,
new_value: Option<String>,
},
}
impl Change {
pub fn description(&self) -> String {
match self {
Self::Added { index, .. } => format!("Added block at index {}", index),
Self::Removed { index, .. } => format!("Removed block at index {}", index),
Self::Modified { index, .. } => format!("Modified block at index {}", index),
Self::Moved {
old_index,
new_index,
..
} => format!("Moved block from {} to {}", old_index, new_index),
Self::TitleChanged {
old_title,
new_title,
} => format!("Title changed from '{}' to '{}'", old_title, new_title),
Self::MetadataChanged { key, .. } => format!("Metadata '{}' changed", key),
}
}
pub fn is_content_change(&self) -> bool {
matches!(
self,
Self::Added { .. } | Self::Removed { .. } | Self::Modified { .. }
)
}
}
#[derive(Debug, Clone)]
pub struct DiffResult {
pub changes: Vec<Change>,
pub original_id: Uuid,
pub modified_id: Uuid,
}
impl DiffResult {
pub fn is_empty(&self) -> bool {
self.changes.is_empty()
}
pub fn len(&self) -> usize {
self.changes.len()
}
pub fn content_changes(&self) -> Vec<&Change> {
self.changes
.iter()
.filter(|c| c.is_content_change())
.collect()
}
pub fn summary(&self) -> String {
let added = self
.changes
.iter()
.filter(|c| matches!(c, Change::Added { .. }))
.count();
let removed = self
.changes
.iter()
.filter(|c| matches!(c, Change::Removed { .. }))
.count();
let modified = self
.changes
.iter()
.filter(|c| matches!(c, Change::Modified { .. }))
.count();
format!(
"{} added, {} removed, {} modified",
added, removed, modified
)
}
}
pub struct DocumentDiffer;
impl DocumentDiffer {
pub fn diff(original: &Document, modified: &Document) -> DiffResult {
let mut changes = Vec::new();
if original.title != modified.title {
changes.push(Change::TitleChanged {
old_title: original.title.clone(),
new_title: modified.title.clone(),
});
}
for (key, value) in &modified.metadata {
let old_value = original.metadata.get(key);
if old_value != Some(value) {
changes.push(Change::MetadataChanged {
key: key.clone(),
old_value: old_value.cloned(),
new_value: Some(value.clone()),
});
}
}
for (key, value) in &original.metadata {
if !modified.metadata.contains_key(key) {
changes.push(Change::MetadataChanged {
key: key.clone(),
old_value: Some(value.clone()),
new_value: None,
});
}
}
let original_blocks: std::collections::HashMap<Uuid, (usize, &Block)> = original
.blocks
.iter()
.enumerate()
.map(|(i, b)| (b.id, (i, b)))
.collect();
let modified_blocks: std::collections::HashMap<Uuid, (usize, &Block)> = modified
.blocks
.iter()
.enumerate()
.map(|(i, b)| (b.id, (i, b)))
.collect();
for (i, block) in modified.blocks.iter().enumerate() {
if let Some((old_idx, old_block)) = original_blocks.get(&block.id) {
if old_block.content != block.content || old_block.block_type != block.block_type {
changes.push(Change::Modified {
old_block: (*old_block).clone(),
new_block: block.clone(),
index: i,
});
} else if *old_idx != i {
changes.push(Change::Moved {
block_id: block.id,
old_index: *old_idx,
new_index: i,
});
}
} else {
changes.push(Change::Added {
block: block.clone(),
index: i,
});
}
}
for (i, block) in original.blocks.iter().enumerate() {
if !modified_blocks.contains_key(&block.id) {
changes.push(Change::Removed {
block: block.clone(),
index: i,
});
}
}
DiffResult {
changes,
original_id: original.id,
modified_id: modified.id,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum MergeStrategy {
KeepFirst,
KeepSecond,
KeepBoth,
FailOnConflict,
}
pub struct DocumentMerger {
strategy: MergeStrategy,
}
impl DocumentMerger {
pub fn new(strategy: MergeStrategy) -> Self {
Self { strategy }
}
pub fn merge(&self, base: &Document, other: &Document) -> Result<Document> {
let mut result = base.clone();
if base.title != other.title {
result.title = match self.strategy {
MergeStrategy::KeepFirst => base.title.clone(),
MergeStrategy::KeepSecond => other.title.clone(),
MergeStrategy::KeepBoth => format!("{} / {}", base.title, other.title),
MergeStrategy::FailOnConflict => {
return Err(BlocksError::ValidationError {
message: "Title conflict".to_string(),
});
}
};
}
for (key, value) in &other.metadata {
if let Some(existing) = result.metadata.get(key) {
if existing != value {
match self.strategy {
MergeStrategy::KeepFirst => {}
MergeStrategy::KeepSecond => {
result.metadata.insert(key.clone(), value.clone());
}
MergeStrategy::KeepBoth => {
result
.metadata
.insert(key.clone(), format!("{} / {}", existing, value));
}
MergeStrategy::FailOnConflict => {
return Err(BlocksError::ValidationError {
message: format!("Metadata conflict for key: {}", key),
});
}
}
}
} else {
result.metadata.insert(key.clone(), value.clone());
}
}
let base_ids: std::collections::HashSet<Uuid> = base.blocks.iter().map(|b| b.id).collect();
for block in &other.blocks {
if !base_ids.contains(&block.id) {
result.blocks.push(block.clone());
} else {
if let Some(existing) = result.blocks.iter_mut().find(|b| b.id == block.id) {
if existing.content != block.content || existing.block_type != block.block_type
{
match self.strategy {
MergeStrategy::KeepFirst => {}
MergeStrategy::KeepSecond => {
*existing = block.clone();
}
MergeStrategy::KeepBoth => {
result.blocks.push(block.clone());
}
MergeStrategy::FailOnConflict => {
return Err(BlocksError::ValidationError {
message: format!("Block conflict for id: {}", block.id),
});
}
}
}
}
}
}
result.update_timestamp();
Ok(result)
}
pub fn merge_three_way(
&self,
base: &Document,
ours: &Document,
theirs: &Document,
) -> Result<Document> {
let intermediate = self.merge(base, ours)?;
self.merge(&intermediate, theirs)
}
}
impl Default for DocumentMerger {
fn default() -> Self {
Self::new(MergeStrategy::KeepSecond)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::BlockType;
fn create_test_doc(title: &str) -> Document {
let mut doc = Document::with_title(title.to_string());
doc.add_block(Block::new(BlockType::Text, "Block 1".to_string()));
doc.add_block(Block::new(BlockType::Text, "Block 2".to_string()));
doc
}
#[test]
fn test_diff_identical_documents() {
let doc = create_test_doc("Test");
let diff = DocumentDiffer::diff(&doc, &doc);
assert!(diff.is_empty());
}
#[test]
fn test_diff_title_change() {
let original = create_test_doc("Original");
let mut modified = original.clone();
modified.title = "Modified".to_string();
let diff = DocumentDiffer::diff(&original, &modified);
assert_eq!(diff.len(), 1);
assert!(matches!(&diff.changes[0], Change::TitleChanged { .. }));
}
#[test]
fn test_diff_added_block() {
let original = create_test_doc("Test");
let mut modified = original.clone();
modified.add_block(Block::new(BlockType::Text, "Block 3".to_string()));
let diff = DocumentDiffer::diff(&original, &modified);
let added: Vec<_> = diff
.changes
.iter()
.filter(|c| matches!(c, Change::Added { .. }))
.collect();
assert_eq!(added.len(), 1);
}
#[test]
fn test_diff_removed_block() {
let original = create_test_doc("Test");
let mut modified = original.clone();
modified.blocks.pop();
let diff = DocumentDiffer::diff(&original, &modified);
let removed: Vec<_> = diff
.changes
.iter()
.filter(|c| matches!(c, Change::Removed { .. }))
.collect();
assert_eq!(removed.len(), 1);
}
#[test]
fn test_diff_modified_block() {
let original = create_test_doc("Test");
let mut modified = original.clone();
modified.blocks[0].content = "Modified content".to_string();
let diff = DocumentDiffer::diff(&original, &modified);
let mods: Vec<_> = diff
.changes
.iter()
.filter(|c| matches!(c, Change::Modified { .. }))
.collect();
assert_eq!(mods.len(), 1);
}
#[test]
fn test_diff_summary() {
let original = create_test_doc("Test");
let mut modified = original.clone();
modified.add_block(Block::new(BlockType::Text, "New".to_string()));
modified.blocks[0].content = "Changed".to_string();
let diff = DocumentDiffer::diff(&original, &modified);
let summary = diff.summary();
assert!(summary.contains("added"));
assert!(summary.contains("modified"));
}
#[test]
fn test_merge_keep_second() {
let base = create_test_doc("Base");
let mut other = base.clone();
other.title = "Other".to_string();
let merger = DocumentMerger::new(MergeStrategy::KeepSecond);
let result = merger.merge(&base, &other).unwrap();
assert_eq!(result.title, "Other");
}
#[test]
fn test_merge_keep_first() {
let base = create_test_doc("Base");
let mut other = base.clone();
other.title = "Other".to_string();
let merger = DocumentMerger::new(MergeStrategy::KeepFirst);
let result = merger.merge(&base, &other).unwrap();
assert_eq!(result.title, "Base");
}
#[test]
fn test_merge_keep_both() {
let base = create_test_doc("Base");
let mut other = base.clone();
other.title = "Other".to_string();
let merger = DocumentMerger::new(MergeStrategy::KeepBoth);
let result = merger.merge(&base, &other).unwrap();
assert!(result.title.contains("Base"));
assert!(result.title.contains("Other"));
}
#[test]
fn test_merge_fail_on_conflict() {
let base = create_test_doc("Base");
let mut other = base.clone();
other.title = "Other".to_string();
let merger = DocumentMerger::new(MergeStrategy::FailOnConflict);
let result = merger.merge(&base, &other);
assert!(result.is_err());
}
#[test]
fn test_merge_adds_new_blocks() {
let base = create_test_doc("Base");
let mut other = Document::with_title("Other".to_string());
other.add_block(Block::new(BlockType::Text, "New Block".to_string()));
let merger = DocumentMerger::new(MergeStrategy::KeepSecond);
let result = merger.merge(&base, &other).unwrap();
assert!(result.blocks.len() > base.blocks.len());
}
#[test]
fn test_change_description() {
let change = Change::Added {
block: Block::new(BlockType::Text, "test".to_string()),
index: 0,
};
assert!(change.description().contains("Added"));
}
#[test]
fn test_content_changes_filter() {
let original = create_test_doc("Test");
let mut modified = original.clone();
modified.title = "Changed Title".to_string();
modified.add_block(Block::new(BlockType::Text, "New".to_string()));
let diff = DocumentDiffer::diff(&original, &modified);
let content_changes = diff.content_changes();
assert_eq!(content_changes.len(), 1); }
}