vpr_core/
markdown.rs

1//! Markdown validation and sanitisation for coordination thread messages.
2//!
3//! Provides utilities to construct properly formatted markdown from user-provided prose,
4//! escaping formatting characters that could interfere with message display. Headers and
5//! metadata are validated and formatted, while prose content is sanitised to prevent
6//! unintended markdown interpretation.
7
8use crate::error::{PatientError, PatientResult};
9use crate::NonEmptyText;
10use chrono::{DateTime, Utc};
11use fhir::{AuthorRole, MessageAuthor};
12use uuid::Uuid;
13
14/// Thread header used for all coordination threads.
15const THREAD_HEADER: &str = "# Thread";
16
17/// Generic message structure for parsing.
18///
19/// Each message contains strongly-typed metadata and body content.
20/// Messages are typically separated by horizontal rules in the markdown file.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct Message {
23    /// Strongly-typed message metadata
24    pub metadata: MessageMetadata,
25    /// Main body content (unescaped)
26    pub body: NonEmptyText,
27    /// Optional UUID of message being corrected
28    pub corrects: Option<Uuid>,
29}
30
31/// Represents a single message within a coordination thread with strong typing.
32///
33/// Contains structured metadata only. Body content is kept separate.
34/// Messages are typically separated by horizontal rules in the markdown file.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct MessageMetadata {
37    /// Unique identifier for this message
38    pub message_id: Uuid,
39    /// ISO 8601 timestamp when the message was created
40    pub timestamp: DateTime<Utc>,
41    /// Message author with identity, name, and role
42    pub author: MessageAuthor,
43}
44
45/// Service for markdown validation and sanitisation.
46#[derive(Debug, Clone)]
47pub struct MarkdownService;
48
49impl MarkdownService {
50    /// Creates a new `MarkdownService` instance.
51    pub fn new() -> Self {
52        Self
53    }
54
55    /// Validates and sanitises markdown content for coordination thread messages.
56    ///
57    /// Constructs properly formatted markdown from message metadata and body content.
58    /// Escapes markdown syntax in body to prevent unintended formatting whilst preserving
59    /// readability.
60    ///
61    /// Body escaping rules:
62    /// - `#` at line start → `\#` (prevents headers)
63    /// - Triple backticks → `\`\`\`` (prevents code blocks)
64    /// - Standalone `---`, `***`, `___` → escaped (prevents horizontal rules)
65    ///
66    /// Message format produced:
67    /// ```markdown
68    /// **Message ID:** <uuid>
69    /// **Timestamp:** <iso8601>
70    /// **Author ID:** <uuid>
71    /// **Author name:** <name>
72    /// **Author role:** <role>
73    /// **Corrects:** <uuid> (optional)
74    ///
75    /// Body content here
76    /// ```
77    ///
78    /// # Arguments
79    ///
80    /// * `metadata` - Message metadata containing ID, timestamp, and author details
81    /// * `body` - User-provided content to be escaped (guaranteed non-empty by type)
82    /// * `corrects` - Optional UUID of message being corrected
83    ///
84    /// # Returns
85    ///
86    /// Formatted markdown message with metadata and escaped body content.
87    ///
88    /// # Errors
89    ///
90    /// Returns `PatientError::InvalidInput` if role serialization fails.
91    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        // Format metadata as bold key-value pairs
100        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        // Escape and append body
126        let sanitised_body = self.escape_body(body.as_str());
127        output.push_str(sanitised_body.as_str());
128
129        // Add blank line after body, then separator for message delimiting
130        output.push_str("\n\n---\n");
131
132        NonEmptyText::new(output).map_err(|e| PatientError::InvalidInput(e.to_string()))
133    }
134
135    /// Renders multiple messages into a complete markdown thread.
136    ///
137    /// Takes a collection of messages and renders them all to markdown format.
138    /// Each message is rendered with its metadata and body, separated by horizontal rules.
139    /// The thread starts with a level-1 header "# Thread".
140    ///
141    /// # Arguments
142    ///
143    /// * `messages` - Slice of messages to render
144    ///
145    /// # Returns
146    ///
147    /// Complete markdown content with all messages rendered and separated.
148    ///
149    /// # Errors
150    ///
151    /// Returns `PatientError::InvalidInput` if any message has an empty body.
152    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    /// Parses a markdown thread file into structured messages.
169    ///
170    /// Reads a complete coordination thread from markdown format, splitting on horizontal
171    /// rules (`---`) to separate individual messages. Each message is parsed to extract
172    /// variables and body content with markdown escaping reversed. The thread-level
173    /// header (level-1) is ignored as it's not part of individual messages.
174    ///
175    /// Thread format expected:
176    /// ```markdown
177    /// # Thread Title
178    ///
179    /// **Variable1:** Value1
180    /// **Variable2:** Value2
181    ///
182    /// Body content here
183    ///
184    /// ---
185    ///
186    /// **Variable1:** Value1
187    ///
188    /// Next message
189    /// ...
190    /// ```
191    ///
192    /// # Arguments
193    ///
194    /// * `content` - Complete markdown content from messages.md file
195    ///
196    /// # Returns
197    ///
198    /// Vector of parsed `Message` structures with strong typing.
199    ///
200    /// # Errors
201    ///
202    /// Returns `PatientError::InvalidInput` if content cannot be parsed correctly.
203    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        // Split by horizontal rules (---) that appear on their own line
213        // Use regex-like pattern to handle variable whitespace: split on newline(s), ---, newline(s)
214        // First normalize multiple newlines around --- to make splitting consistent
215        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    /// Parses a single message section into a structured `Message`.
231    ///
232    /// Extracts variables (lines matching `**Key:** Value`) and body content.
233    /// Unescapes markdown syntax in body for display. Skips level-1 headers
234    /// as they belong to the thread, not individual messages.
235    ///
236    /// # Arguments
237    ///
238    /// * `section` - Single message content (between horizontal rules or from start)
239    ///
240    /// # Returns
241    ///
242    /// Parsed `Message` with metadata and unescaped body.
243    ///
244    /// # Errors
245    ///
246    /// Returns `PatientError::InvalidInput` if message format is invalid.
247    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        // State tracking: 0 = looking for variables, 1 = in body
254        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            // Skip level-1 headers (thread title) and following blank line
262            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            // State 0: Looking for variables
271            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                    // Blank line after variables, transition to body
284                    state = 1;
285                    i += 1;
286                    continue;
287                } else if trimmed.is_empty() {
288                    // Skip leading blank lines
289                    i += 1;
290                    continue;
291                } else if !trimmed.is_empty() {
292                    // Non-variable content, must be body starting
293                    state = 1;
294                }
295            }
296
297            // State 1: Collecting body
298            if state == 1 {
299                body_lines.push(line);
300                i += 1;
301            } else {
302                i += 1;
303            }
304        }
305
306        // Join body lines and unescape
307        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        // Parse variables into MessageMetadata
318        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()), // Legacy support
336                "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                    // Legacy support
343                    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
379// Helper methods for escaping and unescaping body content
380impl MarkdownService {
381    /// Escapes markdown formatting characters in body content.
382    ///
383    /// Prevents unintended markdown interpretation by escaping line-start `#`,
384    /// triple backticks, and standalone horizontal rule patterns.
385    ///
386    /// # Arguments
387    ///
388    /// * `body` - User-provided text content to escape
389    ///
390    /// # Returns
391    ///
392    /// Escaped body with markdown syntax characters preceded by backslashes.
393    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            // Escape lines starting with #
401            let escaped_line = if line.trim_start().starts_with('#') {
402                line.replacen('#', r"\#", 1)
403            } else if trimmed == "---" || trimmed == "***" || trimmed == "___" {
404                // Escape horizontal rules
405                format!(r"\{}", trimmed)
406            } else {
407                // Escape triple backticks anywhere in the line
408                line.replace("```", r"\`\`\`")
409            };
410
411            result.push_str(&escaped_line);
412
413            // Add newline unless it's the last line
414            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    /// Unescapes markdown formatting characters in body content.
423    ///
424    /// Reverses escaping applied by `escape_body`, restoring original markdown
425    /// syntax characters for display or further processing.
426    ///
427    /// # Arguments
428    ///
429    /// * `body` - Escaped text content to restore
430    ///
431    /// # Returns
432    ///
433    /// Unescaped body with backslash escapes removed from markdown syntax.
434    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            // Unescape lines starting with \#
442            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                // Unescape horizontal rules
446                trimmed.trim_start_matches('\\').to_string()
447            } else {
448                // Unescape triple backticks
449                line.replace(r"\`\`\`", "```")
450            };
451
452            result.push_str(&unescaped_line);
453
454            // Add newline unless it's the last line
455            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        // NonEmptyText type prevents empty strings at construction time
590        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        // Create a message
745        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        // Parse it back
767        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        // Create first message with new_thread_render
781        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        // Create second message
799        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        // Combine and render thread
817        let thread = service.thread_render(&[msg1, msg2]).unwrap();
818
819        // Parse back
820        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}