use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
#[serde(tag = "kind", content = "text", rename_all = "snake_case")]
pub enum DiffLine {
Context(String),
Added(String),
Removed(String),
}
impl Default for DiffLine {
fn default() -> Self {
Self::Context(String::new())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct CellChange {
pub row: usize,
pub col: usize,
pub from: String,
pub to: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct PropertyChange {
pub name: String,
pub from: Option<String>,
pub to: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct DocumentRevision {
pub revision_id: String,
pub author: Option<String>,
pub timestamp: Option<String>,
pub kind: RevisionKind,
pub anchor: Option<RevisionAnchor>,
pub delta: RevisionDelta,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum RevisionKind {
Insertion,
Deletion,
FormatChange,
Comment,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case", tag = "type")]
pub enum RevisionAnchor {
Paragraph {
index: usize,
},
TableCell {
row: usize,
col: usize,
table_index: usize,
},
Page {
index: usize,
},
Slide {
index: usize,
},
Sheet {
index: usize,
name: Option<String>,
},
}
impl Default for RevisionAnchor {
fn default() -> Self {
Self::Paragraph { index: 0 }
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct RevisionDelta {
pub content: Vec<DiffLine>,
pub table_changes: Vec<CellChange>,
#[serde(default)]
pub property_changes: Vec<PropertyChange>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn should_round_trip_document_revision_with_all_fields() {
let revision = DocumentRevision {
revision_id: "42".to_string(),
author: Some("Alice".to_string()),
timestamp: Some("2024-03-15T10:30:00Z".to_string()),
kind: RevisionKind::Insertion,
anchor: Some(RevisionAnchor::Paragraph { index: 3 }),
delta: RevisionDelta {
content: vec![DiffLine::Added("hello world".to_string())],
..Default::default()
},
};
let json = serde_json::to_string(&revision).expect("serialization must succeed");
let deserialized: DocumentRevision = serde_json::from_str(&json).expect("deserialization must succeed");
assert_eq!(deserialized.revision_id, "42");
assert_eq!(deserialized.author.as_deref(), Some("Alice"));
assert_eq!(deserialized.timestamp.as_deref(), Some("2024-03-15T10:30:00Z"));
assert!(matches!(deserialized.kind, RevisionKind::Insertion));
assert_eq!(deserialized.delta.content.len(), 1);
assert!(matches!(&deserialized.delta.content[0], DiffLine::Added(t) if t == "hello world"));
}
#[test]
fn should_round_trip_document_revision_with_minimal_fields() {
let revision = DocumentRevision {
revision_id: "docx-del-0".to_string(),
author: None,
timestamp: None,
kind: RevisionKind::Deletion,
anchor: None,
delta: RevisionDelta {
content: vec![DiffLine::Removed("old text".to_string())],
..Default::default()
},
};
let json = serde_json::to_string(&revision).expect("serialization must succeed");
let deserialized: DocumentRevision = serde_json::from_str(&json).expect("deserialization must succeed");
assert_eq!(deserialized.revision_id, "docx-del-0");
assert!(deserialized.author.is_none());
assert!(deserialized.timestamp.is_none());
assert!(matches!(deserialized.kind, RevisionKind::Deletion));
assert!(matches!(&deserialized.delta.content[0], DiffLine::Removed(t) if t == "old text"));
}
#[test]
fn should_round_trip_format_change_revision_with_empty_delta() {
let revision = DocumentRevision {
revision_id: "docx-fmt-5".to_string(),
author: Some("Bob".to_string()),
timestamp: None,
kind: RevisionKind::FormatChange,
anchor: Some(RevisionAnchor::Paragraph { index: 0 }),
delta: RevisionDelta::default(),
};
let json = serde_json::to_string(&revision).expect("serialization must succeed");
let deserialized: DocumentRevision = serde_json::from_str(&json).expect("deserialization must succeed");
assert!(matches!(deserialized.kind, RevisionKind::FormatChange));
assert!(deserialized.delta.content.is_empty());
assert!(deserialized.delta.table_changes.is_empty());
assert!(deserialized.delta.property_changes.is_empty());
}
#[test]
fn should_round_trip_all_revision_kinds() {
for kind in [
RevisionKind::Insertion,
RevisionKind::Deletion,
RevisionKind::FormatChange,
RevisionKind::Comment,
] {
let revision = DocumentRevision {
revision_id: "test".to_string(),
author: None,
timestamp: None,
kind,
anchor: None,
delta: RevisionDelta::default(),
};
let json = serde_json::to_string(&revision).expect("serialization must succeed");
let back: DocumentRevision = serde_json::from_str(&json).expect("deserialization must succeed");
assert_eq!(back.kind, kind);
}
}
#[test]
fn should_round_trip_all_revision_anchors() {
let anchors = vec![
RevisionAnchor::Paragraph { index: 2 },
RevisionAnchor::TableCell {
row: 1,
col: 3,
table_index: 0,
},
RevisionAnchor::Page { index: 5 },
RevisionAnchor::Slide { index: 7 },
RevisionAnchor::Sheet {
index: 2,
name: Some("Q1".to_string()),
},
];
for anchor in anchors {
let revision = DocumentRevision {
revision_id: "test".to_string(),
author: None,
timestamp: None,
kind: RevisionKind::Insertion,
anchor: Some(anchor),
delta: RevisionDelta::default(),
};
let json = serde_json::to_string(&revision).expect("serialization must succeed");
let back: DocumentRevision = serde_json::from_str(&json).expect("deserialization must succeed");
assert!(back.anchor.is_some());
}
}
#[test]
fn should_round_trip_cell_change_in_revision_delta() {
let revision = DocumentRevision {
revision_id: "tbl-1".to_string(),
author: None,
timestamp: None,
kind: RevisionKind::Insertion,
anchor: Some(RevisionAnchor::TableCell {
row: 0,
col: 1,
table_index: 0,
}),
delta: RevisionDelta {
content: vec![],
table_changes: vec![CellChange {
row: 0,
col: 1,
from: "old".to_string(),
to: "new".to_string(),
}],
property_changes: vec![],
},
};
let json = serde_json::to_string(&revision).expect("serialization must succeed");
let back: DocumentRevision = serde_json::from_str(&json).expect("deserialization must succeed");
assert_eq!(back.delta.table_changes.len(), 1);
assert_eq!(back.delta.table_changes[0].from, "old");
assert_eq!(back.delta.table_changes[0].to, "new");
}
#[test]
fn should_round_trip_property_change_in_revision_delta() {
let revision = DocumentRevision {
revision_id: "fmt-1".to_string(),
author: None,
timestamp: None,
kind: RevisionKind::FormatChange,
anchor: Some(RevisionAnchor::Paragraph { index: 0 }),
delta: RevisionDelta {
property_changes: vec![PropertyChange {
name: "bold".to_string(),
from: Some("true".to_string()),
to: Some("false".to_string()),
}],
..Default::default()
},
};
let json = serde_json::to_string(&revision).expect("serialization must succeed");
let back: DocumentRevision = serde_json::from_str(&json).expect("deserialization must succeed");
assert_eq!(back.delta.property_changes.len(), 1);
assert_eq!(back.delta.property_changes[0].name, "bold");
assert_eq!(back.delta.property_changes[0].from.as_deref(), Some("true"));
assert_eq!(back.delta.property_changes[0].to.as_deref(), Some("false"));
}
}