1use std::collections::HashSet;
12use std::fmt;
13
14use er7::message::NULL;
15use er7::{Component, Field, Message, Path, Repetition, Segment, Separators, Subcomponent};
16
17use crate::{Action, Policy, Posture, Unrecognised};
18
19type Position = (usize, usize, usize, usize, usize);
22
23#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct Change {
35 pub path: Path,
37 pub action: Action,
39}
40
41impl fmt::Display for Change {
42 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44 write!(f, "{} {}", self.path, self.action)
45 }
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Default)]
75pub struct Report {
76 pub changes: Vec<Change>,
78}
79
80impl Report {
81 #[must_use]
85 pub fn is_empty(&self) -> bool {
86 self.changes.is_empty()
87 }
88
89 #[must_use]
91 pub fn len(&self) -> usize {
92 self.changes.len()
93 }
94}
95
96impl fmt::Display for Report {
97 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99 for change in &self.changes {
100 writeln!(f, "{change}")?;
101 }
102 Ok(())
103 }
104}
105
106#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct Redactor {
133 policy: Policy,
134 key: u64,
135}
136
137impl Redactor {
138 #[must_use]
140 pub fn new(policy: Policy) -> Redactor {
141 Redactor { policy, key: 0 }
142 }
143
144 #[must_use]
151 pub fn with_key(mut self, key: u64) -> Redactor {
152 self.key = key;
153 self
154 }
155
156 #[must_use]
158 pub fn policy(&self) -> &Policy {
159 &self.policy
160 }
161
162 #[must_use]
164 pub fn key(&self) -> u64 {
165 self.key
166 }
167
168 #[must_use]
209 pub fn unrecognised(&self, payload: &str) -> Option<String> {
210 match &self.policy.unrecognised {
211 Unrecognised::Refuse => None,
212 Unrecognised::Pass => Some(payload.to_string()),
213 Unrecognised::Apply(action) => Some(
217 action
218 .apply(payload, self.key)
219 .unwrap_or_else(|| payload.to_string()),
220 ),
221 }
222 }
223
224 pub fn redact(&self, message: &mut Message) -> Report {
230 let mut counts: Vec<usize> = Vec::with_capacity(message.segments.len());
234 let names: Vec<String> = message.segments.iter().map(|s| s.name.clone()).collect();
235 for (index, name) in names.iter().enumerate() {
236 counts.push(names[..index].iter().filter(|n| *n == name).count() + 1);
237 }
238
239 let mut pass = Pass {
240 key: self.key,
241 separators: message.separators,
242 named: HashSet::new(),
243 report: Report::default(),
244 };
245
246 for rule in &self.policy.rules {
247 for index in 0..message.segments.len() {
248 if names[index] != rule.path.segment {
249 continue;
250 }
251 if rule
252 .path
253 .segment_occurrence
254 .is_some_and(|wanted| wanted != counts[index])
255 {
256 continue;
257 }
258 let at = At {
259 name: &names[index],
260 index,
261 occurrence: counts[index],
262 };
263 pass.segment(&mut message.segments[index], at, &rule.path, &rule.action);
264 }
265 }
266
267 if let Posture::Reject(action) = &self.policy.posture {
268 for index in 0..message.segments.len() {
269 let at = At {
270 name: &names[index],
271 index,
272 occurrence: counts[index],
273 };
274 pass.reject_the_rest(&mut message.segments[index], at, action);
275 }
276 }
277
278 pass.report
279 }
280}
281
282impl Default for Redactor {
283 fn default() -> Redactor {
286 Redactor::new(Policy::patient_identifiers())
287 }
288}
289
290#[derive(Debug, Clone, Copy)]
292struct At<'a> {
293 name: &'a str,
294 index: usize,
295 occurrence: usize,
296}
297
298struct Pass {
301 key: u64,
302 separators: Separators,
303 named: HashSet<Position>,
308 report: Report,
309}
310
311impl Pass {
312 fn segment(&mut self, segment: &mut Segment, at: At, path: &Path, action: &Action) {
314 let header = segment.is_header();
315 let numbers: Vec<usize> = match path.field {
316 Some(number) => vec![number],
317 None => (1..=segment.fields.len()).collect(),
318 };
319 for number in numbers {
320 if header && number <= 2 {
324 continue;
325 }
326 let Some(field) = segment.field_mut(number) else {
328 continue;
329 };
330 if action == &Action::Null && path.repetition.is_none() && path.component.is_none() {
331 if !field.is_null() {
332 *field = null_field();
333 self.record(at, number, 1, 1, 1, action);
334 }
335 continue;
336 }
337 let repetitions: Vec<usize> = match path.repetition {
338 Some(number) => vec![number],
339 None => (1..=field.repetitions.len()).collect(),
340 };
341 for repetition in repetitions {
342 let Some(node) = field.repetition_mut(repetition) else {
343 continue;
344 };
345 if action == &Action::Null && path.component.is_none() {
346 if !node.is_null() {
347 *node = null_repetition();
348 self.record(at, number, repetition, 1, 1, action);
349 }
350 continue;
351 }
352 self.repetition(node, at, (number, repetition), path, action);
353 }
354 }
355 }
356
357 fn repetition(
359 &mut self,
360 repetition: &mut Repetition,
361 at: At,
362 (field, index): (usize, usize),
363 path: &Path,
364 action: &Action,
365 ) {
366 let numbers: Vec<usize> = match path.component {
367 Some(number) => vec![number],
368 None => (1..=repetition.components.len()).collect(),
369 };
370 for number in numbers {
371 let Some(component) = repetition.component_mut(number) else {
372 continue;
373 };
374 if action == &Action::Null && path.subcomponent.is_none() {
375 if !component.is_null() {
376 *component = null_component();
377 self.record(at, field, index, number, 1, action);
378 }
379 continue;
380 }
381 let subcomponents: Vec<usize> = match path.subcomponent {
382 Some(number) => vec![number],
383 None => (1..=component.subcomponents.len()).collect(),
384 };
385 for subcomponent in subcomponents {
386 let Some(leaf) = component.subcomponent_mut(subcomponent) else {
387 continue;
388 };
389 let position = (at.index, field, index, number, subcomponent);
390 self.named.insert(position);
391 if self.leaf(leaf, action) {
392 self.record(at, field, index, number, subcomponent, action);
393 }
394 }
395 }
396 }
397
398 fn reject_the_rest(&mut self, segment: &mut Segment, at: At, action: &Action) {
401 let header = segment.is_header();
402 for field in 1..=segment.fields.len() {
403 if header && field <= 2 {
405 continue;
406 }
407 let Some(node) = segment.field_mut(field) else {
408 continue;
409 };
410 for repetition in 1..=node.repetitions.len() {
411 let Some(node) = node.repetition_mut(repetition) else {
412 continue;
413 };
414 for component in 1..=node.components.len() {
415 let Some(node) = node.component_mut(component) else {
416 continue;
417 };
418 for subcomponent in 1..=node.subcomponents.len() {
419 let position = (at.index, field, repetition, component, subcomponent);
420 if self.named.contains(&position) {
421 continue;
422 }
423 let Some(leaf) = node.subcomponent_mut(subcomponent) else {
424 continue;
425 };
426 if self.leaf(leaf, action) {
427 self.record(at, field, repetition, component, subcomponent, action);
428 }
429 }
430 }
431 }
432 }
433 }
434
435 fn leaf(&mut self, leaf: &mut Subcomponent, action: &Action) -> bool {
442 if action == &Action::Null {
443 if leaf.is_null() {
444 return false;
445 }
446 leaf.raw = NULL.to_string();
447 return true;
448 }
449 if leaf.is_empty() || leaf.is_null() {
450 return false;
451 }
452 let value = leaf.value(&self.separators).into_owned();
453 let Some(replacement) = action.apply(&value, self.key) else {
454 return false;
455 };
456 if replacement == value {
457 return false;
460 }
461 leaf.set(&replacement, &self.separators);
464 true
465 }
466
467 fn record(
469 &mut self,
470 at: At,
471 field: usize,
472 repetition: usize,
473 component: usize,
474 subcomponent: usize,
475 action: &Action,
476 ) {
477 self.report.changes.push(Change {
478 path: Path {
479 segment: at.name.to_string(),
480 segment_occurrence: Some(at.occurrence),
481 field: Some(field),
482 repetition: Some(repetition),
483 component: Some(component),
484 subcomponent: Some(subcomponent),
485 },
486 action: action.clone(),
487 });
488 }
489}
490
491fn null_subcomponent() -> Subcomponent {
493 Subcomponent::new(NULL)
494}
495
496fn null_component() -> Component {
498 Component {
499 subcomponents: vec![null_subcomponent()],
500 }
501}
502
503fn null_repetition() -> Repetition {
505 Repetition {
506 components: vec![null_component()],
507 }
508}
509
510fn null_field() -> Field {
512 Field {
513 repetitions: vec![null_repetition()],
514 }
515}
516
517#[cfg(test)]
518mod tests {
519 use super::*;
520 use crate::Rule;
521
522 const ADT: &str = "MSH|^~\\&|ADT1|MCM|LABADT|MCM|20260815140000||ADT^A08|MSG00001|P|2.5\r\
523 PID|1||PATID1234^5^M11^ADT1^MR~123456789^^^USSSA^SS||\
524 JONES^WILLIAM^A^III||19610615|M||C|1200 N ELM STREET^^GREENSBORO^NC\r\
525 NK1|1|JONES^BARBARA^K|SPO\r\
526 OBX|1|NM|2093-3^Cholesterol^LN||187|mg/dL";
527
528 fn message() -> Message {
529 er7::parse(ADT).expect("sample parses")
530 }
531
532 fn redact(policy: Policy, message: &mut Message) -> Report {
533 Redactor::new(policy).redact(message)
534 }
535
536 fn policy(rules: &[&str]) -> Policy {
537 let mut policy = Policy::accept_all();
538 for rule in rules {
539 policy.rules.push(Rule::parse(rule).expect("rule parses"));
540 }
541 policy
542 }
543
544 fn shape(message: &Message) -> Vec<usize> {
547 let mut counts = vec![message.segments.len()];
548 for segment in &message.segments {
549 counts.push(segment.fields.len());
550 for field in &segment.fields {
551 counts.push(field.repetitions.len());
552 for repetition in &field.repetitions {
553 counts.push(repetition.components.len());
554 for component in &repetition.components {
555 counts.push(component.subcomponents.len());
556 }
557 }
558 }
559 }
560 counts
561 }
562
563 #[test]
564 fn preserves_the_shape() {
565 let before = shape(&message());
568 for rules in [
569 vec!["PID-5 replace REDACTED"],
570 vec!["PID-3 pseudonym", "PID-7 first 4"],
571 vec!["PID-11 clear"],
572 vec!["OBX-5 mask *"],
573 ] {
574 let mut message = message();
575 redact(policy(&rules), &mut message);
576 assert_eq!(shape(&message), before, "{rules:?} changed the shape");
577 assert!(er7::parse(&message.to_er7()).is_ok());
579 }
580 }
581
582 #[test]
583 fn does_not_create_a_position() {
584 let mut message = message();
587 let report = redact(policy(&["PID-99 replace X", "ZZZ-1 clear"]), &mut message);
588 assert!(report.is_empty());
589 assert_eq!(message.to_er7(), ADT);
590 }
591
592 #[test]
593 fn leaves_an_empty_leaf_empty() {
594 let mut message = er7::parse("MSH|^~\\&|LAB\rPID|1||||^JOHN").unwrap();
597 let report = redact(
598 policy(&["PID-2 replace X", "PID-5 replace X"]),
599 &mut message,
600 );
601 assert_eq!(message.to_er7(), "MSH|^~\\&|LAB\rPID|1||||^X");
602 assert_eq!(report.len(), 1);
604 }
605
606 #[test]
607 fn leaves_an_explicit_null_alone() {
608 let mut message = er7::parse("MSH|^~\\&|LAB\rPID|1|\"\"|A").unwrap();
611 let report = redact(
612 policy(&["PID-2 replace X", "PID-3 replace X"]),
613 &mut message,
614 );
615 assert_eq!(message.to_er7(), "MSH|^~\\&|LAB\rPID|1|\"\"|X");
616 assert_eq!(report.len(), 1);
617 }
618
619 #[test]
620 fn never_touches_the_delimiter_fields() {
621 let mut message = message();
624 let mut policy = policy(&["MSH-1 replace X", "MSH-2 clear", "MSH-3 replace X"]);
625 policy = policy.posture(Posture::Reject(Action::Mask('#')));
626 redact(policy, &mut message);
627 assert!(message.to_er7().starts_with("MSH|^~\\&|X|"));
628 assert!(er7::parse(&message.to_er7()).is_ok());
629 }
630
631 #[test]
632 fn null_collapses_the_named_position() {
633 let mut message = er7::parse("MSH|^~\\&|LAB\rPID|1||9||SMITH^JOHN").unwrap();
636 let report = redact(policy(&["PID-5 null"]), &mut message);
637 assert_eq!(message.to_er7(), "MSH|^~\\&|LAB\rPID|1||9||\"\"");
638 assert_eq!(report.len(), 1);
639 assert_eq!(report.changes[0].path.to_string(), "PID[1]-5[1].1.1");
640
641 let mut message = er7::parse("MSH|^~\\&|LAB\rPID|1||9||SMITH^JOHN").unwrap();
643 redact(policy(&["PID-5.1 null"]), &mut message);
644 assert_eq!(message.to_er7(), "MSH|^~\\&|LAB\rPID|1||9||\"\"^JOHN");
645
646 let mut message = er7::parse("MSH|^~\\&|LAB\rPID|1|\"\"").unwrap();
648 assert!(redact(policy(&["PID-2 null"]), &mut message).is_empty());
649 }
650
651 #[test]
652 fn applies_rules_in_order() {
653 let mut message = er7::parse("MSH|^~\\&|LAB\rPID|1||PATID1234").unwrap();
657 redact(
658 policy(&["PID-3 replace SMITH", "PID-3 first 2"]),
659 &mut message,
660 );
661 assert_eq!(message.query("PID-3").unwrap().as_deref(), Some("SM"));
662
663 let mut message = er7::parse("MSH|^~\\&|LAB\rPID|1||PATID1234").unwrap();
664 redact(
665 policy(&["PID-3 replace REDACTED", "PID-3 keep"]),
666 &mut message,
667 );
668 assert_eq!(message.query("PID-3").unwrap().as_deref(), Some("REDACTED"));
669 }
670
671 #[test]
672 fn a_rule_that_matches_nothing_does_nothing() {
673 let mut message = message();
676 let report = redact(Policy::patient_identifiers(), &mut message);
677 assert!(!report.is_empty());
678 assert!(!report.changes.iter().any(|c| c.path.segment == "GT1"));
680 assert!(!report.changes.iter().any(|c| c.path.segment == "IN1"));
681 }
682
683 #[test]
684 fn rejecting_by_default_covers_what_no_rule_named() {
685 let mut message = er7::parse("MSH|^~\\&|LAB\rOBX|1|NM|2093-3||187").unwrap();
689 let policy =
690 policy(&["MSH keep", "OBX-2 keep"]).posture(Posture::Reject(Action::redacted()));
691 redact(policy, &mut message);
692 assert_eq!(
693 message.to_er7(),
694 "MSH|^~\\&|LAB\rOBX|REDACTED|NM|REDACTED||REDACTED"
695 );
696 }
697
698 #[test]
699 fn a_segment_wide_accept_is_not_narrowed() {
700 let text = "MSH|^~\\&|LAB|ACME|EHR|CLINIC|20260815120000\rOBX|1|NM|2093-3||187";
705 let mut message = er7::parse(text).unwrap();
706 let policy = policy(&["MSH keep"]).posture(Posture::Reject(Action::redacted()));
707 redact(policy, &mut message);
708 assert_eq!(
709 message.to_er7(),
710 "MSH|^~\\&|LAB|ACME|EHR|CLINIC|20260815120000\rOBX|REDACTED|REDACTED|REDACTED||REDACTED"
711 );
712 }
713
714 #[test]
715 fn reject_beats_accept_for_the_same_field() {
716 for rules in [
721 vec!["PID-5 keep", "PID-5 replace REDACTED"],
722 vec!["PID-5 replace REDACTED", "PID-5 keep"],
723 ] {
724 let mut message = er7::parse("MSH|^~\\&|LAB\rPID|1||9||SMITH").unwrap();
725 redact(policy(&rules), &mut message);
726 assert_eq!(
727 message.query("PID-5").unwrap().as_deref(),
728 Some("REDACTED"),
729 "{rules:?} let the name through"
730 );
731 }
732
733 let mut message = er7::parse("MSH|^~\\&|LAB\rPID|1||9||SMITH^JOHN").unwrap();
736 let policy = policy(&["MSH keep", "PID-5 keep", "PID-5.1 replace REDACTED"])
737 .posture(Posture::Reject(Action::Clear));
738 redact(policy, &mut message);
739 assert_eq!(
740 message.query("PID-5").unwrap().as_deref(),
741 Some("REDACTED^JOHN")
742 );
743 }
744
745 #[test]
746 fn reject_segment_beats_a_narrower_accept() {
747 for rules in [
751 vec!["PID replace REDACTED", "PID-5 keep"],
752 vec!["PID-5 keep", "PID replace REDACTED"],
753 ] {
754 let mut message = er7::parse("MSH|^~\\&|LAB\rPID|1||9||SMITH").unwrap();
755 redact(policy(&rules), &mut message);
756 assert_eq!(
757 message.query("PID-5").unwrap().as_deref(),
758 Some("REDACTED"),
759 "{rules:?} carved the name out of a rejected segment"
760 );
761 }
762 }
763
764 #[test]
765 fn an_unrecognised_payload_follows_the_policy() {
766 let junk = "{\"name\": \"EVERYWOMAN\"}";
770
771 assert_eq!(
774 Redactor::new(Policy::patient_identifiers()).unrecognised(junk),
775 None
776 );
777 assert_eq!(
778 Redactor::new(Policy::all_but_the_header()).unrecognised(junk),
779 None
780 );
781
782 assert_eq!(
784 Redactor::new(Policy::accept_all())
785 .unrecognised(junk)
786 .as_deref(),
787 Some(junk)
788 );
789 let masked = Redactor::new(Policy::reject_all())
790 .unrecognised(junk)
791 .expect("reject_all writes something");
792 assert_eq!(masked, "*".repeat(junk.chars().count()));
793 assert!(!masked.contains("EVERYWOMAN"));
794
795 let policy = Policy::patient_identifiers().on_unrecognised(Unrecognised::Pass);
797 assert_eq!(
798 Redactor::new(policy).unrecognised(junk).as_deref(),
799 Some(junk)
800 );
801 let policy = Policy::accept_all().on_unrecognised(Unrecognised::Refuse);
802 assert_eq!(Redactor::new(policy).unrecognised(junk), None);
803 let policy = Policy::accept_all().on_unrecognised(Unrecognised::Apply(Action::Clear));
804 assert_eq!(
805 Redactor::new(policy).unrecognised(junk).as_deref(),
806 Some("")
807 );
808 }
809
810 #[test]
811 fn a_report_carries_no_values() {
812 let mut message = message();
815 let report = redact(Policy::patient_identifiers(), &mut message);
816 let text = report.to_string();
817 for value in ["JONES", "WILLIAM", "PATID1234", "19610615", "GREENSBORO"] {
818 assert!(!text.contains(value), "the report leaked {value}");
819 }
820 assert!(text.contains("PID[1]-5[1].1.1 replace REDACTED"));
822 assert!(text.contains("NK1[1]-2[1].1.1 replace REDACTED"));
823 }
824
825 #[test]
826 fn covers_every_repetition_and_occurrence() {
827 let mut message =
830 er7::parse("MSH|^~\\&|LAB\rPID|1|555-1111~555-2222\rOBX|1|NM|A\rOBX|2|NM|B").unwrap();
831 redact(policy(&["PID-2 clear", "OBX-3 replace X"]), &mut message);
832 assert_eq!(message.query("PID-2").unwrap().as_deref(), Some("~"));
833 assert_eq!(message.query_all("OBX-3").unwrap(), vec!["X", "X"]);
834
835 let mut message = er7::parse("MSH|^~\\&|LAB\rOBX|1|NM|A\rOBX|2|NM|B").unwrap();
837 redact(policy(&["OBX[2]-3 replace X"]), &mut message);
838 assert_eq!(message.query_all("OBX-3").unwrap(), vec!["A", "X"]);
839 }
840}