Skip to main content

Redactor

Struct Redactor 

Source
pub struct Redactor { /* private fields */ }
Expand description

A policy, plus the key its pseudonyms are derived from.

Example:

use er7_redact::{Policy, Redactor};

let text = "MSH|^~\\&|LAB\rPID|1||PATID1234||EVERYWOMAN^EVE||19610615|F";
let mut message = er7::parse(text)?;

let redactor = Redactor::new(Policy::patient_identifiers()).with_key(42);
let report = redactor.redact(&mut message);

assert_eq!(message.query("PID-5")?.as_deref(), Some("REDACTED^REDACTED"));
assert_eq!(message.query("PID-7")?.as_deref(), Some("1961"));
assert_eq!(report.len(), 4);

// The same key maps the same identifier the same way, in every message.
let mut other = er7::parse("MSH|^~\\&|LAB\rPID|1||PATID1234")?;
redactor.redact(&mut other);
assert_eq!(other.query("PID-3")?, message.query("PID-3")?);

Implementations§

Source§

impl Redactor

Source

pub fn new(policy: Policy) -> Redactor

A redactor for this policy, with the default pseudonym key 0.

Examples found in repository?
examples/redact_a_message.rs (line 14)
7fn main() -> Result<(), er7_redact::Error> {
8    let text = "MSH|^~\\&|ADT1|MCM|LABADT|MCM|20260815140000||ADT^A08|MSG00001|P|2.5\r\
9                PID|1||PATID1234^^^ADT1^MR||EVERYWOMAN^EVE^E||19610615|F|||\
10                1200 N ELM STREET^^GREENSBORO^NC^27401-1020||(919)379-1212\r\
11                OBX|1|NM|2093-3^Cholesterol^LN||187|mg/dL|100-199|H|||F";
12
13    let mut message = er7::parse(text)?;
14    let report = Redactor::new(Policy::patient_identifiers()).redact(&mut message);
15
16    println!("{}\n", message.to_er7().replace('\r', "\n"));
17    println!("{} positions changed:\n{report}", report.len());
18
19    // The identifiers are gone.
20    assert_eq!(
21        message.query("PID-5")?.as_deref(),
22        Some("REDACTED^REDACTED^REDACTED")
23    );
24    assert_eq!(message.query("PID-7")?.as_deref(), Some("1961"));
25    assert_eq!(message.query("PID-11")?.as_deref(), Some("^^^^"));
26    assert_eq!(message.query("PID-13")?.as_deref(), Some(""));
27    assert_ne!(message.query("PID-3.1")?.as_deref(), Some("PATID1234"));
28
29    // The message is not.
30    assert_eq!(message.control_id().as_deref(), Some("MSG00001"));
31    assert_eq!(message.query("PID-3.4")?.as_deref(), Some("ADT1")); // assigning authority
32    assert_eq!(message.query("PID-8")?.as_deref(), Some("F")); // not an identifier
33    assert_eq!(message.query("OBX-5")?.as_deref(), Some("187")); // the clinical content
34    assert!(er7::parse(&message.to_er7()).is_ok());
35
36    // And the shape did not move: every position that held a value still
37    // exists, so a test that asserted on PID-11.3 still finds it.
38    assert_eq!(message.query("PID-11.3")?.as_deref(), Some(""));
39
40    Ok(())
41}
More examples
Hide additional examples
examples/read_the_report.rs (line 15)
8fn main() -> Result<(), er7_redact::Error> {
9    let text = "MSH|^~\\&|ADT1|MCM||||||ADT^A08|MSG1|P|2.5\r\
10                PID|1||PATID1234^^^ADT1^MR||EVERYWOMAN^EVE^E||19610615|F|||\
11                12 ELM ST^^BOSTON^MA^02101||555-555-1111~555-555-2222\r\
12                NK1|1|EVERYMAN^ADAM|SPO";
13
14    let mut message = er7::parse(text)?;
15    let report = Redactor::new(Policy::patient_identifiers()).redact(&mut message);
16
17    for change in &report.changes {
18        println!("{:<18} {}", change.path.to_string(), change.action);
19    }
20
21    // One row per leaf that actually changed — so a name in three
22    // components is three rows, and a repeated field is one row per
23    // repetition.
24    let paths: Vec<String> = report.changes.iter().map(|c| c.path.to_string()).collect();
25    assert!(paths.contains(&"PID[1]-5[1].1.1".to_string()));
26    assert!(paths.contains(&"PID[1]-5[1].3.1".to_string()));
27    assert!(paths.contains(&"PID[1]-13[2].1.1".to_string()));
28
29    // Every path is fully qualified, which means it is also a valid
30    // `er7 --query` argument: paste one in to see what is there now.
31    for change in &report.changes {
32        assert!(message.query(&change.path.to_string()).is_ok());
33    }
34
35    // A report carries no values — not the old text, and not the new. A
36    // log line quoting the old value puts the patient's name into the log.
37    let printed = report.to_string();
38    for value in ["EVERYWOMAN", "PATID1234", "19610615", "BOSTON"] {
39        assert!(!printed.contains(value));
40    }
41
42    // And nothing that was not there contributes a row: this message has
43    // no GT1 segment, so the guarantor rules matched nothing, which is not
44    // an error.
45    assert!(!paths.iter().any(|p| p.starts_with("GT1")));
46
47    println!("\n{} positions changed", report.len());
48    Ok(())
49}
examples/pseudonyms_and_linkage.rs (line 21)
14fn main() -> Result<(), er7_redact::Error> {
15    let admit = "MSH|^~\\&|ADT1|MCM||||||ADT^A01|MSG1|P|2.5\r\
16                 PID|1||PATID1234^^^ADT1^MR||EVERYWOMAN^EVE";
17    let result = "MSH|^~\\&|LAB|ACME||||||ORU^R01|MSG2|P|2.5\r\
18                  PID|1||PATID1234^^^ADT1^MR||EVERYWOMAN^EVE\r\
19                  OBX|1|NM|2093-3^Cholesterol^LN||187";
20
21    let redactor = Redactor::new(Policy::patient_identifiers()).with_key(KEY);
22
23    let mut admit = er7::parse(admit)?;
24    let mut result = er7::parse(result)?;
25    redactor.redact(&mut admit);
26    redactor.redact(&mut result);
27
28    // The record number is gone from both...
29    let one = admit.query("PID-3.1")?.expect("a value");
30    let two = result.query("PID-3.1")?.expect("a value");
31    assert_ne!(one, "PATID1234");
32
33    // ...and the two messages still agree that this is the same patient,
34    // which is what makes them useful as a test case.
35    assert_eq!(one, two);
36    println!("both messages now say PID-3.1 = {one}");
37
38    // A different key produces an unrelated mapping, so two data sets
39    // redacted under different keys cannot be joined.
40    let mut other = er7::parse("MSH|^~\\&|ADT1|MCM\rPID|1||PATID1234^^^ADT1^MR")?;
41    Redactor::new(Policy::patient_identifiers())
42        .with_key(1)
43        .redact(&mut other);
44    assert_ne!(other.query("PID-3.1")?.expect("a value"), one);
45
46    // The function is available directly, for building an expected value
47    // in a test.
48    assert_eq!(pseudonym(KEY, "PATID1234"), one);
49
50    // What it costs. A pseudonym preserves equality on purpose, so anyone
51    // holding the redacted data can count how many messages each patient
52    // generated — and anyone holding the key can invert the mapping by
53    // trying every candidate identifier, because record numbers come from
54    // small spaces. Inside your own trust boundary, that is a fair trade;
55    // for data leaving it, clear the value instead.
56    let mut leaving = er7::parse("MSH|^~\\&|ADT1|MCM\rPID|1||PATID1234^^^ADT1^MR")?;
57    let policy = Policy::patient_identifiers().with("PID-3.1", Action::Clear)?;
58    Redactor::new(policy).redact(&mut leaving);
59    assert_eq!(leaving.query("PID-3.1")?.as_deref(), Some(""));
60
61    Ok(())
62}
examples/redact_absent_empty_null.rs (line 23)
11fn main() -> Result<(), er7_redact::Error> {
12    // PID-1 has a value, PID-2 was sent blank, PID-3 is the explicit null,
13    // and PID-4 onwards was never sent at all.
14    let text = "MSH|^~\\&|LAB\rPID|1||\"\"";
15
16    let policy = Policy::accept_all()
17        .with("PID-1", Action::redacted())?
18        .with("PID-2", Action::redacted())?
19        .with("PID-3", Action::redacted())?
20        .with("PID-9", Action::redacted())?;
21
22    let mut message = er7::parse(text)?;
23    let report = Redactor::new(policy).redact(&mut message);
24
25    // Only the field that carried a value changed.
26    assert_eq!(message.to_er7(), "MSH|^~\\&|LAB\rPID|REDACTED||\"\"");
27    assert_eq!(report.len(), 1);
28
29    // Why each of the other three was left alone:
30    //
31    // PID-2 was empty. Writing REDACTED into it would invent a value, and
32    // would announce that one used to be there — which is a disclosure.
33    assert!(message.segment("PID").unwrap().field(2).unwrap().is_empty());
34    //
35    // PID-3 is the explicit null: an instruction to the receiver to clear
36    // its stored value, not patient data. Overwriting it would turn
37    // "clear this" into a value, and leave a withdrawn record standing.
38    assert!(message.segment("PID").unwrap().field(3).unwrap().is_null());
39    //
40    // PID-9 was never sent. Redaction does not lengthen a segment to reach
41    // a position that is not there: padding would change what the message
42    // says, and eleven new trailing pipes would announce the redaction.
43    assert!(message.segment("PID").unwrap().field(9).is_none());
44
45    // To *make* a position null — to tell the receiver to clear it — ask
46    // for that, which is the one action that changes the shape of a
47    // message, because an HL7 null is a single `""`.
48    let mut message = er7::parse("MSH|^~\\&|LAB\rPID|1||9||SMITH^JOHN")?;
49    let policy = Policy::accept_all().with("PID-5", Action::Null)?;
50    Redactor::new(policy).redact(&mut message);
51    assert_eq!(message.to_er7(), "MSH|^~\\&|LAB\rPID|1||9||\"\"");
52
53    // Compare with `clear`, which says nothing rather than saying "delete".
54    let mut message = er7::parse("MSH|^~\\&|LAB\rPID|1||9||SMITH^JOHN")?;
55    let policy = Policy::accept_all().with("PID-5", Action::Clear)?;
56    Redactor::new(policy).redact(&mut message);
57    assert_eq!(message.to_er7(), "MSH|^~\\&|LAB\rPID|1||9||^");
58
59    println!("absent, empty, and null all survived redaction unchanged");
60    Ok(())
61}
examples/reject_by_default.rs (line 28)
12fn main() -> Result<(), er7_redact::Error> {
13    let text = "MSH|^~\\&|LAB|ACME|EHR|CLINIC|20260815120000||ORU^R01|MSG9|P|2.5\r\
14                PID|1||PATID1234||EVERYWOMAN^EVE||19610615|F\r\
15                OBX|1|NM|2093-3^Cholesterol^LN||187|mg/dL|100-199|H|||F\r\
16                ZPD|1|LOCAL^EXTENSION^SEGMENT";
17
18    // Rejecting by default covers every leaf no rule named; a `keep` rule
19    // accepts a position, exempting it from that. A rejecting rule would
20    // beat the `keep` whichever order the two were written in (D19).
21    let policy = Policy::all_but_the_header()
22        .with("OBX-2", Action::Keep)? // value type
23        .with("OBX-3", Action::Keep)? // observation identifier
24        .with("OBX-5", Action::Keep)? // the number the test asserts on
25        .with("OBX-6", Action::Keep)?; // units
26
27    let mut message = er7::parse(text)?;
28    let report = Redactor::new(policy).redact(&mut message);
29    println!("{}\n", message.to_er7().replace('\r', "\n"));
30
31    // The header is untouched, so the message still routes and still says
32    // which version it is.
33    assert_eq!(message.query("MSH-9")?.as_deref(), Some("ORU^R01"));
34    assert_eq!(message.version().as_deref(), Some("2.5"));
35
36    // The result is intact, because four rules said so.
37    assert_eq!(message.query("OBX-3.2")?.as_deref(), Some("Cholesterol"));
38    assert_eq!(message.query("OBX-5")?.as_deref(), Some("187"));
39
40    // Everything else is gone — including the local segment, which no
41    // curated policy could have known about.
42    assert_eq!(message.query("PID-5.1")?.as_deref(), Some("REDACTED"));
43    assert_eq!(message.query("ZPD-2.1")?.as_deref(), Some("REDACTED"));
44
45    // The cost: values a positional policy would have kept are gone too.
46    assert_eq!(message.query("PID-8")?.as_deref(), Some("REDACTED"));
47    assert_eq!(message.query("OBX-8")?.as_deref(), Some("REDACTED"));
48
49    println!("{} positions changed", report.len());
50
51    // And what this posture is really for: a payload that is not ER7 at
52    // all has no positions to name, so the policy says outright what
53    // becomes of it. The curated policy above refuses one, which is what
54    // makes the CLI exit non-zero rather than write it out.
55    let redactor = Redactor::new(Policy::all_but_the_header());
56    assert_eq!(redactor.unrecognised("{\"name\": \"EVERYWOMAN\"}"), None);
57
58    // `Policy::reject_all` masks it whole instead — nothing routable
59    // survives, and neither does anything else.
60    let redactor = Redactor::new(Policy::reject_all());
61    assert_eq!(
62        redactor.unrecognised("EVERYWOMAN").as_deref(),
63        Some("**********")
64    );
65    Ok(())
66}
examples/write_a_policy.rs (line 67)
8fn main() -> Result<(), er7_redact::Error> {
9    // 1. Built in Rust, rule by rule. Order matters: rules apply in the
10    //    order they are listed. `accept_all` is the starting point that
11    //    redacts nothing until a rule says so — the other one is
12    //    `reject_all`, which redacts everything until a `keep` rule says
13    //    otherwise.
14    let built = Policy::accept_all()
15        .with("PID-3.1", Action::Pseudonym)?
16        .with("PID-5", Action::redacted())?
17        .with("PID-7", Action::First(4))?
18        .with("PID-19", Action::Clear)?;
19
20    // 2. Read from a policy file — the same thing, in the form a team
21    //    reviews in a pull request.
22    let read = Policy::parse(
23        "
24        PID-3.1  pseudonym    # keep linkage, lose the record number
25        PID-5    replace REDACTED
26        PID-7    first 4      # the birth year is enough for most tests
27        PID-19   clear
28        ",
29    )?;
30    assert_eq!(built.rules, read.rules);
31
32    // Both accept by default: a position no rule names is left alone.
33    assert_eq!(built.posture, Posture::Accept);
34    assert_eq!(read.posture, Posture::Accept);
35
36    // They differ on one thing, and it is worth knowing about. A payload
37    // that is not ER7 at all has no positions in it, so no rule can speak
38    // to it. `accept_all` passes one through, because it is a policy that
39    // redacts nothing and says so. A policy *file* that mentions no
40    // disposition refuses one instead: it was written by somebody who may
41    // simply not have considered the case, and refusing loses no value
42    // quietly.
43    assert_eq!(built.unrecognised, Unrecognised::Pass);
44    assert_eq!(read.unrecognised, Unrecognised::Refuse);
45
46    // Either way, say it outright and the two agree.
47    let built = built.on_unrecognised(Unrecognised::Refuse);
48    assert_eq!(built, read);
49
50    // 3. Start from a built-in and add to it. `--show-policy` on the
51    //    command line writes the built-in out as a file to edit.
52    let extended = Policy::patient_identifiers()
53        .with("NTE-3", Action::Clear)? // free text: nothing positional finds what is in here
54        .with("OBX-5", Action::Clear)?;
55    assert_eq!(
56        extended.rules.len(),
57        Policy::patient_identifiers().rules.len() + 2
58    );
59
60    // A policy writes itself back out in the file format, so the one that
61    // ran can be recorded beside the message it redacted.
62    println!("{built}");
63    assert_eq!(Policy::parse(&built.to_string())?, built);
64
65    let text = "MSH|^~\\&|LAB\rPID|1||PATID1234||EVERYWOMAN^EVE||19610615|F";
66    let mut message = er7::parse(text)?;
67    Redactor::new(built).redact(&mut message);
68    assert_eq!(message.query("PID-5.1")?.as_deref(), Some("REDACTED"));
69
70    // A malformed policy is rejected at load time, with the line number:
71    // a typo here means a value that silently was not redacted.
72    let error = Policy::parse("PID-5 obfuscate").unwrap_err();
73    println!("{error}");
74    assert!(error.to_string().contains("policy line 1"));
75
76    Ok(())
77}
Source

