Skip to main content

cdx_core/security/
annotations.rs

1//! Core annotations for minimal annotation support without extensions.
2//!
3//! This module provides lightweight annotation support for frozen/published documents
4//! when the collaboration extension is not available. Core annotations are stored in
5//! `security/annotations.json` and provide basic comment functionality without the
6//! full feature set of the collaboration extension.
7//!
8//! # When to Use
9//!
10//! - For simple read-only annotations on frozen documents
11//! - When the collaboration extension is not available or overkill
12//! - For implementations that don't need threaded discussions or suggestions
13//!
14//! # Example
15//!
16//! ```rust
17//! use cdx_core::security::{Annotation, AnnotationType, AnnotationsFile};
18//! use cdx_core::anchor::ContentAnchor;
19//!
20//! let annotation = Annotation::new(
21//!     "anno-1",
22//!     AnnotationType::Comment,
23//!     ContentAnchor::block("para-1"),
24//!     "Reviewer",
25//!     "This section needs clarification.",
26//! );
27//!
28//! let annotations = AnnotationsFile::new(vec![annotation]);
29//! ```
30
31use serde::{Deserialize, Serialize};
32
33use crate::anchor::ContentAnchor;
34
35/// Core annotations file for `security/annotations.json`.
36///
37/// Provides minimal annotation support for implementations that don't use
38/// the collaboration extension.
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct AnnotationsFile {
41    /// Format version (e.g., "0.1").
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub version: Option<String>,
44
45    /// Array of annotations.
46    pub annotations: Vec<Annotation>,
47}
48
49impl AnnotationsFile {
50    /// Create a new annotations file.
51    #[must_use]
52    pub fn new(annotations: Vec<Annotation>) -> Self {
53        Self {
54            version: Some(crate::SPEC_VERSION.to_string()),
55            annotations,
56        }
57    }
58
59    /// Create an empty annotations file.
60    #[must_use]
61    pub fn empty() -> Self {
62        Self::new(Vec::new())
63    }
64
65    /// Add an annotation.
66    pub fn add(&mut self, annotation: Annotation) {
67        self.annotations.push(annotation);
68    }
69
70    /// Get an annotation by ID.
71    #[must_use]
72    pub fn get(&self, id: &str) -> Option<&Annotation> {
73        self.annotations.iter().find(|a| a.id == id)
74    }
75
76    /// Get all annotations for a specific block.
77    #[must_use]
78    pub fn for_block(&self, block_id: &str) -> Vec<&Annotation> {
79        self.annotations
80            .iter()
81            .filter(|a| a.anchor.block_id == block_id)
82            .collect()
83    }
84
85    /// Check if there are any annotations.
86    #[must_use]
87    pub fn is_empty(&self) -> bool {
88        self.annotations.is_empty()
89    }
90
91    /// Get the number of annotations.
92    #[must_use]
93    pub fn len(&self) -> usize {
94        self.annotations.len()
95    }
96}
97
98impl Default for AnnotationsFile {
99    fn default() -> Self {
100        Self::empty()
101    }
102}
103
104/// A core annotation.
105///
106/// Core annotations are simpler than collaboration comments - they use a plain
107/// string author field instead of the full `Collaborator` object, and don't
108/// support threading, suggestions, or reactions.
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110pub struct Annotation {
111    /// Unique annotation identifier.
112    pub id: String,
113
114    /// Annotation type.
115    #[serde(rename = "type")]
116    pub annotation_type: AnnotationType,
117
118    /// Anchor to document content.
119    pub anchor: ContentAnchor,
120
121    /// Author name or identifier.
122    pub author: String,
123
124    /// ISO 8601 creation timestamp.
125    pub created: String,
126
127    /// Annotation content (text).
128    pub content: String,
129}
130
131impl Annotation {
132    /// Create a new annotation.
133    #[must_use]
134    pub fn new(
135        id: impl Into<String>,
136        annotation_type: AnnotationType,
137        anchor: ContentAnchor,
138        author: impl Into<String>,
139        content: impl Into<String>,
140    ) -> Self {
141        Self {
142            id: id.into(),
143            annotation_type,
144            anchor,
145            author: author.into(),
146            created: chrono_now(),
147            content: content.into(),
148        }
149    }
150
151    /// Create a comment annotation.
152    #[must_use]
153    pub fn comment(
154        id: impl Into<String>,
155        anchor: ContentAnchor,
156        author: impl Into<String>,
157        content: impl Into<String>,
158    ) -> Self {
159        Self::new(id, AnnotationType::Comment, anchor, author, content)
160    }
161
162    /// Create a highlight annotation.
163    #[must_use]
164    pub fn highlight(
165        id: impl Into<String>,
166        anchor: ContentAnchor,
167        author: impl Into<String>,
168        content: impl Into<String>,
169    ) -> Self {
170        Self::new(id, AnnotationType::Highlight, anchor, author, content)
171    }
172
173    /// Create a note annotation.
174    #[must_use]
175    pub fn note(
176        id: impl Into<String>,
177        anchor: ContentAnchor,
178        author: impl Into<String>,
179        content: impl Into<String>,
180    ) -> Self {
181        Self::new(id, AnnotationType::Note, anchor, author, content)
182    }
183
184    /// Create a reaction annotation.
185    #[must_use]
186    pub fn reaction(
187        id: impl Into<String>,
188        anchor: ContentAnchor,
189        author: impl Into<String>,
190        emoji: impl Into<String>,
191    ) -> Self {
192        Self::new(id, AnnotationType::Reaction, anchor, author, emoji)
193    }
194
195    /// Set the creation timestamp.
196    #[must_use]
197    pub fn with_created(mut self, created: impl Into<String>) -> Self {
198        self.created = created.into();
199        self
200    }
201}
202
203/// Annotation type for core annotations.
204#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, strum::Display)]
205#[serde(rename_all = "lowercase")]
206#[strum(serialize_all = "lowercase")]
207pub enum AnnotationType {
208    /// General comment on content.
209    Comment,
210    /// Highlighted text with optional note.
211    Highlight,
212    /// A note attached to content.
213    Note,
214    /// Emoji reaction.
215    Reaction,
216}
217
218impl AnnotationType {
219    /// Get the type as a string.
220    #[must_use]
221    pub const fn as_str(&self) -> &'static str {
222        match self {
223            Self::Comment => "comment",
224            Self::Highlight => "highlight",
225            Self::Note => "note",
226            Self::Reaction => "reaction",
227        }
228    }
229}
230
231/// Get current timestamp in ISO 8601 / RFC 3339 format.
232fn chrono_now() -> String {
233    chrono::Utc::now().to_rfc3339()
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    #[test]
241    fn test_annotations_file_new() {
242        let file = AnnotationsFile::new(vec![]);
243        assert!(file.is_empty());
244        assert_eq!(file.len(), 0);
245        assert_eq!(file.version, Some("0.1".to_string()));
246    }
247
248    #[test]
249    fn test_annotations_file_add() {
250        let mut file = AnnotationsFile::empty();
251        let anno = Annotation::comment("a1", ContentAnchor::block("block-1"), "Author", "Comment");
252        file.add(anno);
253
254        assert_eq!(file.len(), 1);
255        assert!(!file.is_empty());
256    }
257
258    #[test]
259    fn test_annotations_file_get() {
260        let anno1 = Annotation::comment("a1", ContentAnchor::block("b1"), "Auth", "Text 1");
261        let anno2 = Annotation::note("a2", ContentAnchor::block("b2"), "Auth", "Text 2");
262        let file = AnnotationsFile::new(vec![anno1, anno2]);
263
264        assert!(file.get("a1").is_some());
265        assert!(file.get("a2").is_some());
266        assert!(file.get("nonexistent").is_none());
267    }
268
269    #[test]
270    fn test_annotations_file_for_block() {
271        let anno1 = Annotation::comment("a1", ContentAnchor::block("b1"), "Auth", "Text 1");
272        let anno2 = Annotation::note("a2", ContentAnchor::block("b1"), "Auth", "Text 2");
273        let anno3 = Annotation::highlight("a3", ContentAnchor::block("b2"), "Auth", "Text 3");
274        let file = AnnotationsFile::new(vec![anno1, anno2, anno3]);
275
276        let b1_annos = file.for_block("b1");
277        assert_eq!(b1_annos.len(), 2);
278
279        let b2_annos = file.for_block("b2");
280        assert_eq!(b2_annos.len(), 1);
281    }
282
283    #[test]
284    fn test_annotation_new() {
285        let anchor = ContentAnchor::range("block-1", 10, 25);
286        let anno = Annotation::new(
287            "anno-1",
288            AnnotationType::Comment,
289            anchor,
290            "Alice",
291            "A comment",
292        );
293
294        assert_eq!(anno.id, "anno-1");
295        assert_eq!(anno.annotation_type, AnnotationType::Comment);
296        assert_eq!(anno.author, "Alice");
297        assert_eq!(anno.content, "A comment");
298    }
299
300    #[test]
301    fn test_annotation_convenience_constructors() {
302        let anchor = ContentAnchor::block("b1");
303
304        let comment = Annotation::comment("c1", anchor.clone(), "Auth", "Comment text");
305        assert_eq!(comment.annotation_type, AnnotationType::Comment);
306
307        let highlight = Annotation::highlight("h1", anchor.clone(), "Auth", "Highlight note");
308        assert_eq!(highlight.annotation_type, AnnotationType::Highlight);
309
310        let note = Annotation::note("n1", anchor.clone(), "Auth", "Note text");
311        assert_eq!(note.annotation_type, AnnotationType::Note);
312
313        let reaction = Annotation::reaction("r1", anchor, "Auth", "thumbsup");
314        assert_eq!(reaction.annotation_type, AnnotationType::Reaction);
315        assert_eq!(reaction.content, "thumbsup");
316    }
317
318    #[test]
319    fn test_annotation_serialization() {
320        let anchor = ContentAnchor::range("para-1", 0, 10);
321        let anno = Annotation::comment("a1", anchor, "Reviewer", "Needs work")
322            .with_created("2025-01-15T10:00:00Z");
323
324        let json = serde_json::to_string(&anno).unwrap();
325        assert!(json.contains("\"id\":\"a1\""));
326        assert!(json.contains("\"type\":\"comment\""));
327        assert!(json.contains("\"author\":\"Reviewer\""));
328        assert!(json.contains("\"content\":\"Needs work\""));
329        assert!(json.contains("\"blockId\":\"para-1\""));
330    }
331
332    #[test]
333    fn test_annotation_deserialization() {
334        let json = r#"{
335            "id": "anno-1",
336            "type": "highlight",
337            "anchor": {"blockId": "block-1", "start": 5, "end": 15},
338            "author": "Bob",
339            "created": "2025-01-15T12:00:00Z",
340            "content": "Important section"
341        }"#;
342
343        let anno: Annotation = serde_json::from_str(json).unwrap();
344        assert_eq!(anno.id, "anno-1");
345        assert_eq!(anno.annotation_type, AnnotationType::Highlight);
346        assert_eq!(anno.anchor.block_id, "block-1");
347        assert_eq!(anno.anchor.start, Some(5));
348        assert_eq!(anno.anchor.end, Some(15));
349        assert_eq!(anno.author, "Bob");
350        assert_eq!(anno.content, "Important section");
351    }
352
353    #[test]
354    fn test_annotations_file_serialization() {
355        let anno = Annotation::comment("a1", ContentAnchor::block("b1"), "Auth", "Comment")
356            .with_created("2025-01-15T10:00:00Z");
357        let file = AnnotationsFile::new(vec![anno]);
358
359        let json = serde_json::to_string_pretty(&file).unwrap();
360        assert!(json.contains("\"version\": \"0.1\""));
361        assert!(json.contains("\"annotations\""));
362        assert!(json.contains("\"type\": \"comment\""));
363    }
364
365    #[test]
366    fn test_annotations_file_deserialization() {
367        let json = r#"{
368            "version": "0.1",
369            "annotations": [
370                {
371                    "id": "a1",
372                    "type": "note",
373                    "anchor": {"blockId": "intro"},
374                    "author": "Editor",
375                    "created": "2025-01-15T10:00:00Z",
376                    "content": "Consider rephrasing."
377                }
378            ]
379        }"#;
380
381        let file: AnnotationsFile = serde_json::from_str(json).unwrap();
382        assert_eq!(file.version, Some("0.1".to_string()));
383        assert_eq!(file.len(), 1);
384
385        let anno = &file.annotations[0];
386        assert_eq!(anno.id, "a1");
387        assert_eq!(anno.annotation_type, AnnotationType::Note);
388    }
389
390    #[test]
391    fn test_annotation_type_display() {
392        assert_eq!(AnnotationType::Comment.to_string(), "comment");
393        assert_eq!(AnnotationType::Highlight.to_string(), "highlight");
394        assert_eq!(AnnotationType::Note.to_string(), "note");
395        assert_eq!(AnnotationType::Reaction.to_string(), "reaction");
396    }
397
398    #[test]
399    fn test_annotation_type_as_str() {
400        assert_eq!(AnnotationType::Comment.as_str(), "comment");
401        assert_eq!(AnnotationType::Highlight.as_str(), "highlight");
402        assert_eq!(AnnotationType::Note.as_str(), "note");
403        assert_eq!(AnnotationType::Reaction.as_str(), "reaction");
404    }
405}