Skip to main content

kyyn_core/
value.rs

1//! Registry-driven typed decoding — the engine's `Value` layer.
2//!
3//! `ron::Value` cannot represent enum variants (a bare `Weekly` degrades to
4//! `Unit`), so the engine decodes records with *seeded* deserialization
5//! instead: the registry's `FieldType` drives which serde entry point each
6//! field goes through, and `deserialize_enum` + `deserialize_identifier`
7//! recover variant names — payload variants included. What the schema crate
8//! gets from Rust's derive, the engine gets from the registry.
9
10use std::collections::BTreeMap;
11use std::fmt;
12
13use crate::link::Link;
14use crate::registry::{Field, FieldType, Kind};
15use chrono::NaiveDate;
16use rust_decimal::Decimal;
17use serde::de::{self, DeserializeSeed, Deserializer, EnumAccess, VariantAccess, Visitor};
18
19/// A decoded field value, shaped by the registry.
20#[derive(Debug, Clone, PartialEq)]
21pub enum Value {
22    /// An explicit `None`.
23    Null,
24    /// `Str` and `Markdown` fields both land here; the registry knows which.
25    Str(String),
26    Date(NaiveDate),
27    Bool(bool),
28    Int(i64),
29    /// EXACT decimal — travels as a string on the wire ("1250000.00").
30    Decimal(Decimal),
31    /// An external web link: `(title: "…", url: "https://…")`.
32    Hyperlink {
33        title: String,
34        url: String,
35    },
36    Link(Link),
37    Enum {
38        variant: String,
39        fields: BTreeMap<String, Value>,
40    },
41    List(Vec<Value>),
42    Struct(BTreeMap<String, Value>),
43}
44
45impl Value {
46    pub fn as_str(&self) -> Option<&str> {
47        match self {
48            Value::Str(s) => Some(s),
49            _ => None,
50        }
51    }
52
53    pub fn as_link(&self) -> Option<&Link> {
54        match self {
55            Value::Link(l) => Some(l),
56            _ => None,
57        }
58    }
59
60    pub fn variant(&self) -> Option<&str> {
61        match self {
62            Value::Enum { variant, .. } => Some(variant),
63            _ => None,
64        }
65    }
66}
67
68/// Decode one record's RON text against its kind. Unknown fields are skipped
69/// (the validator judges correctness; this is a reader). A field that fails
70/// its declared shape is an error — the caller records the file as unreadable.
71pub fn decode(kind: &Kind, text: &str) -> Result<BTreeMap<String, Value>, String> {
72    let mut d = ron::Deserializer::from_str(text).map_err(|e| e.to_string())?;
73    RecordSeed {
74        fields: &kind.fields,
75    }
76    .deserialize(&mut d)
77    .map_err(|e| e.to_string())
78}
79
80struct RecordSeed<'r> {
81    fields: &'r [Field],
82}
83
84impl<'de> DeserializeSeed<'de> for RecordSeed<'_> {
85    type Value = BTreeMap<String, Value>;
86
87    fn deserialize<D: Deserializer<'de>>(self, d: D) -> Result<Self::Value, D::Error> {
88        // RON paren-records only answer deserialize_any (they are structs to
89        // ron, maps to us) — any + visit_map is the reliable entry.
90        d.deserialize_any(FieldsVisitor {
91            fields: self.fields,
92        })
93    }
94}
95
96struct FieldsVisitor<'r> {
97    fields: &'r [Field],
98}
99
100impl<'de> Visitor<'de> for FieldsVisitor<'_> {
101    type Value = BTreeMap<String, Value>;
102
103    fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
104        write!(f, "a record")
105    }
106
107    fn visit_map<M: de::MapAccess<'de>>(self, mut m: M) -> Result<Self::Value, M::Error> {
108        let mut out = BTreeMap::new();
109        while let Some(key) = m.next_key::<String>()? {
110            match self.fields.iter().find(|f| f.name == key) {
111                Some(field) => {
112                    let v = m.next_value_seed(TypeSeed { ty: &field.ty })?;
113                    out.insert(key, v);
114                }
115                None => {
116                    let _: ron::Value = m.next_value()?; // unknown — skip
117                }
118            }
119        }
120        Ok(out)
121    }
122}
123
124struct TypeSeed<'r> {
125    ty: &'r FieldType,
126}
127
128impl<'de> DeserializeSeed<'de> for TypeSeed<'_> {
129    type Value = Value;
130
131    fn deserialize<D: Deserializer<'de>>(self, d: D) -> Result<Value, D::Error> {
132        match self.ty {
133            FieldType::Str | FieldType::Markdown => d.deserialize_str(StrVisitor).map(Value::Str),
134            FieldType::Date => {
135                let s = d.deserialize_str(StrVisitor)?;
136                NaiveDate::parse_from_str(&s, "%Y-%m-%d")
137                    .map(Value::Date)
138                    .map_err(|e| de::Error::custom(format!("date '{s}': {e}")))
139            }
140            FieldType::Bool => d.deserialize_bool(BoolVisitor).map(Value::Bool),
141            FieldType::Int => d.deserialize_i64(IntVisitor).map(Value::Int),
142            FieldType::Hyperlink => {
143                let fields = [
144                    Field {
145                        name: "title".into(),
146                        doc: String::new(),
147                        ty: FieldType::Str,
148                        role: None,
149                        refers_to: None,
150                    },
151                    Field {
152                        name: "url".into(),
153                        doc: String::new(),
154                        ty: FieldType::Str,
155                        role: None,
156                        refers_to: None,
157                    },
158                ];
159                let map = d.deserialize_any(FieldsVisitor { fields: &fields })?;
160                let get = |k: &str| match map.get(k) {
161                    Some(Value::Str(s)) => Ok(s.clone()),
162                    _ => Err(de::Error::custom(format!(
163                        "hyperlink needs a string '{k}' — (title: \"…\", url: \"https://…\")"
164                    ))),
165                };
166                Ok(Value::Hyperlink {
167                    title: get("title")?,
168                    url: get("url")?,
169                })
170            }
171            FieldType::Decimal => {
172                let s = d.deserialize_str(StrVisitor)?;
173                Decimal::from_str_exact(&s)
174                    .map(Value::Decimal)
175                    .map_err(|e| {
176                        de::Error::custom(format!(
177                            "decimal '{s}': {e} — exact decimals travel as strings, \
178                             e.g. \"1250000.00\", never floats"
179                        ))
180                    })
181            }
182            FieldType::Link { .. } => d
183                .deserialize_str(StrVisitor)
184                .map(|s| Value::Link(Link::new(s))),
185            FieldType::Enum(variants) => d.deserialize_enum("", &[], EnumVisitor { variants }),
186            FieldType::Option(inner) => d.deserialize_option(OptVisitor { inner }),
187            FieldType::List(inner) => d.deserialize_seq(ListVisitor { inner }),
188            FieldType::Struct(fields) => d
189                .deserialize_any(FieldsVisitor { fields })
190                .map(Value::Struct),
191        }
192    }
193}
194
195struct StrVisitor;
196impl<'de> Visitor<'de> for StrVisitor {
197    type Value = String;
198    fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
199        write!(f, "a string")
200    }
201    fn visit_str<E: de::Error>(self, v: &str) -> Result<String, E> {
202        Ok(v.to_string())
203    }
204    fn visit_string<E: de::Error>(self, v: String) -> Result<String, E> {
205        Ok(v)
206    }
207}
208
209struct IntVisitor;
210
211impl Visitor<'_> for IntVisitor {
212    type Value = i64;
213    fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
214        f.write_str("an integer")
215    }
216    fn visit_i64<E: de::Error>(self, x: i64) -> Result<i64, E> {
217        Ok(x)
218    }
219    fn visit_u64<E: de::Error>(self, x: u64) -> Result<i64, E> {
220        i64::try_from(x).map_err(|_| E::custom(format!("integer {x} overflows i64")))
221    }
222}
223
224struct BoolVisitor;
225impl<'de> Visitor<'de> for BoolVisitor {
226    type Value = bool;
227    fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
228        write!(f, "a bool")
229    }
230    fn visit_bool<E: de::Error>(self, v: bool) -> Result<bool, E> {
231        Ok(v)
232    }
233}
234
235/// Variant name via `deserialize_identifier` (ron's variant seed speaks
236/// identifiers, not strings — found empirically, guarded by tests).
237struct NameSeed;
238impl<'de> DeserializeSeed<'de> for NameSeed {
239    type Value = String;
240    fn deserialize<D: Deserializer<'de>>(self, d: D) -> Result<String, D::Error> {
241        struct S;
242        impl<'de> Visitor<'de> for S {
243            type Value = String;
244            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
245                write!(f, "a variant name")
246            }
247            fn visit_str<E: de::Error>(self, v: &str) -> Result<String, E> {
248                Ok(v.to_string())
249            }
250        }
251        d.deserialize_identifier(S)
252    }
253}
254
255struct EnumVisitor<'r> {
256    variants: &'r [crate::registry::Variant],
257}
258
259impl<'de> Visitor<'de> for EnumVisitor<'_> {
260    type Value = Value;
261
262    fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
263        write!(f, "an enum variant")
264    }
265
266    fn visit_enum<A: EnumAccess<'de>>(self, a: A) -> Result<Value, A::Error> {
267        let (name, variant) = a.variant_seed(NameSeed)?;
268        let Some(spec) = self.variants.iter().find(|v| v.name == name) else {
269            return Err(de::Error::custom(format!(
270                "unknown variant '{name}' (expected one of: {})",
271                self.variants
272                    .iter()
273                    .map(|v| v.name.as_str())
274                    .collect::<Vec<_>>()
275                    .join(", ")
276            )));
277        };
278        let fields = if spec.fields.is_empty() {
279            variant.unit_variant()?;
280            BTreeMap::new()
281        } else {
282            variant
283                .struct_variant(
284                    &[],
285                    FieldsVisitor {
286                        fields: &spec.fields,
287                    },
288                )
289                .map_err(|e| {
290                    de::Error::custom(format!(
291                        "variant '{name}' payload: {e} — payload variants carry NAMED \
292                         fields on disk (`{name}(field: value)`); positional/newtype \
293                         payloads are not supported"
294                    ))
295                })?
296        };
297        Ok(Value::Enum {
298            variant: name,
299            fields,
300        })
301    }
302}
303
304struct OptVisitor<'r> {
305    inner: &'r FieldType,
306}
307
308impl<'de> Visitor<'de> for OptVisitor<'_> {
309    type Value = Value;
310    fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
311        write!(f, "an option")
312    }
313    fn visit_none<E: de::Error>(self) -> Result<Value, E> {
314        Ok(Value::Null)
315    }
316    fn visit_unit<E: de::Error>(self) -> Result<Value, E> {
317        Ok(Value::Null)
318    }
319    fn visit_some<D: Deserializer<'de>>(self, d: D) -> Result<Value, D::Error> {
320        TypeSeed { ty: self.inner }.deserialize(d)
321    }
322}
323
324struct ListVisitor<'r> {
325    inner: &'r FieldType,
326}
327
328impl<'de> Visitor<'de> for ListVisitor<'_> {
329    type Value = Value;
330    fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
331        write!(f, "a list")
332    }
333    fn visit_seq<S: de::SeqAccess<'de>>(self, mut s: S) -> Result<Value, S::Error> {
334        let mut out = Vec::new();
335        while let Some(v) = s.next_element_seed(TypeSeed { ty: self.inner })? {
336            out.push(v);
337        }
338        Ok(Value::List(out))
339    }
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345
346    use crate::registry::Variant;
347
348    /// A hand-built todo-shaped kind — value decoding is registry-driven,
349    /// so the fixture is a registry, not a schema crate.
350    fn todo_kind() -> Kind {
351        let field = |name: &str, ty: FieldType| Field {
352            name: name.into(),
353            doc: String::new(),
354            ty,
355            role: None,
356            refers_to: None,
357        };
358        let unit = |name: &str| Variant {
359            name: name.into(),
360            doc: String::new(),
361            fields: Vec::new(),
362            tone: None,
363        };
364        Kind {
365            name: "todo".into(),
366            doc: String::new(),
367            storage: "facts/todos/{id}.ron".into(),
368            fields: vec![
369                field("id", FieldType::Str),
370                field("title", FieldType::Str),
371                field(
372                    "status",
373                    FieldType::Enum(vec![unit("Open"), unit("Done"), unit("Dropped")]),
374                ),
375                field("due", FieldType::Option(Box::new(FieldType::Date))),
376                field(
377                    "blocked_by",
378                    FieldType::Option(Box::new(FieldType::Link {
379                        allowed: Some(vec!["todo".into()]),
380                    })),
381                ),
382                field(
383                    "sources",
384                    FieldType::List(Box::new(FieldType::Link { allowed: None })),
385                ),
386                field("notes", FieldType::Markdown),
387            ],
388        }
389    }
390
391    #[test]
392    fn decodes_a_todo_with_enum_names_intact() {
393        let text = r#"(
394            id: "ship-it",
395            title: "Ship the thing",
396            status: Open,
397            blocked_by: Some("todo:declare-source"),
398            sources: ["graph:email:AA=="],
399            notes: "Snapshot prose.",
400        )"#;
401        let v = decode(&todo_kind(), text).unwrap();
402        assert_eq!(
403            v["status"].variant(),
404            Some("Open"),
405            "the ron::Value limitation, fixed"
406        );
407        assert_eq!(
408            v["blocked_by"],
409            Value::Link(Link::kb("todo", "declare-source"))
410        );
411        assert_eq!(
412            v["sources"],
413            Value::List(vec![Value::Link(Link::external("graph", "email", "AA=="))])
414        );
415        assert!(!v.contains_key("due"), "absent optional stays absent");
416    }
417
418    #[test]
419    fn decodes_nested_structs_and_null_options() {
420        use crate::registry::Field;
421        // The starter has no nested-struct field — a synthetic kind pins
422        // List(Struct) decoding and explicit None options (the old
423        // action.notes shape).
424        let field = |name: &str, ty: FieldType| Field {
425            name: name.into(),
426            doc: String::new(),
427            ty,
428            role: None,
429            refers_to: None,
430        };
431        let note = vec![
432            field("date", FieldType::Date),
433            field("text", FieldType::Markdown),
434            field(
435                "sources",
436                FieldType::List(Box::new(FieldType::Link { allowed: None })),
437            ),
438        ];
439        let journal = Kind {
440            name: "journal".into(),
441            doc: String::new(),
442            storage: "facts/journal/{id}.ron".into(),
443            fields: vec![
444                field("id", FieldType::Str),
445                field(
446                    "owner",
447                    FieldType::Option(Box::new(FieldType::Link { allowed: None })),
448                ),
449                field(
450                    "origin",
451                    FieldType::Option(Box::new(FieldType::Link { allowed: None })),
452                ),
453                field("notes", FieldType::List(Box::new(FieldType::Struct(note)))),
454            ],
455        };
456        let text = r#"(
457            id: "ship-it",
458            owner: Some("self"),
459            origin: None,
460            notes: [(date: "2026-07-02", text: "Started.", sources: ["graph:event:AA=="])],
461        )"#;
462        let v = decode(&journal, text).unwrap();
463        assert_eq!(v["origin"], Value::Null);
464        assert_eq!(v["owner"], Value::Link(Link::singleton("self")));
465        let Value::List(notes) = &v["notes"] else {
466            panic!("notes is a list")
467        };
468        let Value::Struct(entry) = &notes[0] else {
469            panic!("entry is a struct")
470        };
471        assert_eq!(
472            entry["date"],
473            Value::Date(NaiveDate::from_ymd_opt(2026, 7, 2).unwrap())
474        );
475        assert_eq!(
476            entry["sources"],
477            Value::List(vec![Value::Link(Link::external("graph", "event", "AA=="))])
478        );
479    }
480
481    #[test]
482    fn shape_violations_are_errors_not_garbage() {
483        assert!(
484            decode(&todo_kind(), r#"(id: "x", due: Some("not-a-date"))"#)
485                .unwrap_err()
486                .contains("date")
487        );
488        assert!(decode(&todo_kind(), "(id: ").is_err());
489    }
490
491    #[test]
492    fn payload_variants_decode_with_their_fields() {
493        // A synthetic kind exercising what the starter's shapes don't yet:
494        // an enum variant carrying fields (the RunKind / health-KB shape).
495        let run = Kind {
496            name: "run".into(),
497            doc: String::new(),
498            storage: "runs/{id}.ron".into(),
499            fields: vec![Field {
500                name: "kind".into(),
501                doc: String::new(),
502                ty: FieldType::Enum(vec![
503                    Variant {
504                        name: "Window".into(),
505                        doc: String::new(),
506                        fields: vec![
507                            Field {
508                                name: "from".into(),
509                                doc: String::new(),
510                                ty: FieldType::Date,
511                                role: None,
512                                refers_to: None,
513                            },
514                            Field {
515                                name: "to".into(),
516                                doc: String::new(),
517                                ty: FieldType::Date,
518                                role: None,
519                                refers_to: None,
520                            },
521                        ],
522                        tone: None,
523                    },
524                    Variant {
525                        name: "Snapshot".into(),
526                        doc: String::new(),
527                        fields: vec![],
528                        tone: None,
529                    },
530                ]),
531                role: None,
532                refers_to: None,
533            }],
534        };
535        let v = decode(
536            &run,
537            r#"(kind: Window(from: "2026-07-01", to: "2026-07-08"))"#,
538        )
539        .unwrap();
540        let Value::Enum { variant, fields } = &v["kind"] else {
541            panic!()
542        };
543        assert_eq!(variant, "Window");
544        assert_eq!(
545            fields["from"],
546            Value::Date(NaiveDate::from_ymd_opt(2026, 7, 1).unwrap())
547        );
548
549        let v = decode(&run, "(kind: Snapshot)").unwrap();
550        assert_eq!(v["kind"].variant(), Some("Snapshot"));
551    }
552
553    /// Nesting has no depth limit: list-of-struct containing an enum whose
554    /// payload carries a markdown field and a list — decodes with true types
555    /// at every level.
556    #[test]
557    fn deep_nesting_decodes_with_true_types() {
558        use crate::registry::Variant;
559        let payload_fields = vec![
560            Field {
561                name: "why".into(),
562                doc: String::new(),
563                ty: FieldType::Markdown,
564                role: None,
565                refers_to: None,
566            },
567            Field {
568                name: "tags".into(),
569                doc: String::new(),
570                ty: FieldType::List(Box::new(FieldType::Str)),
571                role: None,
572                refers_to: None,
573            },
574        ];
575        let entry = FieldType::Struct(vec![
576            Field {
577                name: "when".into(),
578                doc: String::new(),
579                ty: FieldType::Date,
580                role: None,
581                refers_to: None,
582            },
583            Field {
584                name: "verdict".into(),
585                doc: String::new(),
586                ty: FieldType::Enum(vec![
587                    Variant {
588                        name: "Flagged".into(),
589                        doc: String::new(),
590                        fields: payload_fields,
591                        tone: None,
592                    },
593                    Variant {
594                        name: "Clear".into(),
595                        doc: String::new(),
596                        fields: vec![],
597                        tone: None,
598                    },
599                ]),
600                role: None,
601                refers_to: None,
602            },
603        ]);
604        let kind = Kind {
605            name: "audit".into(),
606            doc: String::new(),
607            storage: "facts/audit/{id}.ron".into(),
608            fields: vec![
609                Field {
610                    name: "id".into(),
611                    doc: String::new(),
612                    ty: FieldType::Str,
613                    role: None,
614                    refers_to: None,
615                },
616                Field {
617                    name: "entries".into(),
618                    doc: String::new(),
619                    ty: FieldType::List(Box::new(entry)),
620                    role: None,
621                    refers_to: None,
622                },
623            ],
624        };
625        let rec = decode(
626            &kind,
627            r#"(
628            id: "a1",
629            entries: [
630                (when: "2026-07-11", verdict: Flagged(why: "**bad**", tags: ["x", "y"])),
631                (when: "2026-07-10", verdict: Clear),
632            ],
633        )"#,
634        )
635        .expect("deep nesting decodes");
636        let Some(Value::List(entries)) = rec.get("entries") else {
637            panic!("entries")
638        };
639        let Value::Struct(first) = &entries[0] else {
640            panic!("struct entry")
641        };
642        let Some(Value::Enum { variant, fields }) = first.get("verdict") else {
643            panic!("enum")
644        };
645        assert_eq!(variant, "Flagged");
646        assert_eq!(fields.get("why"), Some(&Value::Str("**bad**".into())));
647        assert!(matches!(fields.get("tags"), Some(Value::List(t)) if t.len() == 2));
648    }
649
650    /// The two payload-variant encodings, probed side by side: NAMED-field
651    /// (struct) variants are the supported on-disk form; positional/newtype
652    /// payloads are refused — the registry vocabulary cannot even express a
653    /// nameless payload (Variant.fields all carry names).
654    #[test]
655    fn struct_variant_payloads_decode_newtype_payloads_are_refused() {
656        use crate::registry::Variant;
657        let client_ref = FieldType::Enum(vec![
658            Variant {
659                name: "Known".into(),
660                doc: String::new(),
661                fields: vec![Field {
662                    name: "org".into(),
663                    doc: String::new(),
664                    ty: FieldType::Str,
665                    role: None,
666                    refers_to: None,
667                }],
668                tone: None,
669            },
670            Variant {
671                name: "Unknown".into(),
672                doc: String::new(),
673                fields: vec![],
674                tone: None,
675            },
676        ]);
677        let kind = Kind {
678            name: "probe".into(),
679            doc: String::new(),
680            storage: "facts/probe/{id}.ron".into(),
681            fields: vec![
682                Field {
683                    name: "id".into(),
684                    doc: String::new(),
685                    ty: FieldType::Str,
686                    role: None,
687                    refers_to: None,
688                },
689                Field {
690                    name: "r".into(),
691                    doc: String::new(),
692                    ty: client_ref,
693                    role: None,
694                    refers_to: None,
695                },
696            ],
697        };
698        // Struct-variant encoding: named payload — decodes.
699        let ok =
700            decode(&kind, r#"(id: "p", r: Known(org: "org:absa"))"#).expect("named fields decode");
701        assert!(matches!(ok.get("r"), Some(Value::Enum { variant, .. }) if variant == "Known"));
702
703        // Newtype encoding: positional payload — refused, with a steer.
704        let err =
705            decode(&kind, r#"(id: "p", r: Known("org:absa"))"#).expect_err("positional refused");
706        assert!(err.contains("NAMED fields"), "steering error, got: {err}");
707    }
708
709    #[test]
710    fn numeric_and_hyperlink_fields_decode() {
711        let kind = Kind {
712            name: "project".into(),
713            doc: String::new(),
714            storage: "facts/projects/{id}.ron".into(),
715            fields: vec![
716                Field {
717                    name: "id".into(),
718                    doc: String::new(),
719                    ty: FieldType::Str,
720                    role: None,
721                    refers_to: None,
722                },
723                Field {
724                    name: "headcount".into(),
725                    doc: String::new(),
726                    ty: FieldType::Int,
727                    role: None,
728                    refers_to: None,
729                },
730                Field {
731                    name: "value".into(),
732                    doc: String::new(),
733                    ty: FieldType::Decimal,
734                    role: None,
735                    refers_to: None,
736                },
737                Field {
738                    name: "tracker".into(),
739                    doc: String::new(),
740                    ty: FieldType::Hyperlink,
741                    role: None,
742                    refers_to: None,
743                },
744            ],
745        };
746        let ron = r#"(
747            id: "absa-adt",
748            headcount: 7,
749            value: "1250000.00",
750            tracker: (title: "PMO Tracker", url: "https://example.com/x.xlsx"),
751        )"#;
752        let rec = decode(&kind, ron).expect("decodes");
753        assert_eq!(rec.get("headcount"), Some(&Value::Int(7)));
754        assert_eq!(
755            rec.get("value"),
756            Some(&Value::Decimal(
757                Decimal::from_str_exact("1250000.00").unwrap()
758            ))
759        );
760        assert_eq!(
761            rec.get("tracker"),
762            Some(&Value::Hyperlink {
763                title: "PMO Tracker".into(),
764                url: "https://example.com/x.xlsx".into()
765            })
766        );
767
768        // A float where an exact decimal belongs is an ERROR, not a value.
769        let bad = r#"(id: "x", headcount: 1, value: 1250000.00, tracker: (title: "t", url: "https://e"))"#;
770        assert!(
771            decode(&kind, bad).is_err(),
772            "float decimals must be refused"
773        );
774    }
775}