1use 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
13pub type ConfigShapeResult<T> = Result<T, Box<ShapeReport>>;
15
16#[derive(Clone)]
18pub struct ConfigShape {
19 pub lib: Symbol,
21 pub shape: Arc<dyn Shape>,
23 pub defaults: ConfigTable,
25 pub secret_keys: Vec<String>,
27}
28
29impl ConfigShape {
30 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 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 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 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 pub fn marks_secret(&self, key: &str) -> bool {
104 self.secret_keys.iter().any(|secret| secret == key)
105 }
106
107 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#[derive(Clone, Debug, PartialEq, Eq)]
185pub struct ConfigSecretField {
186 pub lib: Symbol,
188 pub key: String,
190}
191
192pub trait LibConfigDefaults {
194 fn config_shape(&self) -> ConfigShape;
196
197 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}