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
impl Redactor
Sourcepub fn new(policy: Policy) -> Redactor
pub fn new(policy: Policy) -> Redactor
A redactor for this policy, with the default pseudonym key 0.
Examples found in repository?
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
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}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}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}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}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}Sourcepub fn with_key(self, key: u64) -> Redactor
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?
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}Sourcepub fn unrecognised(&self, payload: &str) -> Option<String>
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?
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}Sourcepub fn redact(&self, message: &mut Message) -> Report
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?
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
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}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}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}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}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 Default for Redactor
impl Default for Redactor
Source§fn default() -> Redactor
fn default() -> Redactor
The curated policy (Policy::patient_identifiers) with key 0 —
the same thing the command line does when no policy is given.