Skip to main content

fig_schema/
vocab.rs

1//! Controlled vocabularies, the generic validation seam, and loading a term
2//! set from its document.
3//!
4//! This module knows what a vocabulary *term* is and how to check a value
5//! against a list of them ([`validate_enum`]) — that's the whole of what's
6//! genuinely value-shape about a "constraint". It deliberately does not define
7//! a `Constraint` enum: whether a field's constraint is "a controlled
8//! vocabulary" or something else entirely (a reference into a workspace, a
9//! range, a pattern) is the embedder's call, expressed as its own type that
10//! implements [`Validate`]. See [`crate::FieldRule`].
11//!
12//! It also knows how to load a [`Term`] list from a document ([`parse_vocabulary`]):
13//! the `vocabulary: { field, values }` / `terms:` convention is common enough
14//! across embedders (a frontmatter engine's controlled fields, a batch
15//! renderer's metadata-driven config) that parsing it once here lets them
16//! share the same vocabulary document instead of each hand-rolling the same
17//! parse.
18
19use std::fmt;
20
21use fig::Value;
22
23use crate::present::Tint;
24
25/// One term of a controlled vocabulary.
26///
27/// `#[non_exhaustive]`: a term gains display and lifecycle facets over time, so
28/// it is built from [`Term::value`] and the chainable setters rather than a
29/// struct literal. Reading the fields is unchanged.
30///
31/// ```
32/// use fig_schema::Term;
33///
34/// let t = Term::value("archived").label("Archived").retired(true);
35/// assert_eq!(t.display_label(), "Archived");
36/// assert!(t.retired);
37/// ```
38#[derive(Debug, Clone, PartialEq, Eq)]
39#[non_exhaustive]
40pub struct Term {
41    /// The stored value.
42    pub value: String,
43    /// A human display label (defaults to `value` — see [`Term::display_label`]).
44    pub label: Option<String>,
45    /// A human gloss / help text.
46    pub description: Option<String>,
47    /// Known but no longer offered: still valid where already present, excluded
48    /// from the picker's offered set. Validating one yields [`Validation::Warn`]
49    /// rather than [`Validation::Reject`], even under a closed vocabulary — see
50    /// [`validate_enum`].
51    pub retired: bool,
52    /// A per-value tint (e.g. `public` = positive/green).
53    pub tint: Option<Tint>,
54}
55
56impl Term {
57    /// A bare live term with just a value.
58    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    /// Set the display label.
69    pub fn label(mut self, label: impl Into<String>) -> Self {
70        self.label = Some(label.into());
71        self
72    }
73
74    /// Set the display label from an optional one. `None` leaves it unset.
75    pub fn label_opt(mut self, label: Option<impl Into<String>>) -> Self {
76        self.label = label.map(Into::into);
77        self
78    }
79
80    /// Set the human gloss.
81    pub fn description(mut self, description: impl Into<String>) -> Self {
82        self.description = Some(description.into());
83        self
84    }
85
86    /// Set the human gloss from an optional one. `None` leaves it unset.
87    pub fn description_opt(mut self, description: Option<impl Into<String>>) -> Self {
88        self.description = description.map(Into::into);
89        self
90    }
91
92    /// Mark the term [retired](Term::retired) — known, but no longer offered.
93    pub fn retired(mut self, retired: bool) -> Self {
94        self.retired = retired;
95        self
96    }
97
98    /// Set the per-value tint. Takes a [`Tint`] or an `Option<Tint>`.
99    pub fn tint(mut self, tint: impl Into<Option<Tint>>) -> Self {
100        self.tint = tint.into();
101        self
102    }
103
104    /// The text to display for this term: [`Term::label`] if set, otherwise the
105    /// stored [`Term::value`].
106    pub fn display_label(&self) -> &str {
107        self.label.as_deref().unwrap_or(&self.value)
108    }
109}
110
111/// Whether a reference or list-shaped field holds one entry or many. Pure data
112/// shape — reused by an embedder's own reference/relation constraint (prov's
113/// `spanning`/`cardinality` concepts, for instance) without this crate needing
114/// to know what a "relation" is.
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub enum Cardinality {
117    One,
118    Many,
119}
120
121/// A controlled vocabulary loaded from its own document: the `field` it
122/// governs, whether its value set is closed, and the terms themselves — ready
123/// to hand to [`validate_enum`].
124///
125/// `#[non_exhaustive]`: the document convention can gain declared facts, so
126/// build one with [`VocabularyDoc::new`]. Reading the fields is unchanged.
127#[derive(Debug, Clone, PartialEq)]
128#[non_exhaustive]
129pub struct VocabularyDoc {
130    /// The field this vocabulary governs (`audience`, `tags`).
131    pub field: String,
132    /// Whether the value set is closed (an unknown value is a hard
133    /// [`Validation::Reject`]) or open (a folksonomy — an unknown value only
134    /// [`Validation::Warn`]s, as a possible typo). Any `values` spelling other
135    /// than `"closed"` is open, matching [`validate_enum`]'s own permissive
136    /// default.
137    pub closed: bool,
138    /// The declared terms, in declaration order.
139    pub terms: Vec<Term>,
140}
141
142impl VocabularyDoc {
143    /// A vocabulary declared in code rather than loaded from a document.
144    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
153/// Parse a vocabulary document from its top-level value: a `vocabulary: {
154/// field, values }` marker plus a `terms:` mapping, each entry either a bare
155/// key (a live term with no metadata) or a `{ label?, description?, retired?
156/// }` mapping. Returns `None` when `value` carries no `vocabulary` marker —
157/// i.e. it is not a vocabulary document.
158///
159/// This is the shared file-format half of a controlled vocabulary: [`Term`]
160/// and [`validate_enum`] already say how to *check* a value; this says how to
161/// *load* the term set a document declares, so two independent embedders (a
162/// frontmatter engine, a batch renderer) can point at the same vocabulary
163/// document without either depending on the other.
164///
165/// Key lookup is [`Value::get`], so a duplicated key resolves last-wins, the
166/// same way fig itself reads one. A duplicated *term* is not a lookup and
167/// yields two [`Term`]s with the same value, in declaration order.
168pub 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            // A bare `term:` (null/scalar spec) has no keys to read, so every
178            // lookup below misses and it comes out a live term with no
179            // metadata — the same shape `Term::value` builds.
180            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/// Why a value failed to validate.
203///
204/// Structured rather than pre-rendered, for the same reason [`crate::Presentation`]
205/// carries semantic hints rather than colours: the embedder owns presentation.
206/// A frontend can localize the text, or offer [`Issue::suggestion`] as a
207/// one-tap correction instead of prose. [`Display`](std::fmt::Display) renders
208/// the English default.
209///
210/// `#[non_exhaustive]`: an issue can gain context (a span, a source) without
211/// costing downstream a major. Build one with [`Issue::unknown`],
212/// [`Issue::retired`] or [`Issue::custom`]; reading the fields is unchanged.
213#[derive(Debug, Clone, PartialEq, Eq)]
214#[non_exhaustive]
215pub struct Issue {
216    /// What went wrong.
217    pub kind: IssueKind,
218    /// The offending value, as text.
219    pub value: String,
220    /// A near miss from the vocabulary, when one is close enough to be worth
221    /// offering.
222    pub suggestion: Option<String>,
223}
224
225/// The kind of an [`Issue`].
226///
227/// `#[non_exhaustive]`: new failure kinds get their own variant as the crate
228/// grows, so a `match` needs a `_` arm. [`IssueKind::Custom`] already carries
229/// anything an embedder defines.
230#[derive(Debug, Clone, PartialEq, Eq)]
231#[non_exhaustive]
232pub enum IssueKind {
233    /// Not a member of the vocabulary.
234    Unknown,
235    /// A member, but [retired](Term::retired).
236    Retired,
237    /// An embedder-defined issue (a dangling reference, a range violation, …),
238    /// carrying its own rendered message.
239    Custom(String),
240}
241
242impl Issue {
243    /// A value that is not in the vocabulary.
244    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    /// A value that is in the vocabulary but [retired](Term::retired).
253    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    /// An embedder-defined issue with its own message.
262    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    /// Attach a near-miss suggestion.
271    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/// The result of validating a value against a field's constraint at commit
292/// time.
293#[derive(Debug, Clone, PartialEq, Eq)]
294pub enum Validation {
295    /// The value is fine — apply it.
296    Ok,
297    /// A soft warning (an open vocabulary's unknown value, or a retired term);
298    /// apply, but surface it.
299    Warn(Issue),
300    /// A hard rejection (a closed vocabulary's unknown value); do not apply.
301    Reject(Issue),
302}
303
304impl Validation {
305    /// Whether the value passed cleanly, with nothing to surface.
306    pub fn is_ok(&self) -> bool {
307        matches!(self, Validation::Ok)
308    }
309
310    /// Whether the value must not be applied.
311    pub fn is_reject(&self) -> bool {
312        matches!(self, Validation::Reject(_))
313    }
314
315    /// The issue behind a warning or rejection, if any.
316    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    /// Severity order, for picking the worst result across a sequence.
324    fn rank(&self) -> u8 {
325        match self {
326            Validation::Ok => 0,
327            Validation::Warn(_) => 1,
328            Validation::Reject(_) => 2,
329        }
330    }
331}
332
333/// A field constraint that knows how to check a candidate value. An embedder
334/// implements this on its own constraint type (an enum with a vocabulary
335/// variant, a reference variant, whatever it needs); [`crate::FieldRule::validate`]
336/// dispatches to it generically.
337///
338/// ```
339/// use fig::Value;
340/// use fig_schema::{Term, Validate, Validation, validate_enum};
341///
342/// // The embedder's own constraint type — this crate never sees its shape.
343/// enum Constraint {
344///     Enum { values: Vec<Term>, closed: bool },
345///     Ref,
346/// }
347///
348/// impl Validate for Constraint {
349///     fn validate(&self, value: &Value) -> Validation {
350///         match self {
351///             Constraint::Enum { values, closed } => validate_enum(values, *closed, value),
352///             Constraint::Ref => Validation::Ok, // resolved against the workspace
353///         }
354///     }
355/// }
356///
357/// let visibility = Constraint::Enum {
358///     values: vec![Term::value("public"), Term::value("private")],
359///     closed: true,
360/// };
361/// assert!(visibility.validate(&Value::Str("public".into())).is_ok());
362/// assert!(visibility.validate(&Value::Str("secret".into())).is_reject());
363/// ```
364pub trait Validate {
365    /// Check `value` against this constraint.
366    fn validate(&self, value: &Value) -> Validation;
367}
368
369/// Validate `value` against a controlled vocabulary — the reusable logic
370/// behind any embedder's vocabulary-shaped constraint.
371///
372/// A sequence is validated element-wise, so a rule declared on a list field
373/// itself governs its items just as one declared with
374/// [`PathPat::each_item_of`](crate::PathPat::each_item_of) does; the most
375/// severe element result wins. Any other shape (a mapping, a number under an
376/// enum field) is left to the caller's own backstop — fig's reparse, typically.
377///
378/// A [retired](Term::retired) term warns rather than rejects: it is still a
379/// *known* value, so a document that already holds one stays committable.
380pub 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
397/// Validate one string against the vocabulary.
398fn 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
416/// The live term closest to `value` by a small edit distance, compared
417/// case-insensitively — a lightweight near-miss suggestion. Declaration order
418/// breaks ties.
419fn 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        // Only the winner is cloned; the vocabulary itself is left untouched.
433        .map(|(t, _)| t.value.clone())
434}
435
436/// How many typos still count as a near miss, for a word of `len` characters.
437/// Scaled to the *shorter* of the two words being compared: at a flat two
438/// typos every two-letter string is a near miss for every other, so a short
439/// vocabulary would otherwise suggest nonsense ("hi" → did you mean "no"?).
440fn suggestion_budget(len: usize) -> usize {
441    match len {
442        0..=2 => 0,
443        3..=4 => 1,
444        _ => 2,
445    }
446}
447
448/// Levenshtein distance — a tiny dependency-free implementation for near-miss
449/// suggestions (the vocabulary sets here are small).
450fn 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        // A document already holding a retired value stays committable: the
504        // term is known, merely no longer offered.
505        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        // Two typos apart is the whole of a two-letter word.
525        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        // A retired item alone warns...
551        let warned = Value::Seq(vec![Value::Str("archived".into())]);
552        assert!(matches!(
553            validate_enum(&terms, true, &warned),
554            Validation::Warn(_)
555        ));
556        // ...but an unknown item alongside it rejects the whole sequence.
557        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        // fig's own `Value::get` is last-wins on duplicates. This crate used to
672        // hand-roll a first-wins lookup, so it disagreed with fig about what
673        // the same bytes said.
674        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        // Terms are iterated, not looked up, so both survive in declaration
684        // order — a picker would offer the value twice.
685        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}