1use sheetkit_xml::threaded_comment::{Person, PersonList, ThreadedComment, ThreadedComments};
8
9use crate::error::{Error, Result};
10
11#[derive(Debug, Clone, PartialEq)]
13pub struct ThreadedCommentInput {
14 pub author: String,
16 pub text: String,
18 pub parent_id: Option<String>,
20}
21
22#[derive(Debug, Clone, PartialEq)]
24pub struct PersonInput {
25 pub display_name: String,
27 pub user_id: Option<String>,
29 pub provider_id: Option<String>,
31}
32
33#[derive(Debug, Clone, PartialEq)]
35pub struct ThreadedCommentData {
36 pub id: String,
38 pub cell_ref: String,
40 pub text: String,
42 pub author: String,
44 pub person_id: String,
46 pub date_time: String,
48 pub parent_id: Option<String>,
50 pub done: bool,
52}
53
54#[derive(Debug, Clone, PartialEq)]
56pub struct PersonData {
57 pub id: String,
59 pub display_name: String,
61 pub user_id: Option<String>,
63 pub provider_id: Option<String>,
65}
66
67fn generate_guid() -> String {
69 format!("{{{}}}", uuid::Uuid::new_v4().to_string().to_uppercase())
70}
71
72fn current_timestamp() -> String {
75 let now = chrono::Utc::now();
76 let base = now.format("%Y-%m-%dT%H:%M:%S").to_string();
77 let centiseconds = now.timestamp_subsec_millis() / 10;
78 format!("{base}.{centiseconds:02}")
79}
80
81fn generate_unique_guid(
83 person_list: &PersonList,
84 threaded_comments: Option<&ThreadedComments>,
85) -> String {
86 loop {
87 let id = generate_guid();
88 let person_collision = person_list.persons.iter().any(|p| p.id == id);
89 let comment_collision = threaded_comments
90 .map(|tc| tc.comments.iter().any(|c| c.id == id))
91 .unwrap_or(false);
92 if !person_collision && !comment_collision {
93 return id;
94 }
95 }
96}
97
98pub fn find_or_create_person(
100 person_list: &mut PersonList,
101 display_name: &str,
102 user_id: Option<&str>,
103 provider_id: Option<&str>,
104) -> String {
105 find_or_create_person_with_collision_check(
106 person_list,
107 display_name,
108 user_id,
109 provider_id,
110 None,
111 )
112}
113
114fn find_or_create_person_with_collision_check(
116 person_list: &mut PersonList,
117 display_name: &str,
118 user_id: Option<&str>,
119 provider_id: Option<&str>,
120 threaded_comments: Option<&ThreadedComments>,
121) -> String {
122 if let Some(existing) = person_list
123 .persons
124 .iter()
125 .find(|p| p.display_name == display_name)
126 {
127 return existing.id.clone();
128 }
129
130 let id = generate_unique_guid(person_list, threaded_comments);
131 person_list.persons.push(Person {
132 display_name: display_name.to_string(),
133 id: id.clone(),
134 user_id: user_id.map(|s| s.to_string()),
135 provider_id: provider_id.map(|s| s.to_string()),
136 });
137 id
138}
139
140pub fn add_threaded_comment(
145 threaded_comments: &mut Option<ThreadedComments>,
146 person_list: &mut PersonList,
147 cell: &str,
148 input: &ThreadedCommentInput,
149) -> Result<String> {
150 crate::utils::cell_ref::cell_name_to_coordinates(cell)?;
151
152 if let Some(ref parent_id) = input.parent_id {
153 let tc = threaded_comments.as_ref();
154 let parent_exists = tc
155 .map(|t| t.comments.iter().any(|c| c.id == *parent_id))
156 .unwrap_or(false);
157 if !parent_exists {
158 return Err(Error::ThreadedCommentNotFound {
159 id: parent_id.clone(),
160 });
161 }
162 }
163
164 let person_id = find_or_create_person_with_collision_check(
165 person_list,
166 &input.author,
167 None,
168 None,
169 threaded_comments.as_ref(),
170 );
171 let comment_id = generate_unique_guid(person_list, threaded_comments.as_ref());
172
173 let tc = threaded_comments.get_or_insert_with(ThreadedComments::default);
174 tc.comments.push(ThreadedComment {
175 cell_ref: cell.to_string(),
176 date_time: current_timestamp(),
177 person_id,
178 id: comment_id.clone(),
179 parent_id: input.parent_id.clone(),
180 done: None,
181 text: input.text.clone(),
182 });
183
184 Ok(comment_id)
185}
186
187pub fn get_threaded_comments(
189 threaded_comments: &Option<ThreadedComments>,
190 person_list: &PersonList,
191) -> Vec<ThreadedCommentData> {
192 let Some(tc) = threaded_comments.as_ref() else {
193 return Vec::new();
194 };
195
196 tc.comments
197 .iter()
198 .map(|c| {
199 let author = person_list
200 .persons
201 .iter()
202 .find(|p| p.id == c.person_id)
203 .map(|p| p.display_name.clone())
204 .unwrap_or_default();
205 ThreadedCommentData {
206 id: c.id.clone(),
207 cell_ref: c.cell_ref.clone(),
208 text: c.text.clone(),
209 author,
210 person_id: c.person_id.clone(),
211 date_time: c.date_time.clone(),
212 parent_id: c.parent_id.clone(),
213 done: matches!(c.done.as_deref(), Some("1" | "true")),
214 }
215 })
216 .collect()
217}
218
219pub fn get_threaded_comments_by_cell(
221 threaded_comments: &Option<ThreadedComments>,
222 person_list: &PersonList,
223 cell: &str,
224) -> Vec<ThreadedCommentData> {
225 get_threaded_comments(threaded_comments, person_list)
226 .into_iter()
227 .filter(|c| c.cell_ref == cell)
228 .collect()
229}
230
231pub fn delete_threaded_comment(
235 threaded_comments: &mut Option<ThreadedComments>,
236 comment_id: &str,
237) -> Result<()> {
238 if let Some(ref mut tc) = threaded_comments {
239 let before = tc.comments.len();
240 tc.comments.retain(|c| c.id != comment_id);
241 if tc.comments.len() == before {
242 return Err(Error::ThreadedCommentNotFound {
243 id: comment_id.to_string(),
244 });
245 }
246
247 if tc.comments.is_empty() {
248 *threaded_comments = None;
249 }
250
251 Ok(())
252 } else {
253 Err(Error::ThreadedCommentNotFound {
254 id: comment_id.to_string(),
255 })
256 }
257}
258
259pub fn resolve_threaded_comment(
263 threaded_comments: &mut Option<ThreadedComments>,
264 comment_id: &str,
265 done: bool,
266) -> Result<()> {
267 if let Some(ref mut tc) = threaded_comments {
268 if let Some(comment) = tc.comments.iter_mut().find(|c| c.id == comment_id) {
269 comment.done = if done { Some("1".to_string()) } else { None };
270 return Ok(());
271 }
272 }
273 Err(Error::ThreadedCommentNotFound {
274 id: comment_id.to_string(),
275 })
276}
277
278pub fn add_person(person_list: &mut PersonList, input: &PersonInput) -> String {
280 find_or_create_person(
281 person_list,
282 &input.display_name,
283 input.user_id.as_deref(),
284 input.provider_id.as_deref(),
285 )
286}
287
288pub fn get_persons(person_list: &PersonList) -> Vec<PersonData> {
290 person_list
291 .persons
292 .iter()
293 .map(|p| PersonData {
294 id: p.id.clone(),
295 display_name: p.display_name.clone(),
296 user_id: p.user_id.clone(),
297 provider_id: p.provider_id.clone(),
298 })
299 .collect()
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305
306 #[test]
307 fn test_add_threaded_comment() {
308 let mut tc = None;
309 let mut pl = PersonList::default();
310 let input = ThreadedCommentInput {
311 author: "Alice".to_string(),
312 text: "Hello thread".to_string(),
313 parent_id: None,
314 };
315 let id = add_threaded_comment(&mut tc, &mut pl, "A1", &input).unwrap();
316 assert!(!id.is_empty());
317 assert!(tc.is_some());
318
319 let comments = get_threaded_comments(&tc, &pl);
320 assert_eq!(comments.len(), 1);
321 assert_eq!(comments[0].cell_ref, "A1");
322 assert_eq!(comments[0].text, "Hello thread");
323 assert_eq!(comments[0].author, "Alice");
324 assert!(!comments[0].done);
325 assert!(comments[0].parent_id.is_none());
326 }
327
328 #[test]
329 fn test_add_reply() {
330 let mut tc = None;
331 let mut pl = PersonList::default();
332
333 let parent_id = add_threaded_comment(
334 &mut tc,
335 &mut pl,
336 "A1",
337 &ThreadedCommentInput {
338 author: "Alice".to_string(),
339 text: "Initial comment".to_string(),
340 parent_id: None,
341 },
342 )
343 .unwrap();
344
345 let reply_id = add_threaded_comment(
346 &mut tc,
347 &mut pl,
348 "A1",
349 &ThreadedCommentInput {
350 author: "Bob".to_string(),
351 text: "This is a reply".to_string(),
352 parent_id: Some(parent_id.clone()),
353 },
354 )
355 .unwrap();
356
357 let comments = get_threaded_comments(&tc, &pl);
358 assert_eq!(comments.len(), 2);
359 assert_eq!(comments[1].parent_id, Some(parent_id));
360 assert_ne!(reply_id, comments[0].id);
361 }
362
363 #[test]
364 fn test_reply_to_nonexistent_parent() {
365 let mut tc = None;
366 let mut pl = PersonList::default();
367 let input = ThreadedCommentInput {
368 author: "Alice".to_string(),
369 text: "Bad reply".to_string(),
370 parent_id: Some("{NONEXISTENT}".to_string()),
371 };
372 let result = add_threaded_comment(&mut tc, &mut pl, "A1", &input);
373 assert!(result.is_err());
374 }
375
376 #[test]
377 fn test_get_by_cell() {
378 let mut tc = None;
379 let mut pl = PersonList::default();
380
381 add_threaded_comment(
382 &mut tc,
383 &mut pl,
384 "A1",
385 &ThreadedCommentInput {
386 author: "Alice".to_string(),
387 text: "On A1".to_string(),
388 parent_id: None,
389 },
390 )
391 .unwrap();
392
393 add_threaded_comment(
394 &mut tc,
395 &mut pl,
396 "B2",
397 &ThreadedCommentInput {
398 author: "Bob".to_string(),
399 text: "On B2".to_string(),
400 parent_id: None,
401 },
402 )
403 .unwrap();
404
405 let a1_comments = get_threaded_comments_by_cell(&tc, &pl, "A1");
406 assert_eq!(a1_comments.len(), 1);
407 assert_eq!(a1_comments[0].text, "On A1");
408
409 let b2_comments = get_threaded_comments_by_cell(&tc, &pl, "B2");
410 assert_eq!(b2_comments.len(), 1);
411 assert_eq!(b2_comments[0].text, "On B2");
412
413 let empty = get_threaded_comments_by_cell(&tc, &pl, "C3");
414 assert!(empty.is_empty());
415 }
416
417 #[test]
418 fn test_delete_threaded_comment() {
419 let mut tc = None;
420 let mut pl = PersonList::default();
421
422 let id = add_threaded_comment(
423 &mut tc,
424 &mut pl,
425 "A1",
426 &ThreadedCommentInput {
427 author: "Alice".to_string(),
428 text: "Delete me".to_string(),
429 parent_id: None,
430 },
431 )
432 .unwrap();
433
434 delete_threaded_comment(&mut tc, &id).unwrap();
435 assert!(tc.is_none());
436 }
437
438 #[test]
439 fn test_delete_nonexistent_comment() {
440 let mut tc: Option<ThreadedComments> = None;
441 let result = delete_threaded_comment(&mut tc, "{NONEXISTENT}");
442 assert!(result.is_err());
443 }
444
445 #[test]
446 fn test_delete_one_of_multiple() {
447 let mut tc = None;
448 let mut pl = PersonList::default();
449
450 let id1 = add_threaded_comment(
451 &mut tc,
452 &mut pl,
453 "A1",
454 &ThreadedCommentInput {
455 author: "Alice".to_string(),
456 text: "First".to_string(),
457 parent_id: None,
458 },
459 )
460 .unwrap();
461
462 add_threaded_comment(
463 &mut tc,
464 &mut pl,
465 "B2",
466 &ThreadedCommentInput {
467 author: "Bob".to_string(),
468 text: "Second".to_string(),
469 parent_id: None,
470 },
471 )
472 .unwrap();
473
474 delete_threaded_comment(&mut tc, &id1).unwrap();
475 assert!(tc.is_some());
476 let remaining = get_threaded_comments(&tc, &pl);
477 assert_eq!(remaining.len(), 1);
478 assert_eq!(remaining[0].text, "Second");
479 }
480
481 #[test]
482 fn test_resolve_threaded_comment() {
483 let mut tc = None;
484 let mut pl = PersonList::default();
485
486 let id = add_threaded_comment(
487 &mut tc,
488 &mut pl,
489 "A1",
490 &ThreadedCommentInput {
491 author: "Alice".to_string(),
492 text: "Resolve me".to_string(),
493 parent_id: None,
494 },
495 )
496 .unwrap();
497
498 resolve_threaded_comment(&mut tc, &id, true).unwrap();
499 let comments = get_threaded_comments(&tc, &pl);
500 assert!(comments[0].done);
501
502 resolve_threaded_comment(&mut tc, &id, false).unwrap();
503 let comments = get_threaded_comments(&tc, &pl);
504 assert!(!comments[0].done);
505 }
506
507 #[test]
508 fn test_done_field_accepts_true_string() {
509 let mut tc: Option<ThreadedComments> = Some(ThreadedComments::default());
510 let mut pl = PersonList::default();
511 let id = add_threaded_comment(
512 &mut tc,
513 &mut pl,
514 "A1",
515 &ThreadedCommentInput {
516 author: "Alice".to_string(),
517 text: "check done".to_string(),
518 parent_id: None,
519 },
520 )
521 .unwrap();
522
523 let tc_inner = tc.as_mut().unwrap();
525 tc_inner
526 .comments
527 .iter_mut()
528 .find(|c| c.id == id)
529 .unwrap()
530 .done = Some("true".to_string());
531
532 let comments = get_threaded_comments(&tc, &pl);
533 assert!(comments[0].done, "done='true' should be treated as done");
534 }
535
536 #[test]
537 fn test_resolve_nonexistent() {
538 let mut tc: Option<ThreadedComments> = None;
539 let result = resolve_threaded_comment(&mut tc, "{NONEXISTENT}", true);
540 assert!(result.is_err());
541 }
542
543 #[test]
544 fn test_add_person() {
545 let mut pl = PersonList::default();
546 let id = add_person(
547 &mut pl,
548 &PersonInput {
549 display_name: "Alice".to_string(),
550 user_id: Some("alice@example.com".to_string()),
551 provider_id: Some("ADAL".to_string()),
552 },
553 );
554
555 assert!(!id.is_empty());
556 let persons = get_persons(&pl);
557 assert_eq!(persons.len(), 1);
558 assert_eq!(persons[0].display_name, "Alice");
559 assert_eq!(persons[0].user_id, Some("alice@example.com".to_string()));
560 }
561
562 #[test]
563 fn test_add_duplicate_person() {
564 let mut pl = PersonList::default();
565 let id1 = add_person(
566 &mut pl,
567 &PersonInput {
568 display_name: "Alice".to_string(),
569 user_id: None,
570 provider_id: None,
571 },
572 );
573 let id2 = add_person(
574 &mut pl,
575 &PersonInput {
576 display_name: "Alice".to_string(),
577 user_id: None,
578 provider_id: None,
579 },
580 );
581
582 assert_eq!(id1, id2);
583 assert_eq!(get_persons(&pl).len(), 1);
584 }
585
586 #[test]
587 fn test_get_persons_empty() {
588 let pl = PersonList::default();
589 assert!(get_persons(&pl).is_empty());
590 }
591
592 #[test]
593 fn test_get_threaded_comments_empty() {
594 let tc: Option<ThreadedComments> = None;
595 let pl = PersonList::default();
596 assert!(get_threaded_comments(&tc, &pl).is_empty());
597 }
598
599 #[test]
600 fn test_person_auto_created_on_add_comment() {
601 let mut tc = None;
602 let mut pl = PersonList::default();
603
604 add_threaded_comment(
605 &mut tc,
606 &mut pl,
607 "A1",
608 &ThreadedCommentInput {
609 author: "Alice".to_string(),
610 text: "Auto person".to_string(),
611 parent_id: None,
612 },
613 )
614 .unwrap();
615
616 let persons = get_persons(&pl);
617 assert_eq!(persons.len(), 1);
618 assert_eq!(persons[0].display_name, "Alice");
619 }
620
621 #[test]
622 fn test_comment_has_timestamp() {
623 let mut tc = None;
624 let mut pl = PersonList::default();
625
626 add_threaded_comment(
627 &mut tc,
628 &mut pl,
629 "A1",
630 &ThreadedCommentInput {
631 author: "Alice".to_string(),
632 text: "Timestamp check".to_string(),
633 parent_id: None,
634 },
635 )
636 .unwrap();
637
638 let comments = get_threaded_comments(&tc, &pl);
639 assert!(!comments[0].date_time.is_empty());
640 assert!(comments[0].date_time.contains('T'));
641 }
642
643 #[test]
644 fn test_multiple_authors_multiple_cells() {
645 let mut tc = None;
646 let mut pl = PersonList::default();
647
648 add_threaded_comment(
649 &mut tc,
650 &mut pl,
651 "A1",
652 &ThreadedCommentInput {
653 author: "Alice".to_string(),
654 text: "Alice on A1".to_string(),
655 parent_id: None,
656 },
657 )
658 .unwrap();
659
660 add_threaded_comment(
661 &mut tc,
662 &mut pl,
663 "B2",
664 &ThreadedCommentInput {
665 author: "Bob".to_string(),
666 text: "Bob on B2".to_string(),
667 parent_id: None,
668 },
669 )
670 .unwrap();
671
672 add_threaded_comment(
673 &mut tc,
674 &mut pl,
675 "C3",
676 &ThreadedCommentInput {
677 author: "Alice".to_string(),
678 text: "Alice on C3".to_string(),
679 parent_id: None,
680 },
681 )
682 .unwrap();
683
684 let persons = get_persons(&pl);
685 assert_eq!(persons.len(), 2);
686
687 let all = get_threaded_comments(&tc, &pl);
688 assert_eq!(all.len(), 3);
689 }
690
691 #[test]
692 fn test_generated_ids_are_unique() {
693 let mut ids = std::collections::HashSet::new();
694 for _ in 0..1000 {
695 let id = generate_guid();
696 assert!(ids.insert(id.clone()), "duplicate ID generated: {}", id);
697 }
698 }
699
700 #[test]
701 fn test_generated_id_format() {
702 let id = generate_guid();
703 assert!(id.starts_with('{'));
704 assert!(id.ends_with('}'));
705 let inner = &id[1..id.len() - 1];
706 let parts: Vec<&str> = inner.split('-').collect();
707 assert_eq!(parts.len(), 5, "GUID should have 5 hyphen-separated parts");
708 assert_eq!(parts[0].len(), 8);
709 assert_eq!(parts[1].len(), 4);
710 assert_eq!(parts[2].len(), 4);
711 assert_eq!(parts[3].len(), 4);
712 assert_eq!(parts[4].len(), 12);
713 }
714
715 #[test]
716 fn test_no_id_collision_with_existing_workbook() {
717 let mut pl = PersonList::default();
718 pl.persons.push(Person {
719 display_name: "Existing".to_string(),
720 id: "{EXISTING-PERSON-ID}".to_string(),
721 user_id: None,
722 provider_id: None,
723 });
724
725 let mut tc = Some(ThreadedComments {
726 xmlns: sheetkit_xml::threaded_comment::THREADED_COMMENTS_NS.to_string(),
727 comments: vec![ThreadedComment {
728 cell_ref: "A1".to_string(),
729 date_time: "2024-01-01T00:00:00.00".to_string(),
730 person_id: "{EXISTING-PERSON-ID}".to_string(),
731 id: "{EXISTING-COMMENT-ID}".to_string(),
732 parent_id: None,
733 done: None,
734 text: "Pre-existing".to_string(),
735 }],
736 });
737
738 let new_id = add_threaded_comment(
739 &mut tc,
740 &mut pl,
741 "B2",
742 &ThreadedCommentInput {
743 author: "NewUser".to_string(),
744 text: "New comment".to_string(),
745 parent_id: None,
746 },
747 )
748 .unwrap();
749
750 assert_ne!(new_id, "{EXISTING-COMMENT-ID}");
751 assert_ne!(new_id, "{EXISTING-PERSON-ID}");
752
753 let persons = get_persons(&pl);
754 let new_person = persons
755 .iter()
756 .find(|p| p.display_name == "NewUser")
757 .unwrap();
758 assert_ne!(new_person.id, "{EXISTING-PERSON-ID}");
759 assert_ne!(new_person.id, "{EXISTING-COMMENT-ID}");
760 }
761
762 #[test]
763 fn test_delete_nonexistent_returns_error() {
764 let mut tc = None;
765 let mut pl = PersonList::default();
766
767 add_threaded_comment(
768 &mut tc,
769 &mut pl,
770 "A1",
771 &ThreadedCommentInput {
772 author: "Alice".to_string(),
773 text: "Exists".to_string(),
774 parent_id: None,
775 },
776 )
777 .unwrap();
778
779 let result = delete_threaded_comment(&mut tc, "{DOES-NOT-EXIST}");
780 assert!(result.is_err());
781 let err = result.unwrap_err();
782 assert!(
783 err.to_string().contains("DOES-NOT-EXIST"),
784 "error should contain the missing ID"
785 );
786 }
787
788 #[test]
789 fn test_resolve_nonexistent_returns_error() {
790 let mut tc = None;
791 let mut pl = PersonList::default();
792
793 add_threaded_comment(
794 &mut tc,
795 &mut pl,
796 "A1",
797 &ThreadedCommentInput {
798 author: "Alice".to_string(),
799 text: "Exists".to_string(),
800 parent_id: None,
801 },
802 )
803 .unwrap();
804
805 let result = resolve_threaded_comment(&mut tc, "{DOES-NOT-EXIST}", true);
806 assert!(result.is_err());
807 let err = result.unwrap_err();
808 assert!(
809 err.to_string().contains("DOES-NOT-EXIST"),
810 "error should contain the missing ID"
811 );
812 }
813
814 #[test]
815 fn test_add_comment_invalid_cell_reference() {
816 let mut tc = None;
817 let mut pl = PersonList::default();
818
819 let result = add_threaded_comment(
820 &mut tc,
821 &mut pl,
822 "INVALID",
823 &ThreadedCommentInput {
824 author: "Alice".to_string(),
825 text: "Bad cell".to_string(),
826 parent_id: None,
827 },
828 );
829 assert!(result.is_err());
830
831 let result = add_threaded_comment(
832 &mut tc,
833 &mut pl,
834 "",
835 &ThreadedCommentInput {
836 author: "Alice".to_string(),
837 text: "Empty cell".to_string(),
838 parent_id: None,
839 },
840 );
841 assert!(result.is_err());
842 }
843}