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}