1use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
8#[serde(rename_all = "camelCase")]
9pub struct Comment {
10 pub id: String,
12
13 pub comment_type: CommentType,
15
16 pub block_ref: String,
18
19 #[serde(default, skip_serializing_if = "Option::is_none")]
21 pub range: Option<TextRange>,
22
23 pub author: Collaborator,
25
26 pub created: DateTime<Utc>,
28
29 pub content: String,
31
32 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
34 pub resolved: bool,
35
36 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub resolved_by: Option<Collaborator>,
39
40 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub resolved_at: Option<DateTime<Utc>>,
43
44 #[serde(default, skip_serializing_if = "Vec::is_empty")]
46 pub replies: Vec<Comment>,
47
48 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub parent_id: Option<String>,
51
52 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub priority: Option<Priority>,
55
56 #[serde(default, skip_serializing_if = "Vec::is_empty")]
58 pub tags: Vec<String>,
59}
60
61impl Comment {
62 #[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 #[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 #[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 #[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 #[must_use]
177 pub fn with_range(mut self, range: TextRange) -> Self {
178 self.range = Some(range);
179 self
180 }
181
182 #[must_use]
184 pub fn with_priority(mut self, priority: Priority) -> Self {
185 self.priority = Some(priority);
186 self
187 }
188
189 #[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 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 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 pub fn unresolve(&mut self) {
211 self.resolved = false;
212 self.resolved_by = None;
213 self.resolved_at = None;
214 }
215
216 #[must_use]
218 pub fn is_suggestion(&self) -> bool {
219 matches!(self.comment_type, CommentType::Suggestion { .. })
220 }
221
222 #[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 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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
258#[serde(tag = "type", rename_all = "camelCase")]
259pub enum CommentType {
260 Comment,
262
263 Highlight {
265 color: HighlightColor,
267 },
268
269 Suggestion {
271 original: String,
273 suggested: String,
275 status: SuggestionStatus,
277 },
278
279 Reaction {
281 emoji: String,
283 },
284}
285
286#[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 #[default]
293 Yellow,
294 Green,
296 Blue,
298 Pink,
300 Orange,
302 Purple,
304 Red,
306}
307
308#[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 #[default]
315 Pending,
316 Accepted,
318 Rejected,
320}
321
322#[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,
329 Normal,
331 High,
333 Critical,
335}
336
337#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
339#[serde(rename_all = "camelCase")]
340pub struct TextRange {
341 pub start: usize,
343 pub end: usize,
345}
346
347impl TextRange {
348 #[must_use]
350 pub const fn new(start: usize, end: usize) -> Self {
351 Self { start, end }
352 }
353
354 #[must_use]
356 pub const fn len(&self) -> usize {
357 self.end.saturating_sub(self.start)
358 }
359
360 #[must_use]
362 pub const fn is_empty(&self) -> bool {
363 self.start >= self.end
364 }
365
366 #[must_use]
368 pub const fn contains(&self, pos: usize) -> bool {
369 pos >= self.start && pos < self.end
370 }
371
372 #[must_use]
374 pub const fn overlaps(&self, other: &Self) -> bool {
375 self.start < other.end && other.start < self.end
376 }
377
378 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
387#[serde(rename_all = "camelCase")]
388pub struct Collaborator {
389 pub name: String,
391
392 #[serde(default, skip_serializing_if = "Option::is_none")]
394 pub email: Option<String>,
395
396 #[serde(default, skip_serializing_if = "Option::is_none")]
398 pub avatar: Option<String>,
399
400 #[serde(default, skip_serializing_if = "Option::is_none")]
402 pub user_id: Option<String>,
403
404 #[serde(default, skip_serializing_if = "Option::is_none")]
406 pub color: Option<String>,
407}
408
409impl Collaborator {
410 #[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 #[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 #[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 #[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 #[must_use]
445 pub fn with_color(mut self, color: impl Into<String>) -> Self {
446 self.color = Some(color.into());
447 self
448 }
449}