Skip to main content

hl7_2_from_xsd_into_json_dictionary/
lib.rs

1//! Convert HL7 v2.xml XML Schema files into the JSON dictionary the
2//! `hl7-2` crates read.
3//!
4//! HL7 publishes the v2.xml encoding as a set of XML Schema documents, and
5//! sites that customise HL7 customise those documents: a hospital's `PID`
6//! is the standard's `PID` plus or minus whatever the local system actually
7//! sends. `hl7-2` reads a dictionary rather than a schema, which is what
8//! lets one build serve every release and every dialect — so this crate is
9//! the bridge between the two. Point it at a directory of schemas and it
10//! writes the dictionary that describes them.
11//!
12//! ```no_run
13//! use hl7_2_from_xsd_into_json_dictionary::{Options, convert_directory};
14//!
15//! let document = convert_directory("schemas/paris".as_ref(), &Options::default())?;
16//! std::fs::write("paris.json", document.to_json())?;
17//! # Ok::<(), Box<dyn std::error::Error>>(())
18//! ```
19//!
20//! The dictionary that comes out states cardinality as well as data types,
21//! which is what lets `hl7-2-from-er7-into-xml` in schema mode produce a
22//! document that validates against the very schemas it was built from. See
23//! `spec/index.md` for the conversion rules (source of truth).
24
25#![warn(missing_docs, clippy::pedantic)]
26
27pub mod dictionary;
28pub mod schema;
29
30/// The XML reader this crate is built on, re-exported so a caller can name
31/// [`xml::Element`] without adding its own dependency.
32pub use hl7_2_xml_lite_helper as xml;
33
34pub use dictionary::{Document, Field, Item};
35
36use std::collections::BTreeMap;
37use std::fmt;
38use std::path::{Path, PathBuf};
39
40/// What to put in the document beyond what the schemas say.
41#[derive(Debug, Clone, Default)]
42pub struct Options {
43    /// A name for what this dictionary describes, recorded in the
44    /// description. The reading crate takes the dictionary's name as an
45    /// argument, so this is documentation, not data.
46    pub name: Option<String>,
47    /// Override the release the base-file prefix implies.
48    pub version: Option<String>,
49    /// A bundled release to layer this document over, e.g. `2.5`. Without
50    /// it the document stands alone, which is what a full set of schemas
51    /// justifies.
52    pub inherits: Option<String>,
53    /// `CODE_TRIGGER` to the structure that carries it, e.g. `ADT_A28` to
54    /// `ADT_A05`. Not derivable from the schemas: a schema directory holds
55    /// `ADT_A05.xsd` but never says which trigger events arrive as one.
56    pub aliases: BTreeMap<String, String>,
57    /// Convert only these structures. Empty means every structure schema in
58    /// the directory.
59    pub structures: Vec<String>,
60}
61
62/// Why a directory of schemas could not be read as a dictionary.
63#[derive(Debug)]
64pub enum Error {
65    /// A file could not be read.
66    Io(PathBuf, std::io::Error),
67    /// A file is not well-formed XML.
68    Xml(PathBuf, xml::Error),
69    /// No structure schema names a `<prefix>_segments.xsd` to include, so
70    /// the base files cannot be found.
71    NoPrefix(PathBuf),
72    /// A structure schema does not declare the structure its filename names.
73    NoStructure(PathBuf, String),
74    /// `--structure` named something the directory has no schema for.
75    UnknownStructure(String),
76}
77
78impl fmt::Display for Error {
79    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80        match self {
81            Error::Io(path, error) => write!(f, "{}: {error}", path.display()),
82            Error::Xml(path, error) => write!(f, "{}: {error}", path.display()),
83            Error::NoPrefix(path) => write!(
84                f,
85                "{}: no structure schema includes a '<prefix>_segments.xsd', \
86                 so the base schemas cannot be found",
87                path.display()
88            ),
89            Error::NoStructure(path, id) => write!(
90                f,
91                "{}: declares no '{id}{}' complexType, so it does not describe \
92                 the structure its filename names",
93                path.display(),
94                schema::CONTENT
95            ),
96            Error::UnknownStructure(id) => write!(f, "no schema for structure {id}"),
97        }
98    }
99}
100
101impl std::error::Error for Error {}
102
103/// Convert a directory of HL7 v2.xml schemas into a dictionary document.
104///
105/// # Errors
106///
107/// [`Error`] when the directory cannot be listed or a file cannot be read
108/// ([`Error::Io`]), when a file is not well-formed XML ([`Error::Xml`]),
109/// when no structure schema names a base prefix to include
110/// ([`Error::NoPrefix`]), when a structure schema does not declare the
111/// structure its filename names ([`Error::NoStructure`]), or when
112/// `options.structures` names one the directory has no schema for
113/// ([`Error::UnknownStructure`]).
114pub fn convert_directory(directory: &Path, options: &Options) -> Result<Document, Error> {
115    let structure_files = structure_files(directory)?;
116
117    let mut prefix = None;
118    for path in &structure_files {
119        let root = read_xml(path)?;
120        if let Some(found) = schema::included_prefix(&root) {
121            prefix = Some(found);
122            break;
123        }
124    }
125    let Some(prefix) = prefix else {
126        return Err(Error::NoPrefix(directory.to_path_buf()));
127    };
128
129    let base = directory.join(&prefix);
130    let mut types = schema::Types::default();
131    types.absorb(&read_xml(&with_suffix(&base, "_types.xsd"))?);
132    types.absorb(&read_xml(&with_suffix(&base, "_fields.xsd"))?);
133    let segments = schema::segments(&read_xml(&with_suffix(&base, "_segments.xsd"))?, &types);
134
135    let mut structures = BTreeMap::new();
136    for path in &structure_files {
137        let id = structure_id(path);
138        if !options.structures.is_empty() && !options.structures.contains(&id) {
139            continue;
140        }
141        let root = read_xml(path)?;
142        let items = schema::structure(&root, &id)
143            .ok_or_else(|| Error::NoStructure(path.clone(), id.clone()))?;
144        structures.insert(id, items);
145    }
146    for wanted in &options.structures {
147        if !structures.contains_key(wanted) {
148            return Err(Error::UnknownStructure(wanted.clone()));
149        }
150    }
151
152    let source = directory.file_name().map_or_else(
153        || directory.display().to_string(),
154        |name| name.to_string_lossy().into_owned(),
155    );
156    let mut description = format!(
157        "Generated by hl7-2-from-xsd-into-json-dictionary from the HL7 v2.xml \
158         schemas in {source}/ (base prefix {prefix}). \
159         Edit the schemas and regenerate; do not hand-edit."
160    );
161    if let Some(name) = &options.name {
162        description = format!("{name}: {description}");
163    }
164
165    Ok(Document {
166        version: Some(
167            options
168                .version
169                .clone()
170                .unwrap_or_else(|| version_from_prefix(&prefix)),
171        ),
172        description: Some(description),
173        inherits: options.inherits.clone(),
174        types: types.composites(),
175        segments,
176        aliases: options.aliases.clone(),
177        structures,
178    })
179}
180
181/// `2_5_1` -> `2.5.1`, which is how a dictionary spells a release.
182#[must_use]
183pub fn version_from_prefix(prefix: &str) -> String {
184    prefix.replace('_', ".")
185}
186
187/// Every structure schema in a directory, in sorted order.
188///
189/// A structure file is one that is not a base file, and the base files all
190/// end in `_types.xsd`, `_fields.xsd`, or `_segments.xsd`.
191fn structure_files(directory: &Path) -> Result<Vec<PathBuf>, Error> {
192    let entries =
193        std::fs::read_dir(directory).map_err(|error| Error::Io(directory.to_path_buf(), error))?;
194    let mut paths: Vec<PathBuf> = entries
195        .filter_map(Result::ok)
196        .map(|entry| entry.path())
197        .filter(|path| {
198            let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
199                return false;
200            };
201            // Both comparisons ignore case: schemas arrive as they were
202            // exported, and a vendor shipping `ADT_A05.XSD` should not be
203            // silently skipped on a filesystem that does not care either.
204            let name = name.to_ascii_lowercase();
205            path.extension()
206                .is_some_and(|extension| extension.eq_ignore_ascii_case("xsd"))
207                && !["_types.xsd", "_fields.xsd", "_segments.xsd"]
208                    .iter()
209                    .any(|base| name.ends_with(base))
210        })
211        .collect();
212    paths.sort();
213    Ok(paths)
214}
215
216fn structure_id(path: &Path) -> String {
217    path.file_stem()
218        .map(|stem| stem.to_string_lossy().into_owned())
219        .unwrap_or_default()
220}
221
222fn with_suffix(base: &Path, suffix: &str) -> PathBuf {
223    let mut name = base.as_os_str().to_os_string();
224    name.push(suffix);
225    PathBuf::from(name)
226}
227
228fn read_xml(path: &Path) -> Result<xml::Element, Error> {
229    let text =
230        std::fs::read_to_string(path).map_err(|error| Error::Io(path.to_path_buf(), error))?;
231    xml::parse(&text).map_err(|error| Error::Xml(path.to_path_buf(), error))
232}