use serde::Deserialize;
use serde::de::IgnoredAny;
use std::collections::BTreeMap;
#[derive(Debug, Deserialize)]
pub(crate) struct RawDoc {
#[serde(default)]
pub tree: Option<Vec<RawNode>>,
#[serde(default)]
pub root: Option<Box<RawNode>>,
#[serde(flatten)]
pub extra: BTreeMap<String, IgnoredAny>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct RawNode {
#[serde(default)]
pub id: Option<String>,
#[serde(rename = "type", default)]
pub ty: Option<String>,
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub support_level: Option<String>,
#[serde(default)]
pub source_refs: Vec<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub isolated: bool,
#[serde(default)]
pub provenance: Option<String>,
#[serde(default)]
pub timestamp: Option<String>,
#[serde(default)]
pub result: Option<String>,
#[serde(default)]
pub status: Option<String>,
#[serde(default)]
pub exploration: Option<String>,
#[serde(default)]
pub outcome: Option<String>,
#[serde(default)]
pub why_failed: Option<String>,
#[serde(default)]
pub hypothesis: Option<String>,
#[serde(default)]
pub failure_mode: Option<String>,
#[serde(default)]
pub lesson: Option<String>,
#[serde(default)]
pub prior_direction: Option<String>,
#[serde(default)]
pub new_direction: Option<String>,
#[serde(default)]
pub reason: Option<String>,
#[serde(default)]
pub choice: Option<String>,
#[serde(default)]
pub alternatives: Vec<String>,
#[serde(default)]
pub rationale: Option<String>,
#[serde(default)]
pub evidence: Option<Evidence>,
#[serde(default)]
pub also_depends_on: Vec<String>,
#[serde(default)]
pub children: Vec<RawNode>,
#[serde(flatten)]
pub extra: BTreeMap<String, IgnoredAny>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub(crate) enum Evidence {
One(String),
Many(Vec<String>),
}
impl Evidence {
pub(crate) fn entries(&self) -> Vec<String> {
match self {
Evidence::One(s) => vec![s.clone()],
Evidence::Many(v) => v.clone(),
}
}
}
pub(crate) fn parse_doc(yaml: &str) -> Result<RawDoc, String> {
serde_saphyr::from_str::<RawDoc>(yaml).map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_tree_with_canonical_fields_no_extra() {
let y = "\
tree:
- id: N01
type: question
title: Q?
children:
- id: N02
type: experiment
result: 28.4 BLEU
evidence: [C01, \"Table 2\"]
- id: N03
type: decision
choice: sinusoidal
alternatives:
- learned
evidence: \"Table 3 prose\"
";
let doc = parse_doc(y).expect("parses");
assert!(doc.extra.is_empty());
let tree = doc.tree.expect("tree present");
assert_eq!(tree.len(), 1);
let n1 = &tree[0];
assert_eq!(n1.id.as_deref(), Some("N01"));
assert_eq!(n1.ty.as_deref(), Some("question"));
assert!(n1.extra.is_empty());
assert_eq!(n1.children.len(), 2);
let n2 = &n1.children[0];
assert_eq!(
n2.evidence.as_ref().unwrap().entries(),
vec!["C01", "Table 2"]
);
let n3 = &n1.children[1];
assert_eq!(
n3.evidence.as_ref().unwrap().entries(),
vec!["Table 3 prose"]
);
assert_eq!(n3.alternatives, vec!["learned"]);
}
#[test]
fn unknown_keys_land_in_extra() {
let y = "tree:\n - id: N01\n type: question\n bogus: 42\ntop_bogus: 1\n";
let doc = parse_doc(y).expect("parses");
assert_eq!(doc.extra.keys().collect::<Vec<_>>(), vec!["top_bogus"]);
assert_eq!(
doc.tree.unwrap()[0].extra.keys().collect::<Vec<_>>(),
vec!["bogus"]
);
}
#[test]
fn published_fields_parse_with_empty_extra() {
let y = "\
tree:
- id: N01
type: pivot
title: Change of plan
provenance: user
timestamp: 2026-08-19
prior_direction: dense retrieval
new_direction: sparse retrieval
reason: latency budget
lesson: profile first
children:
- id: N02
type: experiment
result: p95 42ms
status: completed
exploration: grid over k
outcome: sparse wins
";
let doc = parse_doc(y).expect("parses");
let tree = doc.tree.expect("tree present");
let n1 = &tree[0];
assert!(n1.extra.is_empty());
assert_eq!(n1.provenance.as_deref(), Some("user"));
assert_eq!(n1.timestamp.as_deref(), Some("2026-08-19"));
assert_eq!(n1.prior_direction.as_deref(), Some("dense retrieval"));
assert_eq!(n1.new_direction.as_deref(), Some("sparse retrieval"));
assert_eq!(n1.reason.as_deref(), Some("latency budget"));
assert_eq!(n1.lesson.as_deref(), Some("profile first"));
let n2 = &n1.children[0];
assert!(n2.extra.is_empty());
assert_eq!(n2.result.as_deref(), Some("p95 42ms"));
assert_eq!(n2.status.as_deref(), Some("completed"));
assert_eq!(n2.exploration.as_deref(), Some("grid over k"));
assert_eq!(n2.outcome.as_deref(), Some("sparse wins"));
}
#[test]
fn legacy_pivot_keys_land_in_extra() {
let y = "\
tree:
- id: N01
type: pivot
from: dense retrieval
to: sparse retrieval
trigger: latency budget
";
let doc = parse_doc(y).expect("parses");
let n1 = &doc.tree.expect("tree present")[0];
assert_eq!(
n1.extra.keys().collect::<Vec<_>>(),
vec!["from", "to", "trigger"]
);
assert!(n1.prior_direction.is_none());
assert!(n1.new_direction.is_none());
assert!(n1.reason.is_none());
}
#[test]
fn root_single_dialect_parses() {
let doc = parse_doc("root:\n id: N01\n type: question\n").expect("parses");
assert!(doc.tree.is_none());
assert_eq!(doc.root.unwrap().id.as_deref(), Some("N01"));
}
#[test]
fn null_children_default_to_empty() {
let doc = parse_doc("tree:\n - id: N01\n children:\n").expect("parses");
assert!(doc.tree.unwrap()[0].children.is_empty());
}
#[test]
fn multi_document_is_error_not_panic() {
assert!(parse_doc("tree: []\n---\ntree: []\n").is_err());
}
#[test]
fn non_sequence_tree_is_error_not_panic() {
assert!(parse_doc("tree: not-a-list\n").is_err());
}
}