1use std::fmt;
20
21use fig::Value;
22
23use crate::present::Tint;
24
25#[derive(Debug, Clone, PartialEq, Eq)]
39#[non_exhaustive]
40pub struct Term {
41 pub value: String,
43 pub label: Option<String>,
45 pub description: Option<String>,
47 pub retired: bool,
52 pub tint: Option<Tint>,
54}
55
56impl Term {
57 pub fn value(v: impl Into<String>) -> Self {
59 Self {
60 value: v.into(),
61 label: None,
62 description: None,
63 retired: false,
64 tint: None,
65 }
66 }
67
68 pub fn label(mut self, label: impl Into<String>) -> Self {
70 self.label = Some(label.into());
71 self
72 }
73
74 pub fn label_opt(mut self, label: Option<impl Into<String>>) -> Self {
76 self.label = label.map(Into::into);
77 self
78 }
79
80 pub fn description(mut self, description: impl Into<String>) -> Self {
82 self.description = Some(description.into());
83 self
84 }
85
86 pub fn description_opt(mut self, description: Option<impl Into<String>>) -> Self {
88 self.description = description.map(Into::into);
89 self
90 }
91
92 pub fn retired(mut self, retired: bool) -> Self {
94 self.retired = retired;
95 self
96 }
97
98 pub fn tint(mut self, tint: impl Into<Option<Tint>>) -> Self {
100 self.tint = tint.into();
101 self
102 }
103
104 pub fn display_label(&self) -> &str {
107 self.label.as_deref().unwrap_or(&self.value)
108 }
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub enum Cardinality {
117 One,
118 Many,
119}
120
121#[derive(Debug, Clone, PartialEq)]
128#[non_exhaustive]
129pub struct VocabularyDoc {
130 pub field: String,
132 pub closed: bool,
138 pub terms: Vec<Term>,
140}
141
142impl VocabularyDoc {
143 pub fn new(field: impl Into<String>, closed: bool, terms: Vec<Term>) -> Self {
145 Self {
146 field: field.into(),
147 closed,
148 terms,
149 }
150 }
151}
152
153pub fn parse_vocabulary(value: &Value) -> Option<VocabularyDoc> {
169 let marker = value.get("vocabulary")?;
170 let field = marker.get("field")?.as_str()?.to_string();
171 let closed = marker.get("values").and_then(Value::as_str) == Some("closed");
172
173 let mut terms = Vec::new();
174 if let Some(entries) = value.get("terms").and_then(Value::as_mapping) {
175 for (key, spec) in entries {
176 let Some(name) = key.as_str() else { continue };
177 terms.push(Term {
181 value: name.to_string(),
182 label: spec
183 .get("label")
184 .and_then(Value::as_str)
185 .map(str::to_string),
186 description: spec
187 .get("description")
188 .and_then(Value::as_str)
189 .map(str::to_string),
190 retired: spec.get("retired").and_then(Value::as_bool) == Some(true),
191 tint: None,
192 });
193 }
194 }
195 Some(VocabularyDoc {
196 field,
197 closed,
198 terms,
199 })
200}
201
202#[derive(Debug, Clone, PartialEq, Eq)]
214#[non_exhaustive]
215pub struct Issue {
216 pub kind: IssueKind,
218 pub value: String,
220 pub suggestion: Option<String>,
223}
224
225#[derive(Debug, Clone, PartialEq, Eq)]
231#[non_exhaustive]
232pub enum IssueKind {
233 Unknown,
235 Retired,
237 Custom(String),
240}
241
242impl Issue {
243 pub fn unknown(value: impl Into<String>) -> Self {
245 Self {
246 kind: IssueKind::Unknown,
247 value: value.into(),
248 suggestion: None,
249 }
250 }
251
252 pub fn retired(value: impl Into<String>) -> Self {
254 Self {
255 kind: IssueKind::Retired,
256 value: value.into(),
257 suggestion: None,
258 }
259 }
260
261 pub fn custom(value: impl Into<String>, message: impl Into<String>) -> Self {
263 Self {
264 kind: IssueKind::Custom(message.into()),
265 value: value.into(),
266 suggestion: None,
267 }
268 }
269
270 pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
272 self.suggestion = Some(suggestion.into());
273 self
274 }
275}
276
277impl fmt::Display for Issue {
278 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
279 match &self.kind {
280 IssueKind::Custom(message) => f.write_str(message)?,
281 IssueKind::Unknown => write!(f, "“{}” is not a known value", self.value)?,
282 IssueKind::Retired => write!(f, "“{}” is retired and no longer offered", self.value)?,
283 }
284 if let Some(suggestion) = &self.suggestion {
285 write!(f, " — did you mean “{suggestion}”?")?;
286 }
287 Ok(())
288 }
289}
290
291#[derive(Debug, Clone, PartialEq, Eq)]
294pub enum Validation {
295 Ok,
297 Warn(Issue),
300 Reject(Issue),
302}
303
304impl Validation {
305 pub fn is_ok(&self) -> bool {
307 matches!(self, Validation::Ok)
308 }
309
310 pub fn is_reject(&self) -> bool {
312 matches!(self, Validation::Reject(_))
313 }
314
315 pub fn issue(&self) -> Option<&Issue> {
317 match self {
318 Validation::Ok => None,
319 Validation::Warn(issue) | Validation::Reject(issue) => Some(issue),
320 }
321 }
322
323 fn rank(&self) -> u8 {
325 match self {
326 Validation::Ok => 0,
327 Validation::Warn(_) => 1,
328 Validation::Reject(_) => 2,
329 }
330 }
331}
332
333pub trait Validate {
365 fn validate(&self, value: &Value) -> Validation;
367}
368
369pub fn validate_enum(values: &[Term], closed: bool, value: &Value) -> Validation {
381 match value {
382 Value::Str(s) => validate_term(values, closed, s),
383 Value::Seq(items) => {
384 let mut worst = Validation::Ok;
385 for item in items {
386 let result = validate_enum(values, closed, item);
387 if result.rank() > worst.rank() {
388 worst = result;
389 }
390 }
391 worst
392 }
393 _ => Validation::Ok,
394 }
395}
396
397fn validate_term(values: &[Term], closed: bool, s: &str) -> Validation {
399 if values.iter().any(|t| !t.retired && t.value == s) {
400 return Validation::Ok;
401 }
402 if values.iter().any(|t| t.retired && t.value == s) {
403 return Validation::Warn(Issue::retired(s));
404 }
405 let mut issue = Issue::unknown(s);
406 if let Some(near) = nearest_term(values, s) {
407 issue = issue.with_suggestion(near);
408 }
409 if closed {
410 Validation::Reject(issue)
411 } else {
412 Validation::Warn(issue)
413 }
414}
415
416fn nearest_term(terms: &[Term], value: &str) -> Option<String> {
420 let lower = value.to_lowercase();
421 let value_len = lower.chars().count();
422 terms
423 .iter()
424 .filter(|t| !t.retired)
425 .filter_map(|t| {
426 let candidate = t.value.to_lowercase();
427 let distance = edit_distance(&candidate, &lower);
428 let budget = suggestion_budget(candidate.chars().count().min(value_len));
429 (distance <= budget).then_some((t, distance))
430 })
431 .min_by_key(|(_, distance)| *distance)
432 .map(|(t, _)| t.value.clone())
434}
435
436fn suggestion_budget(len: usize) -> usize {
441 match len {
442 0..=2 => 0,
443 3..=4 => 1,
444 _ => 2,
445 }
446}
447
448fn edit_distance(a: &str, b: &str) -> usize {
451 let a: Vec<char> = a.chars().collect();
452 let b: Vec<char> = b.chars().collect();
453 let mut prev: Vec<usize> = (0..=b.len()).collect();
454 let mut cur = vec![0usize; b.len() + 1];
455 for (i, &ca) in a.iter().enumerate() {
456 cur[0] = i + 1;
457 for (j, &cb) in b.iter().enumerate() {
458 let cost = if ca == cb { 0 } else { 1 };
459 cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
460 }
461 std::mem::swap(&mut prev, &mut cur);
462 }
463 prev[b.len()]
464}
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469
470 #[test]
471 fn closed_vocabulary_rejects_unknown_accepts_known() {
472 let terms = vec![Term::value("public"), Term::value("private")];
473 assert_eq!(
474 validate_enum(&terms, true, &Value::Str("public".into())),
475 Validation::Ok
476 );
477 assert!(validate_enum(&terms, true, &Value::Str("familly".into())).is_reject());
478 }
479
480 #[test]
481 fn open_vocabulary_warns_with_a_near_miss() {
482 let terms = vec![Term::value("todo"), Term::value("done")];
483 let Validation::Warn(issue) = validate_enum(&terms, false, &Value::Str("todi".into()))
484 else {
485 panic!("expected a near-miss warning");
486 };
487 assert_eq!(issue.kind, IssueKind::Unknown);
488 assert_eq!(issue.suggestion.as_deref(), Some("todo"));
489 }
490
491 #[test]
492 fn a_closed_rejection_still_carries_a_suggestion() {
493 let terms = vec![Term::value("public"), Term::value("private")];
494 let Validation::Reject(issue) = validate_enum(&terms, true, &Value::Str("privat".into()))
495 else {
496 panic!("expected a rejection");
497 };
498 assert_eq!(issue.suggestion.as_deref(), Some("private"));
499 }
500
501 #[test]
502 fn retired_term_warns_rather_than_rejecting() {
503 let terms = vec![Term::value("active"), Term::value("archived").retired(true)];
506 assert_eq!(
507 validate_enum(&terms, true, &Value::Str("active".into())),
508 Validation::Ok
509 );
510 let result = validate_enum(&terms, true, &Value::Str("archived".into()));
511 assert_eq!(result.issue().map(|i| &i.kind), Some(&IssueKind::Retired));
512 assert!(!result.is_reject());
513 }
514
515 #[test]
516 fn a_retired_term_is_never_suggested() {
517 let terms = vec![Term::value("archived").retired(true)];
518 let result = validate_enum(&terms, false, &Value::Str("archivd".into()));
519 assert_eq!(result.issue().and_then(|i| i.suggestion.as_deref()), None);
520 }
521
522 #[test]
523 fn short_terms_do_not_produce_nonsense_suggestions() {
524 let terms = vec![Term::value("no")];
526 let result = validate_enum(&terms, false, &Value::Str("hi".into()));
527 assert_eq!(result.issue().and_then(|i| i.suggestion.as_deref()), None);
528
529 let terms = vec![Term::value("a")];
530 let result = validate_enum(&terms, false, &Value::Str("zz".into()));
531 assert_eq!(result.issue().and_then(|i| i.suggestion.as_deref()), None);
532 }
533
534 #[test]
535 fn a_rule_on_the_list_itself_validates_each_item() {
536 let terms = vec![Term::value("public")];
537 let seq = Value::Seq(vec![
538 Value::Str("public".into()),
539 Value::Str("bogus".into()),
540 ]);
541 assert!(validate_enum(&terms, true, &seq).is_reject());
542
543 let all_good = Value::Seq(vec![Value::Str("public".into())]);
544 assert_eq!(validate_enum(&terms, true, &all_good), Validation::Ok);
545 }
546
547 #[test]
548 fn the_most_severe_element_result_wins() {
549 let terms = vec![Term::value("active"), Term::value("archived").retired(true)];
550 let warned = Value::Seq(vec![Value::Str("archived".into())]);
552 assert!(matches!(
553 validate_enum(&terms, true, &warned),
554 Validation::Warn(_)
555 ));
556 let rejected = Value::Seq(vec![
558 Value::Str("archived".into()),
559 Value::Str("xyz".into()),
560 ]);
561 assert!(validate_enum(&terms, true, &rejected).is_reject());
562 }
563
564 #[test]
565 fn a_non_string_scalar_is_left_to_the_callers_backstop() {
566 let terms = vec![Term::value("public")];
567 assert_eq!(validate_enum(&terms, true, &Value::Int(3)), Validation::Ok);
568 }
569
570 #[test]
571 fn case_folding_is_not_ascii_only() {
572 let terms = vec![Term::value("Öffentlich")];
573 let result = validate_enum(&terms, false, &Value::Str("ÖFFENTLICH".into()));
574 assert_eq!(
575 result.issue().and_then(|i| i.suggestion.as_deref()),
576 Some("Öffentlich")
577 );
578 }
579
580 #[test]
581 fn issue_renders_an_english_default() {
582 assert_eq!(
583 Issue::unknown("xyz").to_string(),
584 "“xyz” is not a known value"
585 );
586 assert_eq!(
587 Issue::unknown("privat")
588 .with_suggestion("private")
589 .to_string(),
590 "“privat” is not a known value — did you mean “private”?"
591 );
592 assert_eq!(
593 Issue::retired("archived").to_string(),
594 "“archived” is retired and no longer offered"
595 );
596 assert_eq!(
597 Issue::custom("../nope", "no such note").to_string(),
598 "no such note"
599 );
600 }
601
602 #[test]
603 fn display_label_falls_back_to_the_stored_value() {
604 assert_eq!(Term::value("public").display_label(), "public");
605 assert_eq!(
606 Term::value("public").label("Everyone").display_label(),
607 "Everyone"
608 );
609 }
610
611 fn parse(yaml: &str) -> Option<VocabularyDoc> {
612 let doc = fig::Document::parse(yaml.as_bytes(), fig::Format::Yaml).unwrap();
613 parse_vocabulary(&doc.to_value().unwrap())
614 }
615
616 #[test]
617 fn parses_a_closed_vocabulary_and_validates_against_it() {
618 let v = parse(
619 "vocabulary:\n field: audience\n values: closed\n\
620 terms:\n public:\n description: Anyone\n friends: {}\n",
621 )
622 .expect("a vocabulary document");
623 assert_eq!(v.field, "audience");
624 assert!(v.closed);
625 assert_eq!(
626 v.terms
627 .iter()
628 .find(|t| t.value == "public")
629 .and_then(|t| t.description.as_deref()),
630 Some("Anyone")
631 );
632 assert!(validate_enum(&v.terms, v.closed, &Value::Str("public".into())).is_ok());
633 assert!(validate_enum(&v.terms, v.closed, &Value::Str("colleagues".into())).is_reject());
634 }
635
636 #[test]
637 fn an_open_vocabulary_warns_rather_than_rejects() {
638 let v =
639 parse("vocabulary:\n field: tags\n values: open\nterms:\n todo: {}\n done: {}\n")
640 .expect("a vocabulary document");
641 assert!(!v.closed);
642 let result = validate_enum(&v.terms, v.closed, &Value::Str("todi".into()));
643 assert!(matches!(result, Validation::Warn(_)));
644 }
645
646 #[test]
647 fn a_retired_term_is_known_but_not_accepted() {
648 let v = parse(
649 "vocabulary:\n field: status\n values: closed\n\
650 terms:\n active: {}\n archived_2024:\n retired: true\n",
651 )
652 .expect("a vocabulary document");
653 assert!(validate_enum(&v.terms, v.closed, &Value::Str("active".into())).is_ok());
654 let result = validate_enum(&v.terms, v.closed, &Value::Str("archived_2024".into()));
655 assert_eq!(result.issue().map(|i| &i.kind), Some(&IssueKind::Retired));
656 assert!(!result.is_reject());
657 }
658
659 #[test]
660 fn a_bare_term_entry_is_a_live_term_with_no_metadata() {
661 let v = parse("vocabulary:\n field: status\n values: open\nterms:\n active:\n")
662 .expect("a vocabulary document");
663 let t = v.terms.iter().find(|t| t.value == "active").unwrap();
664 assert_eq!(t.label, None);
665 assert_eq!(t.description, None);
666 assert!(!t.retired);
667 }
668
669 #[test]
670 fn a_duplicated_key_resolves_last_wins_the_way_fig_reads_it() {
671 let v = parse(
675 "vocabulary:\n field: audience\n field: tags\n values: closed\nterms:\n a:\n",
676 )
677 .expect("a vocabulary document");
678 assert_eq!(v.field, "tags");
679 }
680
681 #[test]
682 fn a_duplicated_term_is_not_a_lookup_and_stays_twice() {
683 let v = parse("vocabulary:\n field: status\n values: open\nterms:\n a:\n a:\n")
686 .expect("a vocabulary document");
687 assert_eq!(v.terms.iter().filter(|t| t.value == "a").count(), 2);
688 }
689
690 #[test]
691 fn a_document_without_the_marker_is_not_a_vocabulary() {
692 assert!(parse("title: Notes\n").is_none());
693 }
694}