use crate::{Action, Error};
use er7::Path;
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Rule {
pub path: Path,
pub action: Action,
}
impl Rule {
pub fn new(path: &str, action: Action) -> Result<Rule, Error> {
Ok(Rule {
path: Path::parse(path)?,
action,
})
}
pub fn parse(line: &str) -> Result<Rule, Error> {
let at = |e: Error| Error::BadPolicy(format!("rule {:?}: {e}", line.trim()));
let Some((path, action)) = split_line(line) else {
return Err(at(Error::BadPolicy(
"expected a path and an action".to_string(),
)));
};
let action = Action::parse(action).map_err(at)?;
Rule::new(path, action).map_err(at)
}
}
impl fmt::Display for Rule {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} {}", self.path, self.action)
}
}
const FALLBACK: &str = "*";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Policy {
pub rules: Vec<Rule>,
pub fallback: Option<Action>,
}
#[allow(clippy::new_without_default)]
impl Policy {
#[must_use]
pub fn new() -> Policy {
Policy {
rules: Vec::new(),
fallback: None,
}
}
#[must_use]
pub fn patient_identifiers() -> Policy {
let table: &[(&str, Action)] = &[
("PID-2.1", Action::Pseudonym),
("PID-3.1", Action::Pseudonym),
("PID-4.1", Action::Pseudonym),
("PID-5", Action::redacted()),
("PID-6", Action::redacted()),
("PID-7", Action::First(4)),
("PID-9", Action::redacted()),
("PID-11", Action::Clear),
("PID-12", Action::Clear),
("PID-13", Action::Clear),
("PID-14", Action::Clear),
("PID-18.1", Action::Pseudonym),
("PID-19", Action::Clear),
("PID-20", Action::Clear),
("PID-21.1", Action::Pseudonym),
("PID-23", Action::Clear),
("PID-29", Action::First(4)),
("NK1-2", Action::redacted()),
("NK1-4", Action::Clear),
("NK1-5", Action::Clear),
("NK1-6", Action::Clear),
("PV1-5.1", Action::Pseudonym),
("PV1-7", Action::redacted()),
("PV1-8", Action::redacted()),
("PV1-9", Action::redacted()),
("PV1-17", Action::redacted()),
("PV1-19.1", Action::Pseudonym),
("GT1-2.1", Action::Pseudonym),
("GT1-3", Action::redacted()),
("GT1-4", Action::redacted()),
("GT1-5", Action::Clear),
("GT1-6", Action::Clear),
("GT1-7", Action::Clear),
("GT1-8", Action::First(4)),
("GT1-12", Action::Clear),
("IN1-16", Action::redacted()),
("IN1-18", Action::First(4)),
("IN1-19", Action::Clear),
("IN1-36", Action::Pseudonym),
("IN1-49.1", Action::Pseudonym),
];
let rules = table
.iter()
.map(|(path, action)| {
Rule::new(path, action.clone()).expect("built-in paths are well-formed")
})
.collect();
Policy {
rules,
fallback: None,
}
}
#[must_use]
pub fn everything() -> Policy {
Policy::new()
.with("MSH", Action::Keep)
.expect("built-in paths are well-formed")
.fallback(Action::redacted())
}
pub fn with(mut self, path: &str, action: Action) -> Result<Policy, Error> {
self.rules.push(Rule::new(path, action)?);
Ok(self)
}
#[must_use]
pub fn fallback(mut self, action: Action) -> Policy {
self.fallback = match action {
Action::Keep => None,
action => Some(action),
};
self
}
pub fn parse(text: &str) -> Result<Policy, Error> {
let mut policy = Policy::new();
for (index, line) in text.lines().enumerate() {
let line = match line.split_once('#') {
Some((before, _comment)) => before,
None => line,
}
.trim();
if line.is_empty() {
continue;
}
let number = index + 1;
let at = |e: Error| Error::BadPolicy(format!("policy line {number}: {line:?}: {e}"));
let Some((path, action)) = split_line(line) else {
return Err(at(Error::BadPolicy(
"expected a path and an action".to_string(),
)));
};
let action = Action::parse(action).map_err(at)?;
if path == FALLBACK {
policy = policy.fallback(action);
continue;
}
policy.rules.push(Rule::new(path, action).map_err(at)?);
}
Ok(policy)
}
pub fn append(&mut self, other: Policy) {
self.rules.extend(other.rules);
if let Some(fallback) = other.fallback {
self.fallback = Some(fallback);
}
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.rules.is_empty() && self.fallback.is_none()
}
}
impl fmt::Display for Policy {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let width = self
.rules
.iter()
.map(|rule| rule.path.to_string().len())
.chain(self.fallback.iter().map(|_| FALLBACK.len()))
.max()
.unwrap_or(0);
for rule in &self.rules {
let path = rule.path.to_string();
writeln!(f, "{path:<width$} {}", rule.action)?;
}
if let Some(fallback) = &self.fallback {
writeln!(f, "{FALLBACK:<width$} {fallback}")?;
}
Ok(())
}
}
fn split_line(line: &str) -> Option<(&str, &str)> {
let line = line.trim();
let (path, action) = line.split_once(char::is_whitespace)?;
let action = action.trim();
if action.is_empty() {
None
} else {
Some((path, action))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_policy_round_trips_through_display() {
for policy in [
Policy::new(),
Policy::patient_identifiers(),
Policy::everything(),
] {
assert_eq!(Policy::parse(&policy.to_string()).unwrap(), policy);
}
}
#[test]
fn parses_comments_blank_lines_and_the_fallback() {
let policy = Policy::parse(
"\
# a comment on its own line\n\
\n\
PID-5 replace REDACTED # and one after a rule\n\
\t OBX-5 keep \n\
* clear\n",
)
.unwrap();
assert_eq!(policy.rules.len(), 2);
assert_eq!(policy.rules[0].action, Action::redacted());
assert_eq!(policy.rules[1].path.to_string(), "OBX-5");
assert_eq!(policy.fallback, Some(Action::Clear));
let replaced = Policy::parse("* clear\n* replace X").unwrap();
assert_eq!(replaced.fallback, Some(Action::Replace("X".to_string())));
assert_eq!(Policy::parse("* keep").unwrap().fallback, None);
}
#[test]
fn reports_a_bad_policy_line() {
let cases = [
(
"PID-5 obfuscate",
"policy line 1: \"PID-5 obfuscate\": unknown action \"obfuscate\"",
),
(
"MSH keep\nPID-0 clear",
"policy line 2: \"PID-0 clear\": invalid HL7 path \"PID-0\": \
indices are 1-based, so 0 is not a position",
),
(
"PID-5",
"policy line 1: \"PID-5\": expected a path and an action",
),
(
"# comment\n\nPID-7 first three",
"policy line 3: \"PID-7 first three\": action \"first\" wants a number \
of characters, not \"three\"",
),
];
for (text, expected) in cases {
let error = Policy::parse(text).unwrap_err();
assert_eq!(error.to_string(), expected, "parsing {text:?}");
}
}
#[test]
fn the_default_policy_names_the_documented_positions() {
let policy = Policy::patient_identifiers();
assert_eq!(policy.rules.len(), 40);
assert_eq!(policy.fallback, None);
let named: Vec<String> = policy.rules.iter().map(|r| r.path.to_string()).collect();
for path in [
"PID-3.1", "PID-5", "PID-7", "PID-11", "PID-19", "NK1-2", "GT1-3", "IN1-16",
] {
assert!(named.contains(&path.to_string()), "missing {path}");
}
for path in ["NTE-3", "OBX-5", "PID-8", "PID-10", "MSH-4"] {
assert!(!named.contains(&path.to_string()), "unexpected {path}");
}
assert!(Policy::new().is_empty());
}
#[test]
fn appends_in_order() {
let mut policy = Policy::new().with("PID-5", Action::redacted()).unwrap();
policy.append(Policy::parse("PID-7 first 4\n* clear").unwrap());
assert_eq!(policy.rules.len(), 2);
assert_eq!(policy.rules[1].action, Action::First(4));
assert_eq!(policy.fallback, Some(Action::Clear));
}
}