1use 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#[derive(Debug, Clone, PartialEq)]
21pub enum Value {
22 Null,
24 Str(String),
26 Date(NaiveDate),
27 Bool(bool),
28 Int(i64),
29 Decimal(Decimal),
31 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
68pub 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 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()?; }
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
235struct 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 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 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) = ¬es[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 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 #[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 #[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 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 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 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}