Skip to main content

er7_redact/
policy.rs

1//! Rules, policies, the four built-in policies, and the policy file format.
2//!
3//! A [`Rule`] is one HL7 path and one [`Action`]. A [`Policy`] is an
4//! ordered list of rules plus the two things it does by default: its
5//! [`Posture`] — accept or reject every leaf no rule named — and what it
6//! does with a payload that is not ER7 at all ([`Unrecognised`]).
7//!
8//! Specified by spec §5 (the built-in policies) and §6 (the file format).
9
10use crate::{Action, Error};
11use er7::Path;
12use std::fmt;
13
14/// One HL7 path and what to do at it.
15///
16/// The path is an [`er7::Path`], so its notation and semantics are `er7`'s
17/// (that crate's spec §8.1): an omitted occurrence index means *every*
18/// segment of that name and *every* repetition of that field, which is
19/// what lets `OBX-5` cover a message with forty results.
20///
21/// A rule whose action is [`Action::Keep`] **accepts** the position it
22/// names; a rule with any other action **rejects** it. Where both name the
23/// same leaf, the rejecting one wins, whichever order they are in (D19,
24/// spec §2.4).
25///
26/// Example:
27///
28/// ```
29/// # fn main() -> Result<(), er7_redact::Error> {
30/// use er7_redact::{Action, Rule};
31///
32/// let rule = Rule::new("PID-5", Action::redacted())?;
33/// assert_eq!(rule.path.segment, "PID");
34/// assert_eq!(rule.to_string(), "PID-5 replace REDACTED");
35///
36/// // Or read one as a policy file spells it.
37/// assert_eq!(Rule::parse("PID-7 first 4")?.action, Action::First(4));
38/// # Ok(())
39/// # }
40/// ```
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct Rule {
43    /// Where the rule applies.
44    pub path: Path,
45    /// What it does there.
46    pub action: Action,
47}
48
49impl Rule {
50    /// A rule from a path and an action.
51    ///
52    /// # Errors
53    ///
54    /// [`Error::Er7`] wrapping [`er7::Error::BadPath`] when `path` is not
55    /// an HL7 path — a zero index, a missing field number, trailing text.
56    pub fn new(path: &str, action: Action) -> Result<Rule, Error> {
57        Ok(Rule {
58            path: Path::parse(path)?,
59            action,
60        })
61    }
62
63    /// Read one policy line: a path, whitespace, an action (spec §6).
64    ///
65    /// Example:
66    ///
67    /// ```
68    /// # fn main() -> Result<(), er7_redact::Error> {
69    /// use er7_redact::{Action, Rule};
70    ///
71    /// let rule = Rule::parse("  OBX[2]-5   replace NOT ON FILE  ")?;
72    /// assert_eq!(rule.path.to_string(), "OBX[2]-5");
73    /// assert_eq!(rule.action, Action::Replace("NOT ON FILE".to_string()));
74    ///
75    /// assert!(Rule::parse("PID-5").is_err());       // no action
76    /// assert!(Rule::parse("PID-0 clear").is_err()); // not a path
77    /// # Ok(())
78    /// # }
79    /// ```
80    ///
81    /// # Errors
82    ///
83    /// [`Error::BadPolicy`] naming the rule and the problem: a line with
84    /// no action, an action that does not exist, or a path that is not a
85    /// path.
86    pub fn parse(line: &str) -> Result<Rule, Error> {
87        let at = |e: Error| Error::BadPolicy(format!("rule {:?}: {e}", line.trim()));
88        let Some((path, action)) = split_line(line) else {
89            return Err(at(Error::BadPolicy(
90                "expected a path and an action".to_string(),
91            )));
92        };
93        let action = Action::parse(action).map_err(at)?;
94        Rule::new(path, action).map_err(at)
95    }
96}
97
98impl fmt::Display for Rule {
99    /// The path and the action, separated by one space — the policy file
100    /// spelling, so a rule written out reads back as itself.
101    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
102        write!(f, "{} {}", self.path, self.action)
103    }
104}
105
106/// The first word that accepts by default rather than naming a position.
107const ACCEPT: &str = "accept";
108
109/// The first word that rejects by default.
110const REJECT: &str = "reject";
111
112/// The first word that says what an unrecognised payload gets.
113const UNRECOGNISED: &str = "unrecognised";
114
115/// The spelling of [`UNRECOGNISED`] this crate also reads. Both are in the
116/// field, and a policy file that fails over one letter helps nobody.
117const UNRECOGNIZED: &str = "unrecognized";
118
119/// The path that set the fallback before 0.2, kept only to be refused with
120/// a sentence naming its replacement (spec §6.3).
121const REMOVED_FALLBACK: &str = "*";
122
123/// The column the default lines pad their first word to.
124const DEFAULT_WIDTH: usize = UNRECOGNISED.len();
125
126/// What a policy does with every leaf that no rule named (D9, spec §2.6).
127///
128/// A policy has exactly one of these and cannot leave it unstated: "redact
129/// what is listed" and "redact everything except what is listed" are
130/// different enough that guessing between them is not something a
131/// redaction crate may do.
132///
133/// Example:
134///
135/// ```
136/// # fn main() -> Result<(), er7_redact::Error> {
137/// use er7_redact::{Action, Policy, Posture, Redactor};
138///
139/// // Accept by default: only what a rule names is redacted.
140/// let listed = Policy::accept_all().with("PID-5", Action::redacted())?;
141///
142/// // Reject by default: only what a `keep` rule names survives.
143/// let all_but = Policy::reject_all().with("PID-5", Action::Keep)?;
144///
145/// assert_eq!(listed.posture, Posture::Accept);
146/// assert_eq!(all_but.posture, Posture::Reject(Action::redacted()));
147///
148/// let text = "MSH|^~\\&|LAB\rPID|1||9||SMITH";
149/// let mut one = er7::parse(text)?;
150/// let mut two = er7::parse(text)?;
151/// Redactor::new(listed).redact(&mut one);
152/// Redactor::new(all_but).redact(&mut two);
153///
154/// assert_eq!(one.to_er7(), "MSH|^~\\&|LAB\rPID|1||9||REDACTED");
155/// assert_eq!(two.to_er7(), "MSH|^~\\&|REDACTED\rPID|REDACTED||REDACTED||SMITH");
156/// # Ok(())
157/// # }
158/// ```
159#[derive(Debug, Clone, PartialEq, Eq)]
160pub enum Posture {
161    /// Accept by default: a leaf no rule named is left exactly as it is.
162    Accept,
163    /// Reject by default: a leaf no rule named gets this action.
164    ///
165    /// `Reject(Action::Keep)` is a contradiction — rejecting a value by
166    /// leaving it alone — and [`Policy::posture`] normalises it to
167    /// [`Posture::Accept`], which is what it means.
168    Reject(Action),
169}
170
171impl Posture {
172    /// How strict this posture is, for D20; see [`Policy::append`].
173    fn strictness(&self) -> u8 {
174        match self {
175            Posture::Accept => 0,
176            Posture::Reject(_) => 1,
177        }
178    }
179
180    /// Read `accept` or `reject [ACTION]` (spec §6.3).
181    fn parse(word: &str, argument: &str) -> Result<Posture, Error> {
182        if word == ACCEPT {
183            if argument.is_empty() {
184                Ok(Posture::Accept)
185            } else {
186                Err(Error::BadPolicy(format!(
187                    "{ACCEPT:?} takes no argument, but got {argument:?}"
188                )))
189            }
190        } else if argument.is_empty() {
191            // A bare `reject` means the placeholder the built-in policies
192            // write, the same way a bare `replace` does (spec §6.2).
193            Ok(Posture::Reject(Action::redacted()))
194        } else {
195            Ok(normalise_posture(Posture::Reject(Action::parse(argument)?)))
196        }
197    }
198}
199
200impl fmt::Display for Posture {
201    /// The policy file spelling, so a policy written out re-reads as
202    /// itself (D18, spec §6.5).
203    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
204        match self {
205            Posture::Accept => write!(f, "{ACCEPT}"),
206            Posture::Reject(action) => write!(f, "{REJECT:<DEFAULT_WIDTH$}  {action}"),
207        }
208    }
209}
210
211/// What a policy does with a payload that is not ER7 (D21, spec §2.8).
212///
213/// A payload with no header, or one that `er7` cannot parse, has no
214/// positions in it: no rule can name anything, and the posture has no leaf
215/// to reach. This is the only thing a policy can say about it.
216///
217/// Example:
218///
219/// ```
220/// use er7_redact::{Action, Policy, Redactor, Unrecognised};
221///
222/// let junk = "{\"patient\": \"EVERYWOMAN\"}";
223///
224/// // The curated policies refuse: nothing is written, and the caller says so.
225/// assert_eq!(Redactor::default().unrecognised(junk), None);
226///
227/// // `accept_all` passes it through, because it redacts nothing at all.
228/// let passed = Redactor::new(Policy::accept_all()).unrecognised(junk);
229/// assert_eq!(passed.as_deref(), Some(junk));
230///
231/// // `reject_all` masks it whole, because it rejects everything else.
232/// let masked = Redactor::new(Policy::reject_all()).unrecognised(junk);
233/// assert_eq!(masked.as_deref(), Some("*************************"));
234/// ```
235#[derive(Debug, Clone, PartialEq, Eq)]
236pub enum Unrecognised {
237    /// Write the payload out unchanged.
238    Pass,
239    /// Apply this action to the whole payload, as if it were one value.
240    ///
241    /// `Apply(Action::Keep)` and `Apply(Action::Null)` write nothing in
242    /// the payload's place, which is [`Unrecognised::Pass`];
243    /// [`Policy::on_unrecognised`] normalises both to it.
244    Apply(Action),
245    /// Write nothing, and tell the caller the payload did not parse.
246    ///
247    /// The library does not raise this as an error — [`crate::Redactor`]
248    /// cannot fail (spec §9.2). It returns `None`, and the caller decides
249    /// what a refusal costs; the CLI makes it a diagnostic and exit 1
250    /// (spec §10.4).
251    Refuse,
252}
253
254impl Unrecognised {
255    /// Read `refuse`, `pass`, or an action (spec §6.3).
256    fn parse(argument: &str) -> Result<Unrecognised, Error> {
257        match argument.to_ascii_lowercase().as_str() {
258            "" => Err(Error::BadPolicy(format!(
259                "{UNRECOGNISED:?} wants \"refuse\", \"pass\", or an action"
260            ))),
261            "refuse" => Ok(Unrecognised::Refuse),
262            "pass" => Ok(Unrecognised::Pass),
263            _ => Ok(normalise_unrecognised(Unrecognised::Apply(Action::parse(
264                argument,
265            )?))),
266        }
267    }
268}
269
270impl fmt::Display for Unrecognised {
271    /// The policy file spelling, after the `unrecognised` keyword.
272    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
273        match self {
274            Unrecognised::Pass => write!(f, "pass"),
275            Unrecognised::Apply(action) => write!(f, "{action}"),
276            Unrecognised::Refuse => write!(f, "refuse"),
277        }
278    }
279}
280
281/// A posture that rejects by keeping is an accepting one; see
282/// [`Posture::Reject`].
283fn normalise_posture(posture: Posture) -> Posture {
284    match posture {
285        Posture::Reject(Action::Keep) => Posture::Accept,
286        posture => posture,
287    }
288}
289
290/// A disposition that writes nothing in the payload's place passes it
291/// through; see [`Unrecognised::Apply`].
292fn normalise_unrecognised(unrecognised: Unrecognised) -> Unrecognised {
293    match unrecognised {
294        Unrecognised::Apply(Action::Keep | Action::Null) => Unrecognised::Pass,
295        unrecognised => unrecognised,
296    }
297}
298
299/// An ordered list of rules, plus what to do with everything they do not
300/// name.
301///
302/// Rules apply **in order**, each to the message as it stands (D7, spec
303/// §2.4). The [`Posture`] then runs over every leaf that no rule named
304/// (D9, spec §2.6), and [`Unrecognised`] covers a payload that is not ER7
305/// at all (D21, spec §2.8).
306///
307/// # A reject beats an accept (D19)
308///
309/// A rule whose action is [`Action::Keep`] **accepts** the position it
310/// names; any other action **rejects** it. Where a leaf is named by both,
311/// the rejecting rule wins — **whichever order the two rules are in**, and
312/// at whatever depth, so a reject naming a whole segment beats an accept
313/// naming one field inside it.
314///
315/// A leaf named by both is a policy somebody got wrong, and redacting it
316/// is the direction that fails safely (spec §1.5, priority 1): a value
317/// redacted by mistake costs a policy edit, and a value left behind by
318/// mistake cannot be recalled.
319///
320/// The mirror of that rule: an accept naming a whole segment is **not**
321/// narrowed by the posture. `MSH keep` exempts every leaf of the header,
322/// including ones the policy's author never saw. Only a reject rule
323/// reaches back into it.
324///
325/// Example:
326///
327/// ```
328/// # fn main() -> Result<(), er7_redact::Error> {
329/// use er7_redact::{Action, Policy, Redactor};
330///
331/// // Redact what is listed...
332/// let listed = Policy::accept_all()
333///     .with("PID-5", Action::redacted())?
334///     .with("PID-7", Action::First(4))?;
335///
336/// // ...or redact everything that is not.
337/// let everything_else = Policy::reject_all().with("MSH", Action::Keep)?;
338///
339/// let mut message = er7::parse("MSH|^~\\&|LAB\rPID|1||9||SMITH^JOHN||19610615")?;
340/// Redactor::new(listed).redact(&mut message);
341/// assert_eq!(message.to_er7(), "MSH|^~\\&|LAB\rPID|1||9||REDACTED^REDACTED||1961");
342/// # Ok(())
343/// # }
344/// ```
345#[derive(Debug, Clone, PartialEq, Eq)]
346pub struct Policy {
347    /// The rules, in the order they apply.
348    pub rules: Vec<Rule>,
349    /// What every leaf no rule named gets.
350    pub posture: Posture,
351    /// What a payload that is not ER7 gets.
352    pub unrecognised: Unrecognised,
353}
354
355// `Default` is deliberately not implemented, and neither is a `new`. Both
356// would have to choose a posture without being asked: an accepting empty
357// policy silently redacts nothing, and a curated one silently redacts
358// forty positions. A caller names the policy they mean (spec §5).
359impl Policy {
360    /// Accept everything: no rules, nothing redacted, and a payload that
361    /// is not ER7 passed through unchanged (spec §5.6).
362    ///
363    /// This is the starting point for building a policy rule by rule. On
364    /// its own it does nothing at all, and it says so: a policy named
365    /// "accept all" that quietly replaced an unparseable payload with
366    /// `***` would be the one surprise it has no excuse for.
367    ///
368    /// A policy *file* that states no defaults is not quite this: it
369    /// accepts by default too, but it refuses an unrecognised payload,
370    /// because it was written by somebody who did not think about one
371    /// (spec §6.1). Ask for [`Unrecognised::Pass`] in the file to get it.
372    ///
373    /// Example:
374    ///
375    /// ```
376    /// # fn main() -> Result<(), er7_redact::Error> {
377    /// use er7_redact::{Action, Policy, Posture, Redactor, Unrecognised};
378    ///
379    /// let policy = Policy::accept_all();
380    /// assert_eq!(policy.posture, Posture::Accept);
381    /// assert_eq!(policy.unrecognised, Unrecognised::Pass);
382    /// assert!(policy.is_empty());
383    ///
384    /// // It changes nothing, and reports nothing.
385    /// let mut message = er7::parse("MSH|^~\\&|LAB\rPID|1||9||SMITH")?;
386    /// let report = Redactor::new(policy).redact(&mut message);
387    /// assert_eq!(message.to_er7(), "MSH|^~\\&|LAB\rPID|1||9||SMITH");
388    /// assert!(report.is_empty());
389    /// # Ok(())
390    /// # }
391    /// ```
392    #[must_use]
393    pub fn accept_all() -> Policy {
394        Policy {
395            rules: Vec::new(),
396            posture: Posture::Accept,
397            unrecognised: Unrecognised::Pass,
398        }
399    }
400
401    /// Reject everything: no rules, `replace REDACTED` over every leaf,
402    /// and a payload that is not ER7 masked whole (spec §5.6).
403    ///
404    /// The strictest thing in the crate, and it takes the header with it:
405    /// everything from `MSH-3` on reads `REDACTED`, so the message is no
406    /// longer routable or identifiable. [`Policy::all_but_the_header`] is
407    /// the same posture with the header kept, and is usually what is
408    /// wanted.
409    ///
410    /// `MSH-1` and `MSH-2` survive, as they survive everything: they are
411    /// the delimiters themselves (D5, spec §4.4).
412    ///
413    /// Example:
414    ///
415    /// ```
416    /// # fn main() -> Result<(), er7_redact::Error> {
417    /// use er7_redact::{Action, Policy, Redactor};
418    ///
419    /// let policy = Policy::reject_all().with("OBX-2", Action::Keep)?;
420    /// let mut message = er7::parse("MSH|^~\\&|LAB\rOBX|1|NM|2093-3||187")?;
421    /// Redactor::new(policy).redact(&mut message);
422    ///
423    /// assert_eq!(
424    ///     message.to_er7(),
425    ///     "MSH|^~\\&|REDACTED\rOBX|REDACTED|NM|REDACTED||REDACTED",
426    /// );
427    /// # Ok(())
428    /// # }
429    /// ```
430    #[must_use]
431    pub fn reject_all() -> Policy {
432        Policy {
433            rules: Vec::new(),
434            posture: Posture::Reject(Action::redacted()),
435            unrecognised: Unrecognised::Apply(Action::Mask('*')),
436        }
437    }
438
439    /// The curated policy: the positions that carry a patient identifier
440    /// in `PID`, `NK1`, `PV1`, `GT1`, and `IN1`.
441    ///
442    /// It **accepts by default**, so a position the table does not name is
443    /// left as it is, and it **refuses** a payload that is not ER7: a list
444    /// of positions has no opinion about input with no positions in it,
445    /// and refusing is the fail-closed answer (spec §2.8).
446    ///
447    /// The whole table is written out in spec §5.1, with a reason for each
448    /// action. **It is a starting point, not a compliance certification**
449    /// (D14): it does not touch free text, quasi-identifiers, or local `Z`
450    /// segments, and it does not know which positions your senders
451    /// actually use. Read spec §5.4 and §5.5 before relying on it.
452    ///
453    /// Example:
454    ///
455    /// ```
456    /// # fn main() -> Result<(), er7_redact::Error> {
457    /// use er7_redact::{Policy, Redactor};
458    ///
459    /// let mut message = er7::parse(
460    ///     "MSH|^~\\&|LAB\rPID|1||PATID1234||EVERYWOMAN^EVE||19610615|F|||12 ELM ST^^BOSTON",
461    /// )?;
462    /// let report = Redactor::new(Policy::patient_identifiers()).redact(&mut message);
463    ///
464    /// assert_eq!(message.query("PID-5.1")?.as_deref(), Some("REDACTED"));
465    /// assert_eq!(message.query("PID-7")?.as_deref(), Some("1961"));
466    /// assert_eq!(message.query("PID-11.1")?.as_deref(), Some(""));
467    /// assert_ne!(message.query("PID-3")?.as_deref(), Some("PATID1234"));
468    ///
469    /// // The sex is not an identifier, so the default policy leaves it.
470    /// assert_eq!(message.query("PID-8")?.as_deref(), Some("F"));
471    /// assert!(!report.is_empty());
472    /// # Ok(())
473    /// # }
474    /// ```
475    ///
476    /// # Panics
477    ///
478    /// Only if the table below is edited to hold something that is not an
479    /// HL7 path — every entry is a literal, and
480    /// `the_documented_positions_match_the_built_in_policy` in
481    /// `tests/integration.rs` checks the whole table against spec §5.1.
482    #[must_use]
483    pub fn patient_identifiers() -> Policy {
484        // Spec §5.1 is the normative table; this list is its executable
485        // form, in the same order, and the two are changed together.
486        let table: &[(&str, Action)] = &[
487            // PID — patient identification. An identifier field names its
488            // first component, which is the ID number itself: the
489            // assigning authority and identifier type beside it identify
490            // the interface rather than the patient, and a message that
491            // keeps them still looks like the one it came from (spec §5.1).
492            ("PID-2.1", Action::Pseudonym),
493            ("PID-3.1", Action::Pseudonym),
494            ("PID-4.1", Action::Pseudonym),
495            ("PID-5", Action::redacted()),
496            ("PID-6", Action::redacted()),
497            ("PID-7", Action::First(4)),
498            ("PID-9", Action::redacted()),
499            ("PID-11", Action::Clear),
500            ("PID-12", Action::Clear),
501            ("PID-13", Action::Clear),
502            ("PID-14", Action::Clear),
503            ("PID-18.1", Action::Pseudonym),
504            ("PID-19", Action::Clear),
505            ("PID-20", Action::Clear),
506            ("PID-21.1", Action::Pseudonym),
507            ("PID-23", Action::Clear),
508            ("PID-29", Action::First(4)),
509            // NK1 — next of kin.
510            ("NK1-2", Action::redacted()),
511            ("NK1-4", Action::Clear),
512            ("NK1-5", Action::Clear),
513            ("NK1-6", Action::Clear),
514            // PV1 — patient visit.
515            ("PV1-5.1", Action::Pseudonym),
516            ("PV1-7", Action::redacted()),
517            ("PV1-8", Action::redacted()),
518            ("PV1-9", Action::redacted()),
519            ("PV1-17", Action::redacted()),
520            ("PV1-19.1", Action::Pseudonym),
521            // GT1 — guarantor.
522            ("GT1-2.1", Action::Pseudonym),
523            ("GT1-3", Action::redacted()),
524            ("GT1-4", Action::redacted()),
525            ("GT1-5", Action::Clear),
526            ("GT1-6", Action::Clear),
527            ("GT1-7", Action::Clear),
528            ("GT1-8", Action::First(4)),
529            ("GT1-12", Action::Clear),
530            // IN1 — insurance.
531            ("IN1-16", Action::redacted()),
532            ("IN1-18", Action::First(4)),
533            ("IN1-19", Action::Clear),
534            ("IN1-36", Action::Pseudonym),
535            ("IN1-49.1", Action::Pseudonym),
536        ];
537        let rules = table
538            .iter()
539            .map(|(path, action)| {
540                Rule::new(path, action.clone()).expect("built-in paths are well-formed")
541            })
542            .collect();
543        Policy {
544            rules,
545            posture: Posture::Accept,
546            unrecognised: Unrecognised::Refuse,
547        }
548    }
549
550    /// The other posture, curated: reject every value, and keep the `MSH`
551    /// header so the message stays routable (spec §5.2).
552    ///
553    /// Use it when the message is unfamiliar, or when the answer to "is
554    /// there anything else in here?" has to be "no" rather than "not that
555    /// I listed". The cost is that nothing below `MSH` is clinically
556    /// meaningful afterwards; add `Keep` rules for what a test needs.
557    ///
558    /// Like [`Policy::patient_identifiers`] it **refuses** a payload that
559    /// is not ER7 rather than guessing (spec §2.8).
560    ///
561    /// The header exception is an ordinary accept rule, so an ordinary
562    /// reject rule overrides it (D19) — `.with("MSH", Action::redacted())`
563    /// takes the header too.
564    ///
565    /// Example:
566    ///
567    /// ```
568    /// # fn main() -> Result<(), er7_redact::Error> {
569    /// use er7_redact::{Action, Policy, Redactor};
570    ///
571    /// let policy = Policy::all_but_the_header().with("OBX-2", Action::Keep)?;
572    /// let mut message = er7::parse("MSH|^~\\&|LAB\rOBX|1|NM|2093-3||187")?;
573    /// Redactor::new(policy).redact(&mut message);
574    ///
575    /// assert_eq!(
576    ///     message.to_er7(),
577    ///     "MSH|^~\\&|LAB\rOBX|REDACTED|NM|REDACTED||REDACTED",
578    /// );
579    /// # Ok(())
580    /// # }
581    /// ```
582    ///
583    /// # Panics
584    ///
585    /// Only if the `MSH` literal below stops being an HL7 path, which no
586    /// caller can cause.
587    #[must_use]
588    pub fn all_but_the_header() -> Policy {
589        Policy::reject_all()
590            .with("MSH", Action::Keep)
591            .expect("built-in paths are well-formed")
592            .on_unrecognised(Unrecognised::Refuse)
593    }
594
595    /// Add a rule, for building a policy in one expression.
596    ///
597    /// # Errors
598    ///
599    /// [`Error::Er7`] when `path` is not an HL7 path; see [`Rule::new`].
600    pub fn with(mut self, path: &str, action: Action) -> Result<Policy, Error> {
601        self.rules.push(Rule::new(path, action)?);
602        Ok(self)
603    }
604
605    /// Set what every leaf no rule named gets (spec §2.6).
606    ///
607    /// [`Posture::Reject`] with [`Action::Keep`] is normalised to
608    /// [`Posture::Accept`], so that the policy file's `reject keep` and
609    /// this method agree about what they mean.
610    ///
611    /// This is the only way to make a policy *less* strict: appending one
612    /// policy to another never weakens it (D20, [`Policy::append`]).
613    #[must_use]
614    pub fn posture(mut self, posture: Posture) -> Policy {
615        self.posture = normalise_posture(posture);
616        self
617    }
618
619    /// Set what a payload that is not ER7 gets (spec §2.8).
620    ///
621    /// [`Unrecognised::Apply`] with an action that writes nothing —
622    /// [`Action::Keep`] or [`Action::Null`] — is normalised to
623    /// [`Unrecognised::Pass`], which is what it does.
624    #[must_use]
625    pub fn on_unrecognised(mut self, unrecognised: Unrecognised) -> Policy {
626        self.unrecognised = normalise_unrecognised(unrecognised);
627        self
628    }
629
630    /// Read a policy file (spec §6).
631    ///
632    /// Blank lines and `#` comments are ignored; every other line is
633    /// either a path, whitespace, and an action, in the order they apply,
634    /// or one of the three reserved first words — `accept`, `reject`, and
635    /// `unrecognised` — that set what the policy does by default.
636    ///
637    /// A file that states no defaults accepts by default and **refuses** a
638    /// payload that is not ER7: unlike [`Policy::accept_all`], a file was
639    /// written by somebody who may simply not have considered one, and
640    /// refusing is the answer that cannot lose a value quietly.
641    ///
642    /// Example:
643    ///
644    /// ```
645    /// # fn main() -> Result<(), er7_redact::Error> {
646    /// use er7_redact::{Action, Policy, Posture, Unrecognised};
647    ///
648    /// let policy = Policy::parse("
649    ///     MSH    keep      # everything but the header...
650    ///     OBX-5  keep      # ...and the numbers the test asserts on
651    ///
652    ///     reject replace REDACTED
653    ///     unrecognised mask *
654    /// ")?;
655    ///
656    /// assert_eq!(policy.rules.len(), 2);
657    /// assert_eq!(policy.posture, Posture::Reject(Action::redacted()));
658    /// assert_eq!(policy.unrecognised, Unrecognised::Apply(Action::Mask('*')));
659    ///
660    /// // A file that says nothing accepts, and refuses what it cannot read.
661    /// let quiet = Policy::parse("PID-5 clear")?;
662    /// assert_eq!(quiet.posture, Posture::Accept);
663    /// assert_eq!(quiet.unrecognised, Unrecognised::Refuse);
664    ///
665    /// // A malformed line names itself.
666    /// let e = Policy::parse("PID-5 obfuscate").unwrap_err();
667    /// assert_eq!(e.to_string(), "policy line 1: \"PID-5 obfuscate\": unknown action \"obfuscate\"");
668    /// # Ok(())
669    /// # }
670    /// ```
671    ///
672    /// # Errors
673    ///
674    /// [`Error::BadPolicy`] naming the **line number**, the line, and the
675    /// problem. Reading a policy is the one place this crate is strict,
676    /// because a typo means a value that silently was not redacted (spec
677    /// §6.4).
678    pub fn parse(text: &str) -> Result<Policy, Error> {
679        let mut policy = Policy::accept_all().on_unrecognised(Unrecognised::Refuse);
680        for (index, line) in text.lines().enumerate() {
681            // A `#` starts a comment wherever it appears, so replacement
682            // text cannot contain one (spec §16.4).
683            let line = match line.split_once('#') {
684                Some((before, _comment)) => before,
685                None => line,
686            }
687            .trim();
688            if line.is_empty() {
689                continue;
690            }
691            let number = index + 1;
692            let at = |e: Error| Error::BadPolicy(format!("policy line {number}: {line:?}: {e}"));
693
694            // The reserved words come first, and a line is one or the
695            // other: three characters is the whole of a segment name, so
696            // none of them can be a path (spec §6.3).
697            let (word, argument) = match split_line(line) {
698                Some((word, argument)) => (word, argument),
699                None => (line, ""),
700            };
701            let lowercase = word.to_ascii_lowercase();
702            match lowercase.as_str() {
703                ACCEPT | REJECT => {
704                    // A second one replaces the first rather than being
705                    // ignored: a policy has one posture, and quietly
706                    // keeping the earlier one would hide an editing
707                    // mistake (spec §6.3).
708                    policy.posture = Posture::parse(&lowercase, argument).map_err(at)?;
709                    continue;
710                }
711                UNRECOGNISED | UNRECOGNIZED => {
712                    policy.unrecognised = Unrecognised::parse(argument).map_err(at)?;
713                    continue;
714                }
715                REMOVED_FALLBACK => {
716                    // Refused rather than read as a synonym: `*` never
717                    // said which of the two postures it meant (spec §6.3).
718                    let replacement = match argument {
719                        "" | "keep" => ACCEPT.to_string(),
720                        action => format!("{REJECT} {action}"),
721                    };
722                    return Err(at(Error::BadPolicy(format!(
723                        "the default line is now {replacement:?}, not {REMOVED_FALLBACK:?}"
724                    ))));
725                }
726                _ => {}
727            }
728
729            if argument.is_empty() {
730                return Err(at(Error::BadPolicy(
731                    "expected a path and an action".to_string(),
732                )));
733            }
734            let action = Action::parse(argument).map_err(at)?;
735            policy.rules.push(Rule::new(word, action).map_err(at)?);
736        }
737        Ok(policy)
738    }
739
740    /// Append another policy's rules, take the stricter posture, and
741    /// take the appended policy's disposition for an unrecognised payload
742    /// (D20, spec §2.6).
743    ///
744    /// This is how the command line concatenates several `--policy` files
745    /// and `--rule` arguments; order is significant for the rules (D7).
746    ///
747    /// The **posture** cannot be weakened by appending, and deliberately:
748    /// a file of extra rules says nothing about its posture, so it accepts
749    /// by default, and adopting that would switch redaction off for
750    /// everything the file did not name. Silence is indistinguishable from
751    /// a decision, so silence is not trusted. To relax a posture, say so
752    /// with [`Policy::posture`].
753    ///
754    /// The disposition for an **unrecognised payload** is different, and
755    /// the appended policy's wins outright. Nothing is silent there:
756    /// [`Policy::parse`] gives a file that says nothing the strictest
757    /// disposition there is, [`Unrecognised::Refuse`], so every value one
758    /// carries is somebody's decision — and a file that goes to the
759    /// trouble of writing `unrecognised pass` should not be quietly
760    /// overruled by a default it never saw.
761    ///
762    /// Example:
763    ///
764    /// ```
765    /// # fn main() -> Result<(), er7_redact::Error> {
766    /// use er7_redact::{Action, Policy, Posture};
767    ///
768    /// let mut policy = Policy::all_but_the_header();
769    /// policy.append(Policy::parse("OBX-2 keep")?);
770    ///
771    /// // The appended file accepts by default; the strict policy still rejects.
772    /// assert_eq!(policy.posture, Posture::Reject(Action::redacted()));
773    ///
774    /// // And a stricter action in the appended policy does win.
775    /// policy.append(Policy::parse("reject clear")?);
776    /// assert_eq!(policy.posture, Posture::Reject(Action::Clear));
777    /// # Ok(())
778    /// # }
779    /// ```
780    pub fn append(&mut self, other: Policy) {
781        self.rules.extend(other.rules);
782        if other.posture.strictness() >= self.posture.strictness() {
783            self.posture = other.posture;
784        }
785        self.unrecognised = other.unrecognised;
786    }
787
788    /// True when the policy would redact nothing at all: no rules, and it
789    /// accepts by default.
790    ///
791    /// What it does with an unrecognised payload is not part of this: a
792    /// policy that refuses one still redacts nothing.
793    #[must_use]
794    pub fn is_empty(&self) -> bool {
795        self.rules.is_empty() && self.posture == Posture::Accept
796    }
797}
798
799impl fmt::Display for Policy {
800    /// The canonical policy file (spec §6.5): one rule per line, paths
801    /// padded to a common width, then the two default lines — always
802    /// both, whatever they say, so that a reader never has to know which
803    /// default was the quiet one.
804    ///
805    /// Example:
806    ///
807    /// ```
808    /// # fn main() -> Result<(), er7_redact::Error> {
809    /// use er7_redact::{Action, Policy};
810    ///
811    /// let policy = Policy::accept_all()
812    ///     .with("PID-5", Action::redacted())?
813    ///     .with("PID-7", Action::First(4))?
814    ///     .posture(er7_redact::Posture::Reject(Action::Clear));
815    ///
816    /// assert_eq!(policy.to_string(), "\
817    /// PID-5  replace REDACTED
818    /// PID-7  first 4
819    ///
820    /// reject        clear
821    /// unrecognised  pass
822    /// ");
823    ///
824    /// // And it reads back as the same policy.
825    /// assert_eq!(Policy::parse(&policy.to_string())?, policy);
826    /// # Ok(())
827    /// # }
828    /// ```
829    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
830        let width = self
831            .rules
832            .iter()
833            .map(|rule| rule.path.to_string().len())
834            .max()
835            .unwrap_or(0);
836        for rule in &self.rules {
837            let path = rule.path.to_string();
838            writeln!(f, "{path:<width$}  {}", rule.action)?;
839        }
840        if !self.rules.is_empty() {
841            writeln!(f)?;
842        }
843        writeln!(f, "{}", self.posture)?;
844        writeln!(f, "{UNRECOGNISED:<DEFAULT_WIDTH$}  {}", self.unrecognised)
845    }
846}
847
848/// Split a policy line into its first word and the rest, or `None` when it
849/// has only the one.
850fn split_line(line: &str) -> Option<(&str, &str)> {
851    let line = line.trim();
852    let (path, action) = line.split_once(char::is_whitespace)?;
853    let action = action.trim();
854    if action.is_empty() {
855        None
856    } else {
857        Some((path, action))
858    }
859}
860
861#[cfg(test)]
862mod tests {
863    use super::*;
864
865    #[test]
866    fn a_policy_round_trips_through_display() {
867        // D18: the file format is a compatibility surface, so a policy
868        // written out must read back as the same policy (spec §6.5).
869        for policy in [
870            Policy::accept_all(),
871            Policy::reject_all(),
872            Policy::patient_identifiers(),
873            Policy::all_but_the_header(),
874            Policy::accept_all()
875                .posture(Posture::Reject(Action::Null))
876                .on_unrecognised(Unrecognised::Apply(Action::First(4))),
877        ] {
878            assert_eq!(Policy::parse(&policy.to_string()).unwrap(), policy);
879        }
880    }
881
882    #[test]
883    fn parses_comments_blank_lines_and_the_defaults() {
884        let policy = Policy::parse(
885            "\
886            # a comment on its own line\n\
887            \n\
888            PID-5   replace REDACTED   # and one after a rule\n\
889            \t OBX-5  keep \n\
890            REJECT  clear\n\
891            Unrecognized  pass\n",
892        )
893        .unwrap();
894        assert_eq!(policy.rules.len(), 2);
895        assert_eq!(policy.rules[0].action, Action::redacted());
896        assert_eq!(policy.rules[1].path.to_string(), "OBX-5");
897        assert_eq!(policy.posture, Posture::Reject(Action::Clear));
898        assert_eq!(policy.unrecognised, Unrecognised::Pass);
899
900        // A later default line replaces an earlier one (spec §6.3).
901        let replaced = Policy::parse("reject clear\nreject replace X").unwrap();
902        assert_eq!(
903            replaced.posture,
904            Posture::Reject(Action::Replace("X".to_string()))
905        );
906
907        // A bare `reject` is the placeholder the built-ins write, and
908        // rejecting by keeping is accepting.
909        assert_eq!(
910            Policy::parse("reject").unwrap().posture,
911            Posture::Reject(Action::redacted())
912        );
913        assert_eq!(
914            Policy::parse("reject keep").unwrap().posture,
915            Posture::Accept
916        );
917        assert_eq!(
918            Policy::parse("unrecognised null").unwrap().unrecognised,
919            Unrecognised::Pass
920        );
921
922        // A file that says nothing accepts, and refuses what it cannot read.
923        let quiet = Policy::parse("PID-5 clear").unwrap();
924        assert_eq!(quiet.posture, Posture::Accept);
925        assert_eq!(quiet.unrecognised, Unrecognised::Refuse);
926    }
927
928    #[test]
929    fn reports_a_bad_policy_line() {
930        // D15: reading a policy is the one place this crate is strict,
931        // because a typo means a value that silently was not redacted
932        // (spec §6.4).
933        let cases = [
934            (
935                "PID-5 obfuscate",
936                "policy line 1: \"PID-5 obfuscate\": unknown action \"obfuscate\"",
937            ),
938            (
939                "MSH keep\nPID-0 clear",
940                "policy line 2: \"PID-0 clear\": invalid HL7 path \"PID-0\": \
941                 indices are 1-based, so 0 is not a position",
942            ),
943            (
944                "PID-5",
945                "policy line 1: \"PID-5\": expected a path and an action",
946            ),
947            (
948                "# comment\n\nPID-7 first three",
949                "policy line 3: \"PID-7 first three\": action \"first\" wants a number \
950                 of characters, not \"three\"",
951            ),
952            (
953                "accept everything",
954                "policy line 1: \"accept everything\": \"accept\" takes no argument, \
955                 but got \"everything\"",
956            ),
957            (
958                "unrecognised",
959                "policy line 1: \"unrecognised\": \"unrecognised\" wants \"refuse\", \
960                 \"pass\", or an action",
961            ),
962            (
963                "unrecognised sideways",
964                "policy line 1: \"unrecognised sideways\": unknown action \"sideways\"",
965            ),
966            // The `*` line of 0.1, refused with its replacement (spec §6.3).
967            (
968                "MSH keep\n* replace REDACTED",
969                "policy line 2: \"* replace REDACTED\": the default line is now \
970                 \"reject replace REDACTED\", not \"*\"",
971            ),
972            (
973                "* keep",
974                "policy line 1: \"* keep\": the default line is now \"accept\", not \"*\"",
975            ),
976        ];
977        for (text, expected) in cases {
978            let error = Policy::parse(text).unwrap_err();
979            assert_eq!(error.to_string(), expected, "parsing {text:?}");
980        }
981    }
982
983    #[test]
984    fn the_default_policy_names_the_documented_positions() {
985        // D14: the table in spec §5.1 is normative, and this is its
986        // executable form; the two change together. A test cannot say the
987        // list is *sufficient* — no test can (spec §5.5) — only that it is
988        // the list the spec documents.
989        let policy = Policy::patient_identifiers();
990        assert_eq!(policy.rules.len(), 40);
991        assert_eq!(policy.posture, Posture::Accept);
992        assert_eq!(policy.unrecognised, Unrecognised::Refuse);
993
994        let named: Vec<String> = policy.rules.iter().map(|r| r.path.to_string()).collect();
995        for path in [
996            "PID-3.1", "PID-5", "PID-7", "PID-11", "PID-19", "NK1-2", "GT1-3", "IN1-16",
997        ] {
998            assert!(named.contains(&path.to_string()), "missing {path}");
999        }
1000        // Deliberately absent: free text and quasi-identifiers (spec §5.4).
1001        for path in ["NTE-3", "OBX-5", "PID-8", "PID-10", "MSH-4"] {
1002            assert!(!named.contains(&path.to_string()), "unexpected {path}");
1003        }
1004
1005        assert!(Policy::accept_all().is_empty());
1006        assert!(!Policy::reject_all().is_empty());
1007    }
1008
1009    #[test]
1010    fn the_two_bare_postures_say_what_they_are() {
1011        // Spec §5.6: no rules and no field table, and the two defaults
1012        // that match what each one claims about itself.
1013        let accept = Policy::accept_all();
1014        assert!(accept.rules.is_empty());
1015        assert_eq!(accept.posture, Posture::Accept);
1016        assert_eq!(accept.unrecognised, Unrecognised::Pass);
1017
1018        let reject = Policy::reject_all();
1019        assert!(reject.rules.is_empty());
1020        assert_eq!(reject.posture, Posture::Reject(Action::redacted()));
1021        assert_eq!(reject.unrecognised, Unrecognised::Apply(Action::Mask('*')));
1022
1023        // The curated one is the same posture, with the header kept and a
1024        // refusal in place of the mask (spec §5.2).
1025        let curated = Policy::all_but_the_header();
1026        assert_eq!(curated.posture, reject.posture);
1027        assert_eq!(curated.unrecognised, Unrecognised::Refuse);
1028        assert_eq!(curated.rules.len(), 1);
1029        assert_eq!(curated.rules[0].to_string(), "MSH keep");
1030    }
1031
1032    #[test]
1033    fn appends_in_order() {
1034        let mut policy = Policy::accept_all()
1035            .with("PID-5", Action::redacted())
1036            .unwrap();
1037        policy.append(Policy::parse("PID-7 first 4\nreject clear").unwrap());
1038        assert_eq!(policy.rules.len(), 2);
1039        assert_eq!(policy.rules[1].action, Action::First(4));
1040        assert_eq!(policy.posture, Posture::Reject(Action::Clear));
1041    }
1042
1043    #[test]
1044    fn appending_never_weakens_the_defaults() {
1045        // D20: a file of extra rules says nothing about its posture, so it
1046        // accepts by default. Adopting that would switch redaction off for
1047        // everything the file did not name — the one failure spec §1.5
1048        // puts first (spec §2.6).
1049        let mut strict = Policy::all_but_the_header();
1050        strict.append(Policy::parse("OBX-2 keep").unwrap());
1051        assert_eq!(strict.posture, Posture::Reject(Action::redacted()));
1052        assert_eq!(strict.unrecognised, Unrecognised::Refuse);
1053
1054        // Even when the appended policy says `accept` outright.
1055        let mut strict = Policy::all_but_the_header();
1056        strict.append(Policy::parse("accept").unwrap());
1057        assert_eq!(strict.posture, Posture::Reject(Action::redacted()));
1058
1059        // A file that says nothing about an unrecognised payload is given
1060        // the strictest disposition when it is read, so adopting it can
1061        // only tighten a rejecting policy that masked one.
1062        let mut masking = Policy::reject_all();
1063        masking.append(Policy::parse("OBX-2 keep").unwrap());
1064        assert_eq!(masking.unrecognised, Unrecognised::Refuse);
1065
1066        // But a file that asks for one outright is not overruled: nothing
1067        // else in the run said anything about it.
1068        let mut masking = Policy::reject_all();
1069        masking.append(Policy::parse("unrecognised pass").unwrap());
1070        assert_eq!(masking.unrecognised, Unrecognised::Pass);
1071
1072        // Strictening works, in both directions of travel.
1073        // `mask #` is unwritable in a file — a `#` starts a comment
1074        // wherever it appears (spec §16.4) — so this uses another one.
1075        let mut lax = Policy::accept_all();
1076        lax.append(Policy::parse("reject mask X\nunrecognised refuse").unwrap());
1077        assert_eq!(lax.posture, Posture::Reject(Action::Mask('X')));
1078        assert_eq!(lax.unrecognised, Unrecognised::Refuse);
1079
1080        // And relaxing one is done deliberately, which is the only way.
1081        let relaxed = Policy::all_but_the_header().posture(Posture::Accept);
1082        assert_eq!(relaxed.posture, Posture::Accept);
1083    }
1084}