Skip to main content

cdx_core/extensions/collaboration/
revision.rs

1//! Revision history for tracking document evolution.
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5
6use crate::DocumentId;
7
8use super::{Collaborator, CrdtFormat};
9
10/// Revision history for tracking document evolution.
11#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
12#[serde(rename_all = "camelCase")]
13pub struct RevisionHistory {
14    /// List of revisions in chronological order.
15    pub revisions: Vec<Revision>,
16}
17
18impl RevisionHistory {
19    /// Create new empty revision history.
20    #[must_use]
21    pub fn new() -> Self {
22        Self {
23            revisions: Vec::new(),
24        }
25    }
26
27    /// Add a revision.
28    pub fn add(&mut self, revision: Revision) {
29        self.revisions.push(revision);
30    }
31
32    /// Get the latest revision.
33    #[must_use]
34    pub fn latest(&self) -> Option<&Revision> {
35        self.revisions.last()
36    }
37
38    /// Get a revision by version number.
39    #[must_use]
40    pub fn get_version(&self, version: u32) -> Option<&Revision> {
41        self.revisions.iter().find(|r| r.version == version)
42    }
43
44    /// Get the next version number.
45    #[must_use]
46    pub fn next_version(&self) -> u32 {
47        self.revisions
48            .iter()
49            .map(|r| r.version)
50            .max()
51            .map_or(1, |v| v + 1)
52    }
53
54    /// Get the number of revisions.
55    #[must_use]
56    pub fn len(&self) -> usize {
57        self.revisions.len()
58    }
59
60    /// Check if empty.
61    #[must_use]
62    pub fn is_empty(&self) -> bool {
63        self.revisions.is_empty()
64    }
65}
66
67impl Default for RevisionHistory {
68    fn default() -> Self {
69        Self::new()
70    }
71}
72
73/// A single revision in the document history.
74#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
75#[serde(rename_all = "camelCase")]
76pub struct Revision {
77    /// Version number (1-indexed).
78    pub version: u32,
79
80    /// Document ID (hash) of this revision.
81    pub document_id: DocumentId,
82
83    /// When this revision was created.
84    pub created: DateTime<Utc>,
85
86    /// Author of this revision.
87    pub author: Collaborator,
88
89    /// Optional note describing the revision.
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub note: Option<String>,
92
93    /// Tags or labels for this revision.
94    #[serde(default, skip_serializing_if = "Vec::is_empty")]
95    pub tags: Vec<String>,
96}
97
98impl Revision {
99    /// Create a new revision.
100    #[must_use]
101    pub fn new(version: u32, document_id: DocumentId, author: Collaborator) -> Self {
102        Self {
103            version,
104            document_id,
105            created: Utc::now(),
106            author,
107            note: None,
108            tags: Vec::new(),
109        }
110    }
111
112    /// Set the note.
113    #[must_use]
114    pub fn with_note(mut self, note: impl Into<String>) -> Self {
115        self.note = Some(note.into());
116        self
117    }
118
119    /// Add a tag.
120    #[must_use]
121    pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
122        self.tags.push(tag.into());
123        self
124    }
125}
126
127/// Materialization event for CRDT documents.
128///
129/// When a document with CRDT state is exported or exchanged between
130/// different CRDT implementations, it must be materialized to static content.
131#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
132#[serde(rename_all = "camelCase")]
133pub struct MaterializationEvent {
134    /// Timestamp of the materialization.
135    pub timestamp: DateTime<Utc>,
136
137    /// Tool that performed the materialization.
138    pub agent: String,
139
140    /// Original CRDT format (if applicable).
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    pub from_crdt_format: Option<CrdtFormat>,
143
144    /// Target CRDT format (if applicable).
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub to_crdt_format: Option<CrdtFormat>,
147
148    /// Reason for materialization.
149    pub reason: MaterializationReason,
150}
151
152impl MaterializationEvent {
153    /// Create a new materialization event.
154    #[must_use]
155    pub fn new(agent: impl Into<String>, reason: MaterializationReason) -> Self {
156        Self {
157            timestamp: Utc::now(),
158            agent: agent.into(),
159            from_crdt_format: None,
160            to_crdt_format: None,
161            reason,
162        }
163    }
164
165    /// Set the source CRDT format.
166    #[must_use]
167    pub fn with_source_format(mut self, format: CrdtFormat) -> Self {
168        self.from_crdt_format = Some(format);
169        self
170    }
171
172    /// Set the target CRDT format.
173    #[must_use]
174    pub fn with_target_format(mut self, format: CrdtFormat) -> Self {
175        self.to_crdt_format = Some(format);
176        self
177    }
178
179    /// Create an event for cross-tool exchange.
180    #[must_use]
181    pub fn cross_tool_exchange(agent: impl Into<String>, from: CrdtFormat, to: CrdtFormat) -> Self {
182        Self::new(agent, MaterializationReason::CrossToolExchange)
183            .with_source_format(from)
184            .with_target_format(to)
185    }
186
187    /// Create an event for export to static format.
188    #[must_use]
189    pub fn export(agent: impl Into<String>, from: CrdtFormat) -> Self {
190        Self::new(agent, MaterializationReason::Export).with_source_format(from)
191    }
192}
193
194/// Reason for materializing CRDT state.
195#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, strum::Display)]
196#[serde(rename_all = "kebab-case")]
197#[strum(serialize_all = "kebab-case")]
198pub enum MaterializationReason {
199    /// Exchanging between tools using different CRDT formats.
200    CrossToolExchange,
201    /// Exporting to a non-CRDT format.
202    Export,
203    /// Archiving for long-term storage.
204    Archive,
205    /// Manual user request.
206    UserRequest,
207}