pub fn with_key(self, key: u64) -> Redactor

Set the pseudonym key (spec §7.2).

Two data sets redacted under different keys share no pseudonyms and so cannot be joined; two under the same key can. The key is a number in a configuration file, not a managed secret — read spec §7.3 before treating it as one.

Examples found in repository?
examples/pseudonyms_and_linkage.rs (line 21)
14fn main() -> Result<(), er7_redact::Error> {
15    let admit = "MSH|^~\\&|ADT1|MCM||||||ADT^A01|MSG1|P|2.5\r\
16                 PID|1||PATID1234^^^ADT1^MR||EVERYWOMAN^EVE";
17    let result = "MSH|^~\\&|LAB|ACME||||||ORU^R01|MSG2|P|2.5\r\
18                  PID|1||PATID1234^^^ADT1^MR||EVERYWOMAN^EVE\r\
19                  OBX|1|NM|2093-3^Cholesterol^LN||187";
20
21    let redactor = Redactor::new(Policy::patient_identifiers()).with_key(KEY);
22
23    let mut admit = er7::parse(admit)?;
24    let mut result = er7::parse(result)?;
25    redactor.redact(&mut admit);
26    redactor.redact(&mut result);
27
28    // The record number is gone from both...
29    let one = admit.query("PID-3.1")?.expect("a value");
30    let two = result.query("PID-3.1")?.expect("a value");
31    assert_ne!(one, "PATID1234");
32
33    // ...and the two messages still agree that this is the same patient,
34    // which is what makes them useful as a test case.
35    assert_eq!(one, two);
36    println!("both messages now say PID-3.1 = {one}");
37
38    // A different key produces an unrelated mapping, so two data sets
39    // redacted under different keys cannot be joined.
40    let mut other = er7::parse("MSH|^~\\&|ADT1|MCM\rPID|1||PATID1234^^^ADT1^MR")?;
41    Redactor::new(Policy::patient_identifiers())
42        .with_key(1)
43        .redact(&mut other);
44    assert_ne!(other.query("PID-3.1")?.expect("a value"), one);
45
46    // The function is available directly, for building an expected value
47    // in a test.
48    assert_eq!(pseudonym(KEY, "PATID1234"), one);
49
50    // What it costs. A pseudonym preserves equality on purpose, so anyone
51    // holding the redacted data can count how many messages each patient
52    // generated — and anyone holding the key can invert the mapping by
53    // trying every candidate identifier, because record numbers come from
54    // small spaces. Inside your own trust boundary, that is a fair trade;
55    // for data leaving it, clear the value instead.
56    let mut leaving = er7::parse("MSH|^~\\&|ADT1|MCM\rPID|1||PATID1234^^^ADT1^MR")?;
57    let policy = Policy::patient_identifiers().with("PID-3.1", Action::Clear)?;
58    Redactor::new(policy).redact(&mut leaving);
59    assert_eq!(leaving.query("PID-3.1")?.as_deref(), Some(""));
60
61    Ok(())
62}
Source

