use std::collections::HashSet;
use std::fmt;
use er7::message::NULL;
use er7::{Component, Field, Message, Path, Repetition, Segment, Separators, Subcomponent};
use crate::{Action, Policy, Posture, Unrecognised};
type Position = (usize, usize, usize, usize, usize);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Change {
pub path: Path,
pub action: Action,
}
impl fmt::Display for Change {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} {}", self.path, self.action)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Report {
pub changes: Vec<Change>,
}
impl Report {
#[must_use]
pub fn is_empty(&self) -> bool {
self.changes.is_empty()
}
#[must_use]
pub fn len(&self) -> usize {
self.changes.len()
}
}
impl fmt::Display for Report {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for change in &self.changes {
writeln!(f, "{change}")?;
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Redactor {
policy: Policy,
key: u64,
}
impl Redactor {
#[must_use]
pub fn new(policy: Policy) -> Redactor {
Redactor { policy, key: 0 }
}
#[must_use]
pub fn with_key(mut self, key: u64) -> Redactor {
self.key = key;
self
}
#[must_use]
pub fn policy(&self) -> &Policy {
&self.policy
}
#[must_use]
pub fn key(&self) -> u64 {
self.key
}
#[must_use]
pub fn unrecognised(&self, payload: &str) -> Option<String> {
match &self.policy.unrecognised {
Unrecognised::Refuse => None,
Unrecognised::Pass => Some(payload.to_string()),
Unrecognised::Apply(action) => Some(
action
.apply(payload, self.key)
.unwrap_or_else(|| payload.to_string()),
),
}
}
pub fn redact(&self, message: &mut Message) -> Report {
let mut counts: Vec<usize> = Vec::with_capacity(message.segments.len());
let names: Vec<String> = message.segments.iter().map(|s| s.name.clone()).collect();
for (index, name) in names.iter().enumerate() {
counts.push(names[..index].iter().filter(|n| *n == name).count() + 1);
}
let mut pass = Pass {
key: self.key,
separators: message.separators,
named: HashSet::new(),
report: Report::default(),
};
for rule in &self.policy.rules {
for index in 0..message.segments.len() {
if names[index] != rule.path.segment {
continue;
}
if rule
.path
.segment_occurrence
.is_some_and(|wanted| wanted != counts[index])
{
continue;
}
let at = At {
name: &names[index],
index,
occurrence: counts[index],
};
pass.segment(&mut message.segments[index], at, &rule.path, &rule.action);
}
}
if let Posture::Reject(action) = &self.policy.posture {
for index in 0..message.segments.len() {
let at = At {
name: &names[index],
index,
occurrence: counts[index],
};
pass.reject_the_rest(&mut message.segments[index], at, action);
}
}
pass.report
}
}
impl Default for Redactor {
fn default() -> Redactor {
Redactor::new(Policy::patient_identifiers())
}
}
#[derive(Debug, Clone, Copy)]
struct At<'a> {
name: &'a str,
index: usize,
occurrence: usize,
}
struct Pass {
key: u64,
separators: Separators,
named: HashSet<Position>,
report: Report,
}
impl Pass {
fn segment(&mut self, segment: &mut Segment, at: At, path: &Path, action: &Action) {
let header = segment.is_header();
let numbers: Vec<usize> = match path.field {
Some(number) => vec![number],
None => (1..=segment.fields.len()).collect(),
};
for number in numbers {
if header && number <= 2 {
continue;
}
let Some(field) = segment.field_mut(number) else {
continue;
};
if action == &Action::Null && path.repetition.is_none() && path.component.is_none() {
if !field.is_null() {
*field = null_field();
self.record(at, number, 1, 1, 1, action);
}
continue;
}
let repetitions: Vec<usize> = match path.repetition {
Some(number) => vec![number],
None => (1..=field.repetitions.len()).collect(),
};
for repetition in repetitions {
let Some(node) = field.repetition_mut(repetition) else {
continue;
};
if action == &Action::Null && path.component.is_none() {
if !node.is_null() {
*node = null_repetition();
self.record(at, number, repetition, 1, 1, action);
}
continue;
}
self.repetition(node, at, (number, repetition), path, action);
}
}
}
fn repetition(
&mut self,
repetition: &mut Repetition,
at: At,
(field, index): (usize, usize),
path: &Path,
action: &Action,
) {
let numbers: Vec<usize> = match path.component {
Some(number) => vec![number],
None => (1..=repetition.components.len()).collect(),
};
for number in numbers {
let Some(component) = repetition.component_mut(number) else {
continue;
};
if action == &Action::Null && path.subcomponent.is_none() {
if !component.is_null() {
*component = null_component();
self.record(at, field, index, number, 1, action);
}
continue;
}
let subcomponents: Vec<usize> = match path.subcomponent {
Some(number) => vec![number],
None => (1..=component.subcomponents.len()).collect(),
};
for subcomponent in subcomponents {
let Some(leaf) = component.subcomponent_mut(subcomponent) else {
continue;
};
let position = (at.index, field, index, number, subcomponent);
self.named.insert(position);
if self.leaf(leaf, action) {
self.record(at, field, index, number, subcomponent, action);
}
}
}
}
fn reject_the_rest(&mut self, segment: &mut Segment, at: At, action: &Action) {
let header = segment.is_header();
for field in 1..=segment.fields.len() {
if header && field <= 2 {
continue;
}
let Some(node) = segment.field_mut(field) else {
continue;
};
for repetition in 1..=node.repetitions.len() {
let Some(node) = node.repetition_mut(repetition) else {
continue;
};
for component in 1..=node.components.len() {
let Some(node) = node.component_mut(component) else {
continue;
};
for subcomponent in 1..=node.subcomponents.len() {
let position = (at.index, field, repetition, component, subcomponent);
if self.named.contains(&position) {
continue;
}
let Some(leaf) = node.subcomponent_mut(subcomponent) else {
continue;
};
if self.leaf(leaf, action) {
self.record(at, field, repetition, component, subcomponent, action);
}
}
}
}
}
}
fn leaf(&mut self, leaf: &mut Subcomponent, action: &Action) -> bool {
if action == &Action::Null {
if leaf.is_null() {
return false;
}
leaf.raw = NULL.to_string();
return true;
}
if leaf.is_empty() || leaf.is_null() {
return false;
}
let value = leaf.value(&self.separators).into_owned();
let Some(replacement) = action.apply(&value, self.key) else {
return false;
};
if replacement == value {
return false;
}
leaf.set(&replacement, &self.separators);
true
}
fn record(
&mut self,
at: At,
field: usize,
repetition: usize,
component: usize,
subcomponent: usize,
action: &Action,
) {
self.report.changes.push(Change {
path: Path {
segment: at.name.to_string(),
segment_occurrence: Some(at.occurrence),
field: Some(field),
repetition: Some(repetition),
component: Some(component),
subcomponent: Some(subcomponent),
},
action: action.clone(),
});
}
}
fn null_subcomponent() -> Subcomponent {
Subcomponent::new(NULL)
}
fn null_component() -> Component {
Component {
subcomponents: vec![null_subcomponent()],
}
}
fn null_repetition() -> Repetition {
Repetition {
components: vec![null_component()],
}
}
fn null_field() -> Field {
Field {
repetitions: vec![null_repetition()],
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Rule;
const ADT: &str = "MSH|^~\\&|ADT1|MCM|LABADT|MCM|20260815140000||ADT^A08|MSG00001|P|2.5\r\
PID|1||PATID1234^5^M11^ADT1^MR~123456789^^^USSSA^SS||\
JONES^WILLIAM^A^III||19610615|M||C|1200 N ELM STREET^^GREENSBORO^NC\r\
NK1|1|JONES^BARBARA^K|SPO\r\
OBX|1|NM|2093-3^Cholesterol^LN||187|mg/dL";
fn message() -> Message {
er7::parse(ADT).expect("sample parses")
}
fn redact(policy: Policy, message: &mut Message) -> Report {
Redactor::new(policy).redact(message)
}
fn policy(rules: &[&str]) -> Policy {
let mut policy = Policy::accept_all();
for rule in rules {
policy.rules.push(Rule::parse(rule).expect("rule parses"));
}
policy
}
fn shape(message: &Message) -> Vec<usize> {
let mut counts = vec![message.segments.len()];
for segment in &message.segments {
counts.push(segment.fields.len());
for field in &segment.fields {
counts.push(field.repetitions.len());
for repetition in &field.repetitions {
counts.push(repetition.components.len());
for component in &repetition.components {
counts.push(component.subcomponents.len());
}
}
}
}
counts
}
#[test]
fn preserves_the_shape() {
let before = shape(&message());
for rules in [
vec!["PID-5 replace REDACTED"],
vec!["PID-3 pseudonym", "PID-7 first 4"],
vec!["PID-11 clear"],
vec!["OBX-5 mask *"],
] {
let mut message = message();
redact(policy(&rules), &mut message);
assert_eq!(shape(&message), before, "{rules:?} changed the shape");
assert!(er7::parse(&message.to_er7()).is_ok());
}
}
#[test]
fn does_not_create_a_position() {
let mut message = message();
let report = redact(policy(&["PID-99 replace X", "ZZZ-1 clear"]), &mut message);
assert!(report.is_empty());
assert_eq!(message.to_er7(), ADT);
}
#[test]
fn leaves_an_empty_leaf_empty() {
let mut message = er7::parse("MSH|^~\\&|LAB\rPID|1||||^JOHN").unwrap();
let report = redact(
policy(&["PID-2 replace X", "PID-5 replace X"]),
&mut message,
);
assert_eq!(message.to_er7(), "MSH|^~\\&|LAB\rPID|1||||^X");
assert_eq!(report.len(), 1);
}
#[test]
fn leaves_an_explicit_null_alone() {
let mut message = er7::parse("MSH|^~\\&|LAB\rPID|1|\"\"|A").unwrap();
let report = redact(
policy(&["PID-2 replace X", "PID-3 replace X"]),
&mut message,
);
assert_eq!(message.to_er7(), "MSH|^~\\&|LAB\rPID|1|\"\"|X");
assert_eq!(report.len(), 1);
}
#[test]
fn never_touches_the_delimiter_fields() {
let mut message = message();
let mut policy = policy(&["MSH-1 replace X", "MSH-2 clear", "MSH-3 replace X"]);
policy = policy.posture(Posture::Reject(Action::Mask('#')));
redact(policy, &mut message);
assert!(message.to_er7().starts_with("MSH|^~\\&|X|"));
assert!(er7::parse(&message.to_er7()).is_ok());
}
#[test]
fn null_collapses_the_named_position() {
let mut message = er7::parse("MSH|^~\\&|LAB\rPID|1||9||SMITH^JOHN").unwrap();
let report = redact(policy(&["PID-5 null"]), &mut message);
assert_eq!(message.to_er7(), "MSH|^~\\&|LAB\rPID|1||9||\"\"");
assert_eq!(report.len(), 1);
assert_eq!(report.changes[0].path.to_string(), "PID[1]-5[1].1.1");
let mut message = er7::parse("MSH|^~\\&|LAB\rPID|1||9||SMITH^JOHN").unwrap();
redact(policy(&["PID-5.1 null"]), &mut message);
assert_eq!(message.to_er7(), "MSH|^~\\&|LAB\rPID|1||9||\"\"^JOHN");
let mut message = er7::parse("MSH|^~\\&|LAB\rPID|1|\"\"").unwrap();
assert!(redact(policy(&["PID-2 null"]), &mut message).is_empty());
}
#[test]
fn applies_rules_in_order() {
let mut message = er7::parse("MSH|^~\\&|LAB\rPID|1||PATID1234").unwrap();
redact(
policy(&["PID-3 replace SMITH", "PID-3 first 2"]),
&mut message,
);
assert_eq!(message.query("PID-3").unwrap().as_deref(), Some("SM"));
let mut message = er7::parse("MSH|^~\\&|LAB\rPID|1||PATID1234").unwrap();
redact(
policy(&["PID-3 replace REDACTED", "PID-3 keep"]),
&mut message,
);
assert_eq!(message.query("PID-3").unwrap().as_deref(), Some("REDACTED"));
}
#[test]
fn a_rule_that_matches_nothing_does_nothing() {
let mut message = message();
let report = redact(Policy::patient_identifiers(), &mut message);
assert!(!report.is_empty());
assert!(!report.changes.iter().any(|c| c.path.segment == "GT1"));
assert!(!report.changes.iter().any(|c| c.path.segment == "IN1"));
}
#[test]
fn rejecting_by_default_covers_what_no_rule_named() {
let mut message = er7::parse("MSH|^~\\&|LAB\rOBX|1|NM|2093-3||187").unwrap();
let policy =
policy(&["MSH keep", "OBX-2 keep"]).posture(Posture::Reject(Action::redacted()));
redact(policy, &mut message);
assert_eq!(
message.to_er7(),
"MSH|^~\\&|LAB\rOBX|REDACTED|NM|REDACTED||REDACTED"
);
}
#[test]
fn a_segment_wide_accept_is_not_narrowed() {
let text = "MSH|^~\\&|LAB|ACME|EHR|CLINIC|20260815120000\rOBX|1|NM|2093-3||187";
let mut message = er7::parse(text).unwrap();
let policy = policy(&["MSH keep"]).posture(Posture::Reject(Action::redacted()));
redact(policy, &mut message);
assert_eq!(
message.to_er7(),
"MSH|^~\\&|LAB|ACME|EHR|CLINIC|20260815120000\rOBX|REDACTED|REDACTED|REDACTED||REDACTED"
);
}
#[test]
fn reject_beats_accept_for_the_same_field() {
for rules in [
vec!["PID-5 keep", "PID-5 replace REDACTED"],
vec!["PID-5 replace REDACTED", "PID-5 keep"],
] {
let mut message = er7::parse("MSH|^~\\&|LAB\rPID|1||9||SMITH").unwrap();
redact(policy(&rules), &mut message);
assert_eq!(
message.query("PID-5").unwrap().as_deref(),
Some("REDACTED"),
"{rules:?} let the name through"
);
}
let mut message = er7::parse("MSH|^~\\&|LAB\rPID|1||9||SMITH^JOHN").unwrap();
let policy = policy(&["MSH keep", "PID-5 keep", "PID-5.1 replace REDACTED"])
.posture(Posture::Reject(Action::Clear));
redact(policy, &mut message);
assert_eq!(
message.query("PID-5").unwrap().as_deref(),
Some("REDACTED^JOHN")
);
}
#[test]
fn reject_segment_beats_a_narrower_accept() {
for rules in [
vec!["PID replace REDACTED", "PID-5 keep"],
vec!["PID-5 keep", "PID replace REDACTED"],
] {
let mut message = er7::parse("MSH|^~\\&|LAB\rPID|1||9||SMITH").unwrap();
redact(policy(&rules), &mut message);
assert_eq!(
message.query("PID-5").unwrap().as_deref(),
Some("REDACTED"),
"{rules:?} carved the name out of a rejected segment"
);
}
}
#[test]
fn an_unrecognised_payload_follows_the_policy() {
let junk = "{\"name\": \"EVERYWOMAN\"}";
assert_eq!(
Redactor::new(Policy::patient_identifiers()).unrecognised(junk),
None
);
assert_eq!(
Redactor::new(Policy::all_but_the_header()).unrecognised(junk),
None
);
assert_eq!(
Redactor::new(Policy::accept_all())
.unrecognised(junk)
.as_deref(),
Some(junk)
);
let masked = Redactor::new(Policy::reject_all())
.unrecognised(junk)
.expect("reject_all writes something");
assert_eq!(masked, "*".repeat(junk.chars().count()));
assert!(!masked.contains("EVERYWOMAN"));
let policy = Policy::patient_identifiers().on_unrecognised(Unrecognised::Pass);
assert_eq!(
Redactor::new(policy).unrecognised(junk).as_deref(),
Some(junk)
);
let policy = Policy::accept_all().on_unrecognised(Unrecognised::Refuse);
assert_eq!(Redactor::new(policy).unrecognised(junk), None);
let policy = Policy::accept_all().on_unrecognised(Unrecognised::Apply(Action::Clear));
assert_eq!(
Redactor::new(policy).unrecognised(junk).as_deref(),
Some("")
);
}
#[test]
fn a_report_carries_no_values() {
let mut message = message();
let report = redact(Policy::patient_identifiers(), &mut message);
let text = report.to_string();
for value in ["JONES", "WILLIAM", "PATID1234", "19610615", "GREENSBORO"] {
assert!(!text.contains(value), "the report leaked {value}");
}
assert!(text.contains("PID[1]-5[1].1.1 replace REDACTED"));
assert!(text.contains("NK1[1]-2[1].1.1 replace REDACTED"));
}
#[test]
fn covers_every_repetition_and_occurrence() {
let mut message =
er7::parse("MSH|^~\\&|LAB\rPID|1|555-1111~555-2222\rOBX|1|NM|A\rOBX|2|NM|B").unwrap();
redact(policy(&["PID-2 clear", "OBX-3 replace X"]), &mut message);
assert_eq!(message.query("PID-2").unwrap().as_deref(), Some("~"));
assert_eq!(message.query_all("OBX-3").unwrap(), vec!["X", "X"]);
let mut message = er7::parse("MSH|^~\\&|LAB\rOBX|1|NM|A\rOBX|2|NM|B").unwrap();
redact(policy(&["OBX[2]-3 replace X"]), &mut message);
assert_eq!(message.query_all("OBX-3").unwrap(), vec!["A", "X"]);
}
}