use std::fmt::Write as _;
use std::io::{Read, Write};
use std::process::ExitCode;
use er7::{Message, RenderOptions, Terminator};
use er7_redact::{Policy, Posture, Redactor, Report, Rule, Unrecognised};
const USAGE: &str = "\
Redact patient detail from HL7 v2 messages in the ER7 pipe-hat encoding.
Usage: er7-redact [OPTIONS] [FILE]
Arguments:
[FILE] Input holding one or more messages, or a batch file;
\"-\" or omitted reads standard input
Options:
-p, --policy <FILE> Read rules from a policy file; may be repeated
-r, --rule <RULE> Add one rule, e.g. \"PID-5 replace REDACTED\";
may be repeated
--accept-all Accept every value no rule names; applied last,
this switches off a policy file's \"reject\"
--reject-all Reject every value no rule names, the MSH
header included
--all-but-the-header Reject every value no rule names, but keep the
MSH header so the message stays routable
-k, --key <KEY> Pseudonym key, a number; default 0
-m, --message <N> Use only the Nth message of the input
-t, --terminator <KIND> Segment terminator to write: cr (default), lf, crlf
-o, --output <FILE> Write to FILE instead of standard output
--report Write what would change, and not the message
--show-policy Write the policy that would be applied, and exit
-h, --help Print help
-V, --version Print version
With no --policy, --rule, or posture flag, the built-in policy of the
crate's spec section 5.1 is applied: the patient identifiers in PID, NK1,
PV1, GT1, and IN1. It is a starting point, not a compliance certification.
A payload that is not ER7 fails the run, unless a policy file says to pass
it through or to mask it whole, or --reject-all masks it.";
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(Exit::Help) => {
println!("{USAGE}");
ExitCode::SUCCESS
}
Err(Exit::Version) => {
println!("er7-redact {}", env!("CARGO_PKG_VERSION"));
ExitCode::SUCCESS
}
Err(Exit::Failed(message)) => {
eprintln!("er7-redact: error: {message}");
ExitCode::FAILURE
}
}
}
enum Exit {
Help,
Version,
Failed(String),
}
fn fail<T>(message: impl Into<String>) -> Result<T, Exit> {
Err(Exit::Failed(message.into()))
}
fn run() -> Result<(), Exit> {
let mut policies: Vec<String> = Vec::new();
let mut rules: Vec<String> = Vec::new();
let mut start: Option<Start> = None;
let mut accept_all = false;
let mut key: u64 = 0;
let mut report_only = false;
let mut show_policy = false;
let mut which: Option<usize> = None;
let mut terminator = Terminator::Cr;
let mut input: Option<String> = None;
let mut output: Option<String> = None;
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
let mut value = |name: &str| match args.next() {
Some(value) => Ok(value),
None => fail(format!("missing value for {name}")),
};
match arg.as_str() {
"-h" | "--help" => return Err(Exit::Help),
"-V" | "--version" => return Err(Exit::Version),
"-a" | "--all" => {
return fail(
"--all is now --all-but-the-header \
(or --reject-all, which redacts the header too)",
);
}
"--accept-all" => {
accept_all = true;
start = Some(Start::Neutral);
}
"--reject-all" => start = Some(Start::RejectAll),
"--all-but-the-header" => start = Some(Start::AllButTheHeader),
"--report" => report_only = true,
"--show-policy" => show_policy = true,
"-p" | "--policy" => policies.push(value("--policy")?),
"-r" | "--rule" => rules.push(value("--rule")?),
"-o" | "--output" => output = Some(value("--output")?),
"-k" | "--key" => {
let text = value("--key")?;
match text.parse::<u64>() {
Ok(n) => key = n,
Err(_) => return fail(format!("--key wants a number, not {text:?}")),
}
}
"-m" | "--message" => {
let text = value("--message")?;
match text.parse::<usize>() {
Ok(n) if n >= 1 => which = Some(n),
_ => return fail(format!("--message wants a number from 1, not {text:?}")),
}
}
"-t" | "--terminator" => {
let text = value("--terminator")?;
terminator = match text.as_str() {
"cr" => Terminator::Cr,
"lf" => Terminator::Lf,
"crlf" => Terminator::CrLf,
_ => return fail(format!("--terminator wants cr, lf, or crlf, not {text:?}")),
}
}
"-" if input.is_some() => return fail("more than one input file given"),
"-" => input = Some("-".to_string()),
_ if arg.starts_with('-') => return fail(format!("unknown option: {arg}")),
_ if input.is_some() => return fail("more than one input file given"),
_ => input = Some(arg),
}
}
let policy = policy(start, accept_all, &policies, &rules)?;
if show_policy {
return write_output(output.as_deref(), &policy.to_string());
}
let text = read_input(input.as_deref())?;
let sources = split_input(&text, which)?;
let redactor = Redactor::new(policy).with_key(key);
let payloads = redact_payloads(&redactor, &sources)?;
let options = RenderOptions {
terminator,
trailing_terminator: true,
};
let rendered = if report_only {
report(&payloads, redactor.policy())
} else {
payloads
.iter()
.map(|payload| payload.to_er7_with(options))
.collect()
};
write_output(output.as_deref(), &rendered)
}
fn split_input(text: &str, which: Option<usize>) -> Result<Vec<&str>, Exit> {
let sources = er7::split_messages(text);
if sources.is_empty() {
return fail("input contains no HL7 segments");
}
let Some(n) = which else { return Ok(sources) };
match sources.get(n - 1) {
Some(&source) => Ok(vec![source]),
None => fail(format!(
"--message {n}, but the input holds {}",
sources.len()
)),
}
}
fn redact_payloads(redactor: &Redactor, sources: &[&str]) -> Result<Vec<Payload>, Exit> {
let mut payloads = Vec::with_capacity(sources.len());
for (index, source) in sources.iter().enumerate() {
match er7::parse(source) {
Ok(mut message) => {
let report = redactor.redact(&mut message);
payloads.push(Payload::Message(Box::new(message), report));
}
Err(e) => match redactor.unrecognised(source) {
Some(text) => payloads.push(Payload::Unrecognised(text)),
None => return fail(format!("message {}: {e}", index + 1)),
},
}
}
Ok(payloads)
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Start {
Neutral,
RejectAll,
AllButTheHeader,
}
enum Payload {
Message(Box<Message>, Report),
Unrecognised(String),
}
impl Payload {
fn to_er7_with(&self, options: RenderOptions) -> String {
match self {
Payload::Message(message, _) => message.to_er7_with(options),
Payload::Unrecognised(text) => {
let mut text = text.clone();
if !text.ends_with(['\r', '\n']) {
text.push_str(match options.terminator {
Terminator::Cr => "\r",
Terminator::Lf => "\n",
Terminator::CrLf => "\r\n",
});
}
text
}
}
}
}
fn policy(
start: Option<Start>,
accept_all: bool,
policies: &[String],
rules: &[String],
) -> Result<Policy, Exit> {
let neutral = || Policy::accept_all().on_unrecognised(Unrecognised::Refuse);
let named_nothing = start.is_none() && policies.is_empty() && rules.is_empty();
let mut policy = match start {
_ if named_nothing => Policy::patient_identifiers(),
Some(Start::RejectAll) => Policy::reject_all(),
Some(Start::AllButTheHeader) => Policy::all_but_the_header(),
Some(Start::Neutral) | None => neutral(),
};
for path in policies {
let text = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(e) => return fail(format!("reading {path}: {e}")),
};
match Policy::parse(&text) {
Ok(parsed) => policy.append(parsed),
Err(e) => return fail(format!("{path}: {e}")),
}
}
for text in rules {
match Rule::parse(text) {
Ok(rule) => policy.rules.push(rule),
Err(e) => return fail(e.to_string()),
}
}
if accept_all {
policy = policy.posture(Posture::Accept);
}
Ok(policy)
}
fn read_input(path: Option<&str>) -> Result<String, Exit> {
match path {
None | Some("-") => {
let mut buffer = String::new();
match std::io::stdin().read_to_string(&mut buffer) {
Ok(_) => Ok(buffer),
Err(e) => fail(format!("reading standard input: {e}")),
}
}
Some(path) => match std::fs::read_to_string(path) {
Ok(text) => Ok(text),
Err(e) => fail(format!("reading {path}: {e}")),
},
}
}
fn write_output(path: Option<&str>, text: &str) -> Result<(), Exit> {
match path {
Some(path) => match std::fs::write(path, text) {
Ok(()) => Ok(()),
Err(e) => fail(format!("writing {path}: {e}")),
},
None => match std::io::stdout().write_all(text.as_bytes()) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => Ok(()),
Err(e) => fail(format!("writing to standard output: {e}")),
},
}
}
fn report(payloads: &[Payload], policy: &Policy) -> String {
let mut out = String::new();
for (index, payload) in payloads.iter().enumerate() {
if index > 0 {
out.push('\n');
}
if payloads.len() > 1 {
let _ = writeln!(out, "# message {}", index + 1);
}
let report = match payload {
Payload::Message(_, report) => report,
Payload::Unrecognised(_) => {
let what = match &policy.unrecognised {
Unrecognised::Pass => "passed through".to_string(),
Unrecognised::Apply(action) => action.to_string(),
Unrecognised::Refuse => unreachable!("a refused payload failed the run"),
};
let _ = writeln!(out, "# message {}: unrecognised payload, {what}", index + 1);
continue;
}
};
let width = report
.changes
.iter()
.map(|change| change.path.to_string().len())
.max()
.unwrap_or(0)
.clamp(8, 28);
for change in &report.changes {
let path = change.path.to_string();
let _ = writeln!(out, "{path:<width$} {}", change.action);
}
}
out
}