use std::fmt;
use fig::Value;
use crate::present::Tint;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Term {
pub value: String,
pub label: Option<String>,
pub description: Option<String>,
pub retired: bool,
pub tint: Option<Tint>,
}
impl Term {
pub fn value(v: impl Into<String>) -> Self {
Self {
value: v.into(),
label: None,
description: None,
retired: false,
tint: None,
}
}
pub fn display_label(&self) -> &str {
self.label.as_deref().unwrap_or(&self.value)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Cardinality {
One,
Many,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Issue {
pub kind: IssueKind,
pub value: String,
pub suggestion: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IssueKind {
Unknown,
Retired,
Custom(String),
}
impl Issue {
pub fn unknown(value: impl Into<String>) -> Self {
Self {
kind: IssueKind::Unknown,
value: value.into(),
suggestion: None,
}
}
pub fn retired(value: impl Into<String>) -> Self {
Self {
kind: IssueKind::Retired,
value: value.into(),
suggestion: None,
}
}
pub fn custom(value: impl Into<String>, message: impl Into<String>) -> Self {
Self {
kind: IssueKind::Custom(message.into()),
value: value.into(),
suggestion: None,
}
}
pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
self.suggestion = Some(suggestion.into());
self
}
}
impl fmt::Display for Issue {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.kind {
IssueKind::Custom(message) => f.write_str(message)?,
IssueKind::Unknown => write!(f, "“{}” is not a known value", self.value)?,
IssueKind::Retired => write!(f, "“{}” is retired and no longer offered", self.value)?,
}
if let Some(suggestion) = &self.suggestion {
write!(f, " — did you mean “{suggestion}”?")?;
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Validation {
Ok,
Warn(Issue),
Reject(Issue),
}
impl Validation {
pub fn is_ok(&self) -> bool {
matches!(self, Validation::Ok)
}
pub fn is_reject(&self) -> bool {
matches!(self, Validation::Reject(_))
}
pub fn issue(&self) -> Option<&Issue> {
match self {
Validation::Ok => None,
Validation::Warn(issue) | Validation::Reject(issue) => Some(issue),
}
}
fn rank(&self) -> u8 {
match self {
Validation::Ok => 0,
Validation::Warn(_) => 1,
Validation::Reject(_) => 2,
}
}
}
pub trait Validate {
fn validate(&self, value: &Value) -> Validation;
}
pub fn validate_enum(values: &[Term], closed: bool, value: &Value) -> Validation {
match value {
Value::Str(s) => validate_term(values, closed, s),
Value::Seq(items) => {
let mut worst = Validation::Ok;
for item in items {
let result = validate_enum(values, closed, item);
if result.rank() > worst.rank() {
worst = result;
}
}
worst
}
_ => Validation::Ok,
}
}
fn validate_term(values: &[Term], closed: bool, s: &str) -> Validation {
if values.iter().any(|t| !t.retired && t.value == s) {
return Validation::Ok;
}
if values.iter().any(|t| t.retired && t.value == s) {
return Validation::Warn(Issue::retired(s));
}
let mut issue = Issue::unknown(s);
if let Some(near) = nearest_term(values, s) {
issue = issue.with_suggestion(near);
}
if closed {
Validation::Reject(issue)
} else {
Validation::Warn(issue)
}
}
fn nearest_term(terms: &[Term], value: &str) -> Option<String> {
let lower = value.to_lowercase();
let value_len = lower.chars().count();
terms
.iter()
.filter(|t| !t.retired)
.filter_map(|t| {
let candidate = t.value.to_lowercase();
let distance = edit_distance(&candidate, &lower);
let budget = suggestion_budget(candidate.chars().count().min(value_len));
(distance <= budget).then_some((t, distance))
})
.min_by_key(|(_, distance)| *distance)
.map(|(t, _)| t.value.clone())
}
fn suggestion_budget(len: usize) -> usize {
match len {
0..=2 => 0,
3..=4 => 1,
_ => 2,
}
}
fn edit_distance(a: &str, b: &str) -> usize {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
let mut prev: Vec<usize> = (0..=b.len()).collect();
let mut cur = vec![0usize; b.len() + 1];
for (i, &ca) in a.iter().enumerate() {
cur[0] = i + 1;
for (j, &cb) in b.iter().enumerate() {
let cost = if ca == cb { 0 } else { 1 };
cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
}
std::mem::swap(&mut prev, &mut cur);
}
prev[b.len()]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn closed_vocabulary_rejects_unknown_accepts_known() {
let terms = vec![Term::value("public"), Term::value("private")];
assert_eq!(
validate_enum(&terms, true, &Value::Str("public".into())),
Validation::Ok
);
assert!(validate_enum(&terms, true, &Value::Str("familly".into())).is_reject());
}
#[test]
fn open_vocabulary_warns_with_a_near_miss() {
let terms = vec![Term::value("todo"), Term::value("done")];
let Validation::Warn(issue) = validate_enum(&terms, false, &Value::Str("todi".into()))
else {
panic!("expected a near-miss warning");
};
assert_eq!(issue.kind, IssueKind::Unknown);
assert_eq!(issue.suggestion.as_deref(), Some("todo"));
}
#[test]
fn a_closed_rejection_still_carries_a_suggestion() {
let terms = vec![Term::value("public"), Term::value("private")];
let Validation::Reject(issue) = validate_enum(&terms, true, &Value::Str("privat".into()))
else {
panic!("expected a rejection");
};
assert_eq!(issue.suggestion.as_deref(), Some("private"));
}
#[test]
fn retired_term_warns_rather_than_rejecting() {
let terms = vec![
Term::value("active"),
Term {
retired: true,
..Term::value("archived")
},
];
assert_eq!(
validate_enum(&terms, true, &Value::Str("active".into())),
Validation::Ok
);
let result = validate_enum(&terms, true, &Value::Str("archived".into()));
assert_eq!(result.issue().map(|i| &i.kind), Some(&IssueKind::Retired));
assert!(!result.is_reject());
}
#[test]
fn a_retired_term_is_never_suggested() {
let terms = vec![Term {
retired: true,
..Term::value("archived")
}];
let result = validate_enum(&terms, false, &Value::Str("archivd".into()));
assert_eq!(result.issue().and_then(|i| i.suggestion.as_deref()), None);
}
#[test]
fn short_terms_do_not_produce_nonsense_suggestions() {
let terms = vec![Term::value("no")];
let result = validate_enum(&terms, false, &Value::Str("hi".into()));
assert_eq!(result.issue().and_then(|i| i.suggestion.as_deref()), None);
let terms = vec![Term::value("a")];
let result = validate_enum(&terms, false, &Value::Str("zz".into()));
assert_eq!(result.issue().and_then(|i| i.suggestion.as_deref()), None);
}
#[test]
fn a_rule_on_the_list_itself_validates_each_item() {
let terms = vec![Term::value("public")];
let seq = Value::Seq(vec![
Value::Str("public".into()),
Value::Str("bogus".into()),
]);
assert!(validate_enum(&terms, true, &seq).is_reject());
let all_good = Value::Seq(vec![Value::Str("public".into())]);
assert_eq!(validate_enum(&terms, true, &all_good), Validation::Ok);
}
#[test]
fn the_most_severe_element_result_wins() {
let terms = vec![
Term::value("active"),
Term {
retired: true,
..Term::value("archived")
},
];
let warned = Value::Seq(vec![Value::Str("archived".into())]);
assert!(matches!(
validate_enum(&terms, true, &warned),
Validation::Warn(_)
));
let rejected = Value::Seq(vec![
Value::Str("archived".into()),
Value::Str("xyz".into()),
]);
assert!(validate_enum(&terms, true, &rejected).is_reject());
}
#[test]
fn a_non_string_scalar_is_left_to_the_callers_backstop() {
let terms = vec![Term::value("public")];
assert_eq!(validate_enum(&terms, true, &Value::Int(3)), Validation::Ok);
}
#[test]
fn case_folding_is_not_ascii_only() {
let terms = vec![Term::value("Öffentlich")];
let result = validate_enum(&terms, false, &Value::Str("ÖFFENTLICH".into()));
assert_eq!(
result.issue().and_then(|i| i.suggestion.as_deref()),
Some("Öffentlich")
);
}
#[test]
fn issue_renders_an_english_default() {
assert_eq!(
Issue::unknown("xyz").to_string(),
"“xyz” is not a known value"
);
assert_eq!(
Issue::unknown("privat")
.with_suggestion("private")
.to_string(),
"“privat” is not a known value — did you mean “private”?"
);
assert_eq!(
Issue::retired("archived").to_string(),
"“archived” is retired and no longer offered"
);
assert_eq!(
Issue::custom("../nope", "no such note").to_string(),
"no such note"
);
}
#[test]
fn display_label_falls_back_to_the_stored_value() {
assert_eq!(Term::value("public").display_label(), "public");
assert_eq!(
Term {
label: Some("Everyone".into()),
..Term::value("public")
}
.display_label(),
"Everyone"
);
}
}