Skip to main content

lattice_config/
schema.rs

1//! TC.1 — what an option's value *is shaped like*, as data.
2//!
3//! Design: [`typed-configuration.md`](../../../docs/dev/architecture/typed-configuration.md).
4//!
5//! Options have always been typed on the Rust side — `Option<T>` over an
6//! [`OptionType`](crate::OptionType). What they have never been is typed to
7//! anything that is not Rust. The type-erased surface every runtime-name
8//! consumer goes through (`:set`, the TOML loader, plugin introspection,
9//! `:describe-option`) exposed a `type_label(): &str` and a formatted `String`,
10//! and a label plus a string is not enough to render a composite, validate a
11//! field, or write a value back to TOML.
12//!
13//! [`ConfigSchema`] is that description and [`ConfigValue`] is a value shaped
14//! by it. Together they are what makes design §5.12's `:customize` — "a
15//! type-aware editing buffer" — possible at all, and what lets a plugin declare
16//! a record without the host needing a Rust type for it (WIT has no generics;
17//! self-description is the expressible answer).
18//!
19//! **Additive by construction.** `parse` / `format` keep their round-trip
20//! contract and remain the `:set` surface, because a command line is a text
21//! surface and typing a record into one is not an improvement. A scalar option
22//! behaves identically before and after this module existed — its schema is
23//! *derived* from what it already declares rather than written by hand.
24
25use std::collections::BTreeMap;
26
27/// The leaf kinds. Deliberately the three the ABI already carries plus nothing:
28/// a float would need a parse/format round-trip that survives every locale and
29/// every plugin language's formatter, and no option in the workspace wants one.
30/// Adding it later is additive; guessing now is not.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
32pub enum ScalarKind {
33    /// `true` / `false`; matched by [`ConfigValue::Bool`].
34    Bool,
35    /// A signed 64-bit integer; matched by [`ConfigValue::Int`].
36    Int,
37    /// A UTF-8 string; matched by [`ConfigValue::Str`].
38    Str,
39}
40
41impl ScalarKind {
42    /// The word a mismatch message uses. Matches `OptionType::type_label`'s
43    /// vocabulary for the three primitives, so the two surfaces do not disagree
44    /// about what an integer is called.
45    pub fn label(self) -> &'static str {
46        match self {
47            ScalarKind::Bool => "boolean",
48            ScalarKind::Int => "integer",
49            ScalarKind::Str => "string",
50        }
51    }
52}
53
54/// One field of a [`ConfigSchema::Record`].
55///
56/// `doc` is carried per field rather than only per option because that is what
57/// `:describe-option` and `:customize` render beside the field — an option-level
58/// doc string describing six fields is the wall of prose the schema exists to
59/// replace.
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct SchemaField {
62    /// The field's key in the record (and in TOML). Matched exactly.
63    pub name: String,
64    /// The shape the field's value must have.
65    pub schema: ConfigSchema,
66    /// A missing required field is a validation error naming its path; a
67    /// missing optional one is simply absent from the value.
68    pub required: bool,
69    /// Per-field help rendered beside the field.
70    pub doc: String,
71}
72
73impl SchemaField {
74    /// A required field. The common case, so it is the short constructor.
75    pub fn new(name: impl Into<String>, schema: ConfigSchema, doc: impl Into<String>) -> Self {
76        Self {
77            name: name.into(),
78            schema,
79            required: true,
80            doc: doc.into(),
81        }
82    }
83
84    /// Builder: make this field optional.
85    pub fn optional(mut self) -> Self {
86        self.required = false;
87        self
88    }
89}
90
91/// The declared shape of an option's value.
92///
93/// `Enum` is not sugar for `Scalar(Str)`: it is the difference between
94/// `:customize` offering a picker and offering a text field. It is derived from
95/// `OptionType::enumerate`, but only for a type that declares that enumeration
96/// CLOSED (`enumerate_is_exhaustive`) — several types use `enumerate` as a
97/// completion hint over an open space, and describing those as enums would be
98/// worse than describing them as strings.
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub enum ConfigSchema {
101    /// A single boolean, integer or string.
102    Scalar(ScalarKind),
103    /// A closed set of string forms. Carries the forms in declaration order —
104    /// completion and `:customize` both show them in the order the type meant.
105    Enum(Vec<String>),
106    /// A homogeneous list; every element has the inner shape.
107    List(Box<ConfigSchema>),
108    /// A fixed set of named fields. Unknown fields are a validation
109    /// error, not ignored.
110    Record(Vec<SchemaField>),
111}
112
113impl ConfigSchema {
114    /// Shorthand constructors, because the common shapes are written often
115    /// enough that `ConfigSchema::Scalar(ScalarKind::Str)` becomes noise.
116    /// This one is `Scalar(Str)`.
117    ///
118    /// # Examples
119    ///
120    /// ```
121    /// use lattice_config::{ConfigSchema, SchemaField};
122    ///
123    /// let template = ConfigSchema::record([
124    ///     SchemaField::new("key", ConfigSchema::string(), "Selection key"),
125    ///     SchemaField::new("empty", ConfigSchema::bool(), "Start empty").optional(),
126    /// ]);
127    /// let schema = ConfigSchema::list(template);
128    /// assert!(schema.is_composite());
129    /// assert_eq!(schema.label(), "list<record>");
130    /// assert_eq!(ConfigSchema::int().label(), "integer");
131    /// ```
132    pub fn string() -> Self {
133        ConfigSchema::Scalar(ScalarKind::Str)
134    }
135    /// `Scalar(Int)`.
136    pub fn int() -> Self {
137        ConfigSchema::Scalar(ScalarKind::Int)
138    }
139    /// `Scalar(Bool)`.
140    pub fn bool() -> Self {
141        ConfigSchema::Scalar(ScalarKind::Bool)
142    }
143    /// A list whose every element has shape `inner`.
144    pub fn list(inner: ConfigSchema) -> Self {
145        ConfigSchema::List(Box::new(inner))
146    }
147    /// A record with `fields`, in declaration order (the order
148    /// `:customize` renders them; validation does not depend on it).
149    pub fn record(fields: impl IntoIterator<Item = SchemaField>) -> Self {
150        ConfigSchema::Record(fields.into_iter().collect())
151    }
152
153    /// Whether this shape has anything below its top level. The line the TOML
154    /// loader and `:describe-option` branch on: a scalar-shaped option keeps
155    /// every path it has today, a composite one takes the tree path.
156    pub fn is_composite(&self) -> bool {
157        matches!(self, ConfigSchema::List(_) | ConfigSchema::Record(_))
158    }
159
160    /// A one-line rendering for `:describe-option`'s type column
161    /// (`list<record>`, `enum`, `string`). The full field-by-field rendering is
162    /// TC.8's; this is what fits where `type_label()` used to go.
163    pub fn label(&self) -> String {
164        match self {
165            ConfigSchema::Scalar(k) => k.label().to_string(),
166            ConfigSchema::Enum(_) => "enum".to_string(),
167            ConfigSchema::List(inner) => format!("list<{}>", inner.label()),
168            ConfigSchema::Record(_) => "record".to_string(),
169        }
170    }
171}
172
173/// A value shaped by a [`ConfigSchema`].
174///
175/// `Record` is a `BTreeMap` rather than a `Vec<(String, ConfigValue)>` so two
176/// values that differ only in field order compare equal — an option's value
177/// arriving from `lattice.toml` (which does not preserve order across a
178/// round-trip) and the same value built in `init.rs` must not be two different
179/// values. The wire form is an association list, because WIT has no map; the
180/// conversion is where the ordering stops mattering.
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub enum ConfigValue {
183    /// A boolean leaf.
184    Bool(bool),
185    /// An integer leaf.
186    Int(i64),
187    /// A string leaf — also the representation of an
188    /// [`ConfigSchema::Enum`] form.
189    Str(String),
190    /// A list of values (homogeneous only if its schema says so).
191    List(Vec<ConfigValue>),
192    /// Named fields, compared order-independently.
193    Record(BTreeMap<String, ConfigValue>),
194}
195
196impl ConfigValue {
197    /// Build a record from pairs. The wire and TOML forms both arrive as pairs.
198    pub fn record(fields: impl IntoIterator<Item = (String, ConfigValue)>) -> Self {
199        ConfigValue::Record(fields.into_iter().collect())
200    }
201
202    /// The word a mismatch message uses for what was actually found.
203    pub fn kind_label(&self) -> &'static str {
204        match self {
205            ConfigValue::Bool(_) => "boolean",
206            ConfigValue::Int(_) => "integer",
207            ConfigValue::Str(_) => "string",
208            ConfigValue::List(_) => "list",
209            ConfigValue::Record(_) => "record",
210        }
211    }
212
213    /// Read a scalar back out. `None` on a kind mismatch rather than a panic —
214    /// callers are usually walking a tree they have already validated, and the
215    /// ones that have not should not be able to crash the editor over a config
216    /// file. This one reads a [`ConfigValue::Str`].
217    pub fn as_str(&self) -> Option<&str> {
218        match self {
219            ConfigValue::Str(s) => Some(s),
220            _ => None,
221        }
222    }
223    /// The boolean in a [`ConfigValue::Bool`]; `None` for any other kind.
224    pub fn as_bool(&self) -> Option<bool> {
225        match self {
226            ConfigValue::Bool(b) => Some(*b),
227            _ => None,
228        }
229    }
230    /// The integer in a [`ConfigValue::Int`]; `None` for any other kind.
231    pub fn as_int(&self) -> Option<i64> {
232        match self {
233            ConfigValue::Int(i) => Some(*i),
234            _ => None,
235        }
236    }
237    /// The elements of a [`ConfigValue::List`]; `None` for any other kind.
238    pub fn as_list(&self) -> Option<&[ConfigValue]> {
239        match self {
240            ConfigValue::List(items) => Some(items),
241            _ => None,
242        }
243    }
244    /// A record's field by name. `None` if the field is absent or
245    /// `self` is not a [`ConfigValue::Record`].
246    pub fn field(&self, name: &str) -> Option<&ConfigValue> {
247        match self {
248            ConfigValue::Record(map) => map.get(name),
249            _ => None,
250        }
251    }
252}
253
254/// A validation failure, carrying **where** it happened.
255///
256/// The path is the whole point, and it is what no hand-rolled parser produced:
257/// `capture-templates[2].target.file: expected string, got integer` is
258/// actionable where "invalid capture template" is not. It is also the concrete
259/// thing a user gets out of typed configuration before `:customize` exists.
260#[derive(Debug, Clone, PartialEq, Eq)]
261pub struct SchemaError {
262    /// Dotted / indexed path from the option's root. Empty at the root itself.
263    pub path: String,
264    /// What was wrong at `path` (`expected string, got integer`,
265    /// `required field is missing`, ...). [`Display`](std::fmt::Display)
266    /// renders `path: message`, or just `message` at the root.
267    pub message: String,
268}
269
270impl std::fmt::Display for SchemaError {
271    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
272        if self.path.is_empty() {
273            f.write_str(&self.message)
274        } else {
275            write!(f, "{}: {}", self.path, self.message)
276        }
277    }
278}
279
280impl std::error::Error for SchemaError {}
281
282/// Check `value` against `schema`, reporting the first failure with its path.
283///
284/// **First failure, not all of them.** A shape mismatch high in the tree makes
285/// everything under it meaningless, so a list of twelve consequential errors
286/// would bury the one that matters; the loader's contract is to warn and keep
287/// going per *key*, and one clear message per key is what serves it.
288///
289/// Rules: scalars must match their kind exactly (no coercion); an
290/// [`ConfigSchema::Enum`] needs a [`ConfigValue::Str`] among its forms; a
291/// record rejects missing required fields AND unknown fields. Paths use
292/// `.field` for record fields and `[i]` for list indices.
293///
294/// # Examples
295///
296/// ```
297/// use lattice_config::schema::validate;
298/// use lattice_config::{ConfigSchema, ConfigValue, SchemaField};
299///
300/// let schema = ConfigSchema::list(ConfigSchema::record([
301///     SchemaField::new("key", ConfigSchema::string(), ""),
302///     SchemaField::new("file", ConfigSchema::string(), ""),
303/// ]));
304/// let good = ConfigValue::record([
305///     ("key".to_string(), ConfigValue::Str("t".into())),
306///     ("file".to_string(), ConfigValue::Str("todo.org".into())),
307/// ]);
308/// let bad = ConfigValue::record([
309///     ("key".to_string(), ConfigValue::Str("n".into())),
310///     ("file".to_string(), ConfigValue::Int(3)),
311/// ]);
312/// let value = ConfigValue::List(vec![good, bad]);
313///
314/// let err = validate(&schema, &value).unwrap_err();
315/// assert_eq!(err.to_string(), "[1].file: expected string, got integer");
316/// ```
317pub fn validate(schema: &ConfigSchema, value: &ConfigValue) -> Result<(), SchemaError> {
318    validate_at("", schema, value)
319}
320
321fn validate_at(path: &str, schema: &ConfigSchema, value: &ConfigValue) -> Result<(), SchemaError> {
322    let mismatch = |expected: &str| {
323        Err(SchemaError {
324            path: path.to_string(),
325            message: format!("expected {expected}, got {}", value.kind_label()),
326        })
327    };
328    match schema {
329        ConfigSchema::Scalar(kind) => match (kind, value) {
330            (ScalarKind::Bool, ConfigValue::Bool(_))
331            | (ScalarKind::Int, ConfigValue::Int(_))
332            | (ScalarKind::Str, ConfigValue::Str(_)) => Ok(()),
333            _ => mismatch(kind.label()),
334        },
335        ConfigSchema::Enum(forms) => {
336            let Some(got) = value.as_str() else {
337                return mismatch("string");
338            };
339            if forms.iter().any(|f| f == got) {
340                Ok(())
341            } else {
342                Err(SchemaError {
343                    path: path.to_string(),
344                    // The valid set, inline: an enum's whole advantage over a
345                    // free string is that the answer is finite, so a rejection
346                    // that does not show it wastes the one thing it knows.
347                    message: format!("expected one of {}, got `{got}`", forms.join(" | ")),
348                })
349            }
350        }
351        ConfigSchema::List(inner) => {
352            let Some(items) = value.as_list() else {
353                return mismatch("list");
354            };
355            for (i, item) in items.iter().enumerate() {
356                validate_at(&format!("{path}[{i}]"), inner, item)?;
357            }
358            Ok(())
359        }
360        ConfigSchema::Record(fields) => {
361            let ConfigValue::Record(map) = value else {
362                return mismatch("record");
363            };
364            for field in fields {
365                let child = if path.is_empty() {
366                    field.name.clone()
367                } else {
368                    format!("{path}.{}", field.name)
369                };
370                match map.get(&field.name) {
371                    Some(v) => validate_at(&child, &field.schema, v)?,
372                    None if field.required => {
373                        return Err(SchemaError {
374                            path: child,
375                            message: "required field is missing".to_string(),
376                        });
377                    }
378                    None => {}
379                }
380            }
381            // An unknown field is an ERROR, not a warning that scrolls past.
382            // A misspelled key in a config file is the single most common way
383            // configuration silently does nothing, and the shape is known
384            // exactly — so there is no reason to accept it and every reason to
385            // say which key and what was expected.
386            for key in map.keys() {
387                if !fields.iter().any(|f| &f.name == key) {
388                    let child = if path.is_empty() {
389                        key.clone()
390                    } else {
391                        format!("{path}.{key}")
392                    };
393                    let known: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect();
394                    return Err(SchemaError {
395                        path: child,
396                        message: format!("unknown field; expected one of {}", known.join(" | ")),
397                    });
398                }
399            }
400            Ok(())
401        }
402    }
403}
404
405// ── TOML interop ──────────────────────────────────────────────────────────
406//
407// Lives here rather than in the loader because two callers need it: the loader
408// (a composite option's value written natively in `lattice.toml`) and
409// `ConfigValue`'s own `OptionType::parse` (the `:set` text surface, below).
410// One conversion, so the two homes cannot drift about what a TOML table means.
411
412/// Join a schema path onto an option name. A record field needs the separating
413/// dot (`opt` + `target.file`); an index does not (`opt` + `[2]`).
414pub fn dot_path(path: &str) -> String {
415    if path.is_empty() || path.starts_with('[') {
416        path.to_string()
417    } else {
418        format!(".{path}")
419    }
420}
421
422/// TC.2 — a TOML value as a [`ConfigValue`] tree.
423///
424/// Pure and schema-blind: it answers "what shape is this", and
425/// [`crate::schema::validate`] answers "is that the right shape". Keeping the
426/// two apart is what lets the shape error and the schema error carry the same
427/// kind of path.
428///
429/// Floats and datetimes are refused rather than stringified. `ConfigValue` has
430/// no kind for either, and quietly turning `1.5` into `"1.5"` would make an
431/// option's value depend on the host's float formatter — the sort of thing that
432/// works until a locale or a plugin language disagrees. Adding a kind later is
433/// additive; guessing now is not.
434pub fn toml_to_config_value(value: &toml::Value) -> Result<ConfigValue, SchemaError> {
435    fn go(path: &str, value: &toml::Value) -> Result<ConfigValue, SchemaError> {
436        match value {
437            toml::Value::String(s) => Ok(ConfigValue::Str(s.clone())),
438            toml::Value::Integer(i) => Ok(ConfigValue::Int(*i)),
439            toml::Value::Boolean(b) => Ok(ConfigValue::Bool(*b)),
440            toml::Value::Array(items) => {
441                let mut out = Vec::with_capacity(items.len());
442                for (i, item) in items.iter().enumerate() {
443                    out.push(go(&format!("{path}[{i}]"), item)?);
444                }
445                Ok(ConfigValue::List(out))
446            }
447            toml::Value::Table(table) => {
448                let mut out = std::collections::BTreeMap::new();
449                for (k, v) in table {
450                    let child = if path.is_empty() {
451                        k.clone()
452                    } else {
453                        format!("{path}.{k}")
454                    };
455                    out.insert(k.clone(), go(&child, v)?);
456                }
457                Ok(ConfigValue::Record(out))
458            }
459            toml::Value::Float(_) => Err(SchemaError {
460                path: path.to_string(),
461                message: "floating-point values are not a configuration value shape".to_string(),
462            }),
463            toml::Value::Datetime(_) => Err(SchemaError {
464                path: path.to_string(),
465                message: "datetime values are not a configuration value shape".to_string(),
466            }),
467        }
468    }
469    go("", value).map_err(|mut e| {
470        e.path = dot_path(&e.path);
471        e
472    })
473}
474
475/// A [`ConfigValue`] as TOML. The inverse of [`toml_to_config_value`], used by
476/// `ConfigValue`'s `format()` — which is what `:set foo?` echoes and what
477/// `:describe-option` shows.
478///
479/// Total: every `ConfigValue` kind has a TOML counterpart, which is not true in
480/// the other direction (floats and datetimes have no `ConfigValue`).
481pub fn config_value_to_toml(value: &ConfigValue) -> toml::Value {
482    match value {
483        ConfigValue::Bool(b) => toml::Value::Boolean(*b),
484        ConfigValue::Int(i) => toml::Value::Integer(*i),
485        ConfigValue::Str(s) => toml::Value::String(s.clone()),
486        ConfigValue::List(items) => {
487            toml::Value::Array(items.iter().map(config_value_to_toml).collect())
488        }
489        ConfigValue::Record(map) => toml::Value::Table(
490            map.iter()
491                .map(|(k, v)| (k.clone(), config_value_to_toml(v)))
492                .collect(),
493        ),
494    }
495}
496
497/// The key `format` wraps a non-record root under. `parse` accepts any name —
498/// see [`unwrap_root`].
499const ROOT_KEY: &str = "value";
500
501/// The inner value of a single-key wrapper table, if `table` is one.
502///
503/// A TOML document cannot BE an array, so a list-rooted option has no bare text
504/// spelling and needs a wrapper. `format` writes `value = …`; `parse` accepts
505/// **any** single key whose payload is an array, not just that one.
506///
507/// The generosity is deliberate and is what makes the migration painless. A
508/// user who wrote `org.capture-templates` as `[[template]]`, or
509/// `agenda-sections` as `[[section]]`, keeps writing exactly that: the option's
510/// value is now the list itself, and their wrapper name — whatever they chose —
511/// unwraps to it. Insisting on `value` would have broken every existing `:set`
512/// string for no gain, since the name carries no information the schema does
513/// not already have.
514///
515/// Only an ARRAY payload unwraps. A single-key table whose value is a table is
516/// a record with one field, which is a shape an option legitimately has.
517fn unwrap_root(table: &toml::Table) -> Option<&toml::Value> {
518    if table.len() != 1 {
519        return None;
520    }
521    let (key, value) = table.iter().next()?;
522    match value {
523        // Any name, for an array: this is the migration path, and the name
524        // carries nothing the schema does not already know.
525        toml::Value::Array(_) => Some(value),
526        // A table under any name is a record with one field — a shape an
527        // option legitimately has — so it is left alone.
528        toml::Value::Table(_) => None,
529        // A scalar root has no bare spelling either, and `format` wraps it
530        // under the reserved name. Here the name DOES have to match: a
531        // single-field record holding a scalar is far commoner than a
532        // scalar-rooted option, so unwrapping every one of those would trade a
533        // real case for a rare one.
534        _ => (key == ROOT_KEY).then_some(value),
535    }
536}
537
538/// TC.3 — a `ConfigValue` is itself an option value type.
539///
540/// This is what a plugin's structured option holds. The shape is NOT here: a
541/// plugin declares its schema at registration and it is carried on the option
542/// spec ([`crate::option::Option::structured`]), because the schema is metadata
543/// about the option — like its doc and its default — rather than data inside
544/// the value. A value that carried its own schema could not survive
545/// `from_value`, which is a static function with no access to the option it is
546/// being set on.
547///
548/// `parse` / `format` are **TOML text**, which keeps the `:set` contract and
549/// costs nothing: the host already has a TOML parser, so a structured option
550/// stays settable from the command line and `:set foo?` still echoes something
551/// a user can read and paste back. It also means the migration of an option
552/// that was a TOML-in-a-string is not a break for anyone who was setting it
553/// that way — the text they wrote still parses, it is simply now validated.
554impl crate::OptionType for ConfigValue {
555    fn parse(s: &str) -> Result<Self, String> {
556        let table = toml::from_str::<toml::Table>(s).map_err(|e| format!("expected TOML: {e}"))?;
557        // The unwrap half of `format`'s wrapper, below. Symmetric on purpose:
558        // `parse(&v.format()) == Ok(v)` is `OptionType`'s contract and a
559        // structured option does not get an exemption from it.
560        if let Some(inner) = unwrap_root(&table) {
561            return toml_to_config_value(inner).map_err(|e| e.to_string());
562        }
563        toml_to_config_value(&toml::Value::Table(table)).map_err(|e| e.to_string())
564    }
565
566    fn format(&self) -> String {
567        // A non-table root has no top-level TOML spelling — a document cannot
568        // BE an array — so a list-rooted value is rendered under the reserved
569        // key `value`, and `parse` unwraps it again.
570        //
571        // The cost is one ambiguity, named rather than hidden: a RECORD option
572        // with exactly one field, and that field a list, cannot round-trip
573        // through this text surface — `parse` reads the wrapper as the wrapper
574        // it usually is. It is not silent when it happens (the unwrapped tree
575        // fails schema validation with a path), `:set` on a composite is
576        // deferred surface anyway (typed-configuration.md §2.2), and
577        // `lattice.toml` and `set-option-value` both carry the tree natively
578        // and never come through here.
579        match config_value_to_toml(self) {
580            toml::Value::Table(t) => toml::to_string_pretty(&t).unwrap_or_default(),
581            other => {
582                let mut t = toml::Table::new();
583                t.insert(ROOT_KEY.to_string(), other);
584                toml::to_string_pretty(&t).unwrap_or_default()
585            }
586        }
587    }
588
589    fn type_label() -> &'static str {
590        "structured"
591    }
592
593    /// Unknowable statically — the shape belongs to the OPTION, not the type.
594    /// Every real structured option is built through
595    /// [`crate::option::Option::structured`], which records the declared schema
596    /// on the spec and is what `ErasedOption::schema()` answers with. This
597    /// fallback exists only so the trait is implementable.
598    fn schema() -> crate::ConfigSchema {
599        crate::ConfigSchema::string()
600    }
601
602    fn to_value(&self) -> ConfigValue {
603        self.clone()
604    }
605
606    fn from_value(value: &ConfigValue) -> Result<Self, String> {
607        Ok(value.clone())
608    }
609}
610
611#[cfg(test)]
612mod tests {
613    #![allow(clippy::unwrap_used, clippy::panic)]
614    use super::*;
615
616    fn template_schema() -> ConfigSchema {
617        ConfigSchema::list(ConfigSchema::record([
618            SchemaField::new("key", ConfigSchema::string(), "the key to press"),
619            SchemaField::new(
620                "target",
621                ConfigSchema::record([SchemaField::new(
622                    "file",
623                    ConfigSchema::string(),
624                    "where it lands",
625                )]),
626                "where the capture goes",
627            ),
628            SchemaField::new("body", ConfigSchema::string(), "the template body").optional(),
629        ]))
630    }
631
632    fn template(key: &str, file: ConfigValue) -> ConfigValue {
633        ConfigValue::record([
634            ("key".to_string(), ConfigValue::Str(key.to_string())),
635            (
636                "target".to_string(),
637                ConfigValue::record([("file".to_string(), file)]),
638            ),
639        ])
640    }
641
642    #[test]
643    fn a_well_shaped_tree_validates() {
644        let v = ConfigValue::List(vec![template("t", ConfigValue::Str("~/org/in.org".into()))]);
645        assert_eq!(validate(&template_schema(), &v), Ok(()));
646    }
647
648    #[test]
649    fn a_mismatch_names_its_path() {
650        // The assertion this module exists for. Rejecting is easy; rejecting
651        // with a location is the thing every hand-rolled parser skipped.
652        let v = ConfigValue::List(vec![
653            template("t", ConfigValue::Str("a".into())),
654            template("n", ConfigValue::Str("b".into())),
655            template("x", ConfigValue::Int(7)),
656        ]);
657        let err = validate(&template_schema(), &v).unwrap_err();
658        assert_eq!(err.path, "[2].target.file");
659        assert!(err.message.contains("expected string"), "{err}");
660        assert!(err.message.contains("integer"), "{err}");
661        assert_eq!(
662            err.to_string(),
663            "[2].target.file: expected string, got integer"
664        );
665    }
666
667    #[test]
668    fn a_missing_required_field_names_itself_not_its_parent() {
669        let v = ConfigValue::List(vec![ConfigValue::record([(
670            "key".to_string(),
671            ConfigValue::Str("t".into()),
672        )])]);
673        let err = validate(&template_schema(), &v).unwrap_err();
674        assert_eq!(err.path, "[0].target");
675        assert!(err.message.contains("required"), "{err}");
676    }
677
678    #[test]
679    fn an_optional_field_may_be_absent() {
680        // `body` is optional; its absence must not be the same error as
681        // `target`'s, or optionality means nothing.
682        let v = ConfigValue::List(vec![template("t", ConfigValue::Str("a".into()))]);
683        assert_eq!(validate(&template_schema(), &v), Ok(()));
684    }
685
686    #[test]
687    fn an_unknown_field_is_refused_by_name() {
688        // A misspelled key is how configuration silently does nothing. The
689        // shape is known exactly, so there is no excuse for accepting it.
690        let v = ConfigValue::List(vec![ConfigValue::record([
691            ("key".to_string(), ConfigValue::Str("t".into())),
692            (
693                "target".to_string(),
694                ConfigValue::record([("file".to_string(), ConfigValue::Str("a".into()))]),
695            ),
696            ("bodyy".to_string(), ConfigValue::Str("oops".into())),
697        ])]);
698        let err = validate(&template_schema(), &v).unwrap_err();
699        assert_eq!(err.path, "[0].bodyy");
700        assert!(err.message.contains("unknown field"), "{err}");
701        assert!(err.message.contains("body"), "{err}");
702    }
703
704    #[test]
705    fn an_enum_rejection_shows_the_valid_set() {
706        let schema = ConfigSchema::Enum(vec!["marker".into(), "indent".into(), "syntax".into()]);
707        let err = validate(&schema, &ConfigValue::Str("manual".into())).unwrap_err();
708        assert!(err.message.contains("marker | indent | syntax"), "{err}");
709        assert!(err.message.contains("manual"), "{err}");
710    }
711
712    #[test]
713    fn record_field_order_does_not_change_the_value() {
714        // Values arrive from TOML (unordered) and from a Rust struct (ordered).
715        // If those compared unequal, "the same config" would be two values.
716        let a = ConfigValue::record([
717            ("key".to_string(), ConfigValue::Str("t".into())),
718            ("body".to_string(), ConfigValue::Str("b".into())),
719        ]);
720        let b = ConfigValue::record([
721            ("body".to_string(), ConfigValue::Str("b".into())),
722            ("key".to_string(), ConfigValue::Str("t".into())),
723        ]);
724        assert_eq!(a, b);
725    }
726
727    #[test]
728    fn labels_read_as_a_type() {
729        assert_eq!(template_schema().label(), "list<record>");
730        assert_eq!(ConfigSchema::int().label(), "integer");
731        assert!(template_schema().is_composite());
732        assert!(!ConfigSchema::string().is_composite());
733        assert!(!ConfigSchema::Enum(vec!["a".into()]).is_composite());
734    }
735}
736
737#[cfg(test)]
738mod config_value_option_type_tests {
739    #![allow(clippy::unwrap_used, clippy::panic)]
740    use super::*;
741    use crate::OptionType;
742
743    fn templates() -> ConfigValue {
744        ConfigValue::List(vec![ConfigValue::record([
745            ("key".to_string(), ConfigValue::Str("t".into())),
746            (
747                "target".to_string(),
748                ConfigValue::record([("file".to_string(), ConfigValue::Str("a.org".into()))]),
749            ),
750        ])])
751    }
752
753    #[test]
754    fn a_record_rooted_value_round_trips_as_a_plain_toml_document() {
755        let v = ConfigValue::record([
756            ("key".to_string(), ConfigValue::Str("t".into())),
757            ("count".to_string(), ConfigValue::Int(3)),
758            ("on".to_string(), ConfigValue::Bool(true)),
759        ]);
760        let text = v.format();
761        assert!(!text.contains("value"), "a record needs no wrapper: {text}");
762        assert_eq!(ConfigValue::parse(&text), Ok(v));
763    }
764
765    #[test]
766    fn a_list_rooted_value_round_trips_through_the_wrapper() {
767        // `OptionType`'s contract is `parse(&v.format()) == Ok(v)`, and a
768        // structured option does not get an exemption from it. A TOML document
769        // cannot BE an array, so the wrapper is how a list-rooted option has a
770        // text form at all — and `parse` has to undo exactly what `format` did.
771        let v = templates();
772        let text = v.format();
773        assert!(
774            text.contains("[[value]]"),
775            "wrapped under the reserved key: {text}"
776        );
777        assert_eq!(ConfigValue::parse(&text), Ok(v));
778    }
779
780    #[test]
781    fn every_kind_round_trips() {
782        for v in [
783            ConfigValue::Bool(true),
784            ConfigValue::Int(-7),
785            ConfigValue::Str("hello".into()),
786            ConfigValue::List(vec![ConfigValue::Int(1), ConfigValue::Int(2)]),
787            templates(),
788        ] {
789            assert_eq!(ConfigValue::parse(&v.format()), Ok(v.clone()), "{v:?}");
790        }
791    }
792
793    #[test]
794    fn a_toml_string_a_user_already_wrote_still_parses() {
795        // The migration promise: an option that WAS a TOML-in-a-string does not
796        // break for someone who was setting it that way. The text is unchanged;
797        // what is new is that the wrapper unwraps to the list the option now
798        // holds, and the result is validated against a schema.
799        let text = "[[template]]\nkey = \"t\"\ntarget = { file = \"a.org\" }\n";
800        let got = ConfigValue::parse(text).expect("still parses");
801        let list = got.as_list().expect("the wrapper unwrapped");
802        assert_eq!(list.len(), 1);
803        assert_eq!(
804            list[0]
805                .field("target")
806                .and_then(|t| t.field("file"))
807                .and_then(ConfigValue::as_str),
808            Some("a.org"),
809        );
810    }
811
812    #[test]
813    fn a_wrapper_is_not_unwrapped_when_its_payload_is_a_table() {
814        // Only an ARRAY payload is a wrapper. A single-key table whose value is
815        // a table is a record with one field, which is a shape an option
816        // legitimately has.
817        let text = "[value]\nfile = \"a.org\"\n";
818        let got = ConfigValue::parse(text).unwrap();
819        assert!(
820            got.field("value").is_some(),
821            "the `value` key survived as a field: {got:?}"
822        );
823    }
824
825    #[test]
826    fn any_wrapper_name_unwraps_so_an_existing_set_string_keeps_working() {
827        // The migration nicety, and the reason `parse` is more generous than
828        // `format`. Someone who wrote `org.capture-templates` as `[[template]]`
829        // — or `agenda-sections` as `[[section]]` — keeps writing exactly that:
830        // the option's value is the list itself now, and their wrapper name,
831        // whatever they chose, unwraps to it. Insisting on `value` would have
832        // broken every existing `:set` string for no gain, since the name
833        // carries nothing the schema does not already know.
834        for wrapper in ["template", "section", "command", "value"] {
835            let text = format!("[[{wrapper}]]\nkey = \"t\"\n");
836            let got = ConfigValue::parse(&text)
837                .unwrap_or_else(|e| panic!("`{wrapper}` should parse: {e}"));
838            let list = got
839                .as_list()
840                .unwrap_or_else(|| panic!("`{wrapper}` should unwrap to a list, got {got:?}"));
841            assert_eq!(list.len(), 1);
842            assert_eq!(
843                list[0].field("key").and_then(ConfigValue::as_str),
844                Some("t")
845            );
846        }
847    }
848}
849
850// ── TC.8: rendering a schema for a reader ─────────────────────────────────
851
852impl ConfigSchema {
853    /// The shape, written out for `:describe-option`.
854    ///
855    /// Lives here rather than in the help builder because it is a property of
856    /// the schema, and the alternative is a walk over `ConfigSchema`'s variants
857    /// in a crate that does not own them — which is the arrangement that goes
858    /// stale the first time a variant is added.
859    ///
860    /// Returns `None` for a shape that says nothing a reader does not already
861    /// know from the type label: a bare scalar. `:describe-option` already
862    /// prints `type: integer` and a block underneath repeating it is noise.
863    ///
864    /// An `enum` renders even though `values:` already lists its forms, because
865    /// the two answer different questions once nesting exists — `values:` is
866    /// flat, and a list-of-enum has forms that belong to the ELEMENT.
867    pub fn describe(&self) -> Option<Vec<String>> {
868        if matches!(self, ConfigSchema::Scalar(_)) {
869            return None;
870        }
871        let mut out = Vec::new();
872        write_schema(&mut out, self, 0, None);
873        Some(out)
874    }
875}
876
877/// One line per node, indented by depth. `field` names the record field this
878/// node belongs to, when it has one.
879fn write_schema(
880    out: &mut Vec<String>,
881    schema: &ConfigSchema,
882    depth: usize,
883    field: Option<&SchemaField>,
884) {
885    let pad = "  ".repeat(depth);
886    // The field's own metadata leads, because a reader scanning for "what do I
887    // write here" is looking for names, not types.
888    let head = match field {
889        Some(f) => {
890            let req = if f.required { "" } else { "?" };
891            format!("{pad}{}{req}: {}", f.name, schema.label())
892        }
893        None => format!("{pad}{}", schema.label()),
894    };
895    match schema {
896        ConfigSchema::Scalar(_) | ConfigSchema::Record(_) | ConfigSchema::List(_) => out.push(head),
897        // The forms inline: an enum's whole advantage is that the answer is
898        // finite, and a reader who has to go looking for the values has lost it.
899        ConfigSchema::Enum(forms) => out.push(format!("{head} — {}", forms.join(" | "))),
900    }
901    // …then the field's doc, indented under it, so the shape stays scannable
902    // when the docs are long.
903    if let Some(f) = field
904        && !f.doc.is_empty()
905    {
906        out.push(format!("{pad}    {}", f.doc));
907    }
908    match schema {
909        ConfigSchema::List(inner) => write_schema(out, inner, depth + 1, None),
910        ConfigSchema::Record(fields) => {
911            for f in fields {
912                write_schema(out, &f.schema, depth + 1, Some(f));
913            }
914        }
915        ConfigSchema::Scalar(_) | ConfigSchema::Enum(_) => {}
916    }
917}
918
919#[cfg(test)]
920mod describe_tests {
921    #![allow(clippy::unwrap_used, clippy::panic)]
922    use super::*;
923
924    fn templates() -> ConfigSchema {
925        ConfigSchema::list(ConfigSchema::record([
926            SchemaField::new("key", ConfigSchema::string(), "the key to press"),
927            SchemaField::new(
928                "when",
929                ConfigSchema::Enum(vec!["overdue".into(), "days".into()]),
930                "which rows",
931            ),
932            SchemaField::new(
933                "target",
934                ConfigSchema::record([SchemaField::new("file", ConfigSchema::string(), "")]),
935                "where it goes",
936            ),
937            SchemaField::new("body", ConfigSchema::string(), "").optional(),
938        ]))
939    }
940
941    #[test]
942    fn a_scalar_says_nothing_the_type_label_has_not_already_said() {
943        // `:describe-option` prints `type: integer` a line above. A block
944        // repeating it is noise, and noise on the common case is what stops
945        // people reading the uncommon one.
946        assert_eq!(ConfigSchema::int().describe(), None);
947        assert_eq!(ConfigSchema::string().describe(), None);
948    }
949
950    #[test]
951    fn a_nested_shape_renders_field_by_field_with_its_docs() {
952        let lines = templates().describe().expect("a list is worth describing");
953        let text = lines.join("\n");
954        assert!(text.starts_with("list<record>"), "{text}");
955        // Field names lead, because a reader scanning for "what do I write
956        // here" is looking for names rather than types.
957        assert!(text.contains("  record"), "{text}");
958        assert!(text.contains("    key: string"), "{text}");
959        assert!(text.contains("      the key to press"), "{text}");
960        // Optionality is visible at a glance, which is the single most common
961        // question a config file raises.
962        assert!(text.contains("    body?: string"), "{text}");
963        assert!(
964            !text.contains("    key?:"),
965            "a required field has no `?`: {text}"
966        );
967        // The second level survives — a renderer that stopped at depth one
968        // would look right in every shape that has no depth two.
969        assert!(text.contains("      file: string"), "{text}");
970    }
971
972    #[test]
973    fn an_enum_shows_its_forms_where_it_appears() {
974        let text = templates().describe().unwrap().join("\n");
975        assert!(
976            text.contains("when: enum — overdue | days"),
977            "the forms belong beside the field that accepts them: {text}"
978        );
979    }
980}