pub fn policy(&self) -> &Policy

The policy this redactor applies.

Source

pub fn key(&self) -> u64

The pseudonym key.

Source

pub fn unrecognised(&self, payload: &str) -> Option<String>

What to write in place of payload, which did not parse as ER7 (D21, spec §2.8).

None means the policy refuses it: nothing should be written, and the caller reports that the payload did not parse. That is not an error this crate raises — Redactor::redact cannot fail — so the caller decides what a refusal costs. The CLI makes it a diagnostic and exit 1 (spec §10.4).

Some(text) is the payload itself where the policy passes it through, or the policy’s action applied to the whole payload as if it were one value.

Example:

use er7_redact::{Action, Policy, Redactor, Unrecognised};

let junk = "not a message";

// The curated policies refuse a payload they cannot read.
assert_eq!(Redactor::default().unrecognised(junk), None);

// The bare postures each do what their name says.
assert_eq!(
    Redactor::new(Policy::accept_all()).unrecognised(junk).as_deref(),
    Some("not a message"),
);
assert_eq!(
    Redactor::new(Policy::reject_all()).unrecognised(junk).as_deref(),
    Some("*************"),
);

// And any of it is overridable.
let policy = Policy::accept_all().on_unrecognised(Unrecognised::Apply(Action::redacted()));
assert_eq!(
    Redactor::new(policy).unrecognised(junk).as_deref(),
    Some("REDACTED"),
);
Examples found in repository?
examples/reject_by_default.rs (line 56)
12fn main() -> Result<(), er7_redact::Error> {
13    let text = "MSH|^~\\&|LAB|ACME|EHR|CLINIC|20260815120000||ORU^R01|MSG9|P|2.5\r\
14                PID|1||PATID1234||EVERYWOMAN^EVE||19610615|F\r\
15                OBX|1|NM|2093-3^Cholesterol^LN||187|mg/dL|100-199|H|||F\r\
16                ZPD|1|LOCAL^EXTENSION^SEGMENT";
17
18    // Rejecting by default covers every leaf no rule named; a `keep` rule
19    // accepts a position, exempting it from that. A rejecting rule would
20    // beat the `keep` whichever order the two were written in (D19).
21    let policy = Policy::all_but_the_header()
22        .with("OBX-2", Action::Keep)? // value type
23        .with("OBX-3", Action::Keep)? // observation identifier
24        .with("OBX-5", Action::Keep)? // the number the test asserts on
25        .with("OBX-6", Action::Keep)?; // units
26
27    let mut message = er7::parse(text)?;
28    let report = Redactor::new(policy).redact(&mut message);
29    println!("{}\n", message.to_er7().replace('\r', "\n"));
30
31    // The header is untouched, so the message still routes and still says
32    // which version it is.
33    assert_eq!(message.query("MSH-9")?.as_deref(), Some("ORU^R01"));
34    assert_eq!(message.version().as_deref(), Some("2.5"));
35
36    // The result is intact, because four rules said so.
37    assert_eq!(message.query("OBX-3.2")?.as_deref(), Some("Cholesterol"));
38    assert_eq!(message.query("OBX-5")?.as_deref(), Some("187"));
39
40    // Everything else is gone — including the local segment, which no
41    // curated policy could have known about.
42    assert_eq!(message.query("PID-5.1")?.as_deref(), Some("REDACTED"));
43    assert_eq!(message.query("ZPD-2.1")?.as_deref(), Some("REDACTED"));
44
45    // The cost: values a positional policy would have kept are gone too.
46    assert_eq!(message.query("PID-8")?.as_deref(), Some("REDACTED"));
47    assert_eq!(message.query("OBX-8")?.as_deref(), Some("REDACTED"));
48
49    println!("{} positions changed", report.len());
50
51    // And what this posture is really for: a payload that is not ER7 at
52    // all has no positions to name, so the policy says outright what
53    // becomes of it. The curated policy above refuses one, which is what
54    // makes the CLI exit non-zero rather than write it out.
55    let redactor = Redactor::new(Policy::all_but_the_header());
56    assert_eq!(redactor.unrecognised("{\"name\": \"EVERYWOMAN\"}"), None);
57
58    // `Policy::reject_all` masks it whole instead — nothing routable
59    // survives, and neither does anything else.
60    let redactor = Redactor::new(Policy::reject_all());
61    assert_eq!(
62        redactor.unrecognised("EVERYWOMAN").as_deref(),
63        Some("**********")
64    );
65    Ok(())
66}
Source

