Skip to main content

fig_schema/
vocab.rs

1//! Controlled vocabularies and the generic validation seam.
2//!
3//! This module knows what a vocabulary *term* is and how to check a value
4//! against a list of them ([`validate_enum`]) — that's the whole of what's
5//! genuinely value-shape about a "constraint". It deliberately does not define
6//! a `Constraint` enum: whether a field's constraint is "a controlled
7//! vocabulary" or something else entirely (a reference into a workspace, a
8//! range, a pattern) is the embedder's call, expressed as its own type that
9//! implements [`Validate`]. See [`crate::FieldRule`].
10
11use std::fmt;
12
13use fig::Value;
14
15use crate::present::Tint;
16
17/// One term of a controlled vocabulary.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct Term {
20    /// The stored value.
21    pub value: String,
22    /// A human display label (defaults to `value` — see [`Term::display_label`]).
23    pub label: Option<String>,
24    /// A human gloss / help text.
25    pub description: Option<String>,
26    /// Known but no longer offered: still valid where already present, excluded
27    /// from the picker's offered set. Validating one yields [`Validation::Warn`]
28    /// rather than [`Validation::Reject`], even under a closed vocabulary — see
29    /// [`validate_enum`].
30    pub retired: bool,
31    /// A per-value tint (e.g. `public` = positive/green).
32    pub tint: Option<Tint>,
33}
34
35impl Term {
36    /// A bare live term with just a value.
37    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    /// The text to display for this term: [`Term::label`] if set, otherwise the
48    /// stored [`Term::value`].
49    pub fn display_label(&self) -> &str {
50        self.label.as_deref().unwrap_or(&self.value)
51    }
52}
53
54/// Whether a reference or list-shaped field holds one entry or many. Pure data
55/// shape — reused by an embedder's own reference/relation constraint (prov's
56/// `spanning`/`cardinality` concepts, for instance) without this crate needing
57/// to know what a "relation" is.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum Cardinality {
60    One,
61    Many,
62}
63
64/// Why a value failed to validate.
65///
66/// Structured rather than pre-rendered, for the same reason [`crate::Presentation`]
67/// carries semantic hints rather than colours: the embedder owns presentation.
68/// A frontend can localize the text, or offer [`Issue::suggestion`] as a
69/// one-tap correction instead of prose. [`Display`](std::fmt::Display) renders
70/// the English default.
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct Issue {
73    /// What went wrong.
74    pub kind: IssueKind,
75    /// The offending value, as text.
76    pub value: String,
77    /// A near miss from the vocabulary, when one is close enough to be worth
78    /// offering.
79    pub suggestion: Option<String>,
80}
81
82/// The kind of an [`Issue`].
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub enum IssueKind {
85    /// Not a member of the vocabulary.
86    Unknown,
87    /// A member, but [retired](Term::retired).
88    Retired,
89    /// An embedder-defined issue (a dangling reference, a range violation, …),
90    /// carrying its own rendered message.
91    Custom(String),
92}
93
94impl Issue {
95    /// A value that is not in the vocabulary.
96    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    /// A value that is in the vocabulary but [retired](Term::retired).
105    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    /// An embedder-defined issue with its own message.
114    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    /// Attach a near-miss suggestion.
123    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/// The result of validating a value against a field's constraint at commit
144/// time.
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub enum Validation {
147    /// The value is fine — apply it.
148    Ok,
149    /// A soft warning (an open vocabulary's unknown value, or a retired term);
150    /// apply, but surface it.
151    Warn(Issue),
152    /// A hard rejection (a closed vocabulary's unknown value); do not apply.
153    Reject(Issue),
154}
155
156impl Validation {
157    /// Whether the value passed cleanly, with nothing to surface.
158    pub fn is_ok(&self) -> bool {
159        matches!(self, Validation::Ok)
160    }
161
162    /// Whether the value must not be applied.
163    pub fn is_reject(&self) -> bool {
164        matches!(self, Validation::Reject(_))
165    }
166
167    /// The issue behind a warning or rejection, if any.
168    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    /// Severity order, for picking the worst result across a sequence.
176    fn rank(&self) -> u8 {
177        match self {
178            Validation::Ok => 0,
179            Validation::Warn(_) => 1,
180            Validation::Reject(_) => 2,
181        }
182    }
183}
184
185/// A field constraint that knows how to check a candidate value. An embedder
186/// implements this on its own constraint type (an enum with a vocabulary
187/// variant, a reference variant, whatever it needs); [`crate::FieldRule::validate`]
188/// dispatches to it generically.
189///
190/// ```
191/// use fig::Value;
192/// use fig_schema::{Term, Validate, Validation, validate_enum};
193///
194/// // The embedder's own constraint type — this crate never sees its shape.
195/// enum Constraint {
196///     Enum { values: Vec<Term>, closed: bool },
197///     Ref,
198/// }
199///
200/// impl Validate for Constraint {
201///     fn validate(&self, value: &Value) -> Validation {
202///         match self {
203///             Constraint::Enum { values, closed } => validate_enum(values, *closed, value),
204///             Constraint::Ref => Validation::Ok, // resolved against the workspace
205///         }
206///     }
207/// }
208///
209/// let visibility = Constraint::Enum {
210///     values: vec![Term::value("public"), Term::value("private")],
211///     closed: true,
212/// };
213/// assert!(visibility.validate(&Value::Str("public".into())).is_ok());
214/// assert!(visibility.validate(&Value::Str("secret".into())).is_reject());
215/// ```
216pub trait Validate {
217    /// Check `value` against this constraint.
218    fn validate(&self, value: &Value) -> Validation;
219}
220
221/// Validate `value` against a controlled vocabulary — the reusable logic
222/// behind any embedder's vocabulary-shaped constraint.
223///
224/// A sequence is validated element-wise, so a rule declared on a list field
225/// itself governs its items just as one declared with
226/// [`PathPat::each_item_of`](crate::PathPat::each_item_of) does; the most
227/// severe element result wins. Any other shape (a mapping, a number under an
228/// enum field) is left to the caller's own backstop — fig's reparse, typically.
229///
230/// A [retired](Term::retired) term warns rather than rejects: it is still a
231/// *known* value, so a document that already holds one stays committable.
232pub 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
249/// Validate one string against the vocabulary.
250fn 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
268/// The live term closest to `value` by a small edit distance, compared
269/// case-insensitively — a lightweight near-miss suggestion. Declaration order
270/// breaks ties.
271fn 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        // Only the winner is cloned; the vocabulary itself is left untouched.
285        .map(|(t, _)| t.value.clone())
286}
287
288/// How many typos still count as a near miss, for a word of `len` characters.
289/// Scaled to the *shorter* of the two words being compared: at a flat two
290/// typos every two-letter string is a near miss for every other, so a short
291/// vocabulary would otherwise suggest nonsense ("hi" → did you mean "no"?).
292fn suggestion_budget(len: usize) -> usize {
293    match len {
294        0..=2 => 0,
295        3..=4 => 1,
296        _ => 2,
297    }
298}
299
300/// Levenshtein distance — a tiny dependency-free implementation for near-miss
301/// suggestions (the vocabulary sets here are small).
302fn 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        // A document already holding a retired value stays committable: the
356        // term is known, merely no longer offered.
357        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        // Two typos apart is the whole of a two-letter word.
386        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        // A retired item alone warns...
418        let warned = Value::Seq(vec![Value::Str("archived".into())]);
419        assert!(matches!(
420            validate_enum(&terms, true, &warned),
421            Validation::Warn(_)
422        ));
423        // ...but an unknown item alongside it rejects the whole sequence.
424        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}