Skip to main content

sim_config/
shape.rs

1//! Shape-backed configuration defaults and validation.
2
3use std::sync::Arc;
4
5use sim_kernel::{
6    Cx, Datum, Diagnostic, MatchScore, Ref, Shape, ShapeMatch, Symbol,
7    datum_store::DatumStore,
8    shape_report::{ShapeReport, shape_report_from_match},
9};
10
11use crate::{ConfigDir, ConfigLayer, ConfigSource, ConfigTable, merge_layers};
12
13/// Result returned by config shape validation.
14pub type ConfigShapeResult<T> = Result<T, Box<ShapeReport>>;
15
16/// Shape-backed contract for one library's configuration table.
17#[derive(Clone)]
18pub struct ConfigShape {
19    /// Library id whose configuration this shape validates.
20    pub lib: Symbol,
21    /// Shape applied to the effective table after built-in defaults are bound.
22    pub shape: Arc<dyn Shape>,
23    /// Built-in defaults for absent fields.
24    pub defaults: ConfigTable,
25    /// Top-level keys whose values are secret-bearing.
26    pub secret_keys: Vec<String>,
27}
28
29impl ConfigShape {
30    /// Creates a config shape with no secret keys.
31    pub fn new(lib: Symbol, shape: Arc<dyn Shape>, defaults: ConfigTable) -> Self {
32        Self {
33            lib,
34            shape,
35            defaults,
36            secret_keys: Vec::new(),
37        }
38    }
39
40    /// Adds secret keys, deduplicating them at the shape boundary.
41    pub fn with_secret_keys(mut self, keys: impl IntoIterator<Item = impl Into<String>>) -> Self {
42        self.secret_keys = dedupe_keys(keys);
43        self
44    }
45
46    /// Validates `table` against this shape and returns the default-bound table.
47    ///
48    /// Built-in defaults are the lowest-precedence layer. The supplied table
49    /// overlays them, then the configured shape checks the effective table. A
50    /// closed table shape therefore reports unknown fields while defaults may
51    /// satisfy required fields.
52    pub fn validate(&self, cx: &mut Cx, table: &ConfigTable) -> ConfigShapeResult<ConfigTable> {
53        if table.lib != self.lib {
54            return Err(Box::new(self.reject_report(
55                cx,
56                table,
57                format!(
58                    "config table for `{}` cannot validate as `{}`",
59                    table.lib.as_qualified_str(),
60                    self.lib.as_qualified_str()
61                ),
62            )));
63        }
64        if self.defaults.lib != self.lib {
65            return Err(Box::new(self.reject_report(
66                cx,
67                table,
68                format!(
69                    "config defaults for `{}` do not match shape lib `{}`",
70                    self.defaults.lib.as_qualified_str(),
71                    self.lib.as_qualified_str()
72                ),
73            )));
74        }
75
76        let effective = self.bind_defaults(table);
77        let matched = self
78            .shape
79            .check_expr(cx, &effective.table)
80            .unwrap_or_else(|error| {
81                ShapeMatch::reject(format!("config shape check failed: {error}"))
82            });
83        if matched.accepted {
84            Ok(effective)
85        } else {
86            Err(Box::new(self.report_from_match(cx, &effective, matched)))
87        }
88    }
89
90    /// Returns this shape's built-in defaults as a merge layer.
91    pub fn defaults_layer(&self) -> ConfigLayer {
92        ConfigLayer::new(
93            ConfigSource::BuiltIn {
94                lib: self.lib.clone(),
95            },
96            ConfigDir {
97                entries: vec![self.defaults.clone()],
98            },
99        )
100    }
101
102    /// Returns true when `key` is marked as secret-bearing by this shape.
103    pub fn marks_secret(&self, key: &str) -> bool {
104        self.secret_keys.iter().any(|secret| secret == key)
105    }
106
107    /// Returns secret field records for report and redaction code.
108    pub fn secret_fields(&self) -> Vec<ConfigSecretField> {
109        dedupe_keys(self.secret_keys.iter().cloned())
110            .into_iter()
111            .map(|key| ConfigSecretField {
112                lib: self.lib.clone(),
113                key,
114            })
115            .collect()
116    }
117
118    fn bind_defaults(&self, table: &ConfigTable) -> ConfigTable {
119        let explicit = ConfigLayer::new(
120            ConfigSource::Explicit {
121                label: "shape-validated-input".to_owned(),
122            },
123            ConfigDir {
124                entries: vec![table.clone()],
125            },
126        );
127        let effective = merge_layers(&[self.defaults_layer(), explicit]);
128        effective
129            .dir
130            .table(&self.lib)
131            .cloned()
132            .unwrap_or_else(|| table.clone())
133    }
134
135    fn report_from_match(
136        &self,
137        cx: &mut Cx,
138        table: &ConfigTable,
139        matched: ShapeMatch,
140    ) -> ShapeReport {
141        let shape_ref = self.shape_ref();
142        let target_ref = self.target_ref(cx, table);
143        shape_report_from_match(cx, shape_ref.clone(), target_ref.clone(), matched.clone())
144            .unwrap_or_else(|error| {
145                fallback_report(
146                    shape_ref,
147                    target_ref,
148                    format!("config shape report failed: {error}"),
149                )
150            })
151    }
152
153    fn reject_report(
154        &self,
155        cx: &mut Cx,
156        table: &ConfigTable,
157        message: impl Into<String>,
158    ) -> ShapeReport {
159        self.report_from_match(cx, table, ShapeMatch::reject(message))
160    }
161
162    fn shape_ref(&self) -> Ref {
163        self.shape.symbol().map(Ref::Symbol).unwrap_or_else(|| {
164            Ref::Symbol(Symbol::qualified(
165                "config-shape",
166                self.lib.as_qualified_str(),
167            ))
168        })
169    }
170
171    fn target_ref(&self, cx: &mut Cx, table: &ConfigTable) -> Ref {
172        Datum::try_from(table.table.clone())
173            .and_then(|datum| cx.datum_store_mut().intern(datum).map(Ref::Content))
174            .unwrap_or_else(|_| {
175                Ref::Symbol(Symbol::qualified(
176                    "config-target",
177                    table.lib.as_qualified_str(),
178                ))
179            })
180    }
181}
182
183/// Secret-bearing config field metadata exported by a [`ConfigShape`].
184#[derive(Clone, Debug, PartialEq, Eq)]
185pub struct ConfigSecretField {
186    /// Library whose table contains the secret field.
187    pub lib: Symbol,
188    /// Top-level field key whose value must be redacted by report surfaces.
189    pub key: String,
190}
191
192/// Default configuration contract exposed by a loadable library.
193pub trait LibConfigDefaults {
194    /// Returns the shape-backed config contract for this library.
195    fn config_shape(&self) -> ConfigShape;
196
197    /// Returns the built-in defaults layer published by this library.
198    fn built_in_config_layer(&self) -> ConfigLayer {
199        self.config_shape().defaults_layer()
200    }
201}
202
203fn dedupe_keys(keys: impl IntoIterator<Item = impl Into<String>>) -> Vec<String> {
204    let mut deduped = Vec::new();
205    for key in keys {
206        let key = key.into();
207        if !deduped.contains(&key) {
208            deduped.push(key);
209        }
210    }
211    deduped
212}
213
214fn fallback_report(shape: Ref, target: Ref, message: impl Into<String>) -> ShapeReport {
215    ShapeReport {
216        id: Ref::Symbol(Symbol::qualified("config-shape", "fallback-report")),
217        shape,
218        target,
219        accepted: false,
220        score: MatchScore::reject(),
221        captures: Ref::Symbol(Symbol::qualified("config-shape", "empty-captures")),
222        diagnostics: vec![Diagnostic::error(message)],
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use std::sync::Arc;
229
230    use sim_kernel::{
231        Cx, Expr, ExprKind, Result, ShapeDoc, Value,
232        shape::{MatchScore, Shape},
233        testing::bare_cx as cx,
234    };
235    use sim_value::{access::field, build::map, build::text};
236
237    use super::*;
238    use crate::{config_field_name, same_config_field};
239
240    fn lib() -> Symbol {
241        Symbol::qualified("model", "defaults")
242    }
243
244    fn shape() -> Arc<dyn Shape> {
245        Arc::new(ClosedTableShape::new(vec![
246            FieldSpec::required("provider", ExprKind::String),
247            FieldSpec::required("enabled", ExprKind::Bool),
248            FieldSpec::optional("api_key", ExprKind::String),
249        ]))
250    }
251
252    fn config_shape() -> ConfigShape {
253        let lib = lib();
254        let defaults = ConfigTable::new(
255            lib.clone(),
256            map(vec![
257                ("provider", text("modeled")),
258                ("enabled", Expr::Bool(true)),
259            ]),
260        )
261        .unwrap();
262        ConfigShape::new(lib, shape(), defaults).with_secret_keys(["api_key", "api_key"])
263    }
264
265    #[test]
266    fn validate_binds_built_in_defaults() {
267        let mut cx = cx();
268        let table = ConfigTable::new(lib(), map(vec![("provider", text("openai"))])).unwrap();
269
270        let effective = config_shape().validate(&mut cx, &table).unwrap();
271
272        assert_eq!(
273            field(&effective.table, "provider"),
274            Some(&Expr::String("openai".to_owned()))
275        );
276        assert_eq!(field(&effective.table, "enabled"), Some(&Expr::Bool(true)));
277    }
278
279    #[test]
280    fn validate_binds_string_keyed_fields_over_symbol_defaults() {
281        let mut cx = cx();
282        let table = ConfigTable::new(
283            lib(),
284            Expr::Map(vec![(Expr::String("provider".to_owned()), text("openai"))]),
285        )
286        .unwrap();
287
288        let effective = config_shape().validate(&mut cx, &table).unwrap();
289        let Expr::Map(entries) = &effective.table else {
290            panic!("validated config table should stay a map");
291        };
292
293        assert_eq!(
294            field(&effective.table, "provider"),
295            Some(&Expr::String("openai".to_owned()))
296        );
297        assert_eq!(
298            entries
299                .iter()
300                .filter(|(key, _)| config_field_name(key) == Some("provider"))
301                .count(),
302            1
303        );
304    }
305
306    #[test]
307    fn validate_rejects_unknown_fields_with_shape_report() {
308        let mut cx = cx();
309        let table = ConfigTable::new(
310            lib(),
311            map(vec![("provider", text("openai")), ("typo", text("bad"))]),
312        )
313        .unwrap();
314
315        let report = config_shape().validate(&mut cx, &table).unwrap_err();
316
317        assert!(!report.accepted);
318        assert!(report.diagnostics[0].message.contains("extra key typo"));
319    }
320
321    #[test]
322    fn secret_fields_are_shape_metadata_and_deduped() {
323        let shape = config_shape().with_secret_keys(["api_key", "token", "api_key"]);
324
325        assert!(shape.marks_secret("api_key"));
326        assert_eq!(
327            shape.secret_fields(),
328            vec![
329                ConfigSecretField {
330                    lib: lib(),
331                    key: "api_key".to_owned()
332                },
333                ConfigSecretField {
334                    lib: lib(),
335                    key: "token".to_owned()
336                }
337            ]
338        );
339    }
340
341    #[test]
342    fn scalar_kind_errors_are_shape_reports() {
343        let mut cx = cx();
344        let table = ConfigTable::new(
345            lib(),
346            map(vec![("provider", text("openai")), ("enabled", text("yes"))]),
347        )
348        .unwrap();
349
350        let report = config_shape().validate(&mut cx, &table).unwrap_err();
351
352        assert!(!report.accepted);
353        assert!(
354            report.diagnostics[0]
355                .message
356                .contains("enabled expected bool")
357        );
358    }
359
360    #[test]
361    fn loadable_lib_defaults_publish_built_in_layer() {
362        struct DemoLib(ConfigShape);
363        impl LibConfigDefaults for DemoLib {
364            fn config_shape(&self) -> ConfigShape {
365                self.0.clone()
366            }
367        }
368
369        let layer = DemoLib(config_shape()).built_in_config_layer();
370
371        assert_eq!(layer.source, ConfigSource::BuiltIn { lib: lib() });
372        assert!(layer.dir.table(&lib()).is_some());
373    }
374
375    #[derive(Clone)]
376    struct FieldSpec {
377        name: &'static str,
378        kind: ExprKind,
379        required: bool,
380    }
381
382    impl FieldSpec {
383        fn required(name: &'static str, kind: ExprKind) -> Self {
384            Self {
385                name,
386                kind,
387                required: true,
388            }
389        }
390
391        fn optional(name: &'static str, kind: ExprKind) -> Self {
392            Self {
393                name,
394                kind,
395                required: false,
396            }
397        }
398    }
399
400    struct ClosedTableShape {
401        fields: Vec<FieldSpec>,
402    }
403
404    impl ClosedTableShape {
405        fn new(fields: Vec<FieldSpec>) -> Self {
406            Self { fields }
407        }
408
409        fn field(&self, name: &str) -> Option<&FieldSpec> {
410            self.fields.iter().find(|field| field.name == name)
411        }
412    }
413
414    impl Shape for ClosedTableShape {
415        fn symbol(&self) -> Option<Symbol> {
416            Some(Symbol::qualified("test", "closed-config-table"))
417        }
418
419        fn check_value(&self, cx: &mut Cx, value: Value) -> Result<ShapeMatch> {
420            let expr = value.object().as_expr(cx)?;
421            self.check_expr(cx, &expr)
422        }
423
424        fn check_expr(&self, _cx: &mut Cx, expr: &Expr) -> Result<ShapeMatch> {
425            let Expr::Map(entries) = expr else {
426                return Ok(ShapeMatch::reject("config table expected map"));
427            };
428
429            for (index, (key, value)) in entries.iter().enumerate() {
430                if entries[..index]
431                    .iter()
432                    .any(|(prior_key, _)| same_config_field(prior_key, key))
433                {
434                    return Ok(ShapeMatch::reject(format!(
435                        "config table duplicate key {}",
436                        config_field_name(key).unwrap_or("<opaque>")
437                    )));
438                }
439                let Some(key) = config_field_name(key) else {
440                    return Ok(ShapeMatch::reject(
441                        "config table key expected symbol or string",
442                    ));
443                };
444                let Some(field) = self.field(key) else {
445                    return Ok(ShapeMatch::reject(format!("shape-table: extra key {key}")));
446                };
447                if !field.kind.matches(value) {
448                    return Ok(ShapeMatch::reject(format!(
449                        "{key} expected {}",
450                        field.kind.name()
451                    )));
452                }
453            }
454
455            for field in self.fields.iter().filter(|field| field.required) {
456                if !entries
457                    .iter()
458                    .any(|(key, _)| config_field_name(key).is_some_and(|key| key == field.name))
459                {
460                    return Ok(ShapeMatch::reject(format!(
461                        "missing required config key {}",
462                        field.name
463                    )));
464                }
465            }
466
467            Ok(ShapeMatch::accept(MatchScore::exact(entries.len() as i32)))
468        }
469
470        fn describe(&self, _cx: &mut Cx) -> Result<ShapeDoc> {
471            Ok(ShapeDoc::new("closed config table"))
472        }
473    }
474}