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, Presentation};
85
86    #[test]
87    fn closed_enum_rejects_unknown_accepts_known() {
88        let rule = FieldRule {
89            at: PathPat::each_item_of("audience"),
90            ty: Some(FieldType::Str),
91            constraint: Some(Constraint::Enum {
92                values: vec![Term::value("public"), Term::value("private")],
93                closed: true,
94            }),
95            present: Presentation::default(),
96        };
97        assert_eq!(rule.validate(&Value::Str("public".into())), Validation::Ok);
98        assert!(matches!(
99            rule.validate(&Value::Str("familly".into())),
100            Validation::Reject(_)
101        ));
102    }
103
104    #[test]
105    fn reference_constraint_is_not_checked_here() {
106        let rule = FieldRule {
107            at: PathPat::key("part_of"),
108            ty: Some(FieldType::Ref),
109            constraint: Some(Constraint::Reference {
110                relation: "part_of".into(),
111                cardinality: Cardinality::One,
112                spanning: true,
113            }),
114            present: Presentation::default(),
115        };
116        assert_eq!(
117            rule.validate(&Value::Str("anything".into())),
118            Validation::Ok
119        );
120        assert_eq!(rule.reference(), Some("part_of"));
121    }
122
123    #[test]
124    fn schema_rule_for_matches_by_path() {
125        let schema = Schema::new(vec![FieldRule {
126            at: PathPat::key("status"),
127            ty: None,
128            constraint: Some(Constraint::Enum {
129                values: vec![
130                    Term::value("active"),
131                    Term {
132                        retired: true,
133                        ..Term::value("archived")
134                    },
135                ],
136                closed: true,
137            }),
138            present: Presentation::default(),
139        }]);
140        let rule = schema.rule_for(&[Seg::Key("status".into())]).unwrap();
141        assert_eq!(rule.validate(&Value::Str("active".into())), Validation::Ok);
142        // A retired term is still a *member*, so even a closed vocabulary only
143        // warns — not the same failure as a value nobody ever declared.
144        assert_eq!(
145            rule.validate(&Value::Str("archived".into())),
146            Validation::Warn(Issue::retired("archived"))
147        );
148        // A value outside the vocabulary is the hard rejection, and carries the
149        // near-miss a frontend can offer as a one-tap correction.
150        let unknown = rule.validate(&Value::Str("activ".into()));
151        assert!(unknown.is_reject());
152        assert_eq!(
153            unknown.issue().and_then(|i| i.suggestion.as_deref()),
154            Some("active")
155        );
156    }
157}