1use std::fmt;
12
13use fig::Value;
14
15use crate::present::Tint;
16
17#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct Term {
20 pub value: String,
22 pub label: Option<String>,
24 pub description: Option<String>,
26 pub retired: bool,
31 pub tint: Option<Tint>,
33}
34
35impl Term {
36 pub fn value(v: impl Into<String>) -> Self {
38 Self {
39 value: v.into(),
40 label: None,
41 description: None,
42 retired: false,
43 tint: None,
44 }
45 }
46
47 pub fn display_label(&self) -> &str {
50 self.label.as_deref().unwrap_or(&self.value)
51 }
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum Cardinality {
60 One,
61 Many,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct Issue {
73 pub kind: IssueKind,
75 pub value: String,
77 pub suggestion: Option<String>,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq)]
84pub enum IssueKind {
85 Unknown,
87 Retired,
89 Custom(String),
92}
93
94impl Issue {
95 pub fn unknown(value: impl Into<String>) -> Self {
97 Self {
98 kind: IssueKind::Unknown,
99 value: value.into(),
100 suggestion: None,
101 }
102 }
103
104 pub fn retired(value: impl Into<String>) -> Self {
106 Self {
107 kind: IssueKind::Retired,
108 value: value.into(),
109 suggestion: None,
110 }
111 }
112
113 pub fn custom(value: impl Into<String>, message: impl Into<String>) -> Self {
115 Self {
116 kind: IssueKind::Custom(message.into()),
117 value: value.into(),
118 suggestion: None,
119 }
120 }
121
122 pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
124 self.suggestion = Some(suggestion.into());
125 self
126 }
127}
128
129impl fmt::Display for Issue {
130 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131 match &self.kind {
132 IssueKind::Custom(message) => f.write_str(message)?,
133 IssueKind::Unknown => write!(f, "“{}” is not a known value", self.value)?,
134 IssueKind::Retired => write!(f, "“{}” is retired and no longer offered", self.value)?,
135 }
136 if let Some(suggestion) = &self.suggestion {
137 write!(f, " — did you mean “{suggestion}”?")?;
138 }
139 Ok(())
140 }
141}
142
143#[derive(Debug, Clone, PartialEq, Eq)]
146pub enum Validation {
147 Ok,
149 Warn(Issue),
152 Reject(Issue),
154}
155
156impl Validation {
157 pub fn is_ok(&self) -> bool {
159 matches!(self, Validation::Ok)
160 }
161
162 pub fn is_reject(&self) -> bool {
164 matches!(self, Validation::Reject(_))
165 }
166
167 pub fn issue(&self) -> Option<&Issue> {
169 match self {
170 Validation::Ok => None,
171 Validation::Warn(issue) | Validation::Reject(issue) => Some(issue),
172 }
173 }
174
175 fn rank(&self) -> u8 {
177 match self {
178 Validation::Ok => 0,
179 Validation::Warn(_) => 1,
180 Validation::Reject(_) => 2,
181 }
182 }
183}
184
185pub trait Validate {
217 fn validate(&self, value: &Value) -> Validation;
219}
220
221pub fn validate_enum(values: &[Term], closed: bool, value: &Value) -> Validation {
233 match value {
234 Value::Str(s) => validate_term(values, closed, s),
235 Value::Seq(items) => {
236 let mut worst = Validation::Ok;
237 for item in items {
238 let result = validate_enum(values, closed, item);
239 if result.rank() > worst.rank() {
240 worst = result;
241 }
242 }
243 worst
244 }
245 _ => Validation::Ok,
246 }
247}
248
249fn validate_term(values: &[Term], closed: bool, s: &str) -> Validation {
251 if values.iter().any(|t| !t.retired && t.value == s) {
252 return Validation::Ok;
253 }
254 if values.iter().any(|t| t.retired && t.value == s) {
255 return Validation::Warn(Issue::retired(s));
256 }
257 let mut issue = Issue::unknown(s);
258 if let Some(near) = nearest_term(values, s) {
259 issue = issue.with_suggestion(near);
260 }
261 if closed {
262 Validation::Reject(issue)
263 } else {
264 Validation::Warn(issue)
265 }
266}
267
268fn nearest_term(terms: &[Term], value: &str) -> Option<String> {
272 let lower = value.to_lowercase();
273 let value_len = lower.chars().count();
274 terms
275 .iter()
276 .filter(|t| !t.retired)
277 .filter_map(|t| {
278 let candidate = t.value.to_lowercase();
279 let distance = edit_distance(&candidate, &lower);
280 let budget = suggestion_budget(candidate.chars().count().min(value_len));
281 (distance <= budget).then_some((t, distance))
282 })
283 .min_by_key(|(_, distance)| *distance)
284 .map(|(t, _)| t.value.clone())
286}
287
288fn suggestion_budget(len: usize) -> usize {
293 match len {
294 0..=2 => 0,
295 3..=4 => 1,
296 _ => 2,
297 }
298}
299
300fn edit_distance(a: &str, b: &str) -> usize {
303 let a: Vec<char> = a.chars().collect();
304 let b: Vec<char> = b.chars().collect();
305 let mut prev: Vec<usize> = (0..=b.len()).collect();
306 let mut cur = vec![0usize; b.len() + 1];
307 for (i, &ca) in a.iter().enumerate() {
308 cur[0] = i + 1;
309 for (j, &cb) in b.iter().enumerate() {
310 let cost = if ca == cb { 0 } else { 1 };
311 cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
312 }
313 std::mem::swap(&mut prev, &mut cur);
314 }
315 prev[b.len()]
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321
322 #[test]
323 fn closed_vocabulary_rejects_unknown_accepts_known() {
324 let terms = vec![Term::value("public"), Term::value("private")];
325 assert_eq!(
326 validate_enum(&terms, true, &Value::Str("public".into())),
327 Validation::Ok
328 );
329 assert!(validate_enum(&terms, true, &Value::Str("familly".into())).is_reject());
330 }
331
332 #[test]
333 fn open_vocabulary_warns_with_a_near_miss() {
334 let terms = vec![Term::value("todo"), Term::value("done")];
335 let Validation::Warn(issue) = validate_enum(&terms, false, &Value::Str("todi".into()))
336 else {
337 panic!("expected a near-miss warning");
338 };
339 assert_eq!(issue.kind, IssueKind::Unknown);
340 assert_eq!(issue.suggestion.as_deref(), Some("todo"));
341 }
342
343 #[test]
344 fn a_closed_rejection_still_carries_a_suggestion() {
345 let terms = vec![Term::value("public"), Term::value("private")];
346 let Validation::Reject(issue) = validate_enum(&terms, true, &Value::Str("privat".into()))
347 else {
348 panic!("expected a rejection");
349 };
350 assert_eq!(issue.suggestion.as_deref(), Some("private"));
351 }
352
353 #[test]
354 fn retired_term_warns_rather_than_rejecting() {
355 let terms = vec![
358 Term::value("active"),
359 Term {
360 retired: true,
361 ..Term::value("archived")
362 },
363 ];
364 assert_eq!(
365 validate_enum(&terms, true, &Value::Str("active".into())),
366 Validation::Ok
367 );
368 let result = validate_enum(&terms, true, &Value::Str("archived".into()));
369 assert_eq!(result.issue().map(|i| &i.kind), Some(&IssueKind::Retired));
370 assert!(!result.is_reject());
371 }
372
373 #[test]
374 fn a_retired_term_is_never_suggested() {
375 let terms = vec![Term {
376 retired: true,
377 ..Term::value("archived")
378 }];
379 let result = validate_enum(&terms, false, &Value::Str("archivd".into()));
380 assert_eq!(result.issue().and_then(|i| i.suggestion.as_deref()), None);
381 }
382
383 #[test]
384 fn short_terms_do_not_produce_nonsense_suggestions() {
385 let terms = vec![Term::value("no")];
387 let result = validate_enum(&terms, false, &Value::Str("hi".into()));
388 assert_eq!(result.issue().and_then(|i| i.suggestion.as_deref()), None);
389
390 let terms = vec![Term::value("a")];
391 let result = validate_enum(&terms, false, &Value::Str("zz".into()));
392 assert_eq!(result.issue().and_then(|i| i.suggestion.as_deref()), None);
393 }
394
395 #[test]
396 fn a_rule_on_the_list_itself_validates_each_item() {
397 let terms = vec![Term::value("public")];
398 let seq = Value::Seq(vec![
399 Value::Str("public".into()),
400 Value::Str("bogus".into()),
401 ]);
402 assert!(validate_enum(&terms, true, &seq).is_reject());
403
404 let all_good = Value::Seq(vec![Value::Str("public".into())]);
405 assert_eq!(validate_enum(&terms, true, &all_good), Validation::Ok);
406 }
407
408 #[test]
409 fn the_most_severe_element_result_wins() {
410 let terms = vec![
411 Term::value("active"),
412 Term {
413 retired: true,
414 ..Term::value("archived")
415 },
416 ];
417 let warned = Value::Seq(vec![Value::Str("archived".into())]);
419 assert!(matches!(
420 validate_enum(&terms, true, &warned),
421 Validation::Warn(_)
422 ));
423 let rejected = Value::Seq(vec![
425 Value::Str("archived".into()),
426 Value::Str("xyz".into()),
427 ]);
428 assert!(validate_enum(&terms, true, &rejected).is_reject());
429 }
430
431 #[test]
432 fn a_non_string_scalar_is_left_to_the_callers_backstop() {
433 let terms = vec![Term::value("public")];
434 assert_eq!(validate_enum(&terms, true, &Value::Int(3)), Validation::Ok);
435 }
436
437 #[test]
438 fn case_folding_is_not_ascii_only() {
439 let terms = vec![Term::value("Öffentlich")];
440 let result = validate_enum(&terms, false, &Value::Str("ÖFFENTLICH".into()));
441 assert_eq!(
442 result.issue().and_then(|i| i.suggestion.as_deref()),
443 Some("Öffentlich")
444 );
445 }
446
447 #[test]
448 fn issue_renders_an_english_default() {
449 assert_eq!(
450 Issue::unknown("xyz").to_string(),
451 "“xyz” is not a known value"
452 );
453 assert_eq!(
454 Issue::unknown("privat")
455 .with_suggestion("private")
456 .to_string(),
457 "“privat” is not a known value — did you mean “private”?"
458 );
459 assert_eq!(
460 Issue::retired("archived").to_string(),
461 "“archived” is retired and no longer offered"
462 );
463 assert_eq!(
464 Issue::custom("../nope", "no such note").to_string(),
465 "no such note"
466 );
467 }
468
469 #[test]
470 fn display_label_falls_back_to_the_stored_value() {
471 assert_eq!(Term::value("public").display_label(), "public");
472 assert_eq!(
473 Term {
474 label: Some("Everyone".into()),
475 ..Term::value("public")
476 }
477 .display_label(),
478 "Everyone"
479 );
480 }
481}