use std::collections::BTreeMap;
use std::fmt;
use crate::link::Link;
use crate::registry::{Field, FieldType, Kind};
use chrono::NaiveDate;
use rust_decimal::Decimal;
use serde::de::{self, DeserializeSeed, Deserializer, EnumAccess, VariantAccess, Visitor};
#[derive(Debug, Clone, PartialEq)]
pub enum Value {
Null,
Str(String),
Date(NaiveDate),
Bool(bool),
Int(i64),
Decimal(Decimal),
Hyperlink {
title: String,
url: String,
},
Link(Link),
Enum {
variant: String,
fields: BTreeMap<String, Value>,
},
List(Vec<Value>),
Struct(BTreeMap<String, Value>),
}
impl Value {
pub fn as_str(&self) -> Option<&str> {
match self {
Value::Str(s) => Some(s),
_ => None,
}
}
pub fn as_link(&self) -> Option<&Link> {
match self {
Value::Link(l) => Some(l),
_ => None,
}
}
pub fn variant(&self) -> Option<&str> {
match self {
Value::Enum { variant, .. } => Some(variant),
_ => None,
}
}
}
pub fn decode(kind: &Kind, text: &str) -> Result<BTreeMap<String, Value>, String> {
let mut d = ron::Deserializer::from_str(text).map_err(|e| e.to_string())?;
RecordSeed {
fields: &kind.fields,
}
.deserialize(&mut d)
.map_err(|e| e.to_string())
}
struct RecordSeed<'r> {
fields: &'r [Field],
}
impl<'de> DeserializeSeed<'de> for RecordSeed<'_> {
type Value = BTreeMap<String, Value>;
fn deserialize<D: Deserializer<'de>>(self, d: D) -> Result<Self::Value, D::Error> {
d.deserialize_any(FieldsVisitor {
fields: self.fields,
})
}
}
struct FieldsVisitor<'r> {
fields: &'r [Field],
}
impl<'de> Visitor<'de> for FieldsVisitor<'_> {
type Value = BTreeMap<String, Value>;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "a record")
}
fn visit_map<M: de::MapAccess<'de>>(self, mut m: M) -> Result<Self::Value, M::Error> {
let mut out = BTreeMap::new();
while let Some(key) = m.next_key::<String>()? {
match self.fields.iter().find(|f| f.name == key) {
Some(field) => {
let v = m.next_value_seed(TypeSeed { ty: &field.ty })?;
out.insert(key, v);
}
None => {
let _: ron::Value = m.next_value()?; }
}
}
Ok(out)
}
}
struct TypeSeed<'r> {
ty: &'r FieldType,
}
impl<'de> DeserializeSeed<'de> for TypeSeed<'_> {
type Value = Value;
fn deserialize<D: Deserializer<'de>>(self, d: D) -> Result<Value, D::Error> {
match self.ty {
FieldType::Str | FieldType::Markdown => d.deserialize_str(StrVisitor).map(Value::Str),
FieldType::Date => {
let s = d.deserialize_str(StrVisitor)?;
NaiveDate::parse_from_str(&s, "%Y-%m-%d")
.map(Value::Date)
.map_err(|e| de::Error::custom(format!("date '{s}': {e}")))
}
FieldType::Bool => d.deserialize_bool(BoolVisitor).map(Value::Bool),
FieldType::Int => d.deserialize_i64(IntVisitor).map(Value::Int),
FieldType::Hyperlink => {
let fields = [
Field {
name: "title".into(),
doc: String::new(),
ty: FieldType::Str,
role: None,
refers_to: None,
},
Field {
name: "url".into(),
doc: String::new(),
ty: FieldType::Str,
role: None,
refers_to: None,
},
];
let map = d.deserialize_any(FieldsVisitor { fields: &fields })?;
let get = |k: &str| match map.get(k) {
Some(Value::Str(s)) => Ok(s.clone()),
_ => Err(de::Error::custom(format!(
"hyperlink needs a string '{k}' — (title: \"…\", url: \"https://…\")"
))),
};
Ok(Value::Hyperlink {
title: get("title")?,
url: get("url")?,
})
}
FieldType::Decimal => {
let s = d.deserialize_str(StrVisitor)?;
Decimal::from_str_exact(&s)
.map(Value::Decimal)
.map_err(|e| {
de::Error::custom(format!(
"decimal '{s}': {e} — exact decimals travel as strings, \
e.g. \"1250000.00\", never floats"
))
})
}
FieldType::Link { .. } => d
.deserialize_str(StrVisitor)
.map(|s| Value::Link(Link::new(s))),
FieldType::Enum(variants) => d.deserialize_enum("", &[], EnumVisitor { variants }),
FieldType::Option(inner) => d.deserialize_option(OptVisitor { inner }),
FieldType::List(inner) => d.deserialize_seq(ListVisitor { inner }),
FieldType::Struct(fields) => d
.deserialize_any(FieldsVisitor { fields })
.map(Value::Struct),
}
}
}
struct StrVisitor;
impl<'de> Visitor<'de> for StrVisitor {
type Value = String;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "a string")
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<String, E> {
Ok(v.to_string())
}
fn visit_string<E: de::Error>(self, v: String) -> Result<String, E> {
Ok(v)
}
}
struct IntVisitor;
impl Visitor<'_> for IntVisitor {
type Value = i64;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("an integer")
}
fn visit_i64<E: de::Error>(self, x: i64) -> Result<i64, E> {
Ok(x)
}
fn visit_u64<E: de::Error>(self, x: u64) -> Result<i64, E> {
i64::try_from(x).map_err(|_| E::custom(format!("integer {x} overflows i64")))
}
}
struct BoolVisitor;
impl<'de> Visitor<'de> for BoolVisitor {
type Value = bool;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "a bool")
}
fn visit_bool<E: de::Error>(self, v: bool) -> Result<bool, E> {
Ok(v)
}
}
struct NameSeed;
impl<'de> DeserializeSeed<'de> for NameSeed {
type Value = String;
fn deserialize<D: Deserializer<'de>>(self, d: D) -> Result<String, D::Error> {
struct S;
impl<'de> Visitor<'de> for S {
type Value = String;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "a variant name")
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<String, E> {
Ok(v.to_string())
}
}
d.deserialize_identifier(S)
}
}
struct EnumVisitor<'r> {
variants: &'r [crate::registry::Variant],
}
impl<'de> Visitor<'de> for EnumVisitor<'_> {
type Value = Value;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "an enum variant")
}
fn visit_enum<A: EnumAccess<'de>>(self, a: A) -> Result<Value, A::Error> {
let (name, variant) = a.variant_seed(NameSeed)?;
let Some(spec) = self.variants.iter().find(|v| v.name == name) else {
return Err(de::Error::custom(format!(
"unknown variant '{name}' (expected one of: {})",
self.variants
.iter()
.map(|v| v.name.as_str())
.collect::<Vec<_>>()
.join(", ")
)));
};
let fields = if spec.fields.is_empty() {
variant.unit_variant()?;
BTreeMap::new()
} else {
variant
.struct_variant(
&[],
FieldsVisitor {
fields: &spec.fields,
},
)
.map_err(|e| {
de::Error::custom(format!(
"variant '{name}' payload: {e} — payload variants carry NAMED \
fields on disk (`{name}(field: value)`); positional/newtype \
payloads are not supported"
))
})?
};
Ok(Value::Enum {
variant: name,
fields,
})
}
}
struct OptVisitor<'r> {
inner: &'r FieldType,
}
impl<'de> Visitor<'de> for OptVisitor<'_> {
type Value = Value;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "an option")
}
fn visit_none<E: de::Error>(self) -> Result<Value, E> {
Ok(Value::Null)
}
fn visit_unit<E: de::Error>(self) -> Result<Value, E> {
Ok(Value::Null)
}
fn visit_some<D: Deserializer<'de>>(self, d: D) -> Result<Value, D::Error> {
TypeSeed { ty: self.inner }.deserialize(d)
}
}
struct ListVisitor<'r> {
inner: &'r FieldType,
}
impl<'de> Visitor<'de> for ListVisitor<'_> {
type Value = Value;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "a list")
}
fn visit_seq<S: de::SeqAccess<'de>>(self, mut s: S) -> Result<Value, S::Error> {
let mut out = Vec::new();
while let Some(v) = s.next_element_seed(TypeSeed { ty: self.inner })? {
out.push(v);
}
Ok(Value::List(out))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::registry::Variant;
fn todo_kind() -> Kind {
let field = |name: &str, ty: FieldType| Field {
name: name.into(),
doc: String::new(),
ty,
role: None,
refers_to: None,
};
let unit = |name: &str| Variant {
name: name.into(),
doc: String::new(),
fields: Vec::new(),
tone: None,
};
Kind {
name: "todo".into(),
doc: String::new(),
storage: "facts/todos/{id}.ron".into(),
fields: vec![
field("id", FieldType::Str),
field("title", FieldType::Str),
field(
"status",
FieldType::Enum(vec![unit("Open"), unit("Done"), unit("Dropped")]),
),
field("due", FieldType::Option(Box::new(FieldType::Date))),
field(
"blocked_by",
FieldType::Option(Box::new(FieldType::Link {
allowed: Some(vec!["todo".into()]),
})),
),
field(
"sources",
FieldType::List(Box::new(FieldType::Link { allowed: None })),
),
field("notes", FieldType::Markdown),
],
}
}
#[test]
fn decodes_a_todo_with_enum_names_intact() {
let text = r#"(
id: "ship-it",
title: "Ship the thing",
status: Open,
blocked_by: Some("todo:declare-source"),
sources: ["graph:email:AA=="],
notes: "Snapshot prose.",
)"#;
let v = decode(&todo_kind(), text).unwrap();
assert_eq!(
v["status"].variant(),
Some("Open"),
"the ron::Value limitation, fixed"
);
assert_eq!(
v["blocked_by"],
Value::Link(Link::kb("todo", "declare-source"))
);
assert_eq!(
v["sources"],
Value::List(vec![Value::Link(Link::external("graph", "email", "AA=="))])
);
assert!(!v.contains_key("due"), "absent optional stays absent");
}
#[test]
fn decodes_nested_structs_and_null_options() {
use crate::registry::Field;
let field = |name: &str, ty: FieldType| Field {
name: name.into(),
doc: String::new(),
ty,
role: None,
refers_to: None,
};
let note = vec![
field("date", FieldType::Date),
field("text", FieldType::Markdown),
field(
"sources",
FieldType::List(Box::new(FieldType::Link { allowed: None })),
),
];
let journal = Kind {
name: "journal".into(),
doc: String::new(),
storage: "facts/journal/{id}.ron".into(),
fields: vec![
field("id", FieldType::Str),
field(
"owner",
FieldType::Option(Box::new(FieldType::Link { allowed: None })),
),
field(
"origin",
FieldType::Option(Box::new(FieldType::Link { allowed: None })),
),
field("notes", FieldType::List(Box::new(FieldType::Struct(note)))),
],
};
let text = r#"(
id: "ship-it",
owner: Some("self"),
origin: None,
notes: [(date: "2026-07-02", text: "Started.", sources: ["graph:event:AA=="])],
)"#;
let v = decode(&journal, text).unwrap();
assert_eq!(v["origin"], Value::Null);
assert_eq!(v["owner"], Value::Link(Link::singleton("self")));
let Value::List(notes) = &v["notes"] else {
panic!("notes is a list")
};
let Value::Struct(entry) = ¬es[0] else {
panic!("entry is a struct")
};
assert_eq!(
entry["date"],
Value::Date(NaiveDate::from_ymd_opt(2026, 7, 2).unwrap())
);
assert_eq!(
entry["sources"],
Value::List(vec![Value::Link(Link::external("graph", "event", "AA=="))])
);
}
#[test]
fn shape_violations_are_errors_not_garbage() {
assert!(
decode(&todo_kind(), r#"(id: "x", due: Some("not-a-date"))"#)
.unwrap_err()
.contains("date")
);
assert!(decode(&todo_kind(), "(id: ").is_err());
}
#[test]
fn payload_variants_decode_with_their_fields() {
let run = Kind {
name: "run".into(),
doc: String::new(),
storage: "runs/{id}.ron".into(),
fields: vec![Field {
name: "kind".into(),
doc: String::new(),
ty: FieldType::Enum(vec![
Variant {
name: "Window".into(),
doc: String::new(),
fields: vec![
Field {
name: "from".into(),
doc: String::new(),
ty: FieldType::Date,
role: None,
refers_to: None,
},
Field {
name: "to".into(),
doc: String::new(),
ty: FieldType::Date,
role: None,
refers_to: None,
},
],
tone: None,
},
Variant {
name: "Snapshot".into(),
doc: String::new(),
fields: vec![],
tone: None,
},
]),
role: None,
refers_to: None,
}],
};
let v = decode(
&run,
r#"(kind: Window(from: "2026-07-01", to: "2026-07-08"))"#,
)
.unwrap();
let Value::Enum { variant, fields } = &v["kind"] else {
panic!()
};
assert_eq!(variant, "Window");
assert_eq!(
fields["from"],
Value::Date(NaiveDate::from_ymd_opt(2026, 7, 1).unwrap())
);
let v = decode(&run, "(kind: Snapshot)").unwrap();
assert_eq!(v["kind"].variant(), Some("Snapshot"));
}
#[test]
fn deep_nesting_decodes_with_true_types() {
use crate::registry::Variant;
let payload_fields = vec![
Field {
name: "why".into(),
doc: String::new(),
ty: FieldType::Markdown,
role: None,
refers_to: None,
},
Field {
name: "tags".into(),
doc: String::new(),
ty: FieldType::List(Box::new(FieldType::Str)),
role: None,
refers_to: None,
},
];
let entry = FieldType::Struct(vec![
Field {
name: "when".into(),
doc: String::new(),
ty: FieldType::Date,
role: None,
refers_to: None,
},
Field {
name: "verdict".into(),
doc: String::new(),
ty: FieldType::Enum(vec![
Variant {
name: "Flagged".into(),
doc: String::new(),
fields: payload_fields,
tone: None,
},
Variant {
name: "Clear".into(),
doc: String::new(),
fields: vec![],
tone: None,
},
]),
role: None,
refers_to: None,
},
]);
let kind = Kind {
name: "audit".into(),
doc: String::new(),
storage: "facts/audit/{id}.ron".into(),
fields: vec![
Field {
name: "id".into(),
doc: String::new(),
ty: FieldType::Str,
role: None,
refers_to: None,
},
Field {
name: "entries".into(),
doc: String::new(),
ty: FieldType::List(Box::new(entry)),
role: None,
refers_to: None,
},
],
};
let rec = decode(
&kind,
r#"(
id: "a1",
entries: [
(when: "2026-07-11", verdict: Flagged(why: "**bad**", tags: ["x", "y"])),
(when: "2026-07-10", verdict: Clear),
],
)"#,
)
.expect("deep nesting decodes");
let Some(Value::List(entries)) = rec.get("entries") else {
panic!("entries")
};
let Value::Struct(first) = &entries[0] else {
panic!("struct entry")
};
let Some(Value::Enum { variant, fields }) = first.get("verdict") else {
panic!("enum")
};
assert_eq!(variant, "Flagged");
assert_eq!(fields.get("why"), Some(&Value::Str("**bad**".into())));
assert!(matches!(fields.get("tags"), Some(Value::List(t)) if t.len() == 2));
}
#[test]
fn struct_variant_payloads_decode_newtype_payloads_are_refused() {
use crate::registry::Variant;
let client_ref = FieldType::Enum(vec![
Variant {
name: "Known".into(),
doc: String::new(),
fields: vec![Field {
name: "org".into(),
doc: String::new(),
ty: FieldType::Str,
role: None,
refers_to: None,
}],
tone: None,
},
Variant {
name: "Unknown".into(),
doc: String::new(),
fields: vec![],
tone: None,
},
]);
let kind = Kind {
name: "probe".into(),
doc: String::new(),
storage: "facts/probe/{id}.ron".into(),
fields: vec![
Field {
name: "id".into(),
doc: String::new(),
ty: FieldType::Str,
role: None,
refers_to: None,
},
Field {
name: "r".into(),
doc: String::new(),
ty: client_ref,
role: None,
refers_to: None,
},
],
};
let ok =
decode(&kind, r#"(id: "p", r: Known(org: "org:absa"))"#).expect("named fields decode");
assert!(matches!(ok.get("r"), Some(Value::Enum { variant, .. }) if variant == "Known"));
let err =
decode(&kind, r#"(id: "p", r: Known("org:absa"))"#).expect_err("positional refused");
assert!(err.contains("NAMED fields"), "steering error, got: {err}");
}
#[test]
fn numeric_and_hyperlink_fields_decode() {
let kind = Kind {
name: "project".into(),
doc: String::new(),
storage: "facts/projects/{id}.ron".into(),
fields: vec![
Field {
name: "id".into(),
doc: String::new(),
ty: FieldType::Str,
role: None,
refers_to: None,
},
Field {
name: "headcount".into(),
doc: String::new(),
ty: FieldType::Int,
role: None,
refers_to: None,
},
Field {
name: "value".into(),
doc: String::new(),
ty: FieldType::Decimal,
role: None,
refers_to: None,
},
Field {
name: "tracker".into(),
doc: String::new(),
ty: FieldType::Hyperlink,
role: None,
refers_to: None,
},
],
};
let ron = r#"(
id: "absa-adt",
headcount: 7,
value: "1250000.00",
tracker: (title: "PMO Tracker", url: "https://example.com/x.xlsx"),
)"#;
let rec = decode(&kind, ron).expect("decodes");
assert_eq!(rec.get("headcount"), Some(&Value::Int(7)));
assert_eq!(
rec.get("value"),
Some(&Value::Decimal(
Decimal::from_str_exact("1250000.00").unwrap()
))
);
assert_eq!(
rec.get("tracker"),
Some(&Value::Hyperlink {
title: "PMO Tracker".into(),
url: "https://example.com/x.xlsx".into()
})
);
let bad = r#"(id: "x", headcount: 1, value: 1250000.00, tracker: (title: "t", url: "https://e"))"#;
assert!(
decode(&kind, bad).is_err(),
"float decimals must be refused"
);
}
}