vpr_core/paths/clinical/
letter.rs

1//! Clinical letter on-disk paths.
2//!
3//! This module defines the relative filesystem structure for clinical letters
4//! stored within a VPR patient repository.
5//!
6//! It contains **no I/O**, **no Git logic**, and **no clinical semantics**.
7//! Its sole responsibility is to provide typed, canonical paths so that
8//! path invariants are defined in exactly one place.
9//!
10//! # Path Structure
11//!
12//! Each letter is stored under:
13//! ```text
14//! correspondence/
15//!     letter/
16//!         <timestamp-id>/
17//!             composition.yaml
18//!             body.md
19//!             attachments/
20//! ```
21//!
22//! Where `<timestamp-id>` is a [`TimestampId`] in the format:
23//! `YYYYMMDDTHHMMSS.mmmZ-<uuid>`
24//!
25//! # Usage
26//!
27//! Use [`LetterPaths::new`] to create relative paths, then resolve them
28//! against a patient repository root before performing filesystem operations.
29
30use std::path::{Path, PathBuf};
31
32use crate::TimestampId;
33
34use super::common::CorrespondenceDir;
35
36/// Letter-based correspondence subdirectory.
37///
38/// This is intentionally a type (not a string literal) so that:
39/// - the name is defined once,
40/// - it cannot be misspelled,
41/// - it carries meaning without behaviour.
42#[derive(Debug, Clone, Copy)]
43pub struct LetterDir;
44
45impl LetterDir {
46    pub const NAME: &'static str = "letter";
47}
48
49/// OpenEHR composition metadata file.
50///
51/// Contains the OpenEHR COMPOSITION envelope for the letter,
52/// including identity, authorship, time context, and structured snapshots.
53#[derive(Debug, Clone, Copy)]
54pub struct CompositionYaml;
55
56impl CompositionYaml {
57    pub const NAME: &'static str = "composition.yaml";
58}
59
60/// Canonical clinical letter content file.
61///
62/// Contains the human-readable Markdown letter content.
63#[derive(Debug, Clone, Copy)]
64pub struct BodyMd;
65
66impl BodyMd {
67    pub const NAME: &'static str = "body.md";
68}
69
70/// Letter attachments subdirectory.
71///
72/// Large binary artefacts (PDFs, images, scans) are stored here
73/// using Git LFS when appropriate.
74#[derive(Debug, Clone, Copy)]
75pub struct AttachmentsDir;
76
77impl AttachmentsDir {
78    pub const NAME: &'static str = "attachments";
79}
80
81/// Relative on-disk paths for a single clinical letter.
82///
83/// This represents **where a letter lives**, not what a letter *is*.
84///
85/// The paths are relative to the patient repository root and must be
86/// resolved by repository-level code before filesystem access.
87///
88/// The directory name is derived from a [`TimestampId`], which provides:
89/// - global uniqueness,
90/// - per-patient chronological ordering,
91/// - human-readable audit semantics.
92#[derive(Debug, Clone)]
93pub struct LetterPaths {
94    relative_root: PathBuf,
95}
96
97impl LetterPaths {
98    /// Creates a new relative path set for a letter with the given timestamp ID.
99    ///
100    /// The resulting paths are **relative** and must be joined to a patient
101    /// repository root before filesystem access.
102    ///
103    /// # Arguments
104    ///
105    /// * `letter_id` - The timestamp identifier for this letter
106    pub fn new(letter_id: &TimestampId) -> Self {
107        Self {
108            relative_root: PathBuf::from(CorrespondenceDir::NAME)
109                .join(LetterDir::NAME)
110                .join(letter_id.to_string()),
111        }
112    }
113
114    /// Returns the relative path to the letter directory.
115    pub fn dir(&self) -> &Path {
116        &self.relative_root
117    }
118
119    /// Returns the relative path to `composition.yaml`.
120    pub fn composition_yaml(&self) -> PathBuf {
121        self.relative_root.join(CompositionYaml::NAME)
122    }
123
124    /// Returns the relative path to `body.md`.
125    pub fn body_md(&self) -> PathBuf {
126        self.relative_root.join(BodyMd::NAME)
127    }
128
129    /// Returns the relative path to the attachments directory.
130    pub fn attachments_dir(&self) -> PathBuf {
131        self.relative_root.join(AttachmentsDir::NAME)
132    }
133
134    /// Returns the relative path to a specific attachment file.
135    ///
136    /// This does not validate filenames and performs no I/O.
137    ///
138    /// # Arguments
139    ///
140    /// * `filename` - The name of the attachment file
141    pub fn attachment(&self, filename: &str) -> PathBuf {
142        self.attachments_dir().join(filename)
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use std::str::FromStr;
150
151    #[test]
152    fn test_directory_constants() {
153        assert_eq!(CorrespondenceDir::NAME, "correspondence");
154        assert_eq!(LetterDir::NAME, "letter");
155        assert_eq!(CompositionYaml::NAME, "composition.yaml");
156        assert_eq!(BodyMd::NAME, "body.md");
157        assert_eq!(AttachmentsDir::NAME, "attachments");
158    }
159
160    #[test]
161    fn test_letter_paths_relative_paths() {
162        let timestamp_id =
163            TimestampId::from_str("20260114T143522.045Z-550e8400-e29b-41d4-a716-446655440000")
164                .expect("valid timestamp id");
165
166        let paths = LetterPaths::new(&timestamp_id);
167
168        assert_eq!(
169            paths.dir(),
170            Path::new(
171                "correspondence/letter/20260114T143522.045Z-550e8400-e29b-41d4-a716-446655440000"
172            )
173        );
174
175        assert_eq!(
176            paths.composition_yaml(),
177            PathBuf::from("correspondence/letter/20260114T143522.045Z-550e8400-e29b-41d4-a716-446655440000/composition.yaml")
178        );
179
180        assert_eq!(
181            paths.body_md(),
182            PathBuf::from("correspondence/letter/20260114T143522.045Z-550e8400-e29b-41d4-a716-446655440000/body.md")
183        );
184
185        assert_eq!(
186            paths.attachments_dir(),
187            PathBuf::from("correspondence/letter/20260114T143522.045Z-550e8400-e29b-41d4-a716-446655440000/attachments")
188        );
189    }
190
191    #[test]
192    fn test_letter_paths_attachment_path() {
193        let timestamp_id =
194            TimestampId::from_str("20260114T143522.045Z-550e8400-e29b-41d4-a716-446655440000")
195                .expect("valid timestamp id");
196
197        let paths = LetterPaths::new(&timestamp_id);
198
199        assert_eq!(
200            paths.attachment("letter.pdf"),
201            PathBuf::from("correspondence/letter/20260114T143522.045Z-550e8400-e29b-41d4-a716-446655440000/attachments/letter.pdf")
202        );
203
204        assert_eq!(
205            paths.attachment("scan.png"),
206            PathBuf::from("correspondence/letter/20260114T143522.045Z-550e8400-e29b-41d4-a716-446655440000/attachments/scan.png")
207        );
208    }
209
210    #[test]
211    fn test_different_timestamp_ids_produce_different_paths() {
212        let timestamp_id1 =
213            TimestampId::from_str("20260114T143522.045Z-550e8400-e29b-41d4-a716-446655440000")
214                .expect("valid timestamp id");
215        let timestamp_id2 =
216            TimestampId::from_str("20260115T093015.123Z-661f9511-f3ac-52e5-b827-557766551111")
217                .expect("valid timestamp id");
218
219        let paths1 = LetterPaths::new(&timestamp_id1);
220        let paths2 = LetterPaths::new(&timestamp_id2);
221
222        assert_ne!(paths1.dir(), paths2.dir());
223        assert_ne!(paths1.composition_yaml(), paths2.composition_yaml());
224        assert_ne!(paths1.body_md(), paths2.body_md());
225    }
226
227    #[test]
228    fn test_paths_is_relative_not_absolute() {
229        let timestamp_id =
230            TimestampId::from_str("20260114T143522.045Z-550e8400-e29b-41d4-a716-446655440000")
231                .expect("valid timestamp id");
232
233        let paths = LetterPaths::new(&timestamp_id);
234
235        // Relative paths should not start with '/'
236        assert!(!paths.dir().to_str().unwrap().starts_with('/'));
237        assert!(!paths.composition_yaml().to_str().unwrap().starts_with('/'));
238        assert!(!paths.body_md().to_str().unwrap().starts_with('/'));
239        assert!(!paths.attachments_dir().to_str().unwrap().starts_with('/'));
240    }
241
242    #[test]
243    fn test_caller_can_join_with_patient_root() {
244        let timestamp_id =
245            TimestampId::from_str("20260114T143522.045Z-550e8400-e29b-41d4-a716-446655440000")
246                .expect("valid timestamp id");
247
248        let paths = LetterPaths::new(&timestamp_id);
249        let patient_root =
250            Path::new("/patient_data/clinical/55/0e/550e8400e29b41d4a716446655440000");
251
252        // Callers join paths themselves
253        assert_eq!(
254            patient_root.join(paths.dir()),
255            PathBuf::from("/patient_data/clinical/55/0e/550e8400e29b41d4a716446655440000/correspondence/letter/20260114T143522.045Z-550e8400-e29b-41d4-a716-446655440000")
256        );
257
258        assert_eq!(
259            patient_root.join(paths.composition_yaml()),
260            PathBuf::from("/patient_data/clinical/55/0e/550e8400e29b41d4a716446655440000/correspondence/letter/20260114T143522.045Z-550e8400-e29b-41d4-a716-446655440000/composition.yaml")
261        );
262    }
263}