vpr_core/repositories/
clinical.rs

1//! Patient clinical records management.
2//!
3//! This module handles the creation, linking, and maintenance of per-patient
4//! clinical record repositories within the Versioned Patient Repository (VPR).
5//! Clinical data is stored in lines with OpenEHR Reference Model (RM) structures.
6//! The module initialises new records from validated clinical templates, enforces
7//! directory sharding for scalable storage, and ensures all operations are
8//! version-controlled through Git with optional cryptographic signing.
9
10use crate::author::Author;
11use crate::config::CoreConfig;
12use crate::constants::{CLINICAL_DIR_NAME, DEFAULT_GITIGNORE};
13use crate::error::{PatientError, PatientResult};
14use crate::paths::{
15    clinical::{ehr_status::EhrStatusFile, letter::LetterPaths},
16    common::GitIgnoreFile,
17};
18use crate::repositories::shared::create_uuid_and_shard_dir;
19use crate::NonEmptyText;
20
21// TODO: need to check if this is really needed
22#[cfg(test)]
23use crate::repositories::shared::create_uuid_and_shard_dir_with_source;
24use crate::versioned_files::{
25    ClinicalDomain::Record, FileToWrite, VersionedFileService, VprCommitAction, VprCommitDomain,
26    VprCommitMessage,
27};
28use crate::ShardableUuid;
29use openehr::{
30    extract_rm_version, validate_namespace_uri_safe, ClinicalList, EhrId, EhrStatus,
31    ExternalReference, Letter, LetterData,
32};
33use std::{
34    fs,
35    path::{Path, PathBuf},
36    sync::Arc,
37};
38use vpr_uuid::{Sha256Hash, TimestampId, TimestampIdGenerator};
39
40#[cfg(test)]
41use std::io::ErrorKind;
42use uuid::Uuid;
43
44/// Marker type: clinical record does not yet exist.
45///
46/// This is a zero-sized type used in the type-state pattern to indicate that
47/// a [`ClinicalService`] has not yet been initialised. Services in this state
48/// can only call [`ClinicalService::initialise()`] to create a new clinical record.
49///
50/// # Type Safety
51///
52/// The type system prevents you from calling operations that require an existing
53/// clinical record (like [`link_to_demographics`](ClinicalService::link_to_demographics))
54/// when the service is in the `Uninitialised` state.
55#[derive(Clone, Copy, Debug)]
56pub struct Uninitialised;
57
58/// Marker type: clinical record exists.
59///
60/// This type is used in the type-state pattern to indicate that a [`ClinicalService`]
61/// has been initialised and has a valid clinical record with a known ID.
62///
63/// Services in this state can call operations that require an existing clinical record,
64/// such as [`link_to_demographics`](ClinicalService::link_to_demographics).
65///
66/// # Fields
67///
68/// The clinical ID is stored privately and accessed via the
69/// [`clinical_id()`](ClinicalService::clinical_id) method.
70#[derive(Clone, Copy, Debug)]
71pub struct Initialised {
72    clinical_id: Uuid,
73}
74
75/// Result of reading an existing letter.
76///
77/// Contains the body content and parsed composition metadata.
78#[derive(Debug, Clone)]
79pub struct ReadLetterResult {
80    /// The Markdown content from body.md
81    pub body_content: NonEmptyText,
82    /// Parsed composition metadata from composition.yaml
83    pub letter_data: LetterData,
84}
85
86/// Metadata for a file attachment in a clinical letter.
87///
88/// This structure contains the information needed to reference an attached file
89/// from a letter composition, including both the file storage details and
90/// attachment-specific metadata.
91#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
92pub struct AttachmentMetadata {
93    /// Name of the attachment metadata file (e.g., "attachment_1.yaml")
94    pub metadata_filename: NonEmptyText,
95    /// SHA-256 hash of the file content
96    pub hash: Sha256Hash,
97    /// Path to the file in the files storage (relative to repository root)
98    pub file_storage_path: NonEmptyText,
99    /// Size of the file in bytes
100    pub size_bytes: u64,
101    /// Detected media type (MIME type)
102    pub media_type: Option<NonEmptyText>,
103    /// Original filename from the source
104    pub original_filename: NonEmptyText,
105}
106
107/// Result of reading letter attachments.
108///
109/// Contains the attachment metadata and the actual file content.
110#[derive(Debug, Clone)]
111pub struct LetterAttachment {
112    /// Metadata about the attachment
113    pub metadata: AttachmentMetadata,
114    /// The binary content of the file
115    pub content: Vec<u8>,
116}
117
118/// Service for managing clinical record operations.
119///
120/// This service uses the type-state pattern to enforce correct usage at compile time.
121/// The generic parameter `S` can be either [`Uninitialised`] or [`Initialised`],
122/// determining which operations are available.
123///
124/// # Type States
125///
126/// - `ClinicalService<Uninitialised>` - Created via [`new()`](ClinicalService::new).
127///   Can only call [`initialise()`](ClinicalService::initialise).
128///
129/// - `ClinicalService<Initialised>` - Created via [`with_id()`](ClinicalService::with_id)
130///   or returned from [`initialise()`](ClinicalService::initialise). Can call operations
131///   like [`link_to_demographics()`](ClinicalService::link_to_demographics).
132#[derive(Clone, Debug)]
133pub struct ClinicalService<S> {
134    cfg: Arc<CoreConfig>,
135    state: S,
136}
137
138impl ClinicalService<Uninitialised> {
139    /// Creates a new `ClinicalService` in the uninitialised state.
140    ///
141    /// This is the starting point for creating a new clinical record. The returned
142    /// service can only call [`initialise()`](Self::initialise) to create the record.
143    ///
144    /// # Arguments
145    ///
146    /// * `cfg` - The core configuration containing paths, templates, and system settings.
147    ///
148    /// # Returns
149    ///
150    /// A `ClinicalService<Uninitialised>` ready to initialise a new clinical record.
151    pub fn new(cfg: Arc<CoreConfig>) -> Self {
152        Self {
153            cfg,
154            state: Uninitialised,
155        }
156    }
157}
158
159impl ClinicalService<Initialised> {
160    /// Creates a `ClinicalService` in the initialised state with an existing clinical ID.
161    ///
162    /// Use this when you already have a clinical record and want to perform operations on it,
163    /// such as linking to demographics or updating the EHR status.
164    ///
165    /// # Arguments
166    ///
167    /// * `cfg` - The core configuration containing paths, templates, and system settings.
168    /// * `clinical_id` - The UUID of an existing clinical record.
169    ///
170    /// # Returns
171    ///
172    /// A `ClinicalService<Initialised>` ready to operate on the specified clinical record.
173    ///
174    /// # Note
175    ///
176    /// This constructor does not validate that the clinical record actually exists.
177    /// Operations on a non-existent record will fail at runtime.
178    pub fn with_id(cfg: Arc<CoreConfig>, clinical_id: Uuid) -> Self {
179        Self {
180            cfg,
181            state: Initialised { clinical_id },
182        }
183    }
184
185    /// Returns the clinical ID for this initialised service.
186    ///
187    /// # Returns
188    ///
189    /// The UUID of the clinical record associated with this service.
190    pub fn clinical_id(&self) -> Uuid {
191        self.state.clinical_id
192    }
193}
194
195impl ClinicalService<Uninitialised> {
196    /// Initialises a new clinical record for a patient.
197    ///
198    /// This function creates a new clinical entry with a unique UUID, stores it in a sharded
199    /// directory structure, copies the clinical template into the patient's directory, writes an
200    /// initial `ehr_status.yaml`, and initialises a Git repository for version control.
201    ///
202    /// **This method consumes `self`** and returns a new `ClinicalService<Initialised>` on success,
203    /// enforcing at compile time that you cannot call `initialise()` twice on the same service.
204    ///
205    /// # Arguments
206    ///
207    /// * `author` - The author information for the initial Git commit. Must have a non-empty name.
208    /// * `care_location` - High-level organisational location for the commit (e.g. hospital name).
209    ///   Must be a non-empty string.
210    ///
211    /// # Returns
212    ///
213    /// Returns `ClinicalService<Initialised>` containing the newly created clinical record.
214    /// Use [`clinical_id()`](ClinicalService::clinical_id) to get the UUID.
215    ///
216    /// # Errors
217    ///
218    /// Returns a `PatientError` if:
219    /// - The author's name is empty or whitespace-only ([`PatientError::MissingAuthorName`])
220    /// - The care location is empty or whitespace-only ([`PatientError::MissingCareLocation`])
221    /// - Required inputs or configuration are invalid ([`PatientError::InvalidInput`])
222    /// - The clinical template cannot be located or is invalid
223    /// - A unique patient directory cannot be allocated after 5 attempts
224    /// - File or directory operations fail while creating the record or copying templates ([`PatientError::FileWrite`])
225    /// - Writing `ehr_status.yaml` fails
226    /// - Git repository initialisation fails (e.g., [`PatientError::GitInit`])
227    /// - The initial commit fails (e.g., certificate/signature mismatch)
228    /// - Cleanup of a partially-created record directory fails ([`PatientError::CleanupAfterInitialiseFailed`])
229    pub fn initialise(
230        self,
231        author: Author,
232        care_location: NonEmptyText,
233    ) -> PatientResult<ClinicalService<Initialised>> {
234        author.validate_commit_author()?;
235
236        let clinical_dir = self.clinical_dir();
237        let (clinical_uuid, patient_dir) = create_uuid_and_shard_dir(&clinical_dir)?;
238
239        let commit_message = VprCommitMessage::new(
240            VprCommitDomain::Clinical(Record),
241            VprCommitAction::Create,
242            "Initialised the clinical record",
243            care_location,
244        )?;
245
246        let rm_version = self.cfg.rm_system_version();
247
248        // Prepare ehr_status.yaml content
249        let ehr_status_yaml = EhrStatus::render(
250            rm_version,
251            None,
252            Some(&EhrId::from_uuid(clinical_uuid.uuid())),
253            None,
254        )?;
255
256        let files = [
257            FileToWrite {
258                relative_path: Path::new(GitIgnoreFile::NAME),
259                content: DEFAULT_GITIGNORE,
260                old_content: None,
261            },
262            FileToWrite {
263                relative_path: Path::new(EhrStatusFile::NAME),
264                content: &ehr_status_yaml,
265                old_content: None,
266            },
267        ];
268
269        VersionedFileService::init_and_commit(&patient_dir, &author, &commit_message, &files)?;
270
271        Ok(ClinicalService {
272            cfg: self.cfg,
273            state: Initialised {
274                clinical_id: clinical_uuid.uuid(),
275            },
276        })
277    }
278}
279
280impl ClinicalService<Initialised> {
281    /// Links the clinical record to the patient's demographics.
282    ///
283    /// This function updates the clinical record's `ehr_status.yaml` file to include an
284    /// external reference to the patient's demographics record.
285    ///
286    /// The clinical UUID is obtained from the service's internal state (via [`clinical_id()`](Self::clinical_id)),
287    /// ensuring type safety and preventing mismatched UUIDs.
288    ///
289    /// # Arguments
290    ///
291    /// * `author` - The author information for the Git commit recording this change.
292    ///   Must have a non-empty name.
293    /// * `care_location` - High-level organisational location for the commit (e.g. hospital name).
294    ///   Must be a non-empty string.
295    /// * `demographics_uuid` - The UUID of the associated patient demographics record.
296    ///   Must be a canonical 32-character lowercase hex string.
297    /// * `namespace` - Optional namespace for the external reference URI. If `None`, uses the
298    ///   value configured in [`CoreConfig`]. The namespace must be URI-safe (no special characters
299    ///   like `<`, `>`, `/`, `\`).
300    ///
301    /// # Returns
302    ///
303    /// Returns `Ok(())` on success. The `ehr_status.yaml` file is updated and committed to Git.
304    ///
305    /// # Errors
306    ///
307    /// Returns a `PatientError` if:
308    /// - The author's name is empty or whitespace-only ([`PatientError::MissingAuthorName`])
309    /// - The care location is empty or whitespace-only ([`PatientError::MissingCareLocation`])
310    /// - The demographics UUID cannot be parsed ([`PatientError::Uuid`])
311    /// - The namespace is invalid/unsafe for embedding into a `ehr://{namespace}/mpi` URI ([`PatientError::InvalidInput`])
312    /// - The `ehr_status.yaml` file does not exist ([`PatientError::InvalidInput`])
313    /// - Reading or writing `ehr_status.yaml` fails ([`PatientError::FileRead`] or [`PatientError::FileWrite`])
314    /// - The existing `ehr_status.yaml` cannot be parsed ([`PatientError::Openehr`])
315    /// - Git commit fails (various Git-related error variants)
316    ///
317    /// # Safety & Rollback
318    ///
319    /// If the file write or Git commit fails, this method attempts to restore the previous
320    /// content of `ehr_status.yaml`.
321    pub fn link_to_demographics(
322        &self,
323        author: &Author,
324        care_location: NonEmptyText,
325        demographics_uuid: &str,
326        namespace: Option<NonEmptyText>,
327    ) -> PatientResult<()> {
328        author.validate_commit_author()?;
329
330        let msg = VprCommitMessage::new(
331            VprCommitDomain::Clinical(Record),
332            VprCommitAction::Update,
333            "EHR status linked to demographics",
334            care_location,
335        )?;
336
337        let clinical_uuid = ShardableUuid::parse(&self.clinical_id().simple().to_string())?;
338        let demographics_uuid = ShardableUuid::parse(demographics_uuid)?;
339
340        let namespace = namespace
341            .as_ref()
342            .map(|n| n.as_str())
343            .unwrap_or(self.cfg.vpr_namespace())
344            .trim();
345
346        validate_namespace_uri_safe(namespace)?;
347
348        let patient_dir = self.clinical_patient_dir(&clinical_uuid);
349        let filename = patient_dir.join("ehr_status.yaml");
350
351        if !filename.exists() {
352            return Err(PatientError::InvalidInput(format!(
353                "{} does not exist for clinical record {}",
354                "ehr_status.yaml", clinical_uuid
355            )));
356        }
357
358        let external_reference = Some(vec![ExternalReference {
359            namespace: format!("ehr://{}/mpi", namespace),
360            id: demographics_uuid.uuid(),
361        }]);
362
363        let previous_data = fs::read_to_string(&filename).map_err(PatientError::FileRead)?;
364
365        let rm_version = extract_rm_version(&previous_data)?;
366
367        let yaml_content =
368            EhrStatus::render(rm_version, Some(&previous_data), None, external_reference)?;
369
370        VersionedFileService::write_and_commit_files(
371            &patient_dir,
372            author,
373            &msg,
374            &[FileToWrite {
375                relative_path: Path::new("ehr_status.yaml"),
376                content: &yaml_content,
377                old_content: Some(&previous_data),
378            }],
379        )
380    }
381
382    /// Creates a new clinical letter with optional body and/or attachments.
383    ///
384    /// This is the unified function for creating letters with any combination of:
385    /// - Body content only (markdown text saved as body.md)
386    /// - Attachments only (files stored via FilesService)
387    /// - Both body content AND attachments
388    ///
389    /// At least one of `body_content` or `attachment_files` must be provided.
390    ///
391    /// # Arguments
392    ///
393    /// * `author` - The author information for the Git commit. Must have a non-empty name.
394    /// * `care_location` - High-level organisational location for the commit (e.g. hospital name).
395    ///   Must be a non-empty string.
396    /// * `body_content` - Optional Markdown content for the letter body (body.md).
397    /// * `attachment_files` - Paths to files to attach (e.g., PDFs, images). Can be empty.
398    /// * `clinical_lists` - Optional clinical lists to include in the composition.
399    ///
400    /// # Returns
401    ///
402    /// Returns the generated timestamp ID for the letter on success.
403    ///
404    /// # Errors
405    ///
406    /// Returns a `PatientError` if:
407    /// - Both `body_content` and `attachment_files` are empty/None
408    /// - The author's name is empty or whitespace-only
409    /// - The care location is empty or whitespace-only
410    /// - The clinical UUID cannot be parsed
411    /// - Any attachment file cannot be read or stored
412    /// - Creating the composition.yaml content fails
413    /// - Writing files or committing to Git fails
414    pub fn create_letter(
415        &self,
416        author: &Author,
417        care_location: NonEmptyText,
418        body_content: Option<NonEmptyText>,
419        attachment_files: &[PathBuf],
420        clinical_lists: Option<&[ClinicalList]>,
421    ) -> PatientResult<TimestampId> {
422        // Validate: at least one of body or attachments must be present
423        if body_content.is_none() && attachment_files.is_empty() {
424            return Err(PatientError::InvalidInput(
425                "Letter must have either body content or attachments (or both)".to_string(),
426            ));
427        }
428
429        author.validate_commit_author()?;
430
431        let commit_message = VprCommitMessage::new(
432            VprCommitDomain::Clinical(Record),
433            VprCommitAction::Create,
434            "Created new letter",
435            care_location,
436        )?;
437
438        let timestamp_id = TimestampIdGenerator::generate(None)?;
439        let letter_paths = LetterPaths::new(&timestamp_id);
440
441        let clinical_uuid = ShardableUuid::parse(&self.clinical_id().simple().to_string())?;
442        let patient_dir = self.clinical_patient_dir(&clinical_uuid);
443
444        // Process attachments if provided
445        let mut attachment_metadata_list = Vec::new();
446        let mut attachment_files_to_write = Vec::new();
447
448        if !attachment_files.is_empty() {
449            let clinical_dir = self.clinical_dir();
450            let files_service = vpr_files::FilesService::new(&clinical_dir, clinical_uuid.clone())
451                .map_err(|e| {
452                    PatientError::InvalidInput(format!("Failed to initialize files service: {}", e))
453                })?;
454
455            for (index, file_path) in attachment_files.iter().enumerate() {
456                // Add file to storage
457                let file_metadata = files_service.add(file_path).map_err(|e| {
458                    PatientError::InvalidInput(format!("Failed to add attachment file: {}", e))
459                })?;
460
461                // Create attachment metadata
462                let attachment_filename = format!("attachment_{}.yaml", index + 1);
463                let attachment_metadata = AttachmentMetadata {
464                    metadata_filename: NonEmptyText::new(&attachment_filename).map_err(|e| {
465                        PatientError::InvalidInput(format!("Invalid attachment filename: {}", e))
466                    })?,
467                    hash: Sha256Hash::parse(file_metadata.hash.as_str()).map_err(|e| {
468                        PatientError::InvalidInput(format!("Invalid SHA-256 hash: {}", e))
469                    })?,
470                    file_storage_path: NonEmptyText::new(file_metadata.relative_path.as_str())
471                        .map_err(|e| {
472                            PatientError::InvalidInput(format!("Invalid file storage path: {}", e))
473                        })?,
474                    size_bytes: file_metadata.size_bytes,
475                    media_type: file_metadata
476                        .media_type
477                        .as_ref()
478                        .map(|mt| NonEmptyText::new(mt.as_str()))
479                        .transpose()
480                        .map_err(|e| {
481                            PatientError::InvalidInput(format!("Invalid media type: {}", e))
482                        })?,
483                    original_filename: NonEmptyText::new(&file_metadata.original_filename)
484                        .map_err(|e| {
485                            PatientError::InvalidInput(format!("Invalid original filename: {}", e))
486                        })?,
487                };
488
489                // Serialize attachment metadata to YAML
490                let attachment_yaml = serde_yaml::to_string(&attachment_metadata).map_err(|e| {
491                    PatientError::InvalidInput(format!(
492                        "Failed to serialize attachment metadata: {}",
493                        e
494                    ))
495                })?;
496
497                // Add to files to write
498                let attachment_path = letter_paths.attachment(&attachment_filename);
499                attachment_files_to_write.push((attachment_path, attachment_yaml));
500                attachment_metadata_list.push(attachment_metadata);
501            }
502        }
503
504        // Generate composition.yaml content
505        let rm_version = self.cfg.rm_system_version();
506        let start_time = timestamp_id.timestamp();
507
508        // Build attachment references for the composition
509        let attachment_refs: Vec<openehr::AttachmentReference> = attachment_metadata_list
510            .iter()
511            .map(|meta| openehr::AttachmentReference {
512                path: format!("./attachments/{}", meta.metadata_filename),
513            })
514            .collect();
515
516        // Construct LetterData
517        let letter_data = openehr::LetterData {
518            rm_version,
519            uid: timestamp_id.clone(),
520            composer_name: author.name.to_string(),
521            composer_role: "Clinical Practitioner".to_string(),
522            start_time,
523            clinical_lists: clinical_lists
524                .map(|lists| lists.to_vec())
525                .unwrap_or_default(),
526            has_body: body_content.is_some(),
527            attachments: attachment_refs,
528        };
529
530        let composition_content =
531            Letter::composition_render(rm_version, &letter_data).map_err(|e| {
532                PatientError::InvalidInput(format!("Failed to create letter composition: {}", e))
533            })?;
534
535        let composition_yaml_relative_path = letter_paths.composition_yaml();
536
537        // Build list of all files to write
538        let mut files_to_write_vec = vec![FileToWrite {
539            relative_path: &composition_yaml_relative_path,
540            content: &composition_content,
541            old_content: None,
542        }];
543
544        // Add body.md if provided
545        let body_md_relative_path;
546        if let Some(ref body) = body_content {
547            body_md_relative_path = letter_paths.body_md();
548            files_to_write_vec.push(FileToWrite {
549                relative_path: &body_md_relative_path,
550                content: body.as_str(),
551                old_content: None,
552            });
553        }
554
555        // Add attachment metadata files
556        let attachment_paths: Vec<PathBuf> = attachment_files_to_write
557            .iter()
558            .map(|(path, _)| path.clone())
559            .collect();
560
561        for (path, content) in attachment_paths
562            .iter()
563            .zip(attachment_files_to_write.iter())
564        {
565            files_to_write_vec.push(FileToWrite {
566                relative_path: path,
567                content: content.1.as_str(),
568                old_content: None,
569            });
570        }
571
572        VersionedFileService::write_and_commit_files(
573            &patient_dir,
574            author,
575            &commit_message,
576            &files_to_write_vec,
577        )?;
578
579        Ok(timestamp_id)
580    }
581
582    /// Creates a new clinical letter with body content.
583    ///
584    /// This is a convenience wrapper around [`create_letter`](Self::create_letter)
585    /// for the common case of creating a letter with only body content.
586    ///
587    /// # Arguments
588    ///
589    /// * `author` - The author information for the Git commit.
590    /// * `care_location` - High-level organisational location for the commit.
591    /// * `letter_content` - The Markdown content for the letter body.
592    /// * `clinical_lists` - Optional clinical lists to include.
593    ///
594    /// # Returns
595    ///
596    /// Returns the generated timestamp ID for the letter on success.
597    ///
598    /// This function creates a new letter with the specified content, generates a unique
599    /// timestamp-based ID, and commits both the composition.yaml and body.md files to Git.
600    ///
601    /// # Arguments
602    ///
603    /// * `author` - The author information for the Git commit. Must have a non-empty name.
604    /// * `care_location` - High-level organisational location for the commit (e.g. hospital name).
605    ///   Must be a non-empty string.
606    /// * `letter_content` - The Markdown content for the letter body (body.md).
607    ///
608    /// # Returns
609    ///
610    /// Returns the generated timestamp ID for the letter on success.
611    ///
612    /// # Errors
613    ///
614    /// Returns a `PatientError` if:
615    /// - The author's name is empty or whitespace-only
616    /// - The care location is empty or whitespace-only
617    /// - The clinical UUID cannot be parsed
618    /// - Creating the composition.yaml content fails
619    /// - Writing files or committing to Git fails
620    pub fn new_letter(
621        &self,
622        author: &Author,
623        care_location: NonEmptyText,
624        letter_content: NonEmptyText,
625        clinical_lists: Option<&[ClinicalList]>,
626    ) -> PatientResult<TimestampId> {
627        author.validate_commit_author()?;
628
629        let msg = VprCommitMessage::new(
630            VprCommitDomain::Clinical(Record),
631            VprCommitAction::Create,
632            "Created new letter",
633            care_location,
634        )?;
635
636        let timestamp_id = TimestampIdGenerator::generate(None)?;
637        let letter_paths = LetterPaths::new(&timestamp_id);
638
639        let clinical_uuid = ShardableUuid::parse(&self.clinical_id().simple().to_string())?;
640        let patient_dir = self.clinical_patient_dir(&clinical_uuid);
641
642        let body_md_relative_path = letter_paths.body_md();
643        let composition_yaml_relative_path = letter_paths.composition_yaml();
644
645        // Generate composition.yaml content using Letter::composition_render
646        let rm_version = self.cfg.rm_system_version();
647        let start_time = timestamp_id.timestamp();
648
649        // Construct LetterData for the new letter
650        let letter_data = openehr::LetterData {
651            rm_version,
652            uid: timestamp_id.clone(),
653            composer_name: author.name.to_string(),
654            composer_role: "Clinical Practitioner".to_string(),
655            start_time,
656            clinical_lists: clinical_lists
657                .map(|lists| lists.to_vec())
658                .unwrap_or_default(),
659            has_body: true,
660            attachments: vec![],
661        };
662
663        let composition_content =
664            Letter::composition_render(rm_version, &letter_data).map_err(|e| {
665                PatientError::InvalidInput(format!("Failed to create letter composition: {}", e))
666            })?;
667
668        let files_to_write = [
669            FileToWrite {
670                relative_path: &composition_yaml_relative_path,
671                content: &composition_content,
672                old_content: None,
673            },
674            FileToWrite {
675                relative_path: &body_md_relative_path,
676                content: letter_content.as_str(),
677                old_content: None,
678            },
679        ];
680
681        VersionedFileService::write_and_commit_files(&patient_dir, author, &msg, &files_to_write)?;
682
683        Ok(timestamp_id)
684    }
685
686    /// Reads an existing clinical letter.
687    ///
688    /// This function reads both the body.md and composition.yaml files for a letter,
689    /// parses the composition metadata, and returns them together.
690    ///
691    /// # Arguments
692    ///
693    /// * `timestamp_id` - The timestamp ID of the letter to read.
694    ///
695    /// # Returns
696    ///
697    /// Returns a `ReadLetterResult` containing both the body content and parsed letter data.
698    ///
699    /// # Errors
700    ///
701    /// Returns a `PatientError` if:
702    /// - The timestamp ID cannot be parsed
703    /// - The clinical UUID cannot be parsed
704    /// - The body.md or composition.yaml file does not exist
705    /// - Reading either file fails
706    /// - Parsing the composition.yaml fails
707    pub fn read_letter(&self, timestamp_id: &str) -> PatientResult<ReadLetterResult> {
708        let timestamp_id: vpr_uuid::TimestampId = timestamp_id
709            .parse()
710            .map_err(|e| PatientError::InvalidInput(format!("Invalid timestamp ID: {}", e)))?;
711        let letter_paths = LetterPaths::new(&timestamp_id);
712
713        let clinical_uuid = ShardableUuid::parse(&self.clinical_id().simple().to_string())?;
714        let patient_dir = self.clinical_patient_dir(&clinical_uuid);
715
716        let body_md_path = patient_dir.join(letter_paths.body_md());
717        let composition_yaml_path = patient_dir.join(letter_paths.composition_yaml());
718
719        // Check that both files exist
720        if !body_md_path.exists() {
721            return Err(PatientError::InvalidInput(format!(
722                "Letter body file not found: {}",
723                body_md_path.display()
724            )));
725        }
726
727        if !composition_yaml_path.exists() {
728            return Err(PatientError::InvalidInput(format!(
729                "Letter composition file not found: {}",
730                composition_yaml_path.display()
731            )));
732        }
733
734        // Read the body content
735        let body_content_str = fs::read_to_string(&body_md_path).map_err(PatientError::FileRead)?;
736        let body_content = NonEmptyText::new(&body_content_str).map_err(|_| {
737            PatientError::InvalidInput(format!(
738                "Letter body is empty for timestamp ID: {}",
739                timestamp_id
740            ))
741        })?;
742
743        // Read and parse the composition
744        let composition_yaml =
745            fs::read_to_string(&composition_yaml_path).map_err(PatientError::FileRead)?;
746
747        let rm_version = extract_rm_version(&composition_yaml)?;
748
749        let letter_data = Letter::composition_parse(rm_version, &composition_yaml)
750            .map_err(PatientError::Openehr)?;
751
752        Ok(ReadLetterResult {
753            body_content,
754            letter_data,
755        })
756    }
757
758    /// Retrieves all attachments for a clinical letter.
759    ///
760    /// This function reads all attachment metadata files from the letter's attachments directory
761    /// and retrieves the actual file content from storage.
762    ///
763    /// # Arguments
764    ///
765    /// * `timestamp_id` - The timestamp ID of the letter.
766    ///
767    /// # Returns
768    ///
769    /// Returns a vector of `LetterAttachment` containing metadata and file content for each attachment.
770    ///
771    /// # Errors
772    ///
773    /// Returns a `PatientError` if:
774    /// - The timestamp ID cannot be parsed
775    /// - The clinical UUID cannot be parsed
776    /// - The attachments directory does not exist or cannot be read
777    /// - Any attachment metadata file cannot be read or parsed
778    /// - Any file cannot be retrieved from storage
779    pub fn get_letter_attachments(
780        &self,
781        timestamp_id: &str,
782    ) -> PatientResult<Vec<LetterAttachment>> {
783        let timestamp_id: vpr_uuid::TimestampId = timestamp_id
784            .parse()
785            .map_err(|e| PatientError::InvalidInput(format!("Invalid timestamp ID: {}", e)))?;
786        let letter_paths = LetterPaths::new(&timestamp_id);
787
788        let clinical_uuid = ShardableUuid::parse(&self.clinical_id().simple().to_string())?;
789        let patient_dir = self.clinical_patient_dir(&clinical_uuid);
790
791        let attachments_dir = patient_dir.join(letter_paths.attachments_dir());
792
793        // Check if attachments directory exists
794        if !attachments_dir.exists() {
795            // No attachments - return empty vector
796            return Ok(Vec::new());
797        }
798
799        // Initialize FilesService for reading files
800        let clinical_dir = self.clinical_dir();
801        let files_service =
802            vpr_files::FilesService::new(&clinical_dir, clinical_uuid).map_err(|e| {
803                PatientError::InvalidInput(format!("Failed to initialize files service: {}", e))
804            })?;
805
806        let mut attachments = Vec::new();
807
808        // Read all attachment metadata files
809        let entries = fs::read_dir(&attachments_dir).map_err(|e| {
810            PatientError::FileRead(std::io::Error::new(
811                e.kind(),
812                format!("Failed to read attachments directory: {}", e),
813            ))
814        })?;
815
816        for entry in entries {
817            let entry = entry.map_err(PatientError::FileRead)?;
818            let path = entry.path();
819
820            // Only process .yaml files
821            if path.extension().and_then(|s| s.to_str()) != Some("yaml") {
822                continue;
823            }
824
825            // Read and parse attachment metadata
826            let metadata_content = fs::read_to_string(&path).map_err(PatientError::FileRead)?;
827            let metadata: AttachmentMetadata =
828                serde_yaml::from_str(&metadata_content).map_err(|e| {
829                    PatientError::InvalidInput(format!(
830                        "Failed to parse attachment metadata: {}",
831                        e
832                    ))
833                })?;
834
835            // Retrieve file content from storage using the hash
836            let content = files_service.read(metadata.hash.as_str()).map_err(|e| {
837                PatientError::InvalidInput(format!("Failed to read attachment file: {}", e))
838            })?;
839
840            attachments.push(LetterAttachment { metadata, content });
841        }
842
843        Ok(attachments)
844    }
845
846    /// Creates a new clinical letter with file attachments.
847    ///
848    /// This is a convenience wrapper around [`create_letter`](Self::create_letter)
849    /// for creating a letter with only attachments (no body content).
850    ///
851    /// # Arguments
852    ///
853    /// * `author` - The author information for the Git commit.
854    /// * `care_location` - High-level organisational location for the commit.
855    /// * `attachment_files` - Paths to files to attach (e.g., PDFs, images).
856    /// * `clinical_lists` - Optional clinical lists to include.
857    ///
858    /// # Returns
859    ///
860    /// Returns the generated timestamp ID for the letter on success.
861    pub fn new_letter_with_attachments(
862        &self,
863        author: &Author,
864        care_location: NonEmptyText,
865        attachment_files: &[PathBuf],
866        clinical_lists: Option<&[ClinicalList]>,
867    ) -> PatientResult<TimestampId> {
868        self.create_letter(
869            author,
870            care_location,
871            None,
872            attachment_files,
873            clinical_lists,
874        )
875    }
876}
877
878impl<S> ClinicalService<S> {
879    /// Returns the path to the clinical records directory.
880    ///
881    /// This constructs the base directory for clinical records by joining
882    /// the configured patient data directory with the clinical directory name.
883    ///
884    /// # Returns
885    ///
886    /// A `PathBuf` pointing to the clinical records directory (e.g., `{patient_data_dir}/clinical`).
887    fn clinical_dir(&self) -> PathBuf {
888        let data_dir = self.cfg.patient_data_dir().to_path_buf();
889        data_dir.join(CLINICAL_DIR_NAME)
890    }
891
892    /// Returns the path to a specific patient's clinical record directory.
893    ///
894    /// This constructs the full path to a patient's clinical directory by
895    /// determining the sharded subdirectory based on the UUID and joining
896    /// it with the clinical base directory.
897    ///
898    /// # Arguments
899    ///
900    /// * `clinical_uuid` - The UUID identifying the clinical record.
901    ///
902    /// # Returns
903    ///
904    /// A `PathBuf` pointing to the patient's clinical record directory
905    /// (e.g., `{clinical_dir}/{shard1}/{shard2}/{uuid}`).
906    fn clinical_patient_dir(&self, clinical_uuid: &ShardableUuid) -> PathBuf {
907        let clinical_dir = self.clinical_dir();
908        clinical_uuid.sharded_dir(&clinical_dir)
909    }
910}
911
912#[cfg(test)]
913mod tests {
914    use super::*;
915    use crate::config::rm_system_version_from_env_value;
916    use crate::{EmailAddress, NonEmptyText};
917
918    use crate::CoreConfig;
919    use p256::ecdsa::SigningKey;
920    use p256::pkcs8::{EncodePrivateKey, EncodePublicKey};
921    use rcgen::{CertificateParams, KeyPair};
922    use std::path::Path;
923    use std::sync::Arc;
924    use tempfile::TempDir;
925
926    fn test_cfg(patient_data_dir: &Path) -> Arc<CoreConfig> {
927        let rm_system_version = rm_system_version_from_env_value(None)
928            .expect("rm_system_version_from_env_value should succeed");
929
930        Arc::new(
931            CoreConfig::new(
932                patient_data_dir.to_path_buf(),
933                rm_system_version,
934                crate::NonEmptyText::new("vpr.dev.1").unwrap(),
935            )
936            .expect("CoreConfig::new should succeed"),
937        )
938    }
939
940    #[test]
941    fn create_uuid_and_shard_dir_creates_first_available_candidate() {
942        let temp_dir = TempDir::new().expect("Failed to create temp dir");
943        let clinical_dir = temp_dir.path().join(CLINICAL_DIR_NAME);
944
945        let uuids = vec![ShardableUuid::parse("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
946            .expect("uuid should be canonical")];
947        let mut iter = uuids.into_iter();
948
949        let (uuid, patient_dir) =
950            create_uuid_and_shard_dir_with_source(&clinical_dir, || iter.next().unwrap())
951                .expect("allocation should succeed");
952
953        assert_eq!(uuid.to_string(), "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
954        assert_eq!(
955            patient_dir,
956            clinical_dir
957                .join("aa")
958                .join("aa")
959                .join("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
960        );
961        assert!(patient_dir.exists(), "patient directory should exist");
962    }
963
964    #[test]
965    fn create_uuid_and_shard_dir_skips_existing_candidate() {
966        let temp_dir = TempDir::new().expect("Failed to create temp dir");
967        let clinical_dir = temp_dir.path().join(CLINICAL_DIR_NAME);
968
969        let first = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
970        let second = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
971        let first_dir = clinical_dir
972            .join("aa")
973            .join("aa")
974            .join("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
975        fs::create_dir_all(&first_dir).expect("Failed to pre-create first candidate dir");
976
977        let uuids = vec![
978            ShardableUuid::parse(first).expect("uuid should be canonical"),
979            ShardableUuid::parse(second).expect("uuid should be canonical"),
980        ];
981        let mut iter = uuids.into_iter();
982
983        let (uuid, patient_dir) =
984            create_uuid_and_shard_dir_with_source(&clinical_dir, || iter.next().unwrap())
985                .expect("allocation should succeed");
986
987        assert_eq!(uuid.to_string(), second);
988        assert_eq!(
989            patient_dir,
990            clinical_dir
991                .join("bb")
992                .join("bb")
993                .join("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")
994        );
995        assert!(patient_dir.exists(), "patient directory should exist");
996    }
997
998    #[test]
999    fn create_uuid_and_shard_dir_fails_after_five_attempts() {
1000        let temp_dir = TempDir::new().expect("Failed to create temp dir");
1001        let clinical_dir = temp_dir.path().join(CLINICAL_DIR_NAME);
1002
1003        let ids = [
1004            "11111111111111111111111111111111",
1005            "22222222222222222222222222222222",
1006            "33333333333333333333333333333333",
1007            "44444444444444444444444444444444",
1008            "55555555555555555555555555555555",
1009        ];
1010
1011        for id in ids {
1012            let dir = clinical_dir.join(&id[0..2]).join(&id[2..4]).join(id);
1013            fs::create_dir_all(&dir).expect("Failed to pre-create candidate dir");
1014        }
1015
1016        let uuids = ids
1017            .into_iter()
1018            .map(|s| ShardableUuid::parse(s).expect("uuid should be canonical"))
1019            .collect::<Vec<_>>();
1020        let mut iter = uuids.into_iter();
1021
1022        let err = create_uuid_and_shard_dir_with_source(&clinical_dir, || iter.next().unwrap())
1023            .expect_err("allocation should fail");
1024
1025        match err {
1026            PatientError::PatientDirCreation(e) => {
1027                assert_eq!(e.kind(), ErrorKind::AlreadyExists);
1028            }
1029            other => panic!("unexpected error: {other:?}"),
1030        }
1031    }
1032
1033    #[test]
1034    fn create_uuid_and_shard_dir_returns_error_if_parent_dir_creation_fails() {
1035        let temp_dir = TempDir::new().expect("Failed to create temp dir");
1036        let clinical_dir = temp_dir.path().join(CLINICAL_DIR_NAME);
1037
1038        // For UUID "aa..", the shard prefix directories are "aa/aa".
1039        // Create a *file* at "clinical_dir/aa" so creating "clinical_dir/aa/aa" fails.
1040        fs::create_dir_all(&clinical_dir).expect("Failed to create clinical_dir");
1041        let blocking_path = clinical_dir.join("aa");
1042        fs::write(&blocking_path, b"not a directory").expect("Failed to create blocking file");
1043
1044        let uuids = vec![ShardableUuid::parse("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
1045            .expect("uuid should be canonical")];
1046        let mut iter = uuids.into_iter();
1047
1048        let err = create_uuid_and_shard_dir_with_source(&clinical_dir, || iter.next().unwrap())
1049            .expect_err("allocation should fail when parent dir creation fails");
1050
1051        assert!(matches!(err, PatientError::PatientDirCreation(_)));
1052    }
1053
1054    #[test]
1055    fn test_initialise_fails_fast_on_invalid_author_and_creates_no_files() {
1056        let patient_data_dir = TempDir::new().expect("Failed to create temp dir");
1057
1058        let rm_system_version = rm_system_version_from_env_value(None)
1059            .expect("rm_system_version_from_env_value should succeed");
1060
1061        let cfg = Arc::new(
1062            CoreConfig::new(
1063                patient_data_dir.path().to_path_buf(),
1064                rm_system_version,
1065                crate::NonEmptyText::new("vpr.dev.1").unwrap(),
1066            )
1067            .expect("CoreConfig::new should succeed"),
1068        );
1069
1070        let _service = ClinicalService::new(cfg);
1071
1072        // NonEmptyText validation prevents whitespace-only strings at the type level
1073        let err =
1074            NonEmptyText::new(" ").expect_err("creating NonEmptyText from whitespace should fail");
1075        assert!(matches!(err, crate::TextError::Empty));
1076
1077        assert!(
1078            !patient_data_dir.path().join(CLINICAL_DIR_NAME).exists(),
1079            "initialise should not perform filesystem side-effects when validation fails"
1080        );
1081    }
1082
1083    #[test]
1084    fn test_initialise_rejects_missing_care_location_and_creates_no_files() {
1085        let patient_data_dir = TempDir::new().expect("Failed to create temp dir");
1086
1087        let rm_system_version = rm_system_version_from_env_value(None)
1088            .expect("rm_system_version_from_env_value should succeed");
1089
1090        let cfg = Arc::new(
1091            CoreConfig::new(
1092                patient_data_dir.path().to_path_buf(),
1093                rm_system_version,
1094                crate::NonEmptyText::new("vpr.dev.1").unwrap(),
1095            )
1096            .expect("CoreConfig::new should succeed"),
1097        );
1098
1099        let _service = ClinicalService::new(cfg);
1100
1101        let _author = Author {
1102            name: NonEmptyText::new("Test Author").unwrap(),
1103            role: NonEmptyText::new("Clinician").unwrap(),
1104            email: EmailAddress::parse("test@example.com").unwrap(),
1105            registrations: vec![],
1106            signature: None,
1107            certificate: None,
1108        };
1109
1110        // NonEmptyText validation prevents whitespace-only strings at the type level
1111        let err = NonEmptyText::new(" \t\n")
1112            .expect_err("creating NonEmptyText from whitespace should fail");
1113        assert!(matches!(err, crate::TextError::Empty));
1114
1115        assert!(
1116            !patient_data_dir.path().join(CLINICAL_DIR_NAME).exists(),
1117            "initialise should not perform filesystem side-effects when validation fails"
1118        );
1119    }
1120
1121    #[test]
1122    fn test_initialise_creates_clinical_record() {
1123        // Create a temporary directory for testing
1124        let temp_dir = TempDir::new().expect("Failed to create temp dir");
1125
1126        // Create a test author
1127        let author = Author {
1128            name: NonEmptyText::new("Test Author").unwrap(),
1129            role: NonEmptyText::new("Clinician").unwrap(),
1130            email: EmailAddress::parse("test@example.com").unwrap(),
1131            registrations: vec![],
1132            signature: None,
1133            certificate: None,
1134        };
1135
1136        let cfg = test_cfg(temp_dir.path());
1137        let service = ClinicalService::new(cfg);
1138
1139        // Call initialise
1140        let result = service.initialise(author, NonEmptyText::new("Test Hospital").unwrap());
1141        assert!(result.is_ok(), "initialise should succeed");
1142
1143        let clinical_uuid = result.unwrap();
1144        let clinical_uuid_str = clinical_uuid.clinical_id().simple().to_string();
1145        assert_eq!(clinical_uuid_str.len(), 32, "UUID should be 32 characters");
1146
1147        // Verify directory structure exists
1148        let clinical_dir = temp_dir.path().join(CLINICAL_DIR_NAME);
1149        assert!(clinical_dir.exists(), "clinical directory should exist");
1150
1151        // Extract sharding directories from UUID
1152        let s1 = &clinical_uuid_str[0..2];
1153        let s2 = &clinical_uuid_str[2..4];
1154        let patient_dir = clinical_dir.join(s1).join(s2).join(&clinical_uuid_str);
1155        assert!(patient_dir.exists(), "patient directory should exist");
1156
1157        // Verify core files were created (no longer copying templates)
1158        let ehr_status_file = patient_dir.join("ehr_status.yaml");
1159        assert!(ehr_status_file.exists(), "ehr_status.yaml should exist");
1160
1161        let gitignore_file = patient_dir.join(".gitignore");
1162        assert!(gitignore_file.exists(), ".gitignore should exist");
1163
1164        // Verify Git repository exists and has initial commit
1165        let repo = git2::Repository::open(&patient_dir).expect("Failed to open Git repo");
1166        let head = repo.head().expect("Failed to get HEAD");
1167        let commit = head.peel_to_commit().expect("Failed to get commit");
1168        assert_eq!(
1169            commit.message().unwrap(),
1170            "record:create: Initialised the clinical record\n\nAuthor-Name: Test Author\nAuthor-Role: Clinician\nCare-Location: Test Hospital"
1171        );
1172    }
1173
1174    #[test]
1175    fn test_link_to_demographics_updates_ehr_status() {
1176        // Create a temporary directory for testing
1177        let temp_dir = TempDir::new().expect("Failed to create temp dir");
1178
1179        // Create a test author
1180        let author = Author {
1181            name: NonEmptyText::new("Test Author").unwrap(),
1182            role: NonEmptyText::new("Clinician").unwrap(),
1183            email: EmailAddress::parse("test@example.com").unwrap(),
1184            registrations: vec![],
1185            signature: None,
1186            certificate: None,
1187        };
1188
1189        // Initialise clinical service
1190        let cfg = test_cfg(temp_dir.path());
1191        let service = ClinicalService::new(cfg.clone());
1192
1193        // First, initialise a clinical record
1194        let care_location: NonEmptyText = NonEmptyText::new("Test Hospital").unwrap();
1195        let service = service
1196            .initialise(author.clone(), care_location.clone())
1197            .expect("initialise should succeed");
1198        let clinical_uuid = service.clinical_id();
1199        let clinical_uuid_str = clinical_uuid.simple().to_string();
1200
1201        // Now link to demographics
1202        let demographics_uuid = "12345678123412341234123456789abc";
1203        let result = service.link_to_demographics(&author, care_location, demographics_uuid, None);
1204        assert!(result.is_ok(), "link_to_demographics should succeed");
1205
1206        // Verify ehr_status.yaml was updated with linking information
1207        let clinical_dir = temp_dir.path().join(CLINICAL_DIR_NAME);
1208        let patient_dir = ShardableUuid::parse(&clinical_uuid_str)
1209            .expect("clinical_uuid should be canonical")
1210            .sharded_dir(&clinical_dir);
1211        let ehr_status_file = patient_dir.join("ehr_status.yaml");
1212
1213        assert!(ehr_status_file.exists(), "ehr_status.yaml should exist");
1214    }
1215
1216    #[test]
1217    fn test_link_to_demographics_rejects_invalid_clinical_uuid() {
1218        let temp_dir = TempDir::new().expect("Failed to create temp dir");
1219        let cfg = test_cfg(temp_dir.path());
1220        // This test is no longer applicable with the type-state pattern
1221        // because you cannot call link_to_demographics on an Uninitialised service
1222        // The type system prevents this at compile time
1223        let _service = ClinicalService::new(cfg);
1224
1225        // This would not compile:
1226        // service.link_to_demographics(&author, care_location, "...", None);
1227        // So we just verify the service was created successfully
1228    }
1229
1230    #[test]
1231    fn test_link_to_demographics_rejects_invalid_demographics_uuid() {
1232        let temp_dir = TempDir::new().expect("Failed to create temp dir");
1233        let cfg = test_cfg(temp_dir.path());
1234        let service = ClinicalService::new(cfg);
1235
1236        // Create a valid clinical record first
1237        let author = Author {
1238            name: NonEmptyText::new("Test Author").unwrap(),
1239            role: NonEmptyText::new("Clinician").unwrap(),
1240            email: EmailAddress::parse("test@example.com").unwrap(),
1241            registrations: vec![],
1242            signature: None,
1243            certificate: None,
1244        };
1245
1246        let service = service
1247            .initialise(author.clone(), NonEmptyText::new("Test Hospital").unwrap())
1248            .expect("initialise should succeed");
1249
1250        // Try to link with invalid demographics UUID
1251        let err = service
1252            .link_to_demographics(
1253                &author,
1254                NonEmptyText::new("Test Hospital").unwrap(),
1255                "invalid-demographics-uuid",
1256                None,
1257            )
1258            .expect_err("expected validation failure for invalid demographics UUID");
1259        assert!(matches!(err, PatientError::Uuid(_)));
1260    }
1261
1262    #[test]
1263    fn test_link_to_demographics_rejects_invalid_namespace() {
1264        let temp_dir = TempDir::new().expect("Failed to create temp dir");
1265        let cfg = test_cfg(temp_dir.path());
1266        let service = ClinicalService::new(cfg);
1267
1268        // Create a valid clinical record
1269        let author = Author {
1270            name: NonEmptyText::new("Test Author").unwrap(),
1271            role: NonEmptyText::new("Clinician").unwrap(),
1272            email: EmailAddress::parse("test@example.com").unwrap(),
1273            registrations: vec![],
1274            signature: None,
1275            certificate: None,
1276        };
1277
1278        let service = service
1279            .initialise(author.clone(), NonEmptyText::new("Test Hospital").unwrap())
1280            .expect("initialise should succeed");
1281
1282        // Try to link with unsafe namespace containing invalid characters
1283        let demographics_uuid = ShardableUuid::new();
1284        let demographics_uuid_str = demographics_uuid.to_string();
1285
1286        let err = service
1287            .link_to_demographics(
1288                &author,
1289                NonEmptyText::new("Test Hospital").unwrap(),
1290                &demographics_uuid_str,
1291                Some(NonEmptyText::new("unsafe<namespace>with/special\\chars").unwrap()),
1292            )
1293            .expect_err("expected validation failure for unsafe namespace");
1294        assert!(matches!(err, PatientError::Openehr(_)));
1295    }
1296
1297    #[test]
1298    fn test_link_to_demographics_fails_when_ehr_status_missing() {
1299        let temp_dir = TempDir::new().expect("Failed to create temp dir");
1300        let cfg = test_cfg(temp_dir.path());
1301
1302        // Manually create a clinical directory without ehr_status.yaml
1303        let clinical_dir = temp_dir.path().join(CLINICAL_DIR_NAME);
1304        let clinical_uuid = ShardableUuid::new();
1305        let patient_dir = clinical_uuid.sharded_dir(&clinical_dir);
1306        fs::create_dir_all(&patient_dir).expect("Failed to create patient dir");
1307
1308        // Initialize Git repo but don't create ehr_status.yaml
1309        VersionedFileService::init(&patient_dir).expect("Failed to init git");
1310
1311        let author = Author {
1312            name: NonEmptyText::new("Test Author").unwrap(),
1313            role: NonEmptyText::new("Clinician").unwrap(),
1314            email: EmailAddress::parse("test@example.com").unwrap(),
1315            registrations: vec![],
1316            signature: None,
1317            certificate: None,
1318        };
1319
1320        let demographics_uuid = ShardableUuid::new();
1321        let demographics_uuid_str = demographics_uuid.to_string();
1322
1323        // Should fail because ehr_status.yaml doesn't exist
1324        let service = ClinicalService::with_id(cfg, clinical_uuid.uuid());
1325        let err = service
1326            .link_to_demographics(
1327                &author,
1328                NonEmptyText::new("Test Hospital").unwrap(),
1329                &demographics_uuid_str,
1330                None,
1331            )
1332            .expect_err("link_to_demographics should fail when ehr_status.yaml is missing");
1333
1334        assert!(
1335            matches!(err, PatientError::InvalidInput(_)),
1336            "Should return InvalidInput error when ehr_status.yaml is missing"
1337        );
1338    }
1339
1340    #[test]
1341    fn test_link_to_demographics_rejects_corrupted_ehr_status() {
1342        let temp_dir = TempDir::new().expect("Failed to create temp dir");
1343        let cfg = test_cfg(temp_dir.path());
1344
1345        // Create a clinical directory with corrupted ehr_status.yaml
1346        let clinical_dir = temp_dir.path().join(CLINICAL_DIR_NAME);
1347        let clinical_uuid = ShardableUuid::new();
1348        let patient_dir = clinical_uuid.sharded_dir(&clinical_dir);
1349        fs::create_dir_all(&patient_dir).expect("Failed to create patient dir");
1350
1351        // Initialize Git repo
1352        VersionedFileService::init(&patient_dir).expect("Failed to init git");
1353
1354        // Write corrupted YAML (missing required rm_version field)
1355        let ehr_status_file = patient_dir.join("ehr_status.yaml");
1356        fs::write(&ehr_status_file, "archetype_node_id: some_id\nname: Test")
1357            .expect("Failed to write corrupted file");
1358
1359        let author = Author {
1360            name: NonEmptyText::new("Test Author").unwrap(),
1361            role: NonEmptyText::new("Clinician").unwrap(),
1362            email: EmailAddress::parse("test@example.com").unwrap(),
1363            registrations: vec![],
1364            signature: None,
1365            certificate: None,
1366        };
1367
1368        let demographics_uuid = ShardableUuid::new();
1369        let demographics_uuid_str = demographics_uuid.to_string();
1370
1371        let service = ClinicalService::with_id(cfg, clinical_uuid.uuid());
1372        let err = service
1373            .link_to_demographics(
1374                &author,
1375                NonEmptyText::new("Test Hospital").unwrap(),
1376                &demographics_uuid_str,
1377                None,
1378            )
1379            .expect_err("expected failure due to corrupted ehr_status");
1380
1381        // Should fail when trying to extract RM version from corrupted file
1382        assert!(matches!(
1383            err,
1384            PatientError::InvalidInput(_)
1385                | PatientError::YamlDeserialization(_)
1386                | PatientError::Openehr(_)
1387        ));
1388    }
1389
1390    #[test]
1391    fn test_verify_commit_signature() {
1392        // Create a temporary directory for testing
1393        let temp_dir = TempDir::new().expect("Failed to create temp dir");
1394
1395        // Generate a key pair for signing
1396        let signing_key = SigningKey::random(&mut rand::thread_rng());
1397        let verifying_key = signing_key.verifying_key();
1398
1399        // Encode private key to PEM
1400        let private_key_pem = signing_key
1401            .to_pkcs8_pem(p256::pkcs8::LineEnding::LF)
1402            .expect("Failed to encode private key");
1403
1404        // Encode public key to PEM
1405        let public_key_pem = verifying_key
1406            .to_public_key_pem(p256::pkcs8::LineEnding::LF)
1407            .expect("Failed to encode public key");
1408
1409        let cfg = test_cfg(temp_dir.path());
1410        let service = ClinicalService::new(cfg.clone());
1411        let clinical_dir = cfg
1412            .patient_data_dir()
1413            .join(crate::constants::CLINICAL_DIR_NAME);
1414        let author = Author {
1415            name: NonEmptyText::new("Test Author").unwrap(),
1416            role: NonEmptyText::new("Clinician").unwrap(),
1417            email: EmailAddress::parse("test@example.com").unwrap(),
1418            registrations: vec![],
1419            signature: Some(private_key_pem.to_string().into_bytes()),
1420            certificate: None,
1421        };
1422
1423        // Initialise clinical record
1424        let result = service.initialise(author, NonEmptyText::new("Test Hospital").unwrap());
1425        assert!(result.is_ok(), "initialise should succeed");
1426        let clinical_uuid = result.unwrap();
1427        let clinical_uuid_str = clinical_uuid.clinical_id().simple().to_string();
1428
1429        // Verify the signature
1430        let verify_result = VersionedFileService::verify_commit_signature(
1431            &clinical_dir,
1432            &clinical_uuid_str,
1433            &public_key_pem,
1434        );
1435        assert!(
1436            verify_result.is_ok(),
1437            "verify_commit_signature should succeed"
1438        );
1439        assert!(verify_result.unwrap(), "signature should be valid");
1440
1441        // Verify fails with a wrong public key
1442        let wrong_signing_key = SigningKey::random(&mut rand::thread_rng());
1443        let wrong_pub_pem = wrong_signing_key
1444            .verifying_key()
1445            .to_public_key_pem(p256::pkcs8::LineEnding::LF)
1446            .expect("Failed to encode wrong public key");
1447        let wrong_verify = VersionedFileService::verify_commit_signature(
1448            &clinical_dir,
1449            &clinical_uuid_str,
1450            &wrong_pub_pem,
1451        );
1452        assert!(wrong_verify.is_ok(), "verify with wrong key should succeed");
1453        assert!(
1454            !wrong_verify.unwrap(),
1455            "signature should be invalid with wrong key"
1456        );
1457    }
1458
1459    #[test]
1460    fn test_verify_commit_signature_offline_with_embedded_public_key() {
1461        let temp_dir = TempDir::new().expect("Failed to create temp dir");
1462
1463        let signing_key = SigningKey::random(&mut rand::thread_rng());
1464        let verifying_key = signing_key.verifying_key();
1465
1466        let private_key_pem = signing_key
1467            .to_pkcs8_pem(p256::pkcs8::LineEnding::LF)
1468            .expect("Failed to encode private key");
1469        let public_key_pem = verifying_key
1470            .to_public_key_pem(p256::pkcs8::LineEnding::LF)
1471            .expect("Failed to encode public key");
1472
1473        let cfg = test_cfg(temp_dir.path());
1474        let service = ClinicalService::new(cfg.clone());
1475        let clinical_dir = cfg
1476            .patient_data_dir()
1477            .join(crate::constants::CLINICAL_DIR_NAME);
1478        let author = Author {
1479            name: NonEmptyText::new("Test Author").unwrap(),
1480            role: NonEmptyText::new("Clinician").unwrap(),
1481            email: EmailAddress::parse("test@example.com").unwrap(),
1482            registrations: vec![],
1483            signature: Some(private_key_pem.to_string().into_bytes()),
1484            certificate: None,
1485        };
1486
1487        let clinical_uuid = service
1488            .initialise(author, NonEmptyText::new("Test Hospital").unwrap())
1489            .expect("initialise should succeed");
1490        let clinical_uuid_str = clinical_uuid.clinical_id().simple().to_string();
1491
1492        // Offline verification: no external key material is provided.
1493        let ok =
1494            VersionedFileService::verify_commit_signature(&clinical_dir, &clinical_uuid_str, "")
1495                .expect("verify_commit_signature should succeed");
1496        assert!(ok, "embedded public key verification should succeed");
1497
1498        // Compatibility: verification still works with an explicit public key.
1499        let ok = VersionedFileService::verify_commit_signature(
1500            &clinical_dir,
1501            &clinical_uuid_str,
1502            &public_key_pem,
1503        )
1504        .expect("verify_commit_signature should succeed");
1505        assert!(ok, "verification with explicit public key should succeed");
1506    }
1507
1508    #[test]
1509    fn test_initialise_rejects_mismatched_author_certificate() {
1510        let temp_dir = TempDir::new().expect("Failed to create temp dir");
1511
1512        // Signing key used for commit.
1513        let signing_key = SigningKey::random(&mut rand::thread_rng());
1514        let private_key_pem = signing_key
1515            .to_pkcs8_pem(p256::pkcs8::LineEnding::LF)
1516            .expect("Failed to encode private key");
1517
1518        // Different key used to create a certificate (mismatch).
1519        let other_key = SigningKey::random(&mut rand::thread_rng());
1520        let other_private_key_pem = other_key
1521            .to_pkcs8_pem(p256::pkcs8::LineEnding::LF)
1522            .expect("Failed to encode other private key");
1523        let other_private_key_pem_str = other_private_key_pem.to_string();
1524
1525        let other_keypair = KeyPair::from_pem(&other_private_key_pem_str)
1526            .expect("KeyPair::from_pem should succeed");
1527        let params = CertificateParams::default();
1528        let cert = params
1529            .self_signed(&other_keypair)
1530            .expect("self_signed should succeed");
1531        let cert_pem = cert.pem();
1532
1533        let cfg = test_cfg(temp_dir.path());
1534        let service = ClinicalService::new(cfg);
1535        let author = Author {
1536            name: NonEmptyText::new("Test Author").unwrap(),
1537            role: NonEmptyText::new("Clinician").unwrap(),
1538            email: EmailAddress::parse("test@example.com").unwrap(),
1539            registrations: vec![],
1540            signature: Some(private_key_pem.to_string().into_bytes()),
1541            certificate: Some(cert_pem.into_bytes()),
1542        };
1543
1544        let err = service
1545            .initialise(author, NonEmptyText::new("Test Hospital").unwrap())
1546            .expect_err("initialise should fail due to certificate mismatch");
1547        assert!(matches!(
1548            err,
1549            PatientError::AuthorCertificatePublicKeyMismatch
1550        ));
1551    }
1552
1553    #[test]
1554    fn test_extract_embedded_commit_signature_from_head_commit() {
1555        let temp_dir = TempDir::new().expect("Failed to create temp dir");
1556
1557        let signing_key = SigningKey::random(&mut rand::thread_rng());
1558        let private_key_pem = signing_key
1559            .to_pkcs8_pem(p256::pkcs8::LineEnding::LF)
1560            .expect("Failed to encode private key");
1561
1562        let cfg = test_cfg(temp_dir.path());
1563        let service = ClinicalService::new(cfg);
1564        let author = Author {
1565            name: NonEmptyText::new("Test Author").unwrap(),
1566            role: NonEmptyText::new("Clinician").unwrap(),
1567            email: EmailAddress::parse("test@example.com").unwrap(),
1568            registrations: vec![],
1569            signature: Some(private_key_pem.to_string().into_bytes()),
1570            certificate: None,
1571        };
1572
1573        let clinical_uuid = service
1574            .initialise(author, NonEmptyText::new("Test Hospital").unwrap())
1575            .expect("initialise should succeed");
1576        let clinical_uuid_str = clinical_uuid.clinical_id().simple().to_string();
1577
1578        let clinical_dir = temp_dir.path().join(CLINICAL_DIR_NAME);
1579        let patient_dir = ShardableUuid::parse(&clinical_uuid_str)
1580            .expect("clinical_uuid should be canonical")
1581            .sharded_dir(&clinical_dir);
1582
1583        let repo = git2::Repository::open(&patient_dir).expect("Failed to open Git repo");
1584        let head = repo.head().expect("Failed to get HEAD");
1585        let commit = head.peel_to_commit().expect("Failed to get commit");
1586
1587        let embedded = crate::author::extract_embedded_commit_signature(&commit)
1588            .expect("extract_embedded_commit_signature should succeed");
1589        assert_eq!(embedded.signature.len(), 64);
1590        assert!(!embedded.public_key.is_empty());
1591        assert!(embedded.certificate.is_none());
1592    }
1593
1594    #[test]
1595    fn test_initialise_without_signature_creates_commit_without_embedded_signature() {
1596        let temp_dir = TempDir::new().expect("Failed to create temp dir");
1597
1598        let cfg = test_cfg(temp_dir.path());
1599        let service = ClinicalService::new(cfg.clone());
1600        let clinical_dir = cfg
1601            .patient_data_dir()
1602            .join(crate::constants::CLINICAL_DIR_NAME);
1603
1604        let author = Author {
1605            name: NonEmptyText::new("Test Author").unwrap(),
1606            role: NonEmptyText::new("Clinician").unwrap(),
1607            email: EmailAddress::parse("test@example.com").unwrap(),
1608            registrations: vec![],
1609            signature: None,
1610            certificate: None,
1611        };
1612
1613        let clinical_uuid = service
1614            .initialise(author, NonEmptyText::new("Test Hospital").unwrap())
1615            .expect("initialise should succeed");
1616        let clinical_uuid_str = clinical_uuid.clinical_id().simple().to_string();
1617
1618        let patient_dir = ShardableUuid::parse(&clinical_uuid_str)
1619            .expect("clinical_uuid should be canonical")
1620            .sharded_dir(&clinical_dir);
1621
1622        let repo = git2::Repository::open(&patient_dir).expect("Failed to open Git repo");
1623        let head = repo.head().expect("Failed to get HEAD");
1624        let commit = head.peel_to_commit().expect("Failed to get commit");
1625
1626        assert!(
1627            crate::author::extract_embedded_commit_signature(&commit).is_err(),
1628            "unsigned commits should not contain an embedded signature payload"
1629        );
1630
1631        let ok =
1632            VersionedFileService::verify_commit_signature(&clinical_dir, &clinical_uuid_str, "")
1633                .expect("verify_commit_signature should succeed");
1634        assert!(
1635            !ok,
1636            "verification should be false when no signature is embedded"
1637        );
1638    }
1639
1640    #[test]
1641    fn test_new_letter() {
1642        let patient_data_dir = TempDir::new().expect("Failed to create temp dir");
1643        let cfg = test_cfg(patient_data_dir.path());
1644
1645        let author = Author {
1646            name: NonEmptyText::new("Test Author").unwrap(),
1647            role: NonEmptyText::new("Clinician").unwrap(),
1648            email: EmailAddress::parse("test@example.com").unwrap(),
1649            registrations: vec![],
1650            signature: None,
1651            certificate: None,
1652        };
1653
1654        let service = ClinicalService::new(cfg.clone());
1655        let service = service
1656            .initialise(author.clone(), NonEmptyText::new("Test Hospital").unwrap())
1657            .expect("initialise should succeed");
1658
1659        let result = service.new_letter(
1660            &author,
1661            NonEmptyText::new("Test Hospital").unwrap(),
1662            NonEmptyText::new("Letter content").unwrap(),
1663            None,
1664        );
1665
1666        assert!(result.is_ok(), "new_letter should return Ok");
1667        let timestamp_id = result.unwrap();
1668        println!("Timestamp ID: {}", timestamp_id);
1669
1670        let timestamp_str = timestamp_id.to_string();
1671        assert!(!timestamp_str.is_empty());
1672        assert!(
1673            timestamp_str.contains("Z-"),
1674            "timestamp_id should contain 'Z-' separator"
1675        );
1676    }
1677
1678    #[test]
1679    fn test_new_letter_with_attachments() {
1680        let temp_dir = TempDir::new().expect("Failed to create temp dir");
1681        let cfg = test_cfg(temp_dir.path());
1682
1683        let service = ClinicalService::new(cfg.clone());
1684        let author = Author {
1685            name: NonEmptyText::new("Dr. Test").unwrap(),
1686            role: NonEmptyText::new("Consultant").unwrap(),
1687            email: EmailAddress::parse("test@example.com").unwrap(),
1688            registrations: vec![],
1689            signature: None,
1690            certificate: None,
1691        };
1692
1693        // Initialize clinical record
1694        let service = service
1695            .initialise(author.clone(), NonEmptyText::new("Test Hospital").unwrap())
1696            .expect("initialise should succeed");
1697
1698        // Create a test PDF file
1699        let test_file = temp_dir.path().join("test_document.pdf");
1700        fs::write(&test_file, b"fake PDF content").expect("Failed to create test file");
1701
1702        let attachment_files = vec![test_file.clone()];
1703
1704        // Create letter with attachment
1705        let result = service.new_letter_with_attachments(
1706            &author,
1707            NonEmptyText::new("Test Hospital").unwrap(),
1708            &attachment_files,
1709            None,
1710        );
1711
1712        assert!(result.is_ok(), "new_letter_with_attachments should succeed");
1713        let timestamp_id = result.unwrap();
1714
1715        // Verify the files were created
1716        let clinical_uuid = ShardableUuid::parse(&service.clinical_id().simple().to_string())
1717            .expect("should parse clinical UUID");
1718        let patient_dir = service.clinical_patient_dir(&clinical_uuid);
1719        let letter_paths = LetterPaths::new(&timestamp_id);
1720
1721        // Check composition.yaml exists
1722        let composition_path = patient_dir.join(letter_paths.composition_yaml());
1723        assert!(composition_path.exists(), "composition.yaml should exist");
1724
1725        // Check attachment metadata file exists
1726        let attachment_path = patient_dir.join(letter_paths.attachment("attachment_1.yaml"));
1727        assert!(attachment_path.exists(), "attachment_1.yaml should exist");
1728
1729        // Read and verify attachment metadata
1730        let attachment_content =
1731            fs::read_to_string(&attachment_path).expect("should read attachment metadata");
1732        let attachment_metadata: AttachmentMetadata =
1733            serde_yaml::from_str(&attachment_content).expect("should parse attachment metadata");
1734
1735        assert_eq!(
1736            attachment_metadata.metadata_filename.as_str(),
1737            "attachment_1.yaml"
1738        );
1739        assert_eq!(
1740            attachment_metadata.original_filename.as_str(),
1741            "test_document.pdf"
1742        );
1743        assert_eq!(attachment_metadata.size_bytes, 16); // "fake PDF content".len()
1744        assert!(!attachment_metadata.hash.as_str().is_empty());
1745    }
1746
1747    #[test]
1748    fn test_get_letter_attachments() {
1749        let temp_dir = TempDir::new().expect("Failed to create temp dir");
1750        let cfg = test_cfg(temp_dir.path());
1751
1752        let service = ClinicalService::new(cfg.clone());
1753        let author = Author {
1754            name: NonEmptyText::new("Dr. Test").unwrap(),
1755            role: NonEmptyText::new("Consultant").unwrap(),
1756            email: EmailAddress::parse("test@example.com").unwrap(),
1757            registrations: vec![],
1758            signature: None,
1759            certificate: None,
1760        };
1761
1762        // Initialize clinical record
1763        let service = service
1764            .initialise(author.clone(), NonEmptyText::new("Test Hospital").unwrap())
1765            .expect("initialise should succeed");
1766
1767        // Create test files
1768        let test_file1 = temp_dir.path().join("document1.pdf");
1769        let test_file2 = temp_dir.path().join("image.png");
1770        let content1 = b"PDF content here";
1771        let content2 = b"PNG content here";
1772        fs::write(&test_file1, content1).expect("Failed to create test file 1");
1773        fs::write(&test_file2, content2).expect("Failed to create test file 2");
1774
1775        let attachment_files = vec![test_file1, test_file2];
1776
1777        // Create letter with attachments
1778        let timestamp_id = service
1779            .new_letter_with_attachments(
1780                &author,
1781                NonEmptyText::new("Test Hospital").unwrap(),
1782                &attachment_files,
1783                None,
1784            )
1785            .expect("new_letter_with_attachments should succeed");
1786
1787        // Retrieve attachments
1788        let attachments = service
1789            .get_letter_attachments(&timestamp_id.to_string())
1790            .expect("get_letter_attachments should succeed");
1791
1792        // Verify we got 2 attachments
1793        assert_eq!(attachments.len(), 2, "should have 2 attachments");
1794
1795        // Verify first attachment
1796        assert_eq!(
1797            attachments[0].metadata.original_filename.as_str(),
1798            "document1.pdf"
1799        );
1800        assert_eq!(attachments[0].metadata.size_bytes, content1.len() as u64);
1801        assert_eq!(attachments[0].content, content1);
1802
1803        // Verify second attachment
1804        assert_eq!(
1805            attachments[1].metadata.original_filename.as_str(),
1806            "image.png"
1807        );
1808        assert_eq!(attachments[1].metadata.size_bytes, content2.len() as u64);
1809        assert_eq!(attachments[1].content, content2);
1810    }
1811
1812    #[test]
1813    fn test_get_letter_attachments_empty() {
1814        let temp_dir = TempDir::new().expect("Failed to create temp dir");
1815        let cfg = test_cfg(temp_dir.path());
1816
1817        let service = ClinicalService::new(cfg.clone());
1818        let author = Author {
1819            name: NonEmptyText::new("Dr. Test").unwrap(),
1820            role: NonEmptyText::new("Consultant").unwrap(),
1821            email: EmailAddress::parse("test@example.com").unwrap(),
1822            registrations: vec![],
1823            signature: None,
1824            certificate: None,
1825        };
1826
1827        // Initialize clinical record
1828        let service = service
1829            .initialise(author.clone(), NonEmptyText::new("Test Hospital").unwrap())
1830            .expect("initialise should succeed");
1831
1832        // Create letter without attachments
1833        let timestamp_id = service
1834            .new_letter(
1835                &author,
1836                NonEmptyText::new("Test Hospital").unwrap(),
1837                NonEmptyText::new("Test content").unwrap(),
1838                None,
1839            )
1840            .expect("new_letter should succeed");
1841
1842        // Try to retrieve attachments
1843        let attachments = service
1844            .get_letter_attachments(&timestamp_id.to_string())
1845            .expect("get_letter_attachments should succeed");
1846
1847        // Should return empty vector
1848        assert_eq!(attachments.len(), 0, "should have no attachments");
1849    }
1850
1851    #[test]
1852    fn test_read_letter() {
1853        let temp_dir = TempDir::new().expect("Failed to create temp dir");
1854        let cfg = test_cfg(temp_dir.path());
1855
1856        let service = ClinicalService::new(cfg.clone());
1857        let author = Author {
1858            name: NonEmptyText::new("Dr. Test").unwrap(),
1859            role: NonEmptyText::new("Consultant").unwrap(),
1860            email: EmailAddress::parse("test@example.com").unwrap(),
1861            registrations: vec![],
1862            signature: None,
1863            certificate: None,
1864        };
1865
1866        // Initialize clinical record
1867        let service = service
1868            .initialise(author.clone(), NonEmptyText::new("Test Hospital").unwrap())
1869            .expect("initialise should succeed");
1870
1871        let letter_content = "# Test Letter\n\nThis is a test letter.";
1872
1873        // Create letter
1874        let timestamp_id = service
1875            .new_letter(
1876                &author,
1877                NonEmptyText::new("Test Hospital").unwrap(),
1878                NonEmptyText::new(letter_content).unwrap(),
1879                None,
1880            )
1881            .expect("new_letter should succeed");
1882
1883        // Read letter back
1884        let result = service
1885            .read_letter(&timestamp_id.to_string())
1886            .expect("read_letter should succeed");
1887
1888        // Verify content
1889        assert_eq!(result.body_content.as_str(), letter_content);
1890        assert_eq!(result.letter_data.composer_name, "Dr. Test");
1891        assert_eq!(result.letter_data.composer_role, "Clinical Practitioner");
1892    }
1893
1894    #[test]
1895    fn test_read_letter_invalid_timestamp() {
1896        let temp_dir = TempDir::new().expect("Failed to create temp dir");
1897        let cfg = test_cfg(temp_dir.path());
1898
1899        let service = ClinicalService::new(cfg.clone());
1900        let author = Author {
1901            name: NonEmptyText::new("Dr. Test").unwrap(),
1902            role: NonEmptyText::new("Consultant").unwrap(),
1903            email: EmailAddress::parse("test@example.com").unwrap(),
1904            registrations: vec![],
1905            signature: None,
1906            certificate: None,
1907        };
1908
1909        let service = service
1910            .initialise(author.clone(), NonEmptyText::new("Test Hospital").unwrap())
1911            .expect("initialise should succeed");
1912
1913        // Try to read with invalid timestamp
1914        let result = service.read_letter("invalid-timestamp");
1915
1916        assert!(result.is_err());
1917        assert!(matches!(result, Err(PatientError::InvalidInput(_))));
1918    }
1919
1920    #[test]
1921    fn test_read_letter_nonexistent() {
1922        let temp_dir = TempDir::new().expect("Failed to create temp dir");
1923        let cfg = test_cfg(temp_dir.path());
1924
1925        let service = ClinicalService::new(cfg.clone());
1926        let author = Author {
1927            name: NonEmptyText::new("Dr. Test").unwrap(),
1928            role: NonEmptyText::new("Consultant").unwrap(),
1929            email: EmailAddress::parse("test@example.com").unwrap(),
1930            registrations: vec![],
1931            signature: None,
1932            certificate: None,
1933        };
1934
1935        let service = service
1936            .initialise(author.clone(), NonEmptyText::new("Test Hospital").unwrap())
1937            .expect("initialise should succeed");
1938
1939        // Try to read non-existent letter with valid timestamp format
1940        let fake_timestamp = "20260125T120000.000Z-550e8400-e29b-41d4-a716-446655440000";
1941        let result = service.read_letter(fake_timestamp);
1942
1943        assert!(result.is_err());
1944        assert!(matches!(result, Err(PatientError::InvalidInput(_))));
1945    }
1946
1947    #[test]
1948    fn test_new_letter_with_attachments_nonexistent_file() {
1949        let temp_dir = TempDir::new().expect("Failed to create temp dir");
1950        let cfg = test_cfg(temp_dir.path());
1951
1952        let service = ClinicalService::new(cfg.clone());
1953        let author = Author {
1954            name: NonEmptyText::new("Dr. Test").unwrap(),
1955            role: NonEmptyText::new("Consultant").unwrap(),
1956            email: EmailAddress::parse("test@example.com").unwrap(),
1957            registrations: vec![],
1958            signature: None,
1959            certificate: None,
1960        };
1961
1962        let service = service
1963            .initialise(author.clone(), NonEmptyText::new("Test Hospital").unwrap())
1964            .expect("initialise should succeed");
1965
1966        // Try to attach non-existent file
1967        let nonexistent_file = temp_dir.path().join("does_not_exist.pdf");
1968        let attachment_files = vec![nonexistent_file];
1969
1970        let result = service.new_letter_with_attachments(
1971            &author,
1972            NonEmptyText::new("Test Hospital").unwrap(),
1973            &attachment_files,
1974            None,
1975        );
1976
1977        assert!(result.is_err());
1978        assert!(matches!(result, Err(PatientError::InvalidInput(_))));
1979    }
1980
1981    #[test]
1982    fn test_new_letter_with_attachments_multiple_files() {
1983        let temp_dir = TempDir::new().expect("Failed to create temp dir");
1984        let cfg = test_cfg(temp_dir.path());
1985
1986        let service = ClinicalService::new(cfg.clone());
1987        let author = Author {
1988            name: NonEmptyText::new("Dr. Test").unwrap(),
1989            role: NonEmptyText::new("Consultant").unwrap(),
1990            email: EmailAddress::parse("test@example.com").unwrap(),
1991            registrations: vec![],
1992            signature: None,
1993            certificate: None,
1994        };
1995
1996        let service = service
1997            .initialise(author.clone(), NonEmptyText::new("Test Hospital").unwrap())
1998            .expect("initialise should succeed");
1999
2000        // Create multiple test files
2001        let test_files = vec![
2002            (temp_dir.path().join("doc1.pdf"), b"content1" as &[u8]),
2003            (temp_dir.path().join("doc2.txt"), b"content2"),
2004            (temp_dir.path().join("image.png"), b"content3"),
2005        ];
2006
2007        for (path, content) in &test_files {
2008            fs::write(path, content).expect("Failed to create test file");
2009        }
2010
2011        let attachment_files: Vec<PathBuf> =
2012            test_files.iter().map(|(path, _)| path.clone()).collect();
2013
2014        let timestamp_id = service
2015            .new_letter_with_attachments(
2016                &author,
2017                NonEmptyText::new("Test Hospital").unwrap(),
2018                &attachment_files,
2019                None,
2020            )
2021            .expect("new_letter_with_attachments should succeed");
2022
2023        // Verify all attachments were created
2024        let clinical_uuid = ShardableUuid::parse(&service.clinical_id().simple().to_string())
2025            .expect("should parse clinical UUID");
2026        let patient_dir = service.clinical_patient_dir(&clinical_uuid);
2027        let letter_paths = LetterPaths::new(&timestamp_id);
2028
2029        for i in 1..=3 {
2030            let attachment_path =
2031                patient_dir.join(letter_paths.attachment(&format!("attachment_{}.yaml", i)));
2032            assert!(
2033                attachment_path.exists(),
2034                "attachment_{}.yaml should exist",
2035                i
2036            );
2037        }
2038    }
2039
2040    #[test]
2041    fn test_get_letter_attachments_invalid_timestamp() {
2042        let temp_dir = TempDir::new().expect("Failed to create temp dir");
2043        let cfg = test_cfg(temp_dir.path());
2044
2045        let service = ClinicalService::new(cfg.clone());
2046        let author = Author {
2047            name: NonEmptyText::new("Dr. Test").unwrap(),
2048            role: NonEmptyText::new("Consultant").unwrap(),
2049            email: EmailAddress::parse("test@example.com").unwrap(),
2050            registrations: vec![],
2051            signature: None,
2052            certificate: None,
2053        };
2054
2055        let service = service
2056            .initialise(author.clone(), NonEmptyText::new("Test Hospital").unwrap())
2057            .expect("initialise should succeed");
2058
2059        let result = service.get_letter_attachments("invalid-timestamp");
2060
2061        assert!(result.is_err());
2062        assert!(matches!(result, Err(PatientError::InvalidInput(_))));
2063    }
2064
2065    #[test]
2066    fn test_get_letter_attachments_with_clinical_lists() {
2067        let temp_dir = TempDir::new().expect("Failed to create temp dir");
2068        let cfg = test_cfg(temp_dir.path());
2069
2070        let service = ClinicalService::new(cfg.clone());
2071        let author = Author {
2072            name: NonEmptyText::new("Dr. Test").unwrap(),
2073            role: NonEmptyText::new("Consultant").unwrap(),
2074            email: EmailAddress::parse("test@example.com").unwrap(),
2075            registrations: vec![],
2076            signature: None,
2077            certificate: None,
2078        };
2079
2080        let service = service
2081            .initialise(author.clone(), NonEmptyText::new("Test Hospital").unwrap())
2082            .expect("initialise should succeed");
2083
2084        // Create test file
2085        let test_file = temp_dir.path().join("report.pdf");
2086        fs::write(&test_file, b"test report content").expect("Failed to create test file");
2087
2088        // Create clinical lists
2089        let clinical_lists = vec![openehr::ClinicalList {
2090            name: "Diagnoses".to_string(),
2091            kind: "diagnoses".to_string(),
2092            items: vec![openehr::ClinicalListItem {
2093                text: "Hypertension".to_string(),
2094                code: None,
2095            }],
2096        }];
2097
2098        let timestamp_id = service
2099            .new_letter_with_attachments(
2100                &author,
2101                NonEmptyText::new("Test Hospital").unwrap(),
2102                &[test_file],
2103                Some(&clinical_lists),
2104            )
2105            .expect("new_letter_with_attachments should succeed");
2106
2107        // Verify attachment is retrievable
2108        let attachments = service
2109            .get_letter_attachments(&timestamp_id.to_string())
2110            .expect("get_letter_attachments should succeed");
2111
2112        assert_eq!(attachments.len(), 1);
2113        assert_eq!(
2114            attachments[0].metadata.original_filename.as_str(),
2115            "report.pdf"
2116        );
2117    }
2118
2119    #[test]
2120    fn test_attachment_metadata_serialization() {
2121        let metadata = AttachmentMetadata {
2122            metadata_filename: NonEmptyText::new("attachment_1.yaml").unwrap(),
2123            hash: Sha256Hash::parse(
2124                "abc123def456789012345678901234567890123456789012345678901234abcd",
2125            )
2126            .unwrap(),
2127            file_storage_path: NonEmptyText::new("files/sha256/ab/c1/abc123").unwrap(),
2128            size_bytes: 1024,
2129            media_type: Some(NonEmptyText::new("application/pdf").unwrap()),
2130            original_filename: NonEmptyText::new("test.pdf").unwrap(),
2131        };
2132
2133        // Serialize to YAML
2134        let yaml = serde_yaml::to_string(&metadata).expect("should serialize");
2135        assert!(yaml.contains("filename:"));
2136        assert!(yaml.contains("test.pdf"));
2137
2138        // Deserialize back
2139        let deserialized: AttachmentMetadata =
2140            serde_yaml::from_str(&yaml).expect("should deserialize");
2141        assert_eq!(deserialized.metadata_filename, metadata.metadata_filename);
2142        assert_eq!(deserialized.original_filename, metadata.original_filename);
2143        assert_eq!(deserialized.hash, metadata.hash);
2144        assert_eq!(deserialized.size_bytes, metadata.size_bytes);
2145    }
2146}