Skip to main content

flower_core/
schema.rs

1//! flower-core's own constraint vocabulary, plugged into fig-schema's generic
2//! rule engine.
3//!
4//! fig-schema's [`fig_schema::FieldRule`]/[`fig_schema::Schema`] are generic
5//! over the constraint type; this module supplies flower's: a controlled
6//! vocabulary ([`Constraint::Enum`]) or a link field
7//! ([`Constraint::Reference`], where *spanning* lives — the discovery
8//! backbone). flower-core never learns the word "prov": an embedder (a prov
9//! adapter, a `$schema` detector, …) builds a [`Schema`] and supplies it —
10//! either through [`Backend::schema`](crate::Backend::schema) or by injecting
11//! it into the [`Model`](crate::Model), the same way managed keys arrive today.
12
13use fig::Value;
14use fig_schema::{Cardinality, Term, Validate, Validation, validate_enum};
15
16/// flower's field rule and schema, with flower's own [`Constraint`] plugged
17/// into fig-schema's generic engine.
18pub type FieldRule = fig_schema::FieldRule<Constraint>;
19pub type Schema = fig_schema::Schema<Constraint>;
20
21/// A value constraint on a field.
22#[derive(Debug, Clone)]
23pub enum Constraint {
24    /// A controlled vocabulary — an enumerated set of allowed values.
25    Enum {
26        /// The legal terms.
27        values: Vec<Term>,
28        /// `true`: an unknown value is rejected. `false`: allowed, near-misses warn.
29        closed: bool,
30    },
31    /// A relation / link field. The spanning (containment) backbone lives here.
32    Reference {
33        /// The relation name (`contents`, `part_of`, …).
34        relation: String,
35        /// Single link vs a list of links.
36        cardinality: Cardinality,
37        /// The spanning containment relation (the discovery backbone).
38        spanning: bool,
39    },
40}
41
42impl Validate for Constraint {
43    /// Only a controlled vocabulary constrains scalar *values* here; a
44    /// reference or a type-only rule imposes nothing (fig's reparse remains
45    /// the backstop).
46    fn validate(&self, value: &Value) -> Validation {
47        match self {
48            Constraint::Enum { values, closed } => validate_enum(values, *closed, value),
49            Constraint::Reference { .. } => Validation::Ok,
50        }
51    }
52}
53
54/// Convenience accessors for a flower [`FieldRule`], mirroring what used to be
55/// inherent methods before [`fig_schema::FieldRule`] became generic. A local
56/// trait, since inherent impls can't be added to a foreign generic type.
57pub trait FieldRuleExt {
58    /// The controlled vocabulary this rule enforces, if any.
59    fn enum_constraint(&self) -> Option<(&[Term], bool)>;
60    /// The reference/relation this rule describes, if any.
61    fn reference(&self) -> Option<&str>;
62}
63
64impl FieldRuleExt for FieldRule {
65    fn enum_constraint(&self) -> Option<(&[Term], bool)> {
66        match &self.constraint {
67            Some(Constraint::Enum { values, closed }) => Some((values, *closed)),
68            _ => None,
69        }
70    }
71
72    fn reference(&self) -> Option<&str> {
73        match &self.constraint {
74            Some(Constraint::Reference { relation, .. }) => Some(relation),
75            _ => None,
76        }
77    }
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83    use crate::tree::Seg;
84    use fig_schema::{FieldType, Issue, PathPat};
85
86    #[test]
87    fn closed_enum_rejects_unknown_accepts_known() {
88        let rule = FieldRule::new(PathPat::each_item_of("audience"))
89            .ty(FieldType::Str)
90            .constraint(Constraint::Enum {
91                values: vec![Term::value("public"), Term::value("private")],
92                closed: true,
93            });
94        assert_eq!(rule.validate(&Value::Str("public".into())), Validation::Ok);
95        assert!(matches!(
96            rule.validate(&Value::Str("familly".into())),
97            Validation::Reject(_)
98        ));
99    }
100
101    #[test]
102    fn reference_constraint_is_not_checked_here() {
103        let rule = FieldRule::new(PathPat::key("part_of"))
104            .ty(FieldType::Ref)
105            .constraint(Constraint::Reference {
106                relation: "part_of".into(),
107                cardinality: Cardinality::One,
108                spanning: true,
109            });
110        assert_eq!(
111            rule.validate(&Value::Str("anything".into())),
112            Validation::Ok
113        );
114        assert_eq!(rule.reference(), Some("part_of"));
115    }
116
117    #[test]
118    fn schema_rule_for_matches_by_path() {
119        let schema = Schema::new(vec![FieldRule::new(PathPat::key("status")).constraint(
120            Constraint::Enum {
121                values: vec![Term::value("active"), Term::value("archived").retired(true)],
122                closed: true,
123            },
124        )]);
125        let rule = schema.rule_for(&[Seg::Key("status".into())]).unwrap();
126        assert_eq!(rule.validate(&Value::Str("active".into())), Validation::Ok);
127        // A retired term is still a *member*, so even a closed vocabulary only
128        // warns — not the same failure as a value nobody ever declared.
129        assert_eq!(
130            rule.validate(&Value::Str("archived".into())),
131            Validation::Warn(Issue::retired("archived"))
132        );
133        // A value outside the vocabulary is the hard rejection, and carries the
134        // near-miss a frontend can offer as a one-tap correction.
135        let unknown = rule.validate(&Value::Str("activ".into()));
136        assert!(unknown.is_reject());
137        assert_eq!(
138            unknown.issue().and_then(|i| i.suggestion.as_deref()),
139            Some("active")
140        );
141    }
142}