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}