er7_redact/lib.rs
1//! # ER7 redact
2//!
3//! **[website](https://er7-rust.github.io/er7-redact/)**
4//! •
5//! **[documentation](https://docs.rs/er7-redact/)**
6//! •
7//! **[source](https://github.com/er7-rust/er7-rust/tree/main/er7-redact)**
8//! •
9//! **[crate](https://crates.io/crates/er7-redact)**
10//! •
11//! **[email](mailto:joel@joelparkerhenderson.com)**
12//!
13//! Remove patient detail from HL7 v2 messages in the ER7 pipe-hat
14//! encoding — without breaking the message.
15//!
16//! A redacted message still parses, still declares the same delimiters,
17//! and still holds a value in every position that held one before, so
18//! everything downstream of it behaves the way it did on the original.
19//! That is the whole design: redaction rewrites leaf text and nothing else
20//! (D1).
21//!
22//! Example:
23//!
24//! ```
25//! # fn main() -> Result<(), er7_redact::Error> {
26//! use er7_redact::{Policy, Redactor};
27//!
28//! let text = "MSH|^~\\&|LAB|ACME|EHR|CLINIC|20260815120000||ADT^A08|MSG9|P|2.5\r\
29//! PID|1||PATID1234^^^ACME^MR||EVERYWOMAN^EVE^E||19610615|F|||\
30//! 12 ELM ST^^BOSTON^MA^02101|||555-555-1111";
31//!
32//! let mut message = er7::parse(text)?;
33//! let report = Redactor::new(Policy::patient_identifiers()).redact(&mut message);
34//!
35//! // The name is a placeholder, the birth date is a year, the address and
36//! // the phone number are gone, and the record number is a pseudonym.
37//! assert_eq!(message.query("PID-5")?.as_deref(), Some("REDACTED^REDACTED^REDACTED"));
38//! assert_eq!(message.query("PID-7")?.as_deref(), Some("1961"));
39//! assert_eq!(message.query("PID-11")?.as_deref(), Some("^^^^"));
40//! assert_eq!(message.query("PID-13")?.as_deref(), Some(""));
41//! assert_ne!(message.query("PID-3.1")?.as_deref(), Some("PATID1234"));
42//!
43//! // The shape did not move: the assigning authority is still there, in
44//! // the component it was in, and the message still parses.
45//! assert_eq!(message.query("PID-3.4")?.as_deref(), Some("ACME"));
46//! assert!(er7::parse(&message.to_er7()).is_ok());
47//!
48//! // And there is a record of exactly what changed.
49//! assert_eq!(report.changes[0].path.to_string(), "PID[1]-3[1].1.1");
50//! # Ok(())
51//! # }
52//! ```
53//!
54//! # Accept by default, or reject by default
55//!
56//! Every policy is one of the two, and says which ([`Posture`], spec §2.6):
57//!
58//! ```
59//! # fn main() -> Result<(), er7_redact::Error> {
60//! use er7_redact::{Action, Policy, Redactor};
61//!
62//! // Accept by default — redact the positions the policy lists.
63//! let listed = Policy::patient_identifiers();
64//!
65//! // Reject by default — redact everything except what a `keep` rule names.
66//! let all_but = Policy::all_but_the_header().with("OBX-5", Action::Keep)?;
67//! # let _ = (listed, all_but);
68//! # Ok(())
69//! # }
70//! ```
71//!
72//! Accepting by default is the safer *message* — it leaves the clinical
73//! content a test needs. Rejecting by default is the safer *posture* — it
74//! is the only one that covers a `Z` segment nobody documented, or a field
75//! an interface started sending last week. Where the two disagree inside
76//! one policy, the reject wins (D19).
77//!
78//! # What is here
79//!
80//! | Item | Purpose |
81//! |------|---------|
82//! | [`Redactor`] | a policy and a pseudonym key; the only thing that edits a message |
83//! | [`Policy`], [`Rule`], [`Action`] | what to redact, where, and how |
84//! | [`Posture`] | accept by default, or reject by default |
85//! | [`Unrecognised`] | what a payload that is not ER7 gets |
86//! | [`Policy::patient_identifiers`] | the curated default: `PID`, `NK1`, `PV1`, `GT1`, `IN1` |
87//! | [`Policy::all_but_the_header`] | the other posture, curated: redact all but what you name |
88//! | [`Policy::accept_all`], [`Policy::reject_all`] | the two bare postures, with no rules at all |
89//! | [`Policy::parse`] | read a policy file |
90//! | [`Report`], [`Change`] | what a redaction did, with no values in it |
91//! | [`pseudonym()`] | the stable stand-in an identifier is replaced by |
92//!
93//! # What is deliberately not here
94//!
95//! This crate is a **positional editor, not a compliance tool**. It does
96//! not know whether the positions it redacts are the ones your senders
97//! use; it cannot tell you whether what remains is de-identified, because
98//! that is a judgement about a whole data set made by a person who is
99//! accountable for it; and it cannot find an identifier written into free
100//! text, because no positional rule can.
101//!
102//! There is also no way back. No mapping table, no key escrow, no undo.
103//!
104//! A message this crate has redacted is a message with less in it, which
105//! is progress, and is not the same thing as a safe one.
106//!
107//! # Documentation
108//!
109//! `spec/index.md` in the repository is the normative specification of
110//! everything above; where this documentation and that document disagree,
111//! that document is right. Section references such as "spec §5.1" and rule
112//! IDs such as "D1" throughout these docs point into it.
113//!
114//! The repository also holds a tutorial (`docs/usage/`), the policy
115//! reference (`docs/policies/`), an FAQ (`docs/faq/`), and runnable
116//! programs (`examples/`).
117//!
118//! For the encoding layer underneath — parsing, queries, escape sequences,
119//! the absent/empty/null distinction — see the [`er7`] crate, which is
120//! this crate's only dependency.
121
122#![warn(missing_docs)]
123
124pub mod action;
125pub mod policy;
126pub mod pseudonym;
127pub mod redact;
128
129pub use crate::action::Action;
130pub use crate::policy::{Policy, Posture, Rule, Unrecognised};
131pub use crate::pseudonym::pseudonym;
132pub use crate::redact::{Change, Redactor, Report};
133
134use std::fmt;
135
136/// What can go wrong.
137///
138/// Two variants, arising from exactly two situations: a policy that cannot
139/// be read, and a path that is not a path (D15, spec §9). Redaction itself
140/// cannot fail — a rule that matches nothing does nothing, and a position
141/// that is not there is not created — so [`Redactor::redact`] returns a
142/// [`Report`] rather than a `Result`.
143///
144/// Example:
145///
146/// ```
147/// use er7_redact::{Error, Policy};
148///
149/// assert!(matches!(Policy::parse("PID-5 obfuscate"), Err(Error::BadPolicy(_))));
150/// assert!(matches!(Policy::parse("PID-0 clear"), Err(Error::BadPolicy(_))));
151///
152/// // A path handed straight to the API reports as `er7` phrased it.
153/// use er7_redact::{Action, Rule};
154/// assert!(matches!(Rule::new("PID-0", Action::Clear), Err(Error::Er7(_))));
155/// ```
156#[derive(Debug, Clone, PartialEq, Eq)]
157pub enum Error {
158 /// A policy could not be read: an unknown action, a missing one, a
159 /// count that is not a number, or a line naming something that is not
160 /// a path. Carries a sentence naming the line and the problem (spec
161 /// §6.4).
162 BadPolicy(String),
163 /// A path handed to the API is not a path. Carries `er7`'s own error,
164 /// so that the message reads the same whichever crate reported it.
165 Er7(er7::Error),
166}
167
168impl fmt::Display for Error {
169 /// One complete sentence, with no trailing period and no error prefix,
170 /// so it reads correctly whether a caller writes `{e}`, wraps it, or
171 /// prefixes it as the CLI does.
172 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
173 match self {
174 Error::BadPolicy(detail) => write!(f, "{detail}"),
175 Error::Er7(e) => write!(f, "{e}"),
176 }
177 }
178}
179
180impl std::error::Error for Error {
181 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
182 match self {
183 Error::BadPolicy(_) => None,
184 Error::Er7(e) => Some(e),
185 }
186 }
187}
188
189impl From<er7::Error> for Error {
190 fn from(e: er7::Error) -> Error {
191 Error::Er7(e)
192 }
193}