1use crate::error::{PatientError, PatientResult};
9use crate::NonEmptyText;
10use chrono::{DateTime, Utc};
11use fhir::{AuthorRole, MessageAuthor};
12use uuid::Uuid;
13
14const THREAD_HEADER: &str = "# Thread";
16
17#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct Message {
23 pub metadata: MessageMetadata,
25 pub body: NonEmptyText,
27 pub corrects: Option<Uuid>,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct MessageMetadata {
37 pub message_id: Uuid,
39 pub timestamp: DateTime<Utc>,
41 pub author: MessageAuthor,
43}
44
45#[derive(Debug, Clone)]
47pub struct MarkdownService;
48
49impl MarkdownService {
50 pub fn new() -> Self {
52 Self
53 }
54
55 pub fn message_render(
92 &self,
93 metadata: &MessageMetadata,
94 body: &NonEmptyText,
95 corrects: Option<Uuid>,
96 ) -> PatientResult<NonEmptyText> {
97 let mut output = String::new();
98
99 output.push_str(&format!("**Message ID:** {}\n", metadata.message_id));
101 output.push_str(&format!(
102 "**Timestamp:** {}\n",
103 metadata
104 .timestamp
105 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
106 ));
107 output.push_str(&format!("**Author ID:** {}\n", metadata.author.id));
108 output.push_str(&format!(
109 "**Author name:** {}\n",
110 metadata.author.name.as_str()
111 ));
112
113 let role_str = serde_json::to_string(&metadata.author.role)
114 .map_err(|e| PatientError::InvalidInput(format!("Invalid role: {}", e)))?
115 .trim_matches('"')
116 .to_string();
117 output.push_str(&format!("**Author role:** {}\n", role_str));
118
119 if let Some(corrects_id) = corrects {
120 output.push_str(&format!("**Corrects:** {}\n", corrects_id));
121 }
122
123 output.push('\n');
124
125 let sanitised_body = self.escape_body(body.as_str());
127 output.push_str(sanitised_body.as_str());
128
129 output.push_str("\n\n---\n");
131
132 NonEmptyText::new(output).map_err(|e| PatientError::InvalidInput(e.to_string()))
133 }
134
135 pub fn thread_render(&self, messages: &[Message]) -> PatientResult<NonEmptyText> {
153 let rendered_messages = messages
154 .iter()
155 .map(|msg| self.message_render(&msg.metadata, &msg.body, msg.corrects))
156 .collect::<PatientResult<Vec<NonEmptyText>>>()?;
157
158 let content = rendered_messages
159 .iter()
160 .map(|s| s.as_str())
161 .collect::<Vec<_>>()
162 .join("");
163
164 NonEmptyText::new(format!("{}\n\n{}\n", THREAD_HEADER, content.trim_end()))
165 .map_err(|e| PatientError::InvalidInput(e.to_string()))
166 }
167
168 pub fn thread_parse(&self, content: &str) -> PatientResult<Vec<Message>> {
204 if content.trim().is_empty() {
205 return Err(PatientError::InvalidInput(
206 "Thread content must not be empty".to_string(),
207 ));
208 }
209
210 let mut messages = Vec::new();
211
212 let normalized = content.trim();
216 let raw_sections: Vec<&str> = normalized
217 .split("---")
218 .map(|s| s.trim())
219 .filter(|s| !s.is_empty())
220 .collect();
221
222 for section in raw_sections {
223 let message = self.message_parse(section)?;
224 messages.push(message);
225 }
226
227 Ok(messages)
228 }
229
230 fn message_parse(&self, section: &str) -> PatientResult<Message> {
248 let lines: Vec<&str> = section.lines().collect();
249
250 let mut variables: Vec<(NonEmptyText, NonEmptyText)> = Vec::new();
251 let mut body_lines: Vec<&str> = Vec::new();
252
253 let mut state = 0;
255 let mut i = 0;
256
257 while i < lines.len() {
258 let line = lines[i];
259 let trimmed = line.trim();
260
261 if trimmed.starts_with("# ") && !trimmed.starts_with("##") {
263 i += 1;
264 if i < lines.len() && lines[i].trim().is_empty() {
265 i += 1;
266 }
267 continue;
268 }
269
270 if state == 0 {
272 if trimmed.starts_with("**") && trimmed.contains(":**") {
273 if let Some(colon_pos) = trimmed.find(":**") {
274 let key = NonEmptyText::new(trimmed[2..colon_pos].trim())
275 .map_err(|e| PatientError::InvalidInput(e.to_string()))?;
276 let value = NonEmptyText::new(trimmed[colon_pos + 3..].trim())
277 .map_err(|e| PatientError::InvalidInput(e.to_string()))?;
278 variables.push((key, value));
279 i += 1;
280 continue;
281 }
282 } else if trimmed.is_empty() && !variables.is_empty() {
283 state = 1;
285 i += 1;
286 continue;
287 } else if trimmed.is_empty() {
288 i += 1;
290 continue;
291 } else if !trimmed.is_empty() {
292 state = 1;
294 }
295 }
296
297 if state == 1 {
299 body_lines.push(line);
300 i += 1;
301 } else {
302 i += 1;
303 }
304 }
305
306 let body_text = body_lines.join("\n");
308 if body_text.trim().is_empty() {
309 return Err(PatientError::InvalidInput(
310 "Message must contain body content".to_string(),
311 ));
312 }
313 let unescaped = self.unescape_body(&body_text);
314 let body = NonEmptyText::new(unescaped)
315 .map_err(|_| PatientError::InvalidInput("Body cannot be empty".to_string()))?;
316
317 let mut message_id = None;
319 let mut timestamp = None;
320 let mut author_id = None;
321 let mut author_name = None;
322 let mut author_role = None;
323 let mut corrects = None;
324
325 for (key, value) in &variables {
326 match key.as_str() {
327 "Message ID" => message_id = Uuid::parse_str(value.as_str()).ok(),
328 "Timestamp" => {
329 timestamp = DateTime::parse_from_rfc3339(value.as_str())
330 .ok()
331 .map(|dt| dt.with_timezone(&Utc))
332 }
333 "Author ID" => author_id = Uuid::parse_str(value.as_str()).ok(),
334 "Author name" => author_name = Some(value.clone()),
335 "Author" => author_name = Some(value.clone()), "Author role" => {
337 author_role = AuthorRole::parse(value.as_str())
338 .map_err(|e| PatientError::InvalidInput(e.to_string()))
339 .ok();
340 }
341 "Role" => {
342 author_role = AuthorRole::parse(value.as_str())
344 .map_err(|e| PatientError::InvalidInput(e.to_string()))
345 .ok();
346 }
347 "Corrects" => corrects = Uuid::parse_str(value.as_str()).ok(),
348 _ => {}
349 }
350 }
351
352 let metadata = MessageMetadata {
353 message_id: message_id.ok_or_else(|| {
354 PatientError::InvalidInput("Missing or invalid Message ID".to_string())
355 })?,
356 timestamp: timestamp.ok_or_else(|| {
357 PatientError::InvalidInput("Missing or invalid Timestamp".to_string())
358 })?,
359 author: MessageAuthor {
360 id: author_id.ok_or_else(|| {
361 PatientError::InvalidInput("Missing or invalid Author ID".to_string())
362 })?,
363 name: author_name
364 .ok_or_else(|| PatientError::InvalidInput("Missing Author".to_string()))?,
365 role: author_role.ok_or_else(|| {
366 PatientError::InvalidInput("Missing or invalid Role".to_string())
367 })?,
368 },
369 };
370
371 Ok(Message {
372 metadata,
373 body,
374 corrects,
375 })
376 }
377}
378
379impl MarkdownService {
381 fn escape_body(&self, body: &str) -> NonEmptyText {
394 let mut result = String::new();
395 let lines: Vec<&str> = body.lines().collect();
396
397 for (i, line) in lines.iter().enumerate() {
398 let trimmed = line.trim();
399
400 let escaped_line = if line.trim_start().starts_with('#') {
402 line.replacen('#', r"\#", 1)
403 } else if trimmed == "---" || trimmed == "***" || trimmed == "___" {
404 format!(r"\{}", trimmed)
406 } else {
407 line.replace("```", r"\`\`\`")
409 };
410
411 result.push_str(&escaped_line);
412
413 if i < lines.len() - 1 {
415 result.push('\n');
416 }
417 }
418
419 NonEmptyText::new(result).expect("escaped body should be non-empty")
420 }
421
422 fn unescape_body(&self, body: &str) -> NonEmptyText {
435 let mut result = String::new();
436 let lines: Vec<&str> = body.lines().collect();
437
438 for (i, line) in lines.iter().enumerate() {
439 let trimmed = line.trim();
440
441 let unescaped_line = if line.trim_start().starts_with(r"\#") {
443 line.replacen(r"\#", "#", 1)
444 } else if trimmed == r"\---" || trimmed == r"\***" || trimmed == r"\___" {
445 trimmed.trim_start_matches('\\').to_string()
447 } else {
448 line.replace(r"\`\`\`", "```")
450 };
451
452 result.push_str(&unescaped_line);
453
454 if i < lines.len() - 1 {
456 result.push('\n');
457 }
458 }
459
460 NonEmptyText::new(result).expect("unescaped body should be non-empty")
461 }
462}
463
464impl Default for MarkdownService {
465 fn default() -> Self {
466 Self::new()
467 }
468}
469
470#[cfg(test)]
471mod tests {
472 use super::*;
473
474 #[test]
475 fn test_thread_start_with_body() {
476 let service = MarkdownService::new();
477 let metadata = MessageMetadata {
478 message_id: Uuid::nil(),
479 timestamp: DateTime::parse_from_rfc3339("2026-01-22T10:30:00Z")
480 .unwrap()
481 .with_timezone(&Utc),
482 author: MessageAuthor {
483 id: Uuid::nil(),
484 name: NonEmptyText::new("Test Author").unwrap(),
485 role: AuthorRole::Clinician,
486 },
487 };
488 let msg = Message {
489 metadata,
490 body: NonEmptyText::new("Plain text content").unwrap(),
491 corrects: None,
492 };
493 let result = service.thread_render(&[msg]).unwrap();
494 assert!(result.as_str().starts_with("# Thread\n\n"));
495 assert!(result.as_str().contains("Plain text content"));
496 }
497
498 #[test]
499 fn test_message_prepare_escapes_hash_in_body() {
500 let service = MarkdownService::new();
501 let body =
502 NonEmptyText::new("Patient #12345 has condition\n# This should be escaped").unwrap();
503 let metadata = MessageMetadata {
504 message_id: Uuid::nil(),
505 timestamp: DateTime::parse_from_rfc3339("2026-01-22T10:30:00Z")
506 .unwrap()
507 .with_timezone(&Utc),
508 author: MessageAuthor {
509 id: Uuid::nil(),
510 name: NonEmptyText::new("Test Author").unwrap(),
511 role: AuthorRole::Clinician,
512 },
513 };
514 let result = service.message_render(&metadata, &body, None).unwrap();
515 assert!(result
516 .as_str()
517 .contains("Patient #12345 has condition\n\\# This should be escaped"));
518 }
519
520 #[test]
521 fn test_message_prepare_escapes_code_blocks() {
522 let service = MarkdownService::new();
523 let body = NonEmptyText::new("Example code: ```python\nprint('hello')\n```").unwrap();
524 let metadata = MessageMetadata {
525 message_id: Uuid::nil(),
526 timestamp: DateTime::parse_from_rfc3339("2026-01-22T10:30:00Z")
527 .unwrap()
528 .with_timezone(&Utc),
529 author: MessageAuthor {
530 id: Uuid::nil(),
531 name: NonEmptyText::new("Test Author").unwrap(),
532 role: AuthorRole::Clinician,
533 },
534 };
535 let result = service.message_render(&metadata, &body, None).unwrap();
536 assert!(result
537 .as_str()
538 .contains("Example code: \\`\\`\\`python\nprint('hello')\n\\`\\`\\`"));
539 }
540
541 #[test]
542 fn test_message_prepare_escapes_horizontal_rules() {
543 let service = MarkdownService::new();
544 let body = NonEmptyText::new("Line 1\n---\nLine 2\n***\nLine 3\n___").unwrap();
545 let metadata = MessageMetadata {
546 message_id: Uuid::nil(),
547 timestamp: DateTime::parse_from_rfc3339("2026-01-22T10:30:00Z")
548 .unwrap()
549 .with_timezone(&Utc),
550 author: MessageAuthor {
551 id: Uuid::nil(),
552 name: NonEmptyText::new("Test Author").unwrap(),
553 role: AuthorRole::Clinician,
554 },
555 };
556 let result = service.message_render(&metadata, &body, None).unwrap();
557 assert!(result.as_str().contains("Line 1\n\\---"));
558 assert!(result.as_str().contains("Line 2\n\\***"));
559 assert!(result.as_str().contains("Line 3\n\\___"));
560 }
561
562 #[test]
563 fn test_message_prepare_with_metadata() {
564 let service = MarkdownService::new();
565 let metadata = MessageMetadata {
566 message_id: Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
567 timestamp: DateTime::parse_from_rfc3339("2026-01-22T10:30:00.123Z")
568 .unwrap()
569 .with_timezone(&Utc),
570 author: MessageAuthor {
571 id: Uuid::parse_str("550e8400-e29b-41d4-a716-446655440001").unwrap(),
572 name: NonEmptyText::new("Dr Smith").unwrap(),
573 role: AuthorRole::Clinician,
574 },
575 };
576 let result = service
577 .message_render(&metadata, &NonEmptyText::new("Some content").unwrap(), None)
578 .unwrap();
579 assert!(result
580 .as_str()
581 .contains("**Message ID:** 550e8400-e29b-41d4-a716-446655440000"));
582 assert!(result.as_str().contains("**Author name:** Dr Smith"));
583 assert!(result.as_str().contains("**Author role:** clinician"));
584 assert!(result.as_str().contains("Some content"));
585 }
586
587 #[test]
588 fn test_message_render_empty_body_fails() {
589 let result = NonEmptyText::new("");
591 assert!(result.is_err());
592 let result2 = NonEmptyText::new(" ");
593 assert!(result2.is_err());
594 }
595 #[test]
596 fn test_thread_start_full_example() {
597 let service = MarkdownService::new();
598 let body = NonEmptyText::new(
599 "Patient #12345 presented with symptoms.\n# Important note\nSee details.",
600 )
601 .unwrap();
602 let metadata = MessageMetadata {
603 message_id: Uuid::nil(),
604 timestamp: DateTime::parse_from_rfc3339("2026-01-22T10:30:00Z")
605 .unwrap()
606 .with_timezone(&Utc),
607 author: MessageAuthor {
608 id: Uuid::nil(),
609 name: NonEmptyText::new("Test Author").unwrap(),
610 role: AuthorRole::Clinician,
611 },
612 };
613
614 let msg = Message {
615 metadata,
616 body: body.clone(),
617 corrects: None,
618 };
619 let result = service.thread_render(&[msg]).unwrap();
620
621 assert!(result.as_str().starts_with("# Thread\n\n"));
622 assert!(result
623 .as_str()
624 .contains("Patient #12345 presented with symptoms."));
625 assert!(result.as_str().contains("\\# Important note"));
626 assert!(result.as_str().contains("See details."));
627 }
628
629 #[test]
630 fn test_unescape_body_hash() {
631 let service = MarkdownService::new();
632 let escaped = "Patient #12345 has condition\n\\# This was escaped";
633 let result = service.unescape_body(escaped);
634 assert_eq!(
635 result.as_str(),
636 "Patient #12345 has condition\n# This was escaped"
637 );
638 }
639
640 #[test]
641 fn test_unescape_body_code_blocks() {
642 let service = MarkdownService::new();
643 let escaped = "Example code: \\`\\`\\`python\nprint('hello')\n\\`\\`\\`";
644 let result = service.unescape_body(escaped);
645 assert_eq!(
646 result.as_str(),
647 "Example code: ```python\nprint('hello')\n```"
648 );
649 }
650
651 #[test]
652 fn test_unescape_body_horizontal_rules() {
653 let service = MarkdownService::new();
654 let escaped = "Line 1\n\\---\nLine 2\n\\***\nLine 3\n\\___";
655 let result = service.unescape_body(escaped);
656 assert_eq!(result.as_str(), "Line 1\n---\nLine 2\n***\nLine 3\n___");
657 }
658
659 #[test]
660 fn test_escape_unescape_roundtrip() {
661 let service = MarkdownService::new();
662 let original = "Patient #12345\n# Important\n---\n```code```";
663 let escaped = service.escape_body(original);
664 let unescaped = service.unescape_body(escaped.as_str());
665 assert_eq!(unescaped.as_str(), original);
666 }
667
668 #[test]
669 fn test_parse_thread_single_message_with_all_fields() {
670 let service = MarkdownService::new();
671 let content = "# Thread Title\n\n**Message ID:** 550e8400-e29b-41d4-a716-446655440000\n**Timestamp:** 2026-01-22T10:30:00Z\n**Author ID:** 550e8400-e29b-41d4-a716-446655440001\n**Author:** Dr Smith\n**Role:** Clinician\n\nPatient presented with symptoms.";
672
673 let messages = service.thread_parse(content).unwrap();
674 assert_eq!(messages.len(), 1);
675
676 let msg = &messages[0];
677 assert_eq!(msg.metadata.author.name.as_str(), "Dr Smith");
678 assert_eq!(msg.metadata.author.role, AuthorRole::Clinician);
679 assert_eq!(msg.body.as_str(), "Patient presented with symptoms.");
680 }
681
682 #[test]
683 fn test_parse_thread_single_message_body_only() {
684 let service = MarkdownService::new();
685 let content = "**Message ID:** 550e8400-e29b-41d4-a716-446655440000\n**Timestamp:** 2026-01-22T10:30:00Z\n**Author ID:** 550e8400-e29b-41d4-a716-446655440001\n**Author:** System\n**Role:** System\n\nSimple body content.";
686
687 let messages = service.thread_parse(content).unwrap();
688 assert_eq!(messages.len(), 1);
689
690 let msg = &messages[0];
691 assert_eq!(msg.metadata.author.name.as_str(), "System");
692 assert_eq!(msg.body.as_str(), "Simple body content.");
693 }
694
695 #[test]
696 fn test_parse_thread_multiple_messages() {
697 let service = MarkdownService::new();
698 let content = "# Thread Title\n\n**Message ID:** 550e8400-e29b-41d4-a716-446655440000\n**Timestamp:** 2026-01-22T10:30:00Z\n**Author ID:** 550e8400-e29b-41d4-a716-446655440001\n**Author name:** Dr. Smith\n**Author role:** clinician\n\nFirst content\n\n---\n\n**Message ID:** 550e8400-e29b-41d4-a716-446655440002\n**Timestamp:** 2026-01-22T11:30:00Z\n**Author ID:** 550e8400-e29b-41d4-a716-446655440003\n**Author name:** Patient John\n**Author role:** patient\n\nSecond content";
699
700 let messages = service.thread_parse(content).unwrap();
701 assert_eq!(messages.len(), 2);
702
703 assert_eq!(messages[0].body.as_str(), "First content");
704 assert_eq!(messages[1].body.as_str(), "Second content");
705 }
706
707 #[test]
708 fn test_parse_thread_unescapes_body() {
709 let service = MarkdownService::new();
710 let content = "# Thread Title\n\n**Message ID:** 550e8400-e29b-41d4-a716-446655440000\n**Timestamp:** 2026-01-22T10:30:00Z\n**Author ID:** 550e8400-e29b-41d4-a716-446655440001\n**Author name:** Dr Jones\n**Author role:** clinician\n\nPatient presented.\n\\# Important note\n\\`\\`\\`code\\`\\`\\`";
711
712 let messages = service.thread_parse(content).unwrap();
713 assert_eq!(messages.len(), 1);
714
715 assert_eq!(
716 messages[0].body.as_str(),
717 "Patient presented.\n# Important note\n```code```"
718 );
719 }
720
721 #[test]
722 fn test_parse_thread_empty_content() {
723 let service = MarkdownService::new();
724 let result = service.thread_parse("");
725 assert!(result.is_err());
726
727 let result2 = service.thread_parse(" \n \n ");
728 assert!(result2.is_err());
729 }
730
731 #[test]
732 fn test_parse_thread_message_without_body_fails() {
733 let service = MarkdownService::new();
734 let content = "# Thread Title\n\n**Variable:** Value";
735
736 let result = service.thread_parse(content);
737 assert!(result.is_err());
738 }
739
740 #[test]
741 fn test_parse_thread_roundtrip() {
742 let service = MarkdownService::new();
743
744 let msg_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
746 let author_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440001").unwrap();
747 let body = "Patient #12345 update\n# Important\n```code```";
748 let metadata = MessageMetadata {
749 message_id: msg_id,
750 timestamp: DateTime::parse_from_rfc3339("2026-01-22T10:30:00Z")
751 .unwrap()
752 .with_timezone(&Utc),
753 author: MessageAuthor {
754 id: author_id,
755 name: NonEmptyText::new("Dr Smith").unwrap(),
756 role: AuthorRole::Clinician,
757 },
758 };
759 let msg = Message {
760 metadata,
761 body: NonEmptyText::new(body).unwrap(),
762 corrects: None,
763 };
764 let created = service.thread_render(&[msg]).unwrap();
765
766 let parsed = service.thread_parse(created.as_str()).unwrap();
768 assert_eq!(parsed.len(), 1);
769
770 let msg = &parsed[0];
771 assert_eq!(msg.metadata.author.name.as_str(), "Dr Smith");
772 assert_eq!(msg.metadata.author.role, AuthorRole::Clinician);
773 assert_eq!(msg.body.as_str(), body);
774 }
775
776 #[test]
777 fn test_parse_thread_multiple_messages_roundtrip() {
778 let service = MarkdownService::new();
779
780 let metadata1 = MessageMetadata {
782 message_id: Uuid::nil(),
783 timestamp: DateTime::parse_from_rfc3339("2026-01-22T10:30:00Z")
784 .unwrap()
785 .with_timezone(&Utc),
786 author: MessageAuthor {
787 id: Uuid::nil(),
788 name: NonEmptyText::new("Author 1").unwrap(),
789 role: AuthorRole::Clinician,
790 },
791 };
792 let msg1 = Message {
793 metadata: metadata1,
794 body: NonEmptyText::new("First message content").unwrap(),
795 corrects: None,
796 };
797
798 let metadata2 = MessageMetadata {
800 message_id: Uuid::new_v4(),
801 timestamp: DateTime::parse_from_rfc3339("2026-01-22T11:30:00Z")
802 .unwrap()
803 .with_timezone(&Utc),
804 author: MessageAuthor {
805 id: Uuid::new_v4(),
806 name: NonEmptyText::new("Author 2").unwrap(),
807 role: AuthorRole::Patient,
808 },
809 };
810 let msg2 = Message {
811 metadata: metadata2,
812 body: NonEmptyText::new("Second message content").unwrap(),
813 corrects: None,
814 };
815
816 let thread = service.thread_render(&[msg1, msg2]).unwrap();
818
819 let parsed = service.thread_parse(thread.as_str()).unwrap();
821 assert_eq!(parsed.len(), 2);
822 assert_eq!(parsed[0].body.as_str(), "First message content");
823 assert_eq!(parsed[1].body.as_str(), "Second message content");
824 }
825}