Skip to main content

fig_schema/
consequence.rs

1//! What changing a field *costs* — declared beside the rule, surfaced by the
2//! host before it commits. The three properties a host has to know are on
3//! [`Consequence`] itself, since this module is private and its docs do not
4//! reach an embedder.
5
6use fig::Value;
7
8use crate::field::FieldRule;
9use crate::vocab::Term;
10
11/// What the host should *do* about a consequence — the interaction, not a
12/// measure of how bad it is. Mirrors [`Validation`](crate::Validation)'s
13/// Ok/Warn/Reject: the crate names the response, the embedder renders it.
14///
15/// Exhaustive on purpose, unlike most of this crate's enums. A host that met an
16/// unhandled severity through a `_` arm would quietly under-warn about the
17/// change it was told to warn about hardest, which is the exact failure this
18/// type exists to prevent. Adding a level is worth a major.
19///
20/// Ordering is severity order, ascending — see [`FieldRule::severity_of`].
21#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
22pub enum Severity {
23    /// Say so, but don't interrupt: an inline note beside the field.
24    Notice,
25    /// Ask before committing — an ordinary confirm/cancel.
26    Confirm,
27    /// Ask, and make agreeing deliberate: type the value, hold to confirm,
28    /// whatever the frontend's strongest gesture is. For a change that cannot
29    /// be undone.
30    ConfirmExplicitly,
31}
32
33/// A cost of changing a field, declared on the rule that governs it.
34///
35/// A different fact from [`Presentation`](crate::Presentation)'s
36/// [`Tint`](crate::Tint). A tint says how loudly to draw a field; a consequence
37/// says what happens if the user goes through with the change — that switching
38/// a metadata format rewrites every document in the archive, that turning a
39/// recycle bin off makes deletion unrecoverable. A field can be drawn calmly
40/// and still be expensive to change, and a field can be drawn in red and cost
41/// nothing.
42///
43/// Three properties are deliberate, and a host that assumes otherwise will be
44/// subtly wrong:
45///
46/// - **A guard names the value being landed, not a transition.** There is no
47///   from/to matrix: [`Consequence::when`] names a destination. The host knows
48///   the current value; this crate does not. A cost that only applies coming
49///   *from* a particular value is declared as a plain consequence on the
50///   destination, and suppressed by the host that can see the difference.
51/// - **Deletion resolves to whatever the host's default is.** This crate has no
52///   concept of a field being absent, and [`Value::Null`] is not a spelling for
53///   it — a written null is a real value, which
54///   [`FieldType::Null`](crate::FieldType) coerces. A host removing a field
55///   should ask about the value the removal actually resolves to, or about
56///   nothing.
57/// - **No-op detection is the caller's.** [`FieldRule::consequences_of`] has no
58///   current value to compare against, so re-setting a field to what it already
59///   holds answers exactly as setting it afresh does. Suppressing that is the
60///   host's job — and it is what makes the first property work.
61///
62/// `#[non_exhaustive]`: built from [`Consequence::always`] or
63/// [`Consequence::when`] plus [`Consequence::severity`]. Reading the fields is
64/// unchanged.
65///
66/// ```
67/// use fig::Value;
68/// use fig_schema::{Consequence, Severity};
69///
70/// let c = Consequence::when(false, "Deleted items will be gone for good.")
71///     .severity(Severity::ConfirmExplicitly);
72/// assert_eq!(c.when, Some(Value::Bool(false)));
73/// ```
74#[derive(Debug, Clone, PartialEq)]
75#[non_exhaustive]
76pub struct Consequence {
77    /// The destination value this applies to, or `None` for *any* change to the
78    /// field. Compared with [`Value::eq_canonical`], so a guard written
79    /// `Value::Int(1)` still matches a document that parsed `1` as a
80    /// [`Value::Uint`].
81    pub when: Option<Value>,
82    /// What the host should do about it.
83    pub severity: Severity,
84    /// The sentence to show. Prose rather than a taxonomy: a consequence is
85    /// specific to the field, and no closed set of effect kinds would spare the
86    /// author from writing it.
87    pub message: String,
88}
89
90impl Consequence {
91    /// A consequence of changing the field at all, whatever the new value.
92    /// [`Severity::Notice`] until [`Consequence::severity`] says otherwise.
93    pub fn always(message: impl Into<String>) -> Self {
94        Self {
95            when: None,
96            severity: Severity::Notice,
97            message: message.into(),
98        }
99    }
100
101    /// A consequence of changing the field *to* `value` — a destination, not a
102    /// transition. See [`Consequence`] for why there is no from/to matrix.
103    pub fn when(value: impl Into<Value>, message: impl Into<String>) -> Self {
104        Self {
105            when: Some(value.into()),
106            severity: Severity::Notice,
107            message: message.into(),
108        }
109    }
110
111    /// Set what the host should do about this.
112    pub fn severity(mut self, severity: Severity) -> Self {
113        self.severity = severity;
114        self
115    }
116
117    /// Whether this applies to landing `value`. An unguarded consequence
118    /// applies to every value.
119    pub fn applies_to(&self, value: &Value) -> bool {
120        match &self.when {
121            None => true,
122            Some(guard) => guard.eq_canonical(value),
123        }
124    }
125}
126
127impl<C> FieldRule<C> {
128    /// Every consequence of landing `value` on this field, in declaration
129    /// order — the unguarded ones and the guards that match.
130    ///
131    /// All of them, not the worst: two consequences can be true at once (a
132    /// blanket "this rewrites the archive" and a value-specific "and `none`
133    /// cannot be undone"), and dropping either loses a sentence the user needed.
134    /// For picking one interaction, use [`FieldRule::severity_of`].
135    pub fn consequences_of(&self, value: &Value) -> Vec<&Consequence> {
136        self.on_change
137            .iter()
138            .filter(|c| c.applies_to(value))
139            .collect()
140    }
141
142    /// The most severe consequence of landing `value`, or `None` if there is
143    /// none. Allocates nothing — this runs per keystroke in an editor deciding
144    /// whether to arm a confirm.
145    pub fn severity_of(&self, value: &Value) -> Option<Severity> {
146        self.on_change
147            .iter()
148            .filter(|c| c.applies_to(value))
149            .map(|c| c.severity)
150            .max()
151    }
152}
153
154/// Guards that name a value the vocabulary doesn't have — a lint, run when an
155/// embedder loads its schema, not part of validation.
156///
157/// A guard is silent when it is wrong: `Consequence::when("none", …)` against a
158/// vocabulary spelling it `off` simply never fires, and the user commits the
159/// expensive change with no warning at all. Nothing else in the crate can
160/// notice, because a guard that matches nothing is indistinguishable from a
161/// guard for a value the user hasn't chosen yet.
162///
163/// Only `Some(Value::Str(_))` guards are checked: an unguarded consequence has
164/// no value to look up, and a bool or numeric guard is not a vocabulary term.
165/// A [retired](Term::retired) term counts as present — it is still a known
166/// value, and warning about one is exactly what a retirement wants.
167///
168/// ```
169/// use fig_schema::{Consequence, Term, guards_without_terms};
170///
171/// let terms = [Term::value("off"), Term::value("registry")];
172/// let declared = [
173///     Consequence::when("none", "History will be discarded."),
174///     Consequence::when("off", "History will be discarded."),
175/// ];
176/// assert_eq!(guards_without_terms(&declared, &terms), vec!["none"]);
177/// ```
178pub fn guards_without_terms<'a>(consequences: &'a [Consequence], terms: &[Term]) -> Vec<&'a str> {
179    consequences
180        .iter()
181        .filter_map(|c| match &c.when {
182            Some(Value::Str(s)) => Some(s.as_str()),
183            _ => None,
184        })
185        .filter(|s| !terms.iter().any(|t| t.value == *s))
186        .collect()
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use crate::path::PathPat;
193    use crate::vocab::Validate;
194
195    // The engine is generic over the embedder's constraint type; consequences
196    // need nothing from it, so a unit type stands in.
197    #[derive(Debug, Clone)]
198    struct NoConstraint;
199    impl Validate for NoConstraint {
200        fn validate(&self, _value: &Value) -> crate::Validation {
201            crate::Validation::Ok
202        }
203    }
204
205    fn rule(consequences: Vec<Consequence>) -> FieldRule<NoConstraint> {
206        FieldRule::new(PathPat::key("setting")).on_change_all(consequences)
207    }
208
209    #[test]
210    fn a_float_guard_matches_rather_than_being_skipped() {
211        // Rust's float parsing and float literals are both correctly rounded,
212        // so a document spelling `1.5`, `1.50` or `1.5e0` and a guard written
213        // `1.5` produce identical bits. A guard on a float field is therefore
214        // a real guard, not a coin toss — assert it fires rather than leaving
215        // the question open.
216        let r = rule(vec![Consequence::when(1.5, "The scale changes.")]);
217        assert_eq!(r.consequences_of(&Value::Float(1.5)).len(), 1);
218        assert_eq!(
219            r.consequences_of(&crate::FieldType::Float.coerce("1.50"))
220                .len(),
221            1,
222        );
223        assert!(r.consequences_of(&Value::Float(1.6)).is_empty());
224    }
225
226    #[test]
227    fn guard_matching_goes_through_eq_canonical_not_derived_equality() {
228        // `Int(1) == Uint(1)` is false under the derived comparison, and a
229        // document past `i64::MAX` is the one place fig writes `Uint`. Swapping
230        // `eq_canonical` for `==` would regress this silently, so the property
231        // is asserted rather than assumed.
232        let r = rule(vec![Consequence::when(1i64, "The count changes.")]);
233        assert_eq!(r.consequences_of(&Value::Uint(1)).len(), 1);
234        assert_eq!(
235            rule(vec![Consequence::when(Value::Uint(u64::MAX), "…")])
236                .consequences_of(&Value::Uint(u64::MAX))
237                .len(),
238            1,
239        );
240        // And the boundary the widening rule turns on is still not a match.
241        assert!(
242            rule(vec![Consequence::when(Value::Uint(1), "…")])
243                .consequences_of(&Value::Int(-1))
244                .is_empty()
245        );
246    }
247
248    #[test]
249    fn a_typoed_guard_is_caught_by_the_lint_because_nothing_else_would() {
250        let terms = [Term::value("off"), Term::value("registry")];
251        let declared = [
252            Consequence::always("The archive is rewritten."),
253            Consequence::when("none", "History will be discarded."),
254            Consequence::when(false, "…"),
255            Consequence::when("registry", "…"),
256        ];
257        // Only the string guard that names no term; the unguarded and the
258        // non-string ones have nothing to look up.
259        assert_eq!(guards_without_terms(&declared, &terms), vec!["none"]);
260        // A retired term is still a known value, so a guard on one is fine.
261        let retired = [Term::value("off").retired(true)];
262        assert!(guards_without_terms(&declared[3..], &terms).is_empty());
263        assert!(guards_without_terms(&[Consequence::when("off", "…")], &retired).is_empty());
264    }
265
266    #[test]
267    fn an_unguarded_and_a_matching_guarded_consequence_both_survive() {
268        // The merge bug this shape exists to avoid: severities merge by taking
269        // the worst, prose does not. Keeping only the more severe message would
270        // drop the sentence that explains the blanket cost.
271        let r = rule(vec![
272            Consequence::always("Every document is rewritten."),
273            Consequence::when("none", "Existing ids cannot be recovered.")
274                .severity(Severity::ConfirmExplicitly),
275            Consequence::when("registry", "Ids move into the registry."),
276        ]);
277        let hit = r.consequences_of(&Value::Str("none".into()));
278        assert_eq!(hit.len(), 2);
279        assert_eq!(hit[0].message, "Every document is rewritten.");
280        assert_eq!(hit[1].message, "Existing ids cannot be recovered.");
281        assert_eq!(
282            r.severity_of(&Value::Str("none".into())),
283            Some(Severity::ConfirmExplicitly)
284        );
285        // The other destination keeps the blanket one and its own, at the
286        // severity *it* declared rather than the worst on the field.
287        assert_eq!(r.consequences_of(&Value::Str("registry".into())).len(), 2);
288        assert_eq!(
289            r.severity_of(&Value::Str("registry".into())),
290            Some(Severity::Notice)
291        );
292    }
293
294    #[test]
295    fn asking_twice_about_one_value_answers_the_same_way_because_no_op_detection_is_the_hosts() {
296        // The test you might expect here — "a no-op re-set does not warn" — is
297        // unwritable in this crate, and that is the design, not a gap: there is
298        // no current value and no concept of absence to compare against. So
299        // assert the property that *does* hold and that the host's suppression
300        // depends on: the answer is a function of the destination alone.
301        // Without this, someone writes the straightforward version, finds they
302        // must add current-value tracking, and quietly reverses the decision.
303        let r = rule(vec![Consequence::when("off", "History will be discarded.")]);
304        let value = Value::Str("off".into());
305        let first = r.consequences_of(&value);
306        let second = r.consequences_of(&value);
307        assert_eq!(first, second);
308        assert_eq!(first.len(), 1);
309        assert_eq!(r.severity_of(&value), r.severity_of(&value));
310    }
311
312    #[test]
313    fn a_rule_declaring_nothing_has_no_consequences() {
314        let r: FieldRule<NoConstraint> = FieldRule::new(PathPat::key("title"));
315        assert!(r.consequences_of(&Value::Str("anything".into())).is_empty());
316        assert_eq!(r.severity_of(&Value::Str("anything".into())), None);
317    }
318
319    #[test]
320    fn severity_orders_ascending_so_max_picks_the_loudest() {
321        assert!(Severity::Notice < Severity::Confirm);
322        assert!(Severity::Confirm < Severity::ConfirmExplicitly);
323    }
324}