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, DefaultFactory, Expr, ExprKind, NoopEvalPolicy, Result, ShapeDoc, Value,
232        shape::{MatchScore, Shape},
233    };
234    use sim_value::{access::field, build::map, build::text};
235
236    use super::*;
237
238    fn cx() -> Cx {
239        Cx::new(Arc::new(NoopEvalPolicy), Arc::new(DefaultFactory))
240    }
241
242    fn lib() -> Symbol {
243        Symbol::qualified("model", "defaults")
244    }
245
246    fn shape() -> Arc<dyn Shape> {
247        Arc::new(ClosedTableShape::new(vec![
248            FieldSpec::required("provider", ExprKind::String),
249            FieldSpec::required("enabled", ExprKind::Bool),
250            FieldSpec::optional("api_key", ExprKind::String),
251        ]))
252    }
253
254    fn config_shape() -> ConfigShape {
255        let lib = lib();
256        let defaults = ConfigTable::new(
257            lib.clone(),
258            map(vec![
259                ("provider", text("modeled")),
260                ("enabled", Expr::Bool(true)),
261            ]),
262        )
263        .unwrap();
264        ConfigShape::new(lib, shape(), defaults).with_secret_keys(["api_key", "api_key"])
265    }
266
267    #[test]
268    fn validate_binds_built_in_defaults() {
269        let mut cx = cx();
270        let table = ConfigTable::new(lib(), map(vec![("provider", text("openai"))])).unwrap();
271
272        let effective = config_shape().validate(&mut cx, &table).unwrap();
273
274        assert_eq!(
275            field(&effective.table, "provider"),
276            Some(&Expr::String("openai".to_owned()))
277        );
278        assert_eq!(field(&effective.table, "enabled"), Some(&Expr::Bool(true)));
279    }
280
281    #[test]
282    fn validate_rejects_unknown_fields_with_shape_report() {
283        let mut cx = cx();
284        let table = ConfigTable::new(
285            lib(),
286            map(vec![("provider", text("openai")), ("typo", text("bad"))]),
287        )
288        .unwrap();
289
290        let report = config_shape().validate(&mut cx, &table).unwrap_err();
291
292        assert!(!report.accepted);
293        assert!(report.diagnostics[0].message.contains("extra key typo"));
294    }
295
296    #[test]
297    fn secret_fields_are_shape_metadata_and_deduped() {
298        let shape = config_shape().with_secret_keys(["api_key", "token", "api_key"]);
299
300        assert!(shape.marks_secret("api_key"));
301        assert_eq!(
302            shape.secret_fields(),
303            vec![
304                ConfigSecretField {
305                    lib: lib(),
306                    key: "api_key".to_owned()
307                },
308                ConfigSecretField {
309                    lib: lib(),
310                    key: "token".to_owned()
311                }
312            ]
313        );
314    }
315
316    #[test]
317    fn scalar_kind_errors_are_shape_reports() {
318        let mut cx = cx();
319        let table = ConfigTable::new(
320            lib(),
321            map(vec![("provider", text("openai")), ("enabled", text("yes"))]),
322        )
323        .unwrap();
324
325        let report = config_shape().validate(&mut cx, &table).unwrap_err();
326
327        assert!(!report.accepted);
328        assert!(
329            report.diagnostics[0]
330                .message
331                .contains("enabled expected bool")
332        );
333    }
334
335    #[test]
336    fn loadable_lib_defaults_publish_built_in_layer() {
337        struct DemoLib(ConfigShape);
338        impl LibConfigDefaults for DemoLib {
339            fn config_shape(&self) -> ConfigShape {
340                self.0.clone()
341            }
342        }
343
344        let layer = DemoLib(config_shape()).built_in_config_layer();
345
346        assert_eq!(layer.source, ConfigSource::BuiltIn { lib: lib() });
347        assert!(layer.dir.table(&lib()).is_some());
348    }
349
350    #[derive(Clone)]
351    struct FieldSpec {
352        name: &'static str,
353        kind: ExprKind,
354        required: bool,
355    }
356
357    impl FieldSpec {
358        fn required(name: &'static str, kind: ExprKind) -> Self {
359            Self {
360                name,
361                kind,
362                required: true,
363            }
364        }
365
366        fn optional(name: &'static str, kind: ExprKind) -> Self {
367            Self {
368                name,
369                kind,
370                required: false,
371            }
372        }
373    }
374
375    struct ClosedTableShape {
376        fields: Vec<FieldSpec>,
377    }
378
379    impl ClosedTableShape {
380        fn new(fields: Vec<FieldSpec>) -> Self {
381            Self { fields }
382        }
383
384        fn field(&self, name: &str) -> Option<&FieldSpec> {
385            self.fields.iter().find(|field| field.name == name)
386        }
387    }
388
389    impl Shape for ClosedTableShape {
390        fn symbol(&self) -> Option<Symbol> {
391            Some(Symbol::qualified("test", "closed-config-table"))
392        }
393
394        fn check_value(&self, cx: &mut Cx, value: Value) -> Result<ShapeMatch> {
395            let expr = value.object().as_expr(cx)?;
396            self.check_expr(cx, &expr)
397        }
398
399        fn check_expr(&self, _cx: &mut Cx, expr: &Expr) -> Result<ShapeMatch> {
400            let Expr::Map(entries) = expr else {
401                return Ok(ShapeMatch::reject("config table expected map"));
402            };
403
404            for (key, value) in entries {
405                let Some(key) = key_name(key) else {
406                    return Ok(ShapeMatch::reject("config table key expected symbol"));
407                };
408                let Some(field) = self.field(key) else {
409                    return Ok(ShapeMatch::reject(format!("shape-table: extra key {key}")));
410                };
411                if !field.kind.matches(value) {
412                    return Ok(ShapeMatch::reject(format!(
413                        "{key} expected {}",
414                        field.kind.name()
415                    )));
416                }
417            }
418
419            for field in self.fields.iter().filter(|field| field.required) {
420                if !entries
421                    .iter()
422                    .any(|(key, _)| key_name(key).is_some_and(|key| key == field.name))
423                {
424                    return Ok(ShapeMatch::reject(format!(
425                        "missing required config key {}",
426                        field.name
427                    )));
428                }
429            }
430
431            Ok(ShapeMatch::accept(MatchScore::exact(entries.len() as i32)))
432        }
433
434        fn describe(&self, _cx: &mut Cx) -> Result<ShapeDoc> {
435            Ok(ShapeDoc::new("closed config table"))
436        }
437    }
438
439    fn key_name(key: &Expr) -> Option<&str> {
440        match key {
441            Expr::Symbol(symbol) if symbol.namespace.is_none() => Some(symbol.name.as_ref()),
442            Expr::String(text) => Some(text),
443            _ => None,
444        }
445    }
446}