pub fn redact(&self, message: &mut Message) -> Report

Redact message in place, and report what changed.

This cannot fail (spec §9.2): a rule that matches nothing does nothing, a position that is not there is not created, and an empty or null leaf is left alone.

Examples found in repository?
examples/redact_a_message.rs (line 14)
7fn main() -> Result<(), er7_redact::Error> {
8    let text = "MSH|^~\\&|ADT1|MCM|LABADT|MCM|20260815140000||ADT^A08|MSG00001|P|2.5\r\
9                PID|1||PATID1234^^^ADT1^MR||EVERYWOMAN^EVE^E||19610615|F|||\
10                1200 N ELM STREET^^GREENSBORO^NC^27401-1020||(919)379-1212\r\
11                OBX|1|NM|2093-3^Cholesterol^LN||187|mg/dL|100-199|H|||F";
12
13    let mut message = er7::parse(text)?;
14    let report = Redactor::new(Policy::patient_identifiers()).redact(&mut message);
15
16    println!("{}\n", message.to_er7().replace('\r', "\n"));
17    println!("{} positions changed:\n{report}", report.len());
18
19    // The identifiers are gone.
20    assert_eq!(
21        message.query("PID-5")?.as_deref(),
22        Some("REDACTED^REDACTED^REDACTED")
23    );
24    assert_eq!(message.query("PID-7")?.as_deref(), Some("1961"));
25    assert_eq!(message.query("PID-11")?.as_deref(), Some("^^^^"));
26    assert_eq!(message.query("PID-13")?.as_deref(), Some(""));
27    assert_ne!(message.query("PID-3.1")?.as_deref(), Some("PATID1234"));
28
29    // The message is not.
30    assert_eq!(message.control_id().as_deref(), Some("MSG00001"));
31    assert_eq!(message.query("PID-3.4")?.as_deref(), Some("ADT1")); // assigning authority
32    assert_eq!(message.query("PID-8")?.as_deref(), Some("F")); // not an identifier
33    assert_eq!(message.query("OBX-5")?.as_deref(), Some("187")); // the clinical content
34    assert!(er7::parse(&message.to_er7()).is_ok());
35
36    // And the shape did not move: every position that held a value still
37    // exists, so a test that asserted on PID-11.3 still finds it.
38    assert_eq!(message.query("PID-11.3")?.as_deref(), Some(""));
39
40    Ok(())
41}
More examples
Hide additional examples
examples/read_the_report.rs (line 15)
8fn main() -> Result<(), er7_redact::Error> {
9    let text = "MSH|^~\\&|ADT1|MCM||||||ADT^A08|MSG1|P|2.5\r\
10                PID|1||PATID1234^^^ADT1^MR||EVERYWOMAN^EVE^E||19610615|F|||\
11                12 ELM ST^^BOSTON^MA^02101||555-555-1111~555-555-2222\r\
12                NK1|1|EVERYMAN^ADAM|SPO";
13
14    let mut message = er7::parse(text)?;
15    let report = Redactor::new(Policy::patient_identifiers()).redact(&mut message);
16
17    for change in &report.changes {
18        println!("{:<18} {}", change.path.to_string(), change.action);
19    }
20
21    // One row per leaf that actually changed — so a name in three
22    // components is three rows, and a repeated field is one row per
23    // repetition.
24    let paths: Vec<String> = report.changes.iter().map(|c| c.path.to_string()).collect();
25    assert!(paths.contains(&"PID[1]-5[1].1.1".to_string()));
26    assert!(paths.contains(&"PID[1]-5[1].3.1".to_string()));
27    assert!(paths.contains(&"PID[1]-13[2].1.1".to_string()));
28
29    // Every path is fully qualified, which means it is also a valid
30    // `er7 --query` argument: paste one in to see what is there now.
31    for change in &report.changes {
32        assert!(message.query(&change.path.to_string()).is_ok());
33    }
34
35    // A report carries no values — not the old text, and not the new. A
36    // log line quoting the old value puts the patient's name into the log.
37    let printed = report.to_string();
38    for value in ["EVERYWOMAN", "PATID1234", "19610615", "BOSTON"] {
39        assert!(!printed.contains(value));
40    }
41
42    // And nothing that was not there contributes a row: this message has
43    // no GT1 segment, so the guarantor rules matched nothing, which is not
44    // an error.
45    assert!(!paths.iter().any(|p| p.starts_with("GT1")));
46
47    println!("\n{} positions changed", report.len());
48    Ok(())
49}
examples/pseudonyms_and_linkage.rs (line 25)
14fn main() -> Result<(), er7_redact::Error> {
15    let admit = "MSH|^~\\&|ADT1|MCM||||||ADT^A01|MSG1|P|2.5\r\
16                 PID|1||PATID1234^^^ADT1^MR||EVERYWOMAN^EVE";
17    let result = "MSH|^~\\&|LAB|ACME||||||ORU^R01|MSG2|P|2.5\r\
18                  PID|1||PATID1234^^^ADT1^MR||EVERYWOMAN^EVE\r\
19                  OBX|1|NM|2093-3^Cholesterol^LN||187";
20
21    let redactor = Redactor::new(Policy::patient_identifiers()).with_key(KEY);
22
23    let mut admit = er7::parse(admit)?;
24    let mut result = er7::parse(result)?;
25    redactor.redact(&mut admit);
26    redactor.redact(&mut result);
27
28    // The record number is gone from both...
29    let one = admit.query("PID-3.1")?.expect("a value");
30    let two = result.query("PID-3.1")?.expect("a value");
31    assert_ne!(one, "PATID1234");
32
33    // ...and the two messages still agree that this is the same patient,
34    // which is what makes them useful as a test case.
35    assert_eq!(one, two);
36    println!("both messages now say PID-3.1 = {one}");
37
38    // A different key produces an unrelated mapping, so two data sets
39    // redacted under different keys cannot be joined.
40    let mut other = er7::parse("MSH|^~\\&|ADT1|MCM\rPID|1||PATID1234^^^ADT1^MR")?;
41    Redactor::new(Policy::patient_identifiers())
42        .with_key(1)
43        .redact(&mut other);
44    assert_ne!(other.query("PID-3.1")?.expect("a value"), one);
45
46    // The function is available directly, for building an expected value
47    // in a test.
48    assert_eq!(pseudonym(KEY, "PATID1234"), one);
49
50    // What it costs. A pseudonym preserves equality on purpose, so anyone
51    // holding the redacted data can count how many messages each patient
52    // generated — and anyone holding the key can invert the mapping by
53    // trying every candidate identifier, because record numbers come from
54    // small spaces. Inside your own trust boundary, that is a fair trade;
55    // for data leaving it, clear the value instead.
56    let mut leaving = er7::parse("MSH|^~\\&|ADT1|MCM\rPID|1||PATID1234^^^ADT1^MR")?;
57    let policy = Policy::patient_identifiers().with("PID-3.1", Action::Clear)?;
58    Redactor::new(policy).redact(&mut leaving);
59    assert_eq!(leaving.query("PID-3.1")?.as_deref(), Some(""));
60
61    Ok(())
62}
examples/redact_absent_empty_null.rs (line 23)
11fn main() -> Result<(), er7_redact::Error> {
12    // PID-1 has a value, PID-2 was sent blank, PID-3 is the explicit null,
13    // and PID-4 onwards was never sent at all.
14    let text = "MSH|^~\\&|LAB\rPID|1||\"\"";
15
16    let policy = Policy::accept_all()
17        .with("PID-1", Action::redacted())?
18        .with("PID-2", Action::redacted())?
19        .with("PID-3", Action::redacted())?
20        .with("PID-9", Action::redacted())?;
21
22    let mut message = er7::parse(text)?;
23    let report = Redactor::new(policy).redact(&mut message);
24
25    // Only the field that carried a value changed.
26    assert_eq!(message.to_er7(), "MSH|^~\\&|LAB\rPID|REDACTED||\"\"");
27    assert_eq!(report.len(), 1);
28
29    // Why each of the other three was left alone:
30    //
31    // PID-2 was empty. Writing REDACTED into it would invent a value, and
32    // would announce that one used to be there — which is a disclosure.
33    assert!(message.segment("PID").unwrap().field(2).unwrap().is_empty());
34    //
35    // PID-3 is the explicit null: an instruction to the receiver to clear
36    // its stored value, not patient data. Overwriting it would turn
37    // "clear this" into a value, and leave a withdrawn record standing.
38    assert!(message.segment("PID").unwrap().field(3).unwrap().is_null());
39    //
40    // PID-9 was never sent. Redaction does not lengthen a segment to reach
41    // a position that is not there: padding would change what the message
42    // says, and eleven new trailing pipes would announce the redaction.
43    assert!(message.segment("PID").unwrap().field(9).is_none());
44
45    // To *make* a position null — to tell the receiver to clear it — ask
46    // for that, which is the one action that changes the shape of a
47    // message, because an HL7 null is a single `""`.
48    let mut message = er7::parse("MSH|^~\\&|LAB\rPID|1||9||SMITH^JOHN")?;
49    let policy = Policy::accept_all().with("PID-5", Action::Null)?;
50    Redactor::new(policy).redact(&mut message);
51    assert_eq!(message.to_er7(), "MSH|^~\\&|LAB\rPID|1||9||\"\"");
52
53    // Compare with `clear`, which says nothing rather than saying "delete".
54    let mut message = er7::parse("MSH|^~\\&|LAB\rPID|1||9||SMITH^JOHN")?;
55    let policy = Policy::accept_all().with("PID-5", Action::Clear)?;
56    Redactor::new(policy).redact(&mut message);
57    assert_eq!(message.to_er7(), "MSH|^~\\&|LAB\rPID|1||9||^");
58
59    println!("absent, empty, and null all survived redaction unchanged");
60    Ok(())
61}
examples/reject_by_default.rs (line 28)
12fn main() -> Result<(), er7_redact::Error> {
13    let text = "MSH|^~\\&|LAB|ACME|EHR|CLINIC|20260815120000||ORU^R01|MSG9|P|2.5\r\
14                PID|1||PATID1234||EVERYWOMAN^EVE||19610615|F\r\
15                OBX|1|NM|2093-3^Cholesterol^LN||187|mg/dL|100-199|H|||F\r\
16                ZPD|1|LOCAL^EXTENSION^SEGMENT";
17
18    // Rejecting by default covers every leaf no rule named; a `keep` rule
19    // accepts a position, exempting it from that. A rejecting rule would
20    // beat the `keep` whichever order the two were written in (D19).
21    let policy = Policy::all_but_the_header()
22        .with("OBX-2", Action::Keep)? // value type
23        .with("OBX-3", Action::Keep)? // observation identifier
24        .with("OBX-5", Action::Keep)? // the number the test asserts on
25        .with("OBX-6", Action::Keep)?; // units
26
27    let mut message = er7::parse(text)?;
28    let report = Redactor::new(policy).redact(&mut message);
29    println!("{}\n", message.to_er7().replace('\r', "\n"));
30
31    // The header is untouched, so the message still routes and still says
32    // which version it is.
33    assert_eq!(message.query("MSH-9")?.as_deref(), Some("ORU^R01"));
34    assert_eq!(message.version().as_deref(), Some("2.5"));
35
36    // The result is intact, because four rules said so.
37    assert_eq!(message.query("OBX-3.2")?.as_deref(), Some("Cholesterol"));
38    assert_eq!(message.query("OBX-5")?.as_deref(), Some("187"));
39
40    // Everything else is gone — including the local segment, which no
41    // curated policy could have known about.
42    assert_eq!(message.query("PID-5.1")?.as_deref(), Some("REDACTED"));
43    assert_eq!(message.query("ZPD-2.1")?.as_deref(), Some("REDACTED"));
44
45    // The cost: values a positional policy would have kept are gone too.
46    assert_eq!(message.query("PID-8")?.as_deref(), Some("REDACTED"));
47    assert_eq!(message.query("OBX-8")?.as_deref(), Some("REDACTED"));
48
49    println!("{} positions changed", report.len());
50
51    // And what this posture is really for: a payload that is not ER7 at
52    // all has no positions to name, so the policy says outright what
53    // becomes of it. The curated policy above refuses one, which is what
54    // makes the CLI exit non-zero rather than write it out.
55    let redactor = Redactor::new(Policy::all_but_the_header());
56    assert_eq!(redactor.unrecognised("{\"name\": \"EVERYWOMAN\"}"), None);
57
58    // `Policy::reject_all` masks it whole instead — nothing routable
59    // survives, and neither does anything else.
60    let redactor = Redactor::new(Policy::reject_all());
61    assert_eq!(
62        redactor.unrecognised("EVERYWOMAN").as_deref(),
63        Some("**********")
64    );
65    Ok(())
66}
examples/write_a_policy.rs (line 67)
8fn main() -> Result<(), er7_redact::Error> {
9    // 1. Built in Rust, rule by rule. Order matters: rules apply in the
10    //    order they are listed. `accept_all` is the starting point that
11    //    redacts nothing until a rule says so — the other one is
12    //    `reject_all`, which redacts everything until a `keep` rule says
13    //    otherwise.
14    let built = Policy::accept_all()
15        .with("PID-3.1", Action::Pseudonym)?
16        .with("PID-5", Action::redacted())?
17        .with("PID-7", Action::First(4))?
18        .with("PID-19", Action::Clear)?;
19
20    // 2. Read from a policy file — the same thing, in the form a team
21    //    reviews in a pull request.
22    let read = Policy::parse(
23        "
24        PID-3.1  pseudonym    # keep linkage, lose the record number
25        PID-5    replace REDACTED
26        PID-7    first 4      # the birth year is enough for most tests
27        PID-19   clear
28        ",
29    )?;
30    assert_eq!(built.rules, read.rules);
31
32    // Both accept by default: a position no rule names is left alone.
33    assert_eq!(built.posture, Posture::Accept);
34    assert_eq!(read.posture, Posture::Accept);
35
36    // They differ on one thing, and it is worth knowing about. A payload
37    // that is not ER7 at all has no positions in it, so no rule can speak
38    // to it. `accept_all` passes one through, because it is a policy that
39    // redacts nothing and says so. A policy *file* that mentions no
40    // disposition refuses one instead: it was written by somebody who may
41    // simply not have considered the case, and refusing loses no value
42    // quietly.
43    assert_eq!(built.unrecognised, Unrecognised::Pass);
44    assert_eq!(read.unrecognised, Unrecognised::Refuse);
45
46    // Either way, say it outright and the two agree.
47    let built = built.on_unrecognised(Unrecognised::Refuse);
48    assert_eq!(built, read);
49
50    // 3. Start from a built-in and add to it. `--show-policy` on the
51    //    command line writes the built-in out as a file to edit.
52    let extended = Policy::patient_identifiers()
53        .with("NTE-3", Action::Clear)? // free text: nothing positional finds what is in here
54        .with("OBX-5", Action::Clear)?;
55    assert_eq!(
56        extended.rules.len(),
57        Policy::patient_identifiers().rules.len() + 2
58    );
59
60    // A policy writes itself back out in the file format, so the one that
61    // ran can be recorded beside the message it redacted.
62    println!("{built}");
63    assert_eq!(Policy::parse(&built.to_string())?, built);
64
65    let text = "MSH|^~\\&|LAB\rPID|1||PATID1234||EVERYWOMAN^EVE||19610615|F";
66    let mut message = er7::parse(text)?;
67    Redactor::new(built).redact(&mut message);
68    assert_eq!(message.query("PID-5.1")?.as_deref(), Some("REDACTED"));
69
70    // A malformed policy is rejected at load time, with the line number:
71    // a typo here means a value that silently was not redacted.
72    let error = Policy::parse("PID-5 obfuscate").unwrap_err();
73    println!("{error}");
74    assert!(error.to_string().contains("policy line 1"));
75
76    Ok(())
77}

Trait Implementations§

Source§

impl Clone for Redactor

Source§

fn clone(&self) -> Redactor

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for Redactor

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl Default for Redactor

Source§

fn default() -> Redactor

The curated policy (Policy::patient_identifiers) with key 0 — the same thing the command line does when no policy is given.

Source§

impl Eq for Redactor

Source§

impl PartialEq for Redactor

Source§

fn eq(&self, other: &Redactor) -> bool

Equality operator ==. Read more
1.0.0 (const: unstable) · Source§

fn ne(&self, other: &Rhs) -> bool

Inequality operator !=. Read more
Source§

impl StructuralPartialEq for Redactor

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

Source§

impl<T, U> Into<U> for T
where U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

That is, this conversion is whatever the implementation of From<T> for U chooses to do.

Source§

impl<T> ToOwned for T
where T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.