Skip to main content

cdx_core/extensions/collaboration/
comment.rs

1//! Comments and annotations for Codex documents.
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5
6/// A comment or annotation on document content.
7#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
8#[serde(rename_all = "camelCase")]
9pub struct Comment {
10    /// Unique identifier.
11    pub id: String,
12
13    /// Type of comment.
14    pub comment_type: CommentType,
15
16    /// Reference to the block being commented on.
17    pub block_ref: String,
18
19    /// Text range within the block (if applicable).
20    #[serde(default, skip_serializing_if = "Option::is_none")]
21    pub range: Option<TextRange>,
22
23    /// Author of the comment.
24    pub author: Collaborator,
25
26    /// When the comment was created.
27    pub created: DateTime<Utc>,
28
29    /// Comment content.
30    pub content: String,
31
32    /// Whether the comment has been resolved.
33    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
34    pub resolved: bool,
35
36    /// Who resolved the comment.
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub resolved_by: Option<Collaborator>,
39
40    /// When the comment was resolved.
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub resolved_at: Option<DateTime<Utc>>,
43
44    /// Replies to this comment.
45    #[serde(default, skip_serializing_if = "Vec::is_empty")]
46    pub replies: Vec<Comment>,
47
48    /// Parent comment ID (for nested replies).
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub parent_id: Option<String>,
51
52    /// Priority level.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub priority: Option<Priority>,
55
56    /// Tags or labels.
57    #[serde(default, skip_serializing_if = "Vec::is_empty")]
58    pub tags: Vec<String>,
59}
60
61impl Comment {
62    /// Create a new comment.
63    #[must_use]
64    pub fn new(
65        id: impl Into<String>,
66        block_ref: impl Into<String>,
67        author: Collaborator,
68        content: impl Into<String>,
69    ) -> Self {
70        Self {
71            id: id.into(),
72            comment_type: CommentType::Comment,
73            block_ref: block_ref.into(),
74            range: None,
75            author,
76            created: Utc::now(),
77            content: content.into(),
78            resolved: false,
79            resolved_by: None,
80            resolved_at: None,
81            replies: Vec::new(),
82            parent_id: None,
83            priority: None,
84            tags: Vec::new(),
85        }
86    }
87
88    /// Create a new highlight.
89    #[must_use]
90    pub fn highlight(
91        id: impl Into<String>,
92        block_ref: impl Into<String>,
93        range: TextRange,
94        author: Collaborator,
95        color: HighlightColor,
96    ) -> Self {
97        Self {
98            id: id.into(),
99            comment_type: CommentType::Highlight { color },
100            block_ref: block_ref.into(),
101            range: Some(range),
102            author,
103            created: Utc::now(),
104            content: String::new(),
105            resolved: false,
106            resolved_by: None,
107            resolved_at: None,
108            replies: Vec::new(),
109            parent_id: None,
110            priority: None,
111            tags: Vec::new(),
112        }
113    }
114
115    /// Create a new suggestion.
116    #[must_use]
117    pub fn suggestion(
118        id: impl Into<String>,
119        block_ref: impl Into<String>,
120        range: TextRange,
121        author: Collaborator,
122        original: impl Into<String>,
123        suggested: impl Into<String>,
124    ) -> Self {
125        Self {
126            id: id.into(),
127            comment_type: CommentType::Suggestion {
128                original: original.into(),
129                suggested: suggested.into(),
130                status: SuggestionStatus::Pending,
131            },
132            block_ref: block_ref.into(),
133            range: Some(range),
134            author,
135            created: Utc::now(),
136            content: String::new(),
137            resolved: false,
138            resolved_by: None,
139            resolved_at: None,
140            replies: Vec::new(),
141            parent_id: None,
142            priority: None,
143            tags: Vec::new(),
144        }
145    }
146
147    /// Create a reaction.
148    #[must_use]
149    pub fn reaction(
150        id: impl Into<String>,
151        block_ref: impl Into<String>,
152        author: Collaborator,
153        emoji: impl Into<String>,
154    ) -> Self {
155        Self {
156            id: id.into(),
157            comment_type: CommentType::Reaction {
158                emoji: emoji.into(),
159            },
160            block_ref: block_ref.into(),
161            range: None,
162            author,
163            created: Utc::now(),
164            content: String::new(),
165            resolved: false,
166            resolved_by: None,
167            resolved_at: None,
168            replies: Vec::new(),
169            parent_id: None,
170            priority: None,
171            tags: Vec::new(),
172        }
173    }
174
175    /// Set the text range.
176    #[must_use]
177    pub fn with_range(mut self, range: TextRange) -> Self {
178        self.range = Some(range);
179        self
180    }
181
182    /// Set priority.
183    #[must_use]
184    pub fn with_priority(mut self, priority: Priority) -> Self {
185        self.priority = Some(priority);
186        self
187    }
188
189    /// Add a tag.
190    #[must_use]
191    pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
192        self.tags.push(tag.into());
193        self
194    }
195
196    /// Add a reply.
197    pub fn add_reply(&mut self, mut reply: Comment) {
198        reply.parent_id = Some(self.id.clone());
199        self.replies.push(reply);
200    }
201
202    /// Resolve the comment.
203    pub fn resolve(&mut self, by: Collaborator) {
204        self.resolved = true;
205        self.resolved_by = Some(by);
206        self.resolved_at = Some(Utc::now());
207    }
208
209    /// Unresolve the comment.
210    pub fn unresolve(&mut self) {
211        self.resolved = false;
212        self.resolved_by = None;
213        self.resolved_at = None;
214    }
215
216    /// Check if this is a suggestion.
217    #[must_use]
218    pub fn is_suggestion(&self) -> bool {
219        matches!(self.comment_type, CommentType::Suggestion { .. })
220    }
221
222    /// Get the suggestion status if this is a suggestion.
223    #[must_use]
224    pub fn suggestion_status(&self) -> Option<SuggestionStatus> {
225        match &self.comment_type {
226            CommentType::Suggestion { status, .. } => Some(*status),
227            _ => None,
228        }
229    }
230
231    /// Accept a suggestion.
232    ///
233    /// Returns `true` if the suggestion was accepted, `false` if this is not a suggestion.
234    pub fn accept_suggestion(&mut self) -> bool {
235        if let CommentType::Suggestion { status, .. } = &mut self.comment_type {
236            *status = SuggestionStatus::Accepted;
237            true
238        } else {
239            false
240        }
241    }
242
243    /// Reject a suggestion.
244    ///
245    /// Returns `true` if the suggestion was rejected, `false` if this is not a suggestion.
246    pub fn reject_suggestion(&mut self) -> bool {
247        if let CommentType::Suggestion { status, .. } = &mut self.comment_type {
248            *status = SuggestionStatus::Rejected;
249            true
250        } else {
251            false
252        }
253    }
254}
255
256/// Type of comment or annotation.
257#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
258#[serde(tag = "type", rename_all = "camelCase")]
259pub enum CommentType {
260    /// Standard comment.
261    Comment,
262
263    /// Text highlight with color.
264    Highlight {
265        /// Highlight color.
266        color: HighlightColor,
267    },
268
269    /// Suggested text change.
270    Suggestion {
271        /// Original text being replaced.
272        original: String,
273        /// Suggested replacement text.
274        suggested: String,
275        /// Current status of the suggestion.
276        status: SuggestionStatus,
277    },
278
279    /// Emoji reaction.
280    Reaction {
281        /// Emoji character or shortcode.
282        emoji: String,
283    },
284}
285
286/// Highlight color.
287#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, strum::Display)]
288#[serde(rename_all = "lowercase")]
289#[strum(serialize_all = "lowercase")]
290pub enum HighlightColor {
291    /// Yellow highlight.
292    #[default]
293    Yellow,
294    /// Green highlight.
295    Green,
296    /// Blue highlight.
297    Blue,
298    /// Pink highlight.
299    Pink,
300    /// Orange highlight.
301    Orange,
302    /// Purple highlight.
303    Purple,
304    /// Red highlight.
305    Red,
306}
307
308/// Status of a suggestion.
309#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, strum::Display)]
310#[serde(rename_all = "lowercase")]
311#[strum(serialize_all = "lowercase")]
312pub enum SuggestionStatus {
313    /// Suggestion is pending review.
314    #[default]
315    Pending,
316    /// Suggestion has been accepted.
317    Accepted,
318    /// Suggestion has been rejected.
319    Rejected,
320}
321
322/// Priority level for comments.
323#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, strum::Display)]
324#[serde(rename_all = "lowercase")]
325#[strum(serialize_all = "lowercase")]
326pub enum Priority {
327    /// Low priority.
328    Low,
329    /// Normal priority.
330    Normal,
331    /// High priority.
332    High,
333    /// Critical priority.
334    Critical,
335}
336
337/// A text range within a block.
338#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
339#[serde(rename_all = "camelCase")]
340pub struct TextRange {
341    /// Start offset (inclusive).
342    pub start: usize,
343    /// End offset (exclusive).
344    pub end: usize,
345}
346
347impl TextRange {
348    /// Create a new text range.
349    #[must_use]
350    pub const fn new(start: usize, end: usize) -> Self {
351        Self { start, end }
352    }
353
354    /// Get the length of the range.
355    #[must_use]
356    pub const fn len(&self) -> usize {
357        self.end.saturating_sub(self.start)
358    }
359
360    /// Check if the range is empty.
361    #[must_use]
362    pub const fn is_empty(&self) -> bool {
363        self.start >= self.end
364    }
365
366    /// Check if this range contains a position.
367    #[must_use]
368    pub const fn contains(&self, pos: usize) -> bool {
369        pos >= self.start && pos < self.end
370    }
371
372    /// Check if this range overlaps with another.
373    #[must_use]
374    pub const fn overlaps(&self, other: &Self) -> bool {
375        self.start < other.end && other.start < self.end
376    }
377
378    /// Check if this range fully contains another.
379    #[must_use]
380    pub const fn contains_range(&self, other: &Self) -> bool {
381        self.start <= other.start && other.end <= self.end
382    }
383}
384
385/// Collaborator information for comments and changes.
386#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
387#[serde(rename_all = "camelCase")]
388pub struct Collaborator {
389    /// Display name.
390    pub name: String,
391
392    /// Email address.
393    #[serde(default, skip_serializing_if = "Option::is_none")]
394    pub email: Option<String>,
395
396    /// Avatar URL.
397    #[serde(default, skip_serializing_if = "Option::is_none")]
398    pub avatar: Option<String>,
399
400    /// User ID in an external system.
401    #[serde(default, skip_serializing_if = "Option::is_none")]
402    pub user_id: Option<String>,
403
404    /// Color for real-time cursor coloring (e.g., "#FF5733" or "blue").
405    #[serde(default, skip_serializing_if = "Option::is_none")]
406    pub color: Option<String>,
407}
408
409impl Collaborator {
410    /// Create a new author.
411    #[must_use]
412    pub fn new(name: impl Into<String>) -> Self {
413        Self {
414            name: name.into(),
415            email: None,
416            avatar: None,
417            user_id: None,
418            color: None,
419        }
420    }
421
422    /// Set email.
423    #[must_use]
424    pub fn with_email(mut self, email: impl Into<String>) -> Self {
425        self.email = Some(email.into());
426        self
427    }
428
429    /// Set avatar URL.
430    #[must_use]
431    pub fn with_avatar(mut self, avatar: impl Into<String>) -> Self {
432        self.avatar = Some(avatar.into());
433        self
434    }
435
436    /// Set user ID.
437    #[must_use]
438    pub fn with_user_id(mut self, user_id: impl Into<String>) -> Self {
439        self.user_id = Some(user_id.into());
440        self
441    }
442
443    /// Set color for real-time cursor coloring.
444    #[must_use]
445    pub fn with_color(mut self, color: impl Into<String>) -> Self {
446        self.color = Some(color.into());
447        self
448    }
449}