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}