pub struct Policy {
pub rules: Vec<Rule>,
pub posture: Posture,
pub unrecognised: Unrecognised,
}Expand description
An ordered list of rules, plus what to do with everything they do not name.
Rules apply in order, each to the message as it stands (D7, spec
§2.4). The Posture then runs over every leaf that no rule named
(D9, spec §2.6), and Unrecognised covers a payload that is not ER7
at all (D21, spec §2.8).
§A reject beats an accept (D19)
A rule whose action is Action::Keep accepts the position it
names; any other action rejects it. Where a leaf is named by both,
the rejecting rule wins — whichever order the two rules are in, and
at whatever depth, so a reject naming a whole segment beats an accept
naming one field inside it.
A leaf named by both is a policy somebody got wrong, and redacting it is the direction that fails safely (spec §1.5, priority 1): a value redacted by mistake costs a policy edit, and a value left behind by mistake cannot be recalled.
The mirror of that rule: an accept naming a whole segment is not
narrowed by the posture. MSH keep exempts every leaf of the header,
including ones the policy’s author never saw. Only a reject rule
reaches back into it.
Example:
use er7_redact::{Action, Policy, Redactor};
// Redact what is listed...
let listed = Policy::accept_all()
.with("PID-5", Action::redacted())?
.with("PID-7", Action::First(4))?;
// ...or redact everything that is not.
let everything_else = Policy::reject_all().with("MSH", Action::Keep)?;
let mut message = er7::parse("MSH|^~\\&|LAB\rPID|1||9||SMITH^JOHN||19610615")?;
Redactor::new(listed).redact(&mut message);
assert_eq!(message.to_er7(), "MSH|^~\\&|LAB\rPID|1||9||REDACTED^REDACTED||1961");Fields§
§rules: Vec<Rule>The rules, in the order they apply.
posture: PostureWhat every leaf no rule named gets.
unrecognised: UnrecognisedWhat a payload that is not ER7 gets.
Implementations§
Source§impl Policy
impl Policy
Sourcepub fn accept_all() -> Policy
pub fn accept_all() -> Policy
Accept everything: no rules, nothing redacted, and a payload that is not ER7 passed through unchanged (spec §5.6).
This is the starting point for building a policy rule by rule. On
its own it does nothing at all, and it says so: a policy named
“accept all” that quietly replaced an unparseable payload with
*** would be the one surprise it has no excuse for.
A policy file that states no defaults is not quite this: it
accepts by default too, but it refuses an unrecognised payload,
because it was written by somebody who did not think about one
(spec §6.1). Ask for Unrecognised::Pass in the file to get it.
Example:
use er7_redact::{Action, Policy, Posture, Redactor, Unrecognised};
let policy = Policy::accept_all();
assert_eq!(policy.posture, Posture::Accept);
assert_eq!(policy.unrecognised, Unrecognised::Pass);
assert!(policy.is_empty());
// It changes nothing, and reports nothing.
let mut message = er7::parse("MSH|^~\\&|LAB\rPID|1||9||SMITH")?;
let report = Redactor::new(policy).redact(&mut message);
assert_eq!(message.to_er7(), "MSH|^~\\&|LAB\rPID|1||9||SMITH");
assert!(report.is_empty());Examples found in repository?
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}More examples
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 reject_all() -> Policy
pub fn reject_all() -> Policy
Reject everything: no rules, replace REDACTED over every leaf,
and a payload that is not ER7 masked whole (spec §5.6).
The strictest thing in the crate, and it takes the header with it:
everything from MSH-3 on reads REDACTED, so the message is no
longer routable or identifiable. Policy::all_but_the_header is
the same posture with the header kept, and is usually what is
wanted.
MSH-1 and MSH-2 survive, as they survive everything: they are
the delimiters themselves (D5, spec §4.4).
Example:
use er7_redact::{Action, Policy, Redactor};
let policy = Policy::reject_all().with("OBX-2", Action::Keep)?;
let mut message = er7::parse("MSH|^~\\&|LAB\rOBX|1|NM|2093-3||187")?;
Redactor::new(policy).redact(&mut message);
assert_eq!(
message.to_er7(),
"MSH|^~\\&|REDACTED\rOBX|REDACTED|NM|REDACTED||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 patient_identifiers() -> Policy
pub fn patient_identifiers() -> Policy
The curated policy: the positions that carry a patient identifier
in PID, NK1, PV1, GT1, and IN1.
It accepts by default, so a position the table does not name is left as it is, and it refuses a payload that is not ER7: a list of positions has no opinion about input with no positions in it, and refusing is the fail-closed answer (spec §2.8).
The whole table is written out in spec §5.1, with a reason for each
action. It is a starting point, not a compliance certification
(D14): it does not touch free text, quasi-identifiers, or local Z
segments, and it does not know which positions your senders
actually use. Read spec §5.4 and §5.5 before relying on it.
Example:
use er7_redact::{Policy, Redactor};
let mut message = er7::parse(
"MSH|^~\\&|LAB\rPID|1||PATID1234||EVERYWOMAN^EVE||19610615|F|||12 ELM ST^^BOSTON",
)?;
let report = Redactor::new(Policy::patient_identifiers()).redact(&mut message);
assert_eq!(message.query("PID-5.1")?.as_deref(), Some("REDACTED"));
assert_eq!(message.query("PID-7")?.as_deref(), Some("1961"));
assert_eq!(message.query("PID-11.1")?.as_deref(), Some(""));
assert_ne!(message.query("PID-3")?.as_deref(), Some("PATID1234"));
// The sex is not an identifier, so the default policy leaves it.
assert_eq!(message.query("PID-8")?.as_deref(), Some("F"));
assert!(!report.is_empty());§Panics
Only if the table below is edited to hold something that is not an
HL7 path — every entry is a literal, and
the_documented_positions_match_the_built_in_policy in
tests/integration.rs checks the whole table against spec §5.1.
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}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 all_but_the_header() -> Policy
pub fn all_but_the_header() -> Policy
The other posture, curated: reject every value, and keep the MSH
header so the message stays routable (spec §5.2).
Use it when the message is unfamiliar, or when the answer to “is
there anything else in here?” has to be “no” rather than “not that
I listed”. The cost is that nothing below MSH is clinically
meaningful afterwards; add Keep rules for what a test needs.
Like Policy::patient_identifiers it refuses a payload that
is not ER7 rather than guessing (spec §2.8).
The header exception is an ordinary accept rule, so an ordinary
reject rule overrides it (D19) — .with("MSH", Action::redacted())
takes the header too.
Example:
use er7_redact::{Action, Policy, Redactor};
let policy = Policy::all_but_the_header().with("OBX-2", Action::Keep)?;
let mut message = er7::parse("MSH|^~\\&|LAB\rOBX|1|NM|2093-3||187")?;
Redactor::new(policy).redact(&mut message);
assert_eq!(
message.to_er7(),
"MSH|^~\\&|LAB\rOBX|REDACTED|NM|REDACTED||REDACTED",
);§Panics
Only if the MSH literal below stops being an HL7 path, which no
caller can cause.
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 with(self, path: &str, action: Action) -> Result<Policy, Error>
pub fn with(self, path: &str, action: Action) -> Result<Policy, Error>
Add a rule, for building a policy in one expression.
§Errors
Error::Er7 when path is not an HL7 path; see Rule::new.
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}More examples
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 posture(self, posture: Posture) -> Policy
pub fn posture(self, posture: Posture) -> Policy
Set what every leaf no rule named gets (spec §2.6).
Posture::Reject with Action::Keep is normalised to
Posture::Accept, so that the policy file’s reject keep and
this method agree about what they mean.
This is the only way to make a policy less strict: appending one
policy to another never weakens it (D20, Policy::append).
Sourcepub fn on_unrecognised(self, unrecognised: Unrecognised) -> Policy
pub fn on_unrecognised(self, unrecognised: Unrecognised) -> Policy
Set what a payload that is not ER7 gets (spec §2.8).
Unrecognised::Apply with an action that writes nothing —
Action::Keep or Action::Null — is normalised to
Unrecognised::Pass, which is what it does.
Examples found in repository?
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 parse(text: &str) -> Result<Policy, Error>
pub fn parse(text: &str) -> Result<Policy, Error>
Read a policy file (spec §6).
Blank lines and # comments are ignored; every other line is
either a path, whitespace, and an action, in the order they apply,
or one of the three reserved first words — accept, reject, and
unrecognised — that set what the policy does by default.
A file that states no defaults accepts by default and refuses a
payload that is not ER7: unlike Policy::accept_all, a file was
written by somebody who may simply not have considered one, and
refusing is the answer that cannot lose a value quietly.
Example:
use er7_redact::{Action, Policy, Posture, Unrecognised};
let policy = Policy::parse("
MSH keep # everything but the header...
OBX-5 keep # ...and the numbers the test asserts on
reject replace REDACTED
unrecognised mask *
")?;
assert_eq!(policy.rules.len(), 2);
assert_eq!(policy.posture, Posture::Reject(Action::redacted()));
assert_eq!(policy.unrecognised, Unrecognised::Apply(Action::Mask('*')));
// A file that says nothing accepts, and refuses what it cannot read.
let quiet = Policy::parse("PID-5 clear")?;
assert_eq!(quiet.posture, Posture::Accept);
assert_eq!(quiet.unrecognised, Unrecognised::Refuse);
// A malformed line names itself.
let e = Policy::parse("PID-5 obfuscate").unwrap_err();
assert_eq!(e.to_string(), "policy line 1: \"PID-5 obfuscate\": unknown action \"obfuscate\"");§Errors
Error::BadPolicy naming the line number, the line, and the
problem. Reading a policy is the one place this crate is strict,
because a typo means a value that silently was not redacted (spec
§6.4).
Examples found in repository?
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 append(&mut self, other: Policy)
pub fn append(&mut self, other: Policy)
Append another policy’s rules, take the stricter posture, and take the appended policy’s disposition for an unrecognised payload (D20, spec §2.6).
This is how the command line concatenates several --policy files
and --rule arguments; order is significant for the rules (D7).
The posture cannot be weakened by appending, and deliberately:
a file of extra rules says nothing about its posture, so it accepts
by default, and adopting that would switch redaction off for
everything the file did not name. Silence is indistinguishable from
a decision, so silence is not trusted. To relax a posture, say so
with Policy::posture.
The disposition for an unrecognised payload is different, and
the appended policy’s wins outright. Nothing is silent there:
Policy::parse gives a file that says nothing the strictest
disposition there is, Unrecognised::Refuse, so every value one
carries is somebody’s decision — and a file that goes to the
trouble of writing unrecognised pass should not be quietly
overruled by a default it never saw.
Example:
use er7_redact::{Action, Policy, Posture};
let mut policy = Policy::all_but_the_header();
policy.append(Policy::parse("OBX-2 keep")?);
// The appended file accepts by default; the strict policy still rejects.
assert_eq!(policy.posture, Posture::Reject(Action::redacted()));
// And a stricter action in the appended policy does win.
policy.append(Policy::parse("reject clear")?);
assert_eq!(policy.posture, Posture::Reject(Action::Clear));Trait Implementations§
Source§impl Display for Policy
impl Display for Policy
Source§fn fmt(&self, f: &mut Formatter<'_>) -> Result
fn fmt(&self, f: &mut Formatter<'_>) -> Result
The canonical policy file (spec §6.5): one rule per line, paths padded to a common width, then the two default lines — always both, whatever they say, so that a reader never has to know which default was the quiet one.
Example:
use er7_redact::{Action, Policy};
let policy = Policy::accept_all()
.with("PID-5", Action::redacted())?
.with("PID-7", Action::First(4))?
.posture(er7_redact::Posture::Reject(Action::Clear));
assert_eq!(policy.to_string(), "\
PID-5 replace REDACTED
PID-7 first 4
reject clear
unrecognised pass
");
// And it reads back as the same policy.
assert_eq!(Policy::parse(&policy.to_string())?, policy);