use crate::Error;
use crate::pseudonym::pseudonym;
use std::fmt;
const REDACTED: &str = "REDACTED";
const MASK: char = '*';
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Action {
Keep,
Clear,
Null,
Replace(String),
Mask(char),
First(usize),
Last(usize),
Pseudonym,
}
impl Action {
#[must_use]
pub fn redacted() -> Action {
Action::Replace(REDACTED.to_string())
}
pub fn parse(text: &str) -> Result<Action, Error> {
let text = text.trim();
let (name, argument) = match text.split_once(char::is_whitespace) {
Some((name, argument)) => (name, argument.trim()),
None => (text, ""),
};
let bad = |detail: String| Err(Error::BadPolicy(detail));
let none = |action: Action| {
if argument.is_empty() {
Ok(action)
} else {
Err(Error::BadPolicy(format!(
"action {name:?} takes no argument, but got {argument:?}"
)))
}
};
let count = |what: &str| match argument.parse::<usize>() {
Ok(n) => Ok(n),
Err(_) => Err(Error::BadPolicy(format!(
"action {what:?} wants a number of characters, not {argument:?}"
))),
};
match name.to_ascii_lowercase().as_str() {
"keep" => none(Action::Keep),
"clear" => none(Action::Clear),
"null" => none(Action::Null),
"pseudonym" => none(Action::Pseudonym),
"replace" if argument.is_empty() => Ok(Action::redacted()),
"replace" => Ok(Action::Replace(argument.to_string())),
"mask" if argument.is_empty() => Ok(Action::Mask(MASK)),
"mask" => {
let mut characters = argument.chars();
match (characters.next(), characters.next()) {
(Some(mask), None) => Ok(Action::Mask(mask)),
_ => bad(format!(
"action \"mask\" wants one character, not {argument:?}"
)),
}
}
"first" => Ok(Action::First(count("first")?)),
"last" => Ok(Action::Last(count("last")?)),
"" => bad("expected an action".to_string()),
_ => bad(format!("unknown action {name:?}")),
}
}
#[must_use]
pub fn apply(&self, value: &str, key: u64) -> Option<String> {
match self {
Action::Keep | Action::Null => None,
Action::Clear => Some(String::new()),
Action::Replace(text) => Some(text.clone()),
Action::Mask(mask) => Some(value.chars().map(|_| *mask).collect()),
Action::First(n) => Some(value.chars().take(*n).collect()),
Action::Last(n) => {
let skip = value.chars().count().saturating_sub(*n);
Some(value.chars().skip(skip).collect())
}
Action::Pseudonym => Some(pseudonym(key, value)),
}
}
}
impl fmt::Display for Action {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Action::Keep => write!(f, "keep"),
Action::Clear => write!(f, "clear"),
Action::Null => write!(f, "null"),
Action::Replace(text) if text.is_empty() => write!(f, "clear"),
Action::Replace(text) => write!(f, "replace {text}"),
Action::Mask(mask) => write!(f, "mask {mask}"),
Action::First(n) => write!(f, "first {n}"),
Action::Last(n) => write!(f, "last {n}"),
Action::Pseudonym => write!(f, "pseudonym"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_every_action() {
let cases = [
("keep", Action::Keep),
("clear", Action::Clear),
("null", Action::Null),
("pseudonym", Action::Pseudonym),
("replace REDACTED", Action::redacted()),
("mask *", Action::Mask('*')),
("first 4", Action::First(4)),
("last 4", Action::Last(4)),
];
for (text, action) in cases {
assert_eq!(Action::parse(text).unwrap(), action, "parsing {text:?}");
assert_eq!(action.to_string(), text, "writing {action:?}");
}
assert_eq!(Action::parse("CLEAR").unwrap(), Action::Clear);
assert_eq!(Action::parse("replace").unwrap(), Action::redacted());
assert_eq!(Action::parse("mask").unwrap(), Action::Mask('*'));
assert_eq!(
Action::parse("replace Not On File").unwrap(),
Action::Replace("Not On File".to_string())
);
}
#[test]
fn rejects_malformed_actions() {
for text in [
"",
"obfuscate",
"first",
"first three",
"mask ab",
"clear PID-5",
] {
assert!(
Action::parse(text).is_err(),
"expected {text:?} to be rejected"
);
}
}
#[test]
fn every_action_but_pseudonym_is_idempotent() {
let value = "EVERYWOMAN";
for action in [
Action::Clear,
Action::redacted(),
Action::Mask('*'),
Action::First(4),
Action::Last(4),
Action::First(0),
] {
let once = action.apply(value, 0).expect("writes a value");
let twice = action.apply(&once, 0).expect("writes a value");
assert_eq!(once, twice, "{action} is not idempotent");
}
let once = Action::Pseudonym.apply(value, 0).expect("writes a value");
let twice = Action::Pseudonym.apply(&once, 0).expect("writes a value");
assert_ne!(once, twice);
}
#[test]
fn counts_characters_not_bytes() {
assert_eq!(Action::First(2).apply("naïve", 0).as_deref(), Some("na"));
assert_eq!(Action::First(3).apply("naïve", 0).as_deref(), Some("naï"));
assert_eq!(Action::Last(3).apply("naïve", 0).as_deref(), Some("ïve"));
assert_eq!(
Action::Mask('*').apply("naïve", 0).as_deref(),
Some("*****")
);
}
#[test]
fn zero_counts_are_legal() {
assert_eq!(Action::First(0).apply("PATID1234", 0).as_deref(), Some(""));
assert_eq!(Action::Last(0).apply("PATID1234", 0).as_deref(), Some(""));
}
}