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 format.
232fn chrono_now() -> String {
233    // Use a simple RFC 3339 format
234    // In production, this would use chrono or time crate
235    // For now, return a placeholder that tests can override
236    "2025-01-01T00:00:00Z".to_string()
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn test_annotations_file_new() {
245        let file = AnnotationsFile::new(vec![]);
246        assert!(file.is_empty());
247        assert_eq!(file.len(), 0);
248        assert_eq!(file.version, Some("0.1".to_string()));
249    }
250
251    #[test]
252    fn test_annotations_file_add() {
253        let mut file = AnnotationsFile::empty();
254        let anno = Annotation::comment("a1", ContentAnchor::block("block-1"), "Author", "Comment");
255        file.add(anno);
256
257        assert_eq!(file.len(), 1);
258        assert!(!file.is_empty());
259    }
260
261    #[test]
262    fn test_annotations_file_get() {
263        let anno1 = Annotation::comment("a1", ContentAnchor::block("b1"), "Auth", "Text 1");
264        let anno2 = Annotation::note("a2", ContentAnchor::block("b2"), "Auth", "Text 2");
265        let file = AnnotationsFile::new(vec![anno1, anno2]);
266
267        assert!(file.get("a1").is_some());
268        assert!(file.get("a2").is_some());
269        assert!(file.get("nonexistent").is_none());
270    }
271
272    #[test]
273    fn test_annotations_file_for_block() {
274        let anno1 = Annotation::comment("a1", ContentAnchor::block("b1"), "Auth", "Text 1");
275        let anno2 = Annotation::note("a2", ContentAnchor::block("b1"), "Auth", "Text 2");
276        let anno3 = Annotation::highlight("a3", ContentAnchor::block("b2"), "Auth", "Text 3");
277        let file = AnnotationsFile::new(vec![anno1, anno2, anno3]);
278
279        let b1_annos = file.for_block("b1");
280        assert_eq!(b1_annos.len(), 2);
281
282        let b2_annos = file.for_block("b2");
283        assert_eq!(b2_annos.len(), 1);
284    }
285
286    #[test]
287    fn test_annotation_new() {
288        let anchor = ContentAnchor::range("block-1", 10, 25);
289        let anno = Annotation::new(
290            "anno-1",
291            AnnotationType::Comment,
292            anchor,
293            "Alice",
294            "A comment",
295        );
296
297        assert_eq!(anno.id, "anno-1");
298        assert_eq!(anno.annotation_type, AnnotationType::Comment);
299        assert_eq!(anno.author, "Alice");
300        assert_eq!(anno.content, "A comment");
301    }
302
303    #[test]
304    fn test_annotation_convenience_constructors() {
305        let anchor = ContentAnchor::block("b1");
306
307        let comment = Annotation::comment("c1", anchor.clone(), "Auth", "Comment text");
308        assert_eq!(comment.annotation_type, AnnotationType::Comment);
309
310        let highlight = Annotation::highlight("h1", anchor.clone(), "Auth", "Highlight note");
311        assert_eq!(highlight.annotation_type, AnnotationType::Highlight);
312
313        let note = Annotation::note("n1", anchor.clone(), "Auth", "Note text");
314        assert_eq!(note.annotation_type, AnnotationType::Note);
315
316        let reaction = Annotation::reaction("r1", anchor, "Auth", "thumbsup");
317        assert_eq!(reaction.annotation_type, AnnotationType::Reaction);
318        assert_eq!(reaction.content, "thumbsup");
319    }
320
321    #[test]
322    fn test_annotation_serialization() {
323        let anchor = ContentAnchor::range("para-1", 0, 10);
324        let anno = Annotation::comment("a1", anchor, "Reviewer", "Needs work")
325            .with_created("2025-01-15T10:00:00Z");
326
327        let json = serde_json::to_string(&anno).unwrap();
328        assert!(json.contains("\"id\":\"a1\""));
329        assert!(json.contains("\"type\":\"comment\""));
330        assert!(json.contains("\"author\":\"Reviewer\""));
331        assert!(json.contains("\"content\":\"Needs work\""));
332        assert!(json.contains("\"blockId\":\"para-1\""));
333    }
334
335    #[test]
336    fn test_annotation_deserialization() {
337        let json = r#"{
338            "id": "anno-1",
339            "type": "highlight",
340            "anchor": {"blockId": "block-1", "start": 5, "end": 15},
341            "author": "Bob",
342            "created": "2025-01-15T12:00:00Z",
343            "content": "Important section"
344        }"#;
345
346        let anno: Annotation = serde_json::from_str(json).unwrap();
347        assert_eq!(anno.id, "anno-1");
348        assert_eq!(anno.annotation_type, AnnotationType::Highlight);
349        assert_eq!(anno.anchor.block_id, "block-1");
350        assert_eq!(anno.anchor.start, Some(5));
351        assert_eq!(anno.anchor.end, Some(15));
352        assert_eq!(anno.author, "Bob");
353        assert_eq!(anno.content, "Important section");
354    }
355
356    #[test]
357    fn test_annotations_file_serialization() {
358        let anno = Annotation::comment("a1", ContentAnchor::block("b1"), "Auth", "Comment")
359            .with_created("2025-01-15T10:00:00Z");
360        let file = AnnotationsFile::new(vec![anno]);
361
362        let json = serde_json::to_string_pretty(&file).unwrap();
363        assert!(json.contains("\"version\": \"0.1\""));
364        assert!(json.contains("\"annotations\""));
365        assert!(json.contains("\"type\": \"comment\""));
366    }
367
368    #[test]
369    fn test_annotations_file_deserialization() {
370        let json = r#"{
371            "version": "0.1",
372            "annotations": [
373                {
374                    "id": "a1",
375                    "type": "note",
376                    "anchor": {"blockId": "intro"},
377                    "author": "Editor",
378                    "created": "2025-01-15T10:00:00Z",
379                    "content": "Consider rephrasing."
380                }
381            ]
382        }"#;
383
384        let file: AnnotationsFile = serde_json::from_str(json).unwrap();
385        assert_eq!(file.version, Some("0.1".to_string()));
386        assert_eq!(file.len(), 1);
387
388        let anno = &file.annotations[0];
389        assert_eq!(anno.id, "a1");
390        assert_eq!(anno.annotation_type, AnnotationType::Note);
391    }
392
393    #[test]
394    fn test_annotation_type_display() {
395        assert_eq!(AnnotationType::Comment.to_string(), "comment");
396        assert_eq!(AnnotationType::Highlight.to_string(), "highlight");
397        assert_eq!(AnnotationType::Note.to_string(), "note");
398        assert_eq!(AnnotationType::Reaction.to_string(), "reaction");
399    }
400
401    #[test]
402    fn test_annotation_type_as_str() {
403        assert_eq!(AnnotationType::Comment.as_str(), "comment");
404        assert_eq!(AnnotationType::Highlight.as_str(), "highlight");
405        assert_eq!(AnnotationType::Note.as_str(), "note");
406        assert_eq!(AnnotationType::Reaction.as_str(), "reaction");
407    }
408}