Skip to main content

lattice_plugin_sdk/
shape.rs

1//! A Rust struct as a config schema and a config value, plus the arena
2//! flattening the WIT seam needs (slice TC.4).
3//!
4//! Design: `docs/dev/architecture/typed-configuration.md`.
5//!
6//! TC.3 gave the ABI a way to carry structured configuration: an option
7//! declares a schema, values cross as a tree, the host validates one against
8//! the other. It also made both cross as an **arena** — a flat node list plus a
9//! root index — because WIT has no recursive types. Writing an arena by hand is
10//! exactly as unpleasant as it sounds; the `config-guest` fixture does it once,
11//! deliberately, to prove the encoding, and no real plugin should.
12//!
13//! This module is what a real plugin uses instead. `#[derive(ConfigShape)]` on
14//! an ordinary struct produces [`ConfigShape::schema`], [`ConfigShape::to_value`]
15//! and [`ConfigShape::from_value`]; [`flatten_schema`] / [`flatten_value`] turn
16//! either into an arena.
17//!
18//! **WIT-agnostic, like the rest of the SDK.** The types here are the SDK's own
19//! mirrors, not the generated bindings — a proc-macro crate cannot name a
20//! per-world WIT type, which is why `#[derive(PluginOption)]` hands back an
21//! [`OptionKind`](crate::OptionKind) the plugin maps at the call site. The same
22//! one-line-per-plugin tax applies here: a plugin writes one `fn` mapping
23//! [`SchemaNode`] / [`ValueNode`] to its generated node types, once, not per
24//! option.
25//!
26//! The design claim this slice makes true is that a guest's parse becomes
27//! *total and mechanical* — a walk over a tree the host has already validated —
28//! where before it was a bespoke text parser per option. Without the derive that
29//! claim is aspirational and the tree is simply a worse blob.
30//!
31//! # Examples
32//!
33//! A struct describes itself, round-trips through a [`Value`], and flattens to
34//! the arena form that crosses the WIT boundary:
35//!
36//! ```
37//! use lattice_plugin_sdk::ConfigShape;
38//! use lattice_plugin_sdk::shape::{
39//!     ConfigShape as _, Schema, Value, flatten_value, unflatten_value,
40//! };
41//!
42//! /// Where a capture lands.
43//! #[derive(Debug, PartialEq, ConfigShape)]
44//! struct Target {
45//!     /// The file it is appended to.
46//!     file: String,
47//!     /// Insert under this headline instead of appending.
48//!     headline: Option<String>,
49//! }
50//!
51//! // The schema: a record, fields kebab-cased, `Option<T>` = not required.
52//! let Schema::Record(fields) = Target::schema() else { unreachable!() };
53//! assert_eq!(fields[0].name, "file");
54//! assert!(fields[0].required);
55//! assert!(!fields[1].required);
56//! assert_eq!(fields[1].doc, "Insert under this headline instead of appending.");
57//!
58//! // The value: an absent optional field is omitted, not emitted empty.
59//! let t = Target { file: "refile.org".into(), headline: None };
60//! let v = t.to_value();
61//! assert_eq!(v.field("file").and_then(Value::as_str), Some("refile.org"));
62//! assert_eq!(v.field("headline"), None);
63//! assert_eq!(Target::from_value(&v), Ok(t));
64//!
65//! // The arena: what actually crosses the boundary, and back.
66//! let (nodes, root) = flatten_value(&v);
67//! assert_eq!(unflatten_value(&nodes, root), Ok(v));
68//! ```
69
70use std::collections::BTreeMap;
71
72/// The leaf kinds. Mirrors `lattice_config::ScalarKind` and the WIT
73/// `option-type`.
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
75pub enum ScalarKind {
76    /// `true` / `false`; carried as [`Value::Bool`].
77    Bool,
78    /// A signed 64-bit integer; carried as [`Value::Int`].
79    Int,
80    /// A UTF-8 string; carried as [`Value::Str`].
81    Str,
82}
83
84/// One field of a [`Schema::Record`]. `doc` is per field because that is what
85/// `:describe-option` and `:customize` render beside it.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct Field {
88    /// The field's key as a config file spells it. The derive kebab-cases the
89    /// Rust identifier (`max_depth` → `max-depth`) and strips a raw-identifier
90    /// prefix (`r#match` → `match`), so this is the wire name, not the Rust one.
91    pub name: String,
92    /// The shape the field's value must have.
93    pub schema: Schema,
94    /// Derived from the Rust type: an `Option<T>` field is optional, everything
95    /// else is required. That mapping is the whole reason the derive can decide
96    /// this without an attribute — the type already says it.
97    pub required: bool,
98    /// The field's `///` doc-comment, lines joined and trimmed; empty when the
99    /// field has none.
100    pub doc: String,
101}
102
103/// The declared shape of a value. Mirrors `lattice_config::ConfigSchema`.
104///
105/// # Examples
106///
107/// ```
108/// use lattice_plugin_sdk::shape::{ConfigShape, ScalarKind, Schema};
109///
110/// // The constructors are shorthands for the scalar / list forms.
111/// assert_eq!(Schema::int(), Schema::Scalar(ScalarKind::Int));
112/// assert_eq!(<Vec<String> as ConfigShape>::schema(), Schema::list(Schema::string()));
113/// ```
114#[derive(Debug, Clone, PartialEq, Eq)]
115pub enum Schema {
116    /// A single leaf of the given kind.
117    Scalar(ScalarKind),
118    /// A closed set of strings; a value must be a [`Value::Str`] spelling one
119    /// of them. `#[derive(ConfigShape)]` on an all-unit enum produces this,
120    /// variants kebab-cased in declaration order — and it is what lets
121    /// `:customize` offer a picker instead of a text field.
122    Enum(Vec<String>),
123    /// A homogeneous list whose every element has the inner shape.
124    List(Box<Schema>),
125    /// A struct-like record: named fields, each with its own shape, doc and
126    /// required flag. Field order is declaration order.
127    Record(Vec<Field>),
128}
129
130impl Schema {
131    /// `Schema::Scalar(ScalarKind::Str)`.
132    pub fn string() -> Self {
133        Schema::Scalar(ScalarKind::Str)
134    }
135    /// `Schema::Scalar(ScalarKind::Int)`.
136    pub fn int() -> Self {
137        Schema::Scalar(ScalarKind::Int)
138    }
139    /// `Schema::Scalar(ScalarKind::Bool)`.
140    pub fn bool() -> Self {
141        Schema::Scalar(ScalarKind::Bool)
142    }
143    /// A list whose elements have shape `inner` (boxes it for you).
144    pub fn list(inner: Schema) -> Self {
145        Schema::List(Box::new(inner))
146    }
147}
148
149/// A value shaped by a [`Schema`]. Mirrors `lattice_config::ConfigValue`.
150///
151/// `Record` is a `BTreeMap` for the same reason the host's is: a value written
152/// in TOML (unordered) and the same value built from a struct must compare
153/// equal, or "the same config" is two values.
154///
155/// # Examples
156///
157/// ```
158/// use lattice_plugin_sdk::shape::Value;
159///
160/// let v = Value::record([
161///     ("width".to_string(), Value::Int(80)),
162///     ("wrap".to_string(), Value::Bool(true)),
163/// ]);
164/// assert_eq!(v.field("width").and_then(Value::as_int), Some(80));
165/// // Accessors are kind-checked: the wrong kind is `None`, not a coercion.
166/// assert_eq!(v.field("wrap").and_then(Value::as_int), None);
167/// assert_eq!(v.kind_label(), "record");
168/// ```
169#[derive(Debug, Clone, PartialEq, Eq)]
170pub enum Value {
171    /// A boolean leaf.
172    Bool(bool),
173    /// An integer leaf.
174    Int(i64),
175    /// A string leaf — also how a [`Schema::Enum`] member is carried.
176    Str(String),
177    /// An ordered list of values.
178    List(Vec<Value>),
179    /// Named fields, key-ordered (see the type docs for why). An optional field
180    /// that is absent has no entry at all.
181    Record(BTreeMap<String, Value>),
182}
183
184impl Value {
185    /// Build a [`Value::Record`] from `(key, value)` pairs. A repeated key keeps
186    /// the last value (it is collected into a `BTreeMap`).
187    pub fn record(fields: impl IntoIterator<Item = (String, Value)>) -> Self {
188        Value::Record(fields.into_iter().collect())
189    }
190
191    /// The human name of this value's kind — `"boolean"`, `"integer"`,
192    /// `"string"`, `"list"` or `"record"` — as used in [`ShapeError`] messages
193    /// (`expected integer, got string`).
194    pub fn kind_label(&self) -> &'static str {
195        match self {
196            Value::Bool(_) => "boolean",
197            Value::Int(_) => "integer",
198            Value::Str(_) => "string",
199            Value::List(_) => "list",
200            Value::Record(_) => "record",
201        }
202    }
203
204    /// The boolean, if this is a [`Value::Bool`]; `None` for any other kind.
205    pub fn as_bool(&self) -> Option<bool> {
206        match self {
207            Value::Bool(b) => Some(*b),
208            _ => None,
209        }
210    }
211    /// The integer, if this is a [`Value::Int`]; `None` for any other kind.
212    pub fn as_int(&self) -> Option<i64> {
213        match self {
214            Value::Int(i) => Some(*i),
215            _ => None,
216        }
217    }
218    /// The string, if this is a [`Value::Str`]; `None` for any other kind.
219    pub fn as_str(&self) -> Option<&str> {
220        match self {
221            Value::Str(s) => Some(s),
222            _ => None,
223        }
224    }
225    /// The elements, if this is a [`Value::List`]; `None` for any other kind.
226    pub fn as_list(&self) -> Option<&[Value]> {
227        match self {
228            Value::List(items) => Some(items),
229            _ => None,
230        }
231    }
232    /// The field named `name` (its wire, kebab-case spelling), if this is a
233    /// [`Value::Record`] that has it. `None` both for a missing field and for
234    /// a value that is not a record at all.
235    pub fn field(&self, name: &str) -> Option<&Value> {
236        match self {
237            Value::Record(map) => map.get(name),
238            _ => None,
239        }
240    }
241}
242
243/// A failed [`ConfigShape::from_value`], carrying **where**.
244///
245/// The host validates the tree against the schema before a guest ever sees it,
246/// so in practice a guest's `from_value` fails only on a value the host could
247/// not have checked — a type the schema calls a string but the guest wants to
248/// interpret further (an enum spelled as a string, a path, a duration). Those
249/// are exactly the cases where a path matters, so it is carried rather than
250/// dropped.
251///
252/// `Display` renders `path: message`, or just `message` at the root.
253///
254/// # Examples
255///
256/// ```
257/// use lattice_plugin_sdk::shape::{ConfigShape, Value};
258///
259/// let v = Value::List(vec![Value::Int(1), Value::Str("two".into())]);
260/// let err = <Vec<i64>>::from_value(&v).unwrap_err();
261/// assert_eq!(err.path, "[1]");
262/// assert_eq!(err.to_string(), "[1]: expected integer, got string");
263/// ```
264#[derive(Debug, Clone, PartialEq, Eq)]
265pub struct ShapeError {
266    /// Where the failure is, outermost segment first: field names joined with
267    /// `.`, list indices as `[i]` with no dot before them
268    /// (`templates[2].target.file`). Empty when the root value itself failed.
269    pub path: String,
270    /// What went wrong at that location, e.g. `expected integer, got string` or
271    /// `required field is missing`.
272    pub message: String,
273}
274
275impl ShapeError {
276    /// An error at the root (empty [`path`](Self::path)); callers above it
277    /// prepend their segment with [`under`](Self::under).
278    pub fn new(message: impl Into<String>) -> Self {
279        Self {
280            path: String::new(),
281            message: message.into(),
282        }
283    }
284
285    /// Prepend a segment as this error unwinds back up the walk. The derive
286    /// calls it per field, so a leaf failure arrives at the top with the full
287    /// path assembled and no field having had to know where it lives.
288    ///
289    /// A `.` separator is inserted unless the existing path begins with an
290    /// index segment (`[`), so indices attach directly to their list.
291    ///
292    /// # Examples
293    ///
294    /// ```
295    /// use lattice_plugin_sdk::shape::ShapeError;
296    ///
297    /// // Segments are discovered inside-out, so they are prepended.
298    /// let e = ShapeError::new("boom").under("file").under("[0]").under("targets");
299    /// assert_eq!(e.path, "targets[0].file");
300    /// ```
301    pub fn under(mut self, segment: &str) -> Self {
302        self.path = if self.path.is_empty() {
303            segment.to_string()
304        } else if self.path.starts_with('[') {
305            format!("{segment}{}", self.path)
306        } else {
307            format!("{segment}.{}", self.path)
308        };
309        self
310    }
311}
312
313impl std::fmt::Display for ShapeError {
314    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
315        if self.path.is_empty() {
316            f.write_str(&self.message)
317        } else {
318            write!(f, "{}: {}", self.path, self.message)
319        }
320    }
321}
322
323impl std::error::Error for ShapeError {}
324
325/// A type that can describe its own configuration shape and move through it.
326///
327/// Implemented by `#[derive(ConfigShape)]` for structs, and by hand for the
328/// primitives and containers below. The three methods have to agree:
329/// `to_value` must produce something `schema` accepts, and `from_value` must
330/// invert `to_value`. A type where they disagree is a silently lossy option,
331/// which is the failure the SDK's own round-trip test exists to catch.
332///
333/// Provided impls: `bool`, `i64`, `String`, `Vec<T>` and `Option<T>` (the last
334/// meaning "not required" at field position — see its impl). The derive
335/// supports structs with named fields (→ [`Schema::Record`]) and all-unit enums
336/// (→ [`Schema::Enum`]); tuple structs, data-carrying enums and unions are
337/// compile errors. The derive is re-exported at the crate root as
338/// `lattice_plugin_sdk::ConfigShape`, beside this trait's path in `shape`.
339///
340/// # Examples
341///
342/// ```
343/// use lattice_plugin_sdk::ConfigShape;
344/// use lattice_plugin_sdk::shape::{ConfigShape as _, Schema, Value};
345///
346/// /// How a capture is filed.
347/// #[derive(Debug, PartialEq, ConfigShape)]
348/// enum Disposition { Append, FileUnder }
349///
350/// assert_eq!(
351///     Disposition::schema(),
352///     Schema::Enum(vec!["append".into(), "file-under".into()]),
353/// );
354/// assert_eq!(
355///     Disposition::from_value(&Value::Str("file-under".into())),
356///     Ok(Disposition::FileUnder),
357/// );
358/// assert!(Disposition::from_value(&Value::Str("sideways".into())).is_err());
359/// ```
360pub trait ConfigShape: Sized {
361    /// The declared shape, as sent to the host when the option is registered.
362    fn schema() -> Schema;
363    /// This value as a tree the host can validate against
364    /// [`schema`](Self::schema). Must always be accepted by it.
365    fn to_value(&self) -> Value;
366    /// Read a value back; the inverse of [`to_value`](Self::to_value).
367    ///
368    /// # Errors
369    ///
370    /// A [`ShapeError`] carrying the path to the offending node when a kind is
371    /// wrong, a required field is missing, or an enum string names no variant.
372    fn from_value(value: &Value) -> Result<Self, ShapeError>;
373}
374
375impl ConfigShape for bool {
376    fn schema() -> Schema {
377        Schema::bool()
378    }
379    fn to_value(&self) -> Value {
380        Value::Bool(*self)
381    }
382    fn from_value(value: &Value) -> Result<Self, ShapeError> {
383        value
384            .as_bool()
385            .ok_or_else(|| ShapeError::new(format!("expected boolean, got {}", value.kind_label())))
386    }
387}
388
389impl ConfigShape for i64 {
390    fn schema() -> Schema {
391        Schema::int()
392    }
393    fn to_value(&self) -> Value {
394        Value::Int(*self)
395    }
396    fn from_value(value: &Value) -> Result<Self, ShapeError> {
397        value
398            .as_int()
399            .ok_or_else(|| ShapeError::new(format!("expected integer, got {}", value.kind_label())))
400    }
401}
402
403impl ConfigShape for String {
404    fn schema() -> Schema {
405        Schema::string()
406    }
407    fn to_value(&self) -> Value {
408        Value::Str(self.clone())
409    }
410    fn from_value(value: &Value) -> Result<Self, ShapeError> {
411        value
412            .as_str()
413            .map(str::to_string)
414            .ok_or_else(|| ShapeError::new(format!("expected string, got {}", value.kind_label())))
415    }
416}
417
418impl<T: ConfigShape> ConfigShape for Vec<T> {
419    fn schema() -> Schema {
420        Schema::list(T::schema())
421    }
422    fn to_value(&self) -> Value {
423        Value::List(self.iter().map(T::to_value).collect())
424    }
425    fn from_value(value: &Value) -> Result<Self, ShapeError> {
426        let items = value
427            .as_list()
428            .ok_or_else(|| ShapeError::new(format!("expected list, got {}", value.kind_label())))?;
429        items
430            .iter()
431            .enumerate()
432            .map(|(i, item)| T::from_value(item).map_err(|e| e.under(&format!("[{i}]"))))
433            .collect()
434    }
435}
436
437/// `Option<T>` is how a field says it is **not required**.
438///
439/// The schema of an `Option<T>` is `T`'s — optionality is a property of the
440/// FIELD, not of the value, which is why [`Field::required`] carries it and
441/// this impl does not wrap the schema in anything. A bare `Option<T>` used as a
442/// whole option's type therefore describes itself as `T`; `None` is then
443/// indistinguishable from absent, which is the correct reading at a leaf and
444/// the reason the derive only consults this at field position.
445impl<T: ConfigShape> ConfigShape for Option<T> {
446    fn schema() -> Schema {
447        T::schema()
448    }
449    fn to_value(&self) -> Value {
450        match self {
451            Some(v) => v.to_value(),
452            // Unreachable through the derive, which omits the field entirely
453            // rather than emitting a placeholder. Present because the trait is
454            // total, and an empty record is the least surprising stand-in.
455            None => Value::Record(BTreeMap::new()),
456        }
457    }
458    fn from_value(value: &Value) -> Result<Self, ShapeError> {
459        T::from_value(value).map(Some)
460    }
461}
462
463// ── Arena flattening ───────────────────────────────────────────────────────
464//
465// WIT has no recursive types, so a schema and a value cross as a flat node list
466// plus a root index. These produce that form in the SDK's own node types; the
467// plugin maps them to its generated ones in one function, once.
468
469/// One node of a flattened [`Schema`]. Child links are indices into the node
470/// list [`flatten_schema`] returns.
471#[derive(Debug, Clone, PartialEq, Eq)]
472pub enum SchemaNode {
473    /// [`Schema::Scalar`]; a leaf.
474    Scalar(ScalarKind),
475    /// [`Schema::Enum`]; a leaf carrying the allowed strings.
476    Enum(Vec<String>),
477    /// [`Schema::List`]; the index of the element schema's node.
478    List(u32),
479    /// [`Schema::Record`]; one [`FieldNode`] per field, in declaration order.
480    Record(Vec<FieldNode>),
481}
482
483/// A [`Field`] with its schema as an index.
484#[derive(Debug, Clone, PartialEq, Eq)]
485pub struct FieldNode {
486    /// [`Field::name`] — the wire (kebab-case) key.
487    pub name: String,
488    /// Index of the field's schema node in the same arena.
489    pub schema: u32,
490    /// [`Field::required`].
491    pub required: bool,
492    /// [`Field::doc`].
493    pub doc: String,
494}
495
496/// One node of a flattened [`Value`].
497#[derive(Debug, Clone, PartialEq, Eq)]
498pub enum ValueNode {
499    /// [`Value::Bool`].
500    Bool(bool),
501    /// [`Value::Int`].
502    Int(i64),
503    /// [`Value::Str`].
504    Str(String),
505    /// [`Value::List`]; the element nodes' indices, in order.
506    List(Vec<u32>),
507    /// [`Value::Record`]; `(key, node index)` pairs in key order (the
508    /// `BTreeMap`'s iteration order when flattened).
509    Record(Vec<(String, u32)>),
510}
511
512/// Flatten a schema to `(nodes, root)`.
513///
514/// Post-order: every child is pushed before its parent, so an index is never
515/// handed out for a slot still being built. No dedup — a schema crosses once
516/// per option per load, and hashing every subtree to save a few nodes on a
517/// cold path is the wrong trade.
518///
519/// The root is therefore always the last node: `root == nodes.len() - 1`.
520///
521/// # Examples
522///
523/// ```
524/// use lattice_plugin_sdk::shape::{Schema, SchemaNode, ScalarKind, flatten_schema};
525///
526/// let (nodes, root) = flatten_schema(&Schema::list(Schema::int()));
527/// assert_eq!(nodes, vec![SchemaNode::Scalar(ScalarKind::Int), SchemaNode::List(0)]);
528/// assert_eq!(root, 1);
529/// ```
530pub fn flatten_schema(schema: &Schema) -> (Vec<SchemaNode>, u32) {
531    let mut nodes = Vec::new();
532    let root = push_schema(&mut nodes, schema);
533    (nodes, root)
534}
535
536fn push_schema(nodes: &mut Vec<SchemaNode>, schema: &Schema) -> u32 {
537    let node = match schema {
538        Schema::Scalar(k) => SchemaNode::Scalar(*k),
539        Schema::Enum(forms) => SchemaNode::Enum(forms.clone()),
540        Schema::List(inner) => SchemaNode::List(push_schema(nodes, inner)),
541        Schema::Record(fields) => SchemaNode::Record(
542            fields
543                .iter()
544                .map(|f| FieldNode {
545                    name: f.name.clone(),
546                    schema: push_schema(nodes, &f.schema),
547                    required: f.required,
548                    doc: f.doc.clone(),
549                })
550                .collect(),
551        ),
552    };
553    nodes.push(node);
554    (nodes.len() - 1) as u32
555}
556
557/// Flatten a value to `(nodes, root)`. See [`flatten_schema`].
558pub fn flatten_value(value: &Value) -> (Vec<ValueNode>, u32) {
559    let mut nodes = Vec::new();
560    let root = push_value(&mut nodes, value);
561    (nodes, root)
562}
563
564fn push_value(nodes: &mut Vec<ValueNode>, value: &Value) -> u32 {
565    let node = match value {
566        Value::Bool(b) => ValueNode::Bool(*b),
567        Value::Int(i) => ValueNode::Int(*i),
568        Value::Str(s) => ValueNode::Str(s.clone()),
569        Value::List(items) => ValueNode::List(items.iter().map(|v| push_value(nodes, v)).collect()),
570        Value::Record(map) => ValueNode::Record(
571            map.iter()
572                .map(|(k, v)| (k.clone(), push_value(nodes, v)))
573                .collect(),
574        ),
575    };
576    nodes.push(node);
577    (nodes.len() - 1) as u32
578}
579
580/// Rebuild a [`Value`] from an arena — the read direction, for a guest turning a
581/// `get-option-value` answer back into a tree before `from_value`.
582///
583/// Range- and cycle-checked, for the host's reasons in reverse: the arena a
584/// guest receives is well-formed by construction today, but a guest that
585/// assumed so and recursed would be one host bug away from an unbounded walk in
586/// wasm, where the failure is a trap the user sees as the plugin crashing.
587///
588/// # Errors
589///
590/// A [`ShapeError`] (with an empty path) when any reachable index is out of
591/// range or a node is reached again from its own descendants. A node shared by
592/// two siblings is *not* a cycle and is accepted.
593///
594/// # Examples
595///
596/// ```
597/// use lattice_plugin_sdk::shape::{Value, ValueNode, unflatten_value};
598///
599/// let nodes = [ValueNode::Int(7), ValueNode::List(vec![0, 0])];
600/// assert_eq!(
601///     unflatten_value(&nodes, 1),
602///     Ok(Value::List(vec![Value::Int(7), Value::Int(7)])),
603/// );
604///
605/// // A self-referencing list is refused rather than walked forever.
606/// assert!(unflatten_value(&[ValueNode::List(vec![0])], 0).is_err());
607/// ```
608pub fn unflatten_value(nodes: &[ValueNode], root: u32) -> Result<Value, ShapeError> {
609    fn go(nodes: &[ValueNode], i: u32, on_path: &mut Vec<u32>) -> Result<Value, ShapeError> {
610        let node = nodes.get(i as usize).ok_or_else(|| {
611            ShapeError::new(format!(
612                "node index {i} is out of range ({} nodes)",
613                nodes.len()
614            ))
615        })?;
616        if on_path.contains(&i) {
617            return Err(ShapeError::new(format!("node index {i} is a cycle")));
618        }
619        on_path.push(i);
620        let out = match node {
621            ValueNode::Bool(b) => Value::Bool(*b),
622            ValueNode::Int(n) => Value::Int(*n),
623            ValueNode::Str(s) => Value::Str(s.clone()),
624            ValueNode::List(children) => Value::List(
625                children
626                    .iter()
627                    .map(|c| go(nodes, *c, on_path))
628                    .collect::<Result<Vec<_>, _>>()?,
629            ),
630            ValueNode::Record(fields) => {
631                let mut map = BTreeMap::new();
632                for (k, c) in fields {
633                    map.insert(k.clone(), go(nodes, *c, on_path)?);
634                }
635                Value::Record(map)
636            }
637        };
638        on_path.pop();
639        Ok(out)
640    }
641    go(nodes, root, &mut Vec::new())
642}
643
644#[cfg(test)]
645mod tests {
646    #![allow(clippy::unwrap_used, clippy::panic)]
647    use super::*;
648
649    #[test]
650    fn primitives_describe_and_round_trip() {
651        assert_eq!(<bool as ConfigShape>::schema(), Schema::bool());
652        assert_eq!(<i64 as ConfigShape>::schema(), Schema::int());
653        assert_eq!(<String as ConfigShape>::schema(), Schema::string());
654
655        assert_eq!(bool::from_value(&true.to_value()), Ok(true));
656        assert_eq!(i64::from_value(&(-7i64).to_value()), Ok(-7));
657        assert_eq!(
658            String::from_value(&"x".to_string().to_value()),
659            Ok("x".to_string())
660        );
661    }
662
663    #[test]
664    fn a_wrong_kind_says_what_it_wanted_and_what_it_got() {
665        let err = i64::from_value(&Value::Str("4".into())).unwrap_err();
666        assert!(err.message.contains("expected integer"), "{err}");
667        assert!(err.message.contains("string"), "{err}");
668    }
669
670    #[test]
671    fn a_list_failure_carries_the_index() {
672        // The whole point of `under`: a leaf that fails deep in a list must
673        // arrive at the top knowing where it was, without any element having
674        // been told its own position.
675        let v = Value::List(vec![Value::Int(1), Value::Int(2), Value::Str("no".into())]);
676        let err = <Vec<i64> as ConfigShape>::from_value(&v).unwrap_err();
677        assert_eq!(err.path, "[2]");
678        assert_eq!(err.to_string(), "[2]: expected integer, got string");
679    }
680
681    #[test]
682    fn nested_paths_compose_in_reading_order() {
683        // `under` prepends, because the walk discovers segments from the
684        // inside out. A naive append would spell `file.target[1]`.
685        let e = ShapeError::new("boom")
686            .under("file")
687            .under("target")
688            .under("[1]");
689        assert_eq!(e.path, "[1].target.file");
690    }
691
692    #[test]
693    fn an_arena_round_trips_through_flatten_and_back() {
694        let value = Value::List(vec![Value::record([
695            ("key".to_string(), Value::Str("t".into())),
696            (
697                "target".to_string(),
698                Value::record([("file".to_string(), Value::Str("a.org".into()))]),
699            ),
700        ])]);
701        let (nodes, root) = flatten_value(&value);
702        assert_eq!(unflatten_value(&nodes, root).unwrap(), value);
703    }
704
705    #[test]
706    fn flattening_puts_children_before_their_parent() {
707        // Not cosmetic: an index handed out before its slot exists is an arena
708        // the host reads as out-of-range, and the guest would have no way to
709        // tell which of its options was malformed.
710        let (nodes, root) = flatten_value(&Value::List(vec![Value::Int(1), Value::Int(2)]));
711        assert_eq!(root as usize, nodes.len() - 1, "the root is pushed last");
712        match &nodes[root as usize] {
713            ValueNode::List(children) => {
714                assert!(
715                    children.iter().all(|c| *c < root),
716                    "every child index precedes its parent"
717                );
718            }
719            other => panic!("expected a list node, got {other:?}"),
720        }
721    }
722
723    #[test]
724    fn unflattening_refuses_a_bad_arena_rather_than_trusting_it() {
725        assert!(unflatten_value(&[], 0).is_err());
726        assert!(unflatten_value(&[ValueNode::List(vec![9])], 0).is_err());
727        // A cycle: in wasm an unbounded walk is a trap the user sees as the
728        // plugin crashing, so the guest checks even though today's host cannot
729        // produce one.
730        let cyclic = [ValueNode::List(vec![1]), ValueNode::List(vec![0])];
731        assert!(unflatten_value(&cyclic, 0).is_err());
732    }
733}