Skip to main content

er7_redact/
redact.rs

1//! The engine: applying a policy to a message, and reporting what changed.
2//!
3//! A [`Redactor`] is the only thing in this crate that edits a message. It
4//! walks the tree once per rule, in order, and then once more where the
5//! policy rejects by default, rewriting leaf text and leaving the shape
6//! alone.
7//!
8//! Specified by spec §2 (the model), §4 (what is preserved), and §8 (the
9//! report).
10
11use std::collections::HashSet;
12use std::fmt;
13
14use er7::message::NULL;
15use er7::{Component, Field, Message, Path, Repetition, Segment, Separators, Subcomponent};
16
17use crate::{Action, Policy, Posture, Unrecognised};
18
19/// One leaf's coordinates: the segment's index in the message, then the
20/// 1-based field, repetition, component, and subcomponent numbers.
21type Position = (usize, usize, usize, usize, usize);
22
23/// One position a redaction changed, and what changed it.
24///
25/// The path is fully qualified — every index present, even where it would
26/// be unambiguous — so that a row is a valid `er7 --query` argument and an
27/// audit trail nobody has to interpret (spec §8.3).
28///
29/// A change carries **no values** (D13): not the text that was there, and
30/// not the text that replaced it. A log line quoting the old value puts
31/// the patient's name into the log, the scrollback, and the CI transcript
32/// (spec §8.2).
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct Change {
35    /// Where it happened, e.g. `PID[1]-5[1].2.1`.
36    pub path: Path,
37    /// What happened there.
38    pub action: Action,
39}
40
41impl fmt::Display for Change {
42    /// The path and the action, separated by one space.
43    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44        write!(f, "{} {}", self.path, self.action)
45    }
46}
47
48/// What a redaction did: one entry per position that changed.
49///
50/// Entries are in the order the changes were made — rule by rule, and in
51/// message order within each rule (spec §8.4). A rule that matched nothing
52/// contributes none, and neither does an [`Action::Keep`], an empty leaf,
53/// or a null one.
54///
55/// Example:
56///
57/// ```
58/// # fn main() -> Result<(), er7_redact::Error> {
59/// use er7_redact::{Action, Policy, Redactor};
60///
61/// let mut message = er7::parse("MSH|^~\\&|LAB\rPID|1||9||SMITH^JOHN")?;
62/// let policy = Policy::accept_all().with("PID-5", Action::redacted())?;
63/// let report = Redactor::new(policy).redact(&mut message);
64///
65/// // One row per leaf that actually changed.
66/// let rows: Vec<String> = report.changes.iter().map(|c| c.to_string()).collect();
67/// assert_eq!(rows, [
68///     "PID[1]-5[1].1.1 replace REDACTED",
69///     "PID[1]-5[1].2.1 replace REDACTED",
70/// ]);
71/// # Ok(())
72/// # }
73/// ```
74#[derive(Debug, Clone, PartialEq, Eq, Default)]
75pub struct Report {
76    /// The changes, in the order they were made.
77    pub changes: Vec<Change>,
78}
79
80impl Report {
81    /// True when nothing changed — which means either that the message
82    /// carried none of the positions the policy names, or that the policy
83    /// is wrong. The crate does not presume to say which (spec §2.5).
84    #[must_use]
85    pub fn is_empty(&self) -> bool {
86        self.changes.is_empty()
87    }
88
89    /// How many positions changed.
90    #[must_use]
91    pub fn len(&self) -> usize {
92        self.changes.len()
93    }
94}
95
96impl fmt::Display for Report {
97    /// One change per line. See [`Change`] for the row format.
98    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99        for change in &self.changes {
100            writeln!(f, "{change}")?;
101        }
102        Ok(())
103    }
104}
105
106/// A policy, plus the key its pseudonyms are derived from.
107///
108/// Example:
109///
110/// ```
111/// # fn main() -> Result<(), er7_redact::Error> {
112/// use er7_redact::{Policy, Redactor};
113///
114/// let text = "MSH|^~\\&|LAB\rPID|1||PATID1234||EVERYWOMAN^EVE||19610615|F";
115/// let mut message = er7::parse(text)?;
116///
117/// let redactor = Redactor::new(Policy::patient_identifiers()).with_key(42);
118/// let report = redactor.redact(&mut message);
119///
120/// assert_eq!(message.query("PID-5")?.as_deref(), Some("REDACTED^REDACTED"));
121/// assert_eq!(message.query("PID-7")?.as_deref(), Some("1961"));
122/// assert_eq!(report.len(), 4);
123///
124/// // The same key maps the same identifier the same way, in every message.
125/// let mut other = er7::parse("MSH|^~\\&|LAB\rPID|1||PATID1234")?;
126/// redactor.redact(&mut other);
127/// assert_eq!(other.query("PID-3")?, message.query("PID-3")?);
128/// # Ok(())
129/// # }
130/// ```
131#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct Redactor {
133    policy: Policy,
134    key: u64,
135}
136
137impl Redactor {
138    /// A redactor for this policy, with the default pseudonym key `0`.
139    #[must_use]
140    pub fn new(policy: Policy) -> Redactor {
141        Redactor { policy, key: 0 }
142    }
143
144    /// Set the pseudonym key (spec §7.2).
145    ///
146    /// Two data sets redacted under different keys share no pseudonyms and
147    /// so cannot be joined; two under the same key can. The key is a
148    /// number in a configuration file, not a managed secret — read spec
149    /// §7.3 before treating it as one.
150    #[must_use]
151    pub fn with_key(mut self, key: u64) -> Redactor {
152        self.key = key;
153        self
154    }
155
156    /// The policy this redactor applies.
157    #[must_use]
158    pub fn policy(&self) -> &Policy {
159        &self.policy
160    }
161
162    /// The pseudonym key.
163    #[must_use]
164    pub fn key(&self) -> u64 {
165        self.key
166    }
167
168    /// What to write in place of `payload`, which did not parse as ER7
169    /// (D21, spec §2.8).
170    ///
171    /// `None` means the policy **refuses** it: nothing should be written,
172    /// and the caller reports that the payload did not parse. That is not
173    /// an error this crate raises — [`Redactor::redact`] cannot fail — so
174    /// the caller decides what a refusal costs. The CLI makes it a
175    /// diagnostic and exit 1 (spec §10.4).
176    ///
177    /// `Some(text)` is the payload itself where the policy passes it
178    /// through, or the policy's action applied to the whole payload as if
179    /// it were one value.
180    ///
181    /// Example:
182    ///
183    /// ```
184    /// use er7_redact::{Action, Policy, Redactor, Unrecognised};
185    ///
186    /// let junk = "not a message";
187    ///
188    /// // The curated policies refuse a payload they cannot read.
189    /// assert_eq!(Redactor::default().unrecognised(junk), None);
190    ///
191    /// // The bare postures each do what their name says.
192    /// assert_eq!(
193    ///     Redactor::new(Policy::accept_all()).unrecognised(junk).as_deref(),
194    ///     Some("not a message"),
195    /// );
196    /// assert_eq!(
197    ///     Redactor::new(Policy::reject_all()).unrecognised(junk).as_deref(),
198    ///     Some("*************"),
199    /// );
200    ///
201    /// // And any of it is overridable.
202    /// let policy = Policy::accept_all().on_unrecognised(Unrecognised::Apply(Action::redacted()));
203    /// assert_eq!(
204    ///     Redactor::new(policy).unrecognised(junk).as_deref(),
205    ///     Some("REDACTED"),
206    /// );
207    /// ```
208    #[must_use]
209    pub fn unrecognised(&self, payload: &str) -> Option<String> {
210        match &self.policy.unrecognised {
211            Unrecognised::Refuse => None,
212            Unrecognised::Pass => Some(payload.to_string()),
213            // An action that writes nothing leaves the payload as it is:
214            // `Policy::on_unrecognised` normalises those away, so this
215            // arm is only reachable through the public field.
216            Unrecognised::Apply(action) => Some(
217                action
218                    .apply(payload, self.key)
219                    .unwrap_or_else(|| payload.to_string()),
220            ),
221        }
222    }
223
224    /// Redact `message` in place, and report what changed.
225    ///
226    /// This cannot fail (spec §9.2): a rule that matches nothing does
227    /// nothing, a position that is not there is not created, and an empty
228    /// or null leaf is left alone.
229    pub fn redact(&self, message: &mut Message) -> Report {
230        // The segment name and occurrence of every segment, taken before
231        // anything is borrowed mutably, so that a change can be labelled
232        // with the path that names it.
233        let mut counts: Vec<usize> = Vec::with_capacity(message.segments.len());
234        let names: Vec<String> = message.segments.iter().map(|s| s.name.clone()).collect();
235        for (index, name) in names.iter().enumerate() {
236            counts.push(names[..index].iter().filter(|n| *n == name).count() + 1);
237        }
238
239        let mut pass = Pass {
240            key: self.key,
241            separators: message.separators,
242            named: HashSet::new(),
243            report: Report::default(),
244        };
245
246        for rule in &self.policy.rules {
247            for index in 0..message.segments.len() {
248                if names[index] != rule.path.segment {
249                    continue;
250                }
251                if rule
252                    .path
253                    .segment_occurrence
254                    .is_some_and(|wanted| wanted != counts[index])
255                {
256                    continue;
257                }
258                let at = At {
259                    name: &names[index],
260                    index,
261                    occurrence: counts[index],
262                };
263                pass.segment(&mut message.segments[index], at, &rule.path, &rule.action);
264            }
265        }
266
267        if let Posture::Reject(action) = &self.policy.posture {
268            for index in 0..message.segments.len() {
269                let at = At {
270                    name: &names[index],
271                    index,
272                    occurrence: counts[index],
273                };
274                pass.reject_the_rest(&mut message.segments[index], at, action);
275            }
276        }
277
278        pass.report
279    }
280}
281
282impl Default for Redactor {
283    /// The curated policy ([`Policy::patient_identifiers`]) with key `0` —
284    /// the same thing the command line does when no policy is given.
285    fn default() -> Redactor {
286        Redactor::new(Policy::patient_identifiers())
287    }
288}
289
290/// Which segment a walk is in: enough to label a change with its path.
291#[derive(Debug, Clone, Copy)]
292struct At<'a> {
293    name: &'a str,
294    index: usize,
295    occurrence: usize,
296}
297
298/// One run of a policy over one message: the state that outlives a single
299/// rule, and the descent that every rule shares.
300struct Pass {
301    key: u64,
302    separators: Separators,
303    /// Every leaf position some rule named, so that a rejecting posture
304    /// can skip them. A leaf a `Keep` rule named is in here too — that is
305    /// what `Keep` is for, and it is why an accept naming a whole segment
306    /// is not narrowed by the posture (spec §2.4, §2.6).
307    named: HashSet<Position>,
308    report: Report,
309}
310
311impl Pass {
312    /// Apply one rule to one segment.
313    fn segment(&mut self, segment: &mut Segment, at: At, path: &Path, action: &Action) {
314        let header = segment.is_header();
315        let numbers: Vec<usize> = match path.field {
316            Some(number) => vec![number],
317            None => (1..=segment.fields.len()).collect(),
318        };
319        for number in numbers {
320            // D5: the header's first two fields are the delimiters
321            // themselves. Redacting them would leave a message that either
322            // does not parse or parses into different values.
323            if header && number <= 2 {
324                continue;
325            }
326            // D2: a position the message does not carry is not created.
327            let Some(field) = segment.field_mut(number) else {
328                continue;
329            };
330            if action == &Action::Null && path.repetition.is_none() && path.component.is_none() {
331                if !field.is_null() {
332                    *field = null_field();
333                    self.record(at, number, 1, 1, 1, action);
334                }
335                continue;
336            }
337            let repetitions: Vec<usize> = match path.repetition {
338                Some(number) => vec![number],
339                None => (1..=field.repetitions.len()).collect(),
340            };
341            for repetition in repetitions {
342                let Some(node) = field.repetition_mut(repetition) else {
343                    continue;
344                };
345                if action == &Action::Null && path.component.is_none() {
346                    if !node.is_null() {
347                        *node = null_repetition();
348                        self.record(at, number, repetition, 1, 1, action);
349                    }
350                    continue;
351                }
352                self.repetition(node, at, (number, repetition), path, action);
353            }
354        }
355    }
356
357    /// Apply one rule below a repetition.
358    fn repetition(
359        &mut self,
360        repetition: &mut Repetition,
361        at: At,
362        (field, index): (usize, usize),
363        path: &Path,
364        action: &Action,
365    ) {
366        let numbers: Vec<usize> = match path.component {
367            Some(number) => vec![number],
368            None => (1..=repetition.components.len()).collect(),
369        };
370        for number in numbers {
371            let Some(component) = repetition.component_mut(number) else {
372                continue;
373            };
374            if action == &Action::Null && path.subcomponent.is_none() {
375                if !component.is_null() {
376                    *component = null_component();
377                    self.record(at, field, index, number, 1, action);
378                }
379                continue;
380            }
381            let subcomponents: Vec<usize> = match path.subcomponent {
382                Some(number) => vec![number],
383                None => (1..=component.subcomponents.len()).collect(),
384            };
385            for subcomponent in subcomponents {
386                let Some(leaf) = component.subcomponent_mut(subcomponent) else {
387                    continue;
388                };
389                let position = (at.index, field, index, number, subcomponent);
390                self.named.insert(position);
391                if self.leaf(leaf, action) {
392                    self.record(at, field, index, number, subcomponent, action);
393                }
394            }
395        }
396    }
397
398    /// Apply a rejecting posture's action to every leaf of one segment
399    /// that no rule named (D9, spec §2.6).
400    fn reject_the_rest(&mut self, segment: &mut Segment, at: At, action: &Action) {
401        let header = segment.is_header();
402        for field in 1..=segment.fields.len() {
403            // D5 again: the posture reaches no further than a rule does.
404            if header && field <= 2 {
405                continue;
406            }
407            let Some(node) = segment.field_mut(field) else {
408                continue;
409            };
410            for repetition in 1..=node.repetitions.len() {
411                let Some(node) = node.repetition_mut(repetition) else {
412                    continue;
413                };
414                for component in 1..=node.components.len() {
415                    let Some(node) = node.component_mut(component) else {
416                        continue;
417                    };
418                    for subcomponent in 1..=node.subcomponents.len() {
419                        let position = (at.index, field, repetition, component, subcomponent);
420                        if self.named.contains(&position) {
421                            continue;
422                        }
423                        let Some(leaf) = node.subcomponent_mut(subcomponent) else {
424                            continue;
425                        };
426                        if self.leaf(leaf, action) {
427                            self.record(at, field, repetition, component, subcomponent, action);
428                        }
429                    }
430                }
431            }
432        }
433    }
434
435    /// Apply an action to one leaf. Returns whether anything changed.
436    ///
437    /// A leaf is where the two skips live: an empty leaf has nothing to
438    /// redact, and writing into it would invent a value; a null leaf is an
439    /// instruction to the receiver rather than patient data, and
440    /// overwriting it would turn "clear this" into a value (D3, D4).
441    fn leaf(&mut self, leaf: &mut Subcomponent, action: &Action) -> bool {
442        if action == &Action::Null {
443            if leaf.is_null() {
444                return false;
445            }
446            leaf.raw = NULL.to_string();
447            return true;
448        }
449        if leaf.is_empty() || leaf.is_null() {
450            return false;
451        }
452        let value = leaf.value(&self.separators).into_owned();
453        let Some(replacement) = action.apply(&value, self.key) else {
454            return false;
455        };
456        if replacement == value {
457            // Nothing to do, and nothing to report — and leaving the raw
458            // text alone keeps the sender's own spelling of it (D17).
459            return false;
460        }
461        // `set` encodes any delimiter in the replacement, so a redaction
462        // can never break the message (D11).
463        leaf.set(&replacement, &self.separators);
464        true
465    }
466
467    /// Add a row to the report, with the path fully qualified (spec §8.3).
468    fn record(
469        &mut self,
470        at: At,
471        field: usize,
472        repetition: usize,
473        component: usize,
474        subcomponent: usize,
475        action: &Action,
476    ) {
477        self.report.changes.push(Change {
478            path: Path {
479                segment: at.name.to_string(),
480                segment_occurrence: Some(at.occurrence),
481                field: Some(field),
482                repetition: Some(repetition),
483                component: Some(component),
484                subcomponent: Some(subcomponent),
485            },
486            action: action.clone(),
487        });
488    }
489}
490
491/// The explicit HL7 null, as a leaf.
492fn null_subcomponent() -> Subcomponent {
493    Subcomponent::new(NULL)
494}
495
496/// The explicit HL7 null, as a component.
497fn null_component() -> Component {
498    Component {
499        subcomponents: vec![null_subcomponent()],
500    }
501}
502
503/// The explicit HL7 null, as a repetition.
504fn null_repetition() -> Repetition {
505    Repetition {
506        components: vec![null_component()],
507    }
508}
509
510/// The explicit HL7 null, as a field.
511fn null_field() -> Field {
512    Field {
513        repetitions: vec![null_repetition()],
514    }
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520    use crate::Rule;
521
522    const ADT: &str = "MSH|^~\\&|ADT1|MCM|LABADT|MCM|20260815140000||ADT^A08|MSG00001|P|2.5\r\
523                       PID|1||PATID1234^5^M11^ADT1^MR~123456789^^^USSSA^SS||\
524                       JONES^WILLIAM^A^III||19610615|M||C|1200 N ELM STREET^^GREENSBORO^NC\r\
525                       NK1|1|JONES^BARBARA^K|SPO\r\
526                       OBX|1|NM|2093-3^Cholesterol^LN||187|mg/dL";
527
528    fn message() -> Message {
529        er7::parse(ADT).expect("sample parses")
530    }
531
532    fn redact(policy: Policy, message: &mut Message) -> Report {
533        Redactor::new(policy).redact(message)
534    }
535
536    fn policy(rules: &[&str]) -> Policy {
537        let mut policy = Policy::accept_all();
538        for rule in rules {
539            policy.rules.push(Rule::parse(rule).expect("rule parses"));
540        }
541        policy
542    }
543
544    /// Every count in the tree, so that a test can assert the shape did
545    /// not move.
546    fn shape(message: &Message) -> Vec<usize> {
547        let mut counts = vec![message.segments.len()];
548        for segment in &message.segments {
549            counts.push(segment.fields.len());
550            for field in &segment.fields {
551                counts.push(field.repetitions.len());
552                for repetition in &field.repetitions {
553                    counts.push(repetition.components.len());
554                    for component in &repetition.components {
555                        counts.push(component.subcomponents.len());
556                    }
557                }
558            }
559        }
560        counts
561    }
562
563    #[test]
564    fn preserves_the_shape() {
565        // D1: redaction rewrites leaf text and nothing else, so every path
566        // that resolved to a value before still resolves to one.
567        let before = shape(&message());
568        for rules in [
569            vec!["PID-5 replace REDACTED"],
570            vec!["PID-3 pseudonym", "PID-7 first 4"],
571            vec!["PID-11 clear"],
572            vec!["OBX-5 mask *"],
573        ] {
574            let mut message = message();
575            redact(policy(&rules), &mut message);
576            assert_eq!(shape(&message), before, "{rules:?} changed the shape");
577            // And what came out is still a message.
578            assert!(er7::parse(&message.to_er7()).is_ok());
579        }
580    }
581
582    #[test]
583    fn does_not_create_a_position() {
584        // D2: a rule for a field the segment does not have is a no-op, not
585        // a reason to pad the segment out to reach it.
586        let mut message = message();
587        let report = redact(policy(&["PID-99 replace X", "ZZZ-1 clear"]), &mut message);
588        assert!(report.is_empty());
589        assert_eq!(message.to_er7(), ADT);
590    }
591
592    #[test]
593    fn leaves_an_empty_leaf_empty() {
594        // D3: writing REDACTED into an empty field would invent a value,
595        // and would announce that one used to be there.
596        let mut message = er7::parse("MSH|^~\\&|LAB\rPID|1||||^JOHN").unwrap();
597        let report = redact(
598            policy(&["PID-2 replace X", "PID-5 replace X"]),
599            &mut message,
600        );
601        assert_eq!(message.to_er7(), "MSH|^~\\&|LAB\rPID|1||||^X");
602        // One row: the empty positions contributed nothing.
603        assert_eq!(report.len(), 1);
604    }
605
606    #[test]
607    fn leaves_an_explicit_null_alone() {
608        // D4: a null is an instruction to the receiver, not patient data.
609        // Overwriting it would turn "clear this value" into a value.
610        let mut message = er7::parse("MSH|^~\\&|LAB\rPID|1|\"\"|A").unwrap();
611        let report = redact(
612            policy(&["PID-2 replace X", "PID-3 replace X"]),
613            &mut message,
614        );
615        assert_eq!(message.to_er7(), "MSH|^~\\&|LAB\rPID|1|\"\"|X");
616        assert_eq!(report.len(), 1);
617    }
618
619    #[test]
620    fn never_touches_the_delimiter_fields() {
621        // D5: MSH-1 and MSH-2 are the delimiters. A rule naming them is
622        // accepted and applied to nothing, and so is a rejecting posture.
623        let mut message = message();
624        let mut policy = policy(&["MSH-1 replace X", "MSH-2 clear", "MSH-3 replace X"]);
625        policy = policy.posture(Posture::Reject(Action::Mask('#')));
626        redact(policy, &mut message);
627        assert!(message.to_er7().starts_with("MSH|^~\\&|X|"));
628        assert!(er7::parse(&message.to_er7()).is_ok());
629    }
630
631    #[test]
632    fn null_collapses_the_named_position() {
633        // D6: the one action that changes shape, because an HL7 null is a
634        // single `""` and not a `""` in every component (spec §3.4).
635        let mut message = er7::parse("MSH|^~\\&|LAB\rPID|1||9||SMITH^JOHN").unwrap();
636        let report = redact(policy(&["PID-5 null"]), &mut message);
637        assert_eq!(message.to_er7(), "MSH|^~\\&|LAB\rPID|1||9||\"\"");
638        assert_eq!(report.len(), 1);
639        assert_eq!(report.changes[0].path.to_string(), "PID[1]-5[1].1.1");
640
641        // A path that stops deeper nulls only what it names.
642        let mut message = er7::parse("MSH|^~\\&|LAB\rPID|1||9||SMITH^JOHN").unwrap();
643        redact(policy(&["PID-5.1 null"]), &mut message);
644        assert_eq!(message.to_er7(), "MSH|^~\\&|LAB\rPID|1||9||\"\"^JOHN");
645
646        // And nulling a null again changes nothing (D10).
647        let mut message = er7::parse("MSH|^~\\&|LAB\rPID|1|\"\"").unwrap();
648        assert!(redact(policy(&["PID-2 null"]), &mut message).is_empty());
649    }
650
651    #[test]
652    fn applies_rules_in_order() {
653        // D7: rules run one after another against the message as it
654        // stands, so a later rule sees the earlier rule's output — and a
655        // `Keep` cannot undo a redaction (spec §2.4).
656        let mut message = er7::parse("MSH|^~\\&|LAB\rPID|1||PATID1234").unwrap();
657        redact(
658            policy(&["PID-3 replace SMITH", "PID-3 first 2"]),
659            &mut message,
660        );
661        assert_eq!(message.query("PID-3").unwrap().as_deref(), Some("SM"));
662
663        let mut message = er7::parse("MSH|^~\\&|LAB\rPID|1||PATID1234").unwrap();
664        redact(
665            policy(&["PID-3 replace REDACTED", "PID-3 keep"]),
666            &mut message,
667        );
668        assert_eq!(message.query("PID-3").unwrap().as_deref(), Some("REDACTED"));
669    }
670
671    #[test]
672    fn a_rule_that_matches_nothing_does_nothing() {
673        // D8: a policy is written against a family of messages, not one,
674        // so a segment this message does not have is not an error.
675        let mut message = message();
676        let report = redact(Policy::patient_identifiers(), &mut message);
677        assert!(!report.is_empty());
678        // No GT1 or IN1 in this message, so no rows name them.
679        assert!(!report.changes.iter().any(|c| c.path.segment == "GT1"));
680        assert!(!report.changes.iter().any(|c| c.path.segment == "IN1"));
681    }
682
683    #[test]
684    fn rejecting_by_default_covers_what_no_rule_named() {
685        // D9: rejecting by default inverts the model — redact everything
686        // except what a rule named — and a `Keep` rule is how a position
687        // is exempted (spec §2.6).
688        let mut message = er7::parse("MSH|^~\\&|LAB\rOBX|1|NM|2093-3||187").unwrap();
689        let policy =
690            policy(&["MSH keep", "OBX-2 keep"]).posture(Posture::Reject(Action::redacted()));
691        redact(policy, &mut message);
692        assert_eq!(
693            message.to_er7(),
694            "MSH|^~\\&|LAB\rOBX|REDACTED|NM|REDACTED||REDACTED"
695        );
696    }
697
698    #[test]
699    fn a_segment_wide_accept_is_not_narrowed() {
700        // D9, spec §2.4: an accept naming a whole segment exempts every
701        // leaf of it from the posture — including the ones the policy's
702        // author never saw, which is the point of writing `MSH keep`
703        // rather than a rule per field.
704        let text = "MSH|^~\\&|LAB|ACME|EHR|CLINIC|20260815120000\rOBX|1|NM|2093-3||187";
705        let mut message = er7::parse(text).unwrap();
706        let policy = policy(&["MSH keep"]).posture(Posture::Reject(Action::redacted()));
707        redact(policy, &mut message);
708        assert_eq!(
709            message.to_er7(),
710            "MSH|^~\\&|LAB|ACME|EHR|CLINIC|20260815120000\rOBX|REDACTED|REDACTED|REDACTED||REDACTED"
711        );
712    }
713
714    #[test]
715    fn reject_beats_accept_for_the_same_field() {
716        // D19: a leaf named by an accept rule and a reject rule is a
717        // policy somebody got wrong, and redacting it is the direction
718        // that fails safely (spec §2.4, §1.5 priority 1). It does not
719        // depend on the order the two were written in.
720        for rules in [
721            vec!["PID-5 keep", "PID-5 replace REDACTED"],
722            vec!["PID-5 replace REDACTED", "PID-5 keep"],
723        ] {
724            let mut message = er7::parse("MSH|^~\\&|LAB\rPID|1||9||SMITH").unwrap();
725            redact(policy(&rules), &mut message);
726            assert_eq!(
727                message.query("PID-5").unwrap().as_deref(),
728                Some("REDACTED"),
729                "{rules:?} let the name through"
730            );
731        }
732
733        // And the accept still does its own job: exempting the position
734        // from the posture, for every leaf no reject rule reached.
735        let mut message = er7::parse("MSH|^~\\&|LAB\rPID|1||9||SMITH^JOHN").unwrap();
736        let policy = policy(&["MSH keep", "PID-5 keep", "PID-5.1 replace REDACTED"])
737            .posture(Posture::Reject(Action::Clear));
738        redact(policy, &mut message);
739        assert_eq!(
740            message.query("PID-5").unwrap().as_deref(),
741            Some("REDACTED^JOHN")
742        );
743    }
744
745    #[test]
746    fn reject_segment_beats_a_narrower_accept() {
747        // D19 across depths: a reject naming a whole segment beats an
748        // accept naming one field inside it, and the other way round —
749        // neither order carves the field back out (spec §2.4).
750        for rules in [
751            vec!["PID replace REDACTED", "PID-5 keep"],
752            vec!["PID-5 keep", "PID replace REDACTED"],
753        ] {
754            let mut message = er7::parse("MSH|^~\\&|LAB\rPID|1||9||SMITH").unwrap();
755            redact(policy(&rules), &mut message);
756            assert_eq!(
757                message.query("PID-5").unwrap().as_deref(),
758                Some("REDACTED"),
759                "{rules:?} carved the name out of a rejected segment"
760            );
761        }
762    }
763
764    #[test]
765    fn an_unrecognised_payload_follows_the_policy() {
766        // D21: a payload with no positions in it is the one thing rules
767        // and the posture cannot speak to, so the policy says outright
768        // what happens to it (spec §2.8).
769        let junk = "{\"name\": \"EVERYWOMAN\"}";
770
771        // The curated policies refuse: nothing is written, and the caller
772        // decides what that costs.
773        assert_eq!(
774            Redactor::new(Policy::patient_identifiers()).unrecognised(junk),
775            None
776        );
777        assert_eq!(
778            Redactor::new(Policy::all_but_the_header()).unrecognised(junk),
779            None
780        );
781
782        // The bare postures each do what their name claims.
783        assert_eq!(
784            Redactor::new(Policy::accept_all())
785                .unrecognised(junk)
786                .as_deref(),
787            Some(junk)
788        );
789        let masked = Redactor::new(Policy::reject_all())
790            .unrecognised(junk)
791            .expect("reject_all writes something");
792        assert_eq!(masked, "*".repeat(junk.chars().count()));
793        assert!(!masked.contains("EVERYWOMAN"));
794
795        // And every one of them is overridable, in either direction.
796        let policy = Policy::patient_identifiers().on_unrecognised(Unrecognised::Pass);
797        assert_eq!(
798            Redactor::new(policy).unrecognised(junk).as_deref(),
799            Some(junk)
800        );
801        let policy = Policy::accept_all().on_unrecognised(Unrecognised::Refuse);
802        assert_eq!(Redactor::new(policy).unrecognised(junk), None);
803        let policy = Policy::accept_all().on_unrecognised(Unrecognised::Apply(Action::Clear));
804        assert_eq!(
805            Redactor::new(policy).unrecognised(junk).as_deref(),
806            Some("")
807        );
808    }
809
810    #[test]
811    fn a_report_carries_no_values() {
812        // D13: a report is meant to be pasted into a ticket, so it holds
813        // the path and the action and nothing else (spec §8.2).
814        let mut message = message();
815        let report = redact(Policy::patient_identifiers(), &mut message);
816        let text = report.to_string();
817        for value in ["JONES", "WILLIAM", "PATID1234", "19610615", "GREENSBORO"] {
818            assert!(!text.contains(value), "the report leaked {value}");
819        }
820        // Every row is a fully qualified path and an action.
821        assert!(text.contains("PID[1]-5[1].1.1 replace REDACTED"));
822        assert!(text.contains("NK1[1]-2[1].1.1 replace REDACTED"));
823    }
824
825    #[test]
826    fn covers_every_repetition_and_occurrence() {
827        // A rule that leaves an occurrence open covers all of them, which
828        // is `er7`'s R19 doing the work here (spec §2.2).
829        let mut message =
830            er7::parse("MSH|^~\\&|LAB\rPID|1|555-1111~555-2222\rOBX|1|NM|A\rOBX|2|NM|B").unwrap();
831        redact(policy(&["PID-2 clear", "OBX-3 replace X"]), &mut message);
832        assert_eq!(message.query("PID-2").unwrap().as_deref(), Some("~"));
833        assert_eq!(message.query_all("OBX-3").unwrap(), vec!["X", "X"]);
834
835        // And an occurrence index pins one down.
836        let mut message = er7::parse("MSH|^~\\&|LAB\rOBX|1|NM|A\rOBX|2|NM|B").unwrap();
837        redact(policy(&["OBX[2]-3 replace X"]), &mut message);
838        assert_eq!(message.query_all("OBX-3").unwrap(), vec!["A", "X"]);
839    }
840}