use std::fmt;
use fig::Value;
use crate::present::Tint;
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
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 label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
pub fn label_opt(mut self, label: Option<impl Into<String>>) -> Self {
self.label = label.map(Into::into);
self
}
pub fn description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
pub fn description_opt(mut self, description: Option<impl Into<String>>) -> Self {
self.description = description.map(Into::into);
self
}
pub fn retired(mut self, retired: bool) -> Self {
self.retired = retired;
self
}
pub fn tint(mut self, tint: impl Into<Option<Tint>>) -> Self {
self.tint = tint.into();
self
}
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)]
#[non_exhaustive]
pub struct VocabularyDoc {
pub field: String,
pub closed: bool,
pub terms: Vec<Term>,
}
impl VocabularyDoc {
pub fn new(field: impl Into<String>, closed: bool, terms: Vec<Term>) -> Self {
Self {
field: field.into(),
closed,
terms,
}
}
}
pub fn parse_vocabulary(value: &Value) -> Option<VocabularyDoc> {
let marker = value.get("vocabulary")?;
let field = marker.get("field")?.as_str()?.to_string();
let closed = marker.get("values").and_then(Value::as_str) == Some("closed");
let mut terms = Vec::new();
if let Some(entries) = value.get("terms").and_then(Value::as_mapping) {
for (key, spec) in entries {
let Some(name) = key.as_str() else { continue };
terms.push(Term {
value: name.to_string(),
label: spec
.get("label")
.and_then(Value::as_str)
.map(str::to_string),
description: spec
.get("description")
.and_then(Value::as_str)
.map(str::to_string),
retired: spec.get("retired").and_then(Value::as_bool) == Some(true),
tint: None,
});
}
}
Some(VocabularyDoc {
field,
closed,
terms,
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Issue {
pub kind: IssueKind,
pub value: String,
pub suggestion: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
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::value("archived").retired(true)];
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::value("archived").retired(true)];
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::value("archived").retired(true)];
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::value("public").label("Everyone").display_label(),
"Everyone"
);
}
fn parse(yaml: &str) -> Option<VocabularyDoc> {
let doc = fig::Document::parse(yaml.as_bytes(), fig::Format::Yaml).unwrap();
parse_vocabulary(&doc.to_value().unwrap())
}
#[test]
fn parses_a_closed_vocabulary_and_validates_against_it() {
let v = parse(
"vocabulary:\n field: audience\n values: closed\n\
terms:\n public:\n description: Anyone\n friends: {}\n",
)
.expect("a vocabulary document");
assert_eq!(v.field, "audience");
assert!(v.closed);
assert_eq!(
v.terms
.iter()
.find(|t| t.value == "public")
.and_then(|t| t.description.as_deref()),
Some("Anyone")
);
assert!(validate_enum(&v.terms, v.closed, &Value::Str("public".into())).is_ok());
assert!(validate_enum(&v.terms, v.closed, &Value::Str("colleagues".into())).is_reject());
}
#[test]
fn an_open_vocabulary_warns_rather_than_rejects() {
let v =
parse("vocabulary:\n field: tags\n values: open\nterms:\n todo: {}\n done: {}\n")
.expect("a vocabulary document");
assert!(!v.closed);
let result = validate_enum(&v.terms, v.closed, &Value::Str("todi".into()));
assert!(matches!(result, Validation::Warn(_)));
}
#[test]
fn a_retired_term_is_known_but_not_accepted() {
let v = parse(
"vocabulary:\n field: status\n values: closed\n\
terms:\n active: {}\n archived_2024:\n retired: true\n",
)
.expect("a vocabulary document");
assert!(validate_enum(&v.terms, v.closed, &Value::Str("active".into())).is_ok());
let result = validate_enum(&v.terms, v.closed, &Value::Str("archived_2024".into()));
assert_eq!(result.issue().map(|i| &i.kind), Some(&IssueKind::Retired));
assert!(!result.is_reject());
}
#[test]
fn a_bare_term_entry_is_a_live_term_with_no_metadata() {
let v = parse("vocabulary:\n field: status\n values: open\nterms:\n active:\n")
.expect("a vocabulary document");
let t = v.terms.iter().find(|t| t.value == "active").unwrap();
assert_eq!(t.label, None);
assert_eq!(t.description, None);
assert!(!t.retired);
}
#[test]
fn a_duplicated_key_resolves_last_wins_the_way_fig_reads_it() {
let v = parse(
"vocabulary:\n field: audience\n field: tags\n values: closed\nterms:\n a:\n",
)
.expect("a vocabulary document");
assert_eq!(v.field, "tags");
}
#[test]
fn a_duplicated_term_is_not_a_lookup_and_stays_twice() {
let v = parse("vocabulary:\n field: status\n values: open\nterms:\n a:\n a:\n")
.expect("a vocabulary document");
assert_eq!(v.terms.iter().filter(|t| t.value == "a").count(), 2);
}
#[test]
fn a_document_without_the_marker_is_not_a_vocabulary() {
assert!(parse("title: Notes\n").is_none());
}
}