Skip to main content

sim_config/
view.rs

1//! Typed read view over a config table.
2
3use sim_kernel::Expr;
4use sim_value::access::{as_i64, as_str};
5
6use crate::{ConfigError, ConfigResult, ConfigTable, config_field_name};
7
8/// Borrowed typed accessors over one config table.
9#[derive(Clone, Copy, Debug)]
10pub struct ConfigView<'a> {
11    entries: &'a [(Expr, Expr)],
12}
13
14impl<'a> ConfigView<'a> {
15    /// Creates a view over `table`.
16    pub fn new(table: &'a ConfigTable) -> Self {
17        let Expr::Map(entries) = &table.table else {
18            unreachable!("ConfigTable guarantees an Expr::Map table")
19        };
20        Self { entries }
21    }
22
23    /// Creates a view over already-borrowed table entries.
24    pub fn from_entries(entries: &'a [(Expr, Expr)]) -> Self {
25        Self { entries }
26    }
27
28    /// Returns the raw expression for `key`.
29    pub fn get(&self, key: &str) -> Option<&'a Expr> {
30        self.entries.iter().find_map(|(field_key, value)| {
31            config_field_name(field_key)
32                .is_some_and(|field| field == key)
33                .then_some(value)
34        })
35    }
36
37    /// Reads an optional string field.
38    pub fn string(&self, key: &str) -> Option<&'a str> {
39        self.get(key).and_then(as_str)
40    }
41
42    /// Reads a required string field.
43    pub fn required_string(&self, key: &str) -> ConfigResult<&'a str> {
44        self.string(key)
45            .ok_or_else(|| self.required_error(key, "a string"))
46    }
47
48    /// Reads an optional boolean field.
49    pub fn bool(&self, key: &str) -> Option<bool> {
50        match self.get(key) {
51            Some(Expr::Bool(value)) => Some(*value),
52            _ => None,
53        }
54    }
55
56    /// Reads a required boolean field.
57    pub fn required_bool(&self, key: &str) -> ConfigResult<bool> {
58        self.bool(key)
59            .ok_or_else(|| self.required_error(key, "a bool"))
60    }
61
62    /// Reads an optional integer field.
63    pub fn i64(&self, key: &str) -> Option<i64> {
64        self.get(key).and_then(as_i64)
65    }
66
67    /// Reads a required integer field.
68    pub fn required_i64(&self, key: &str) -> ConfigResult<i64> {
69        self.i64(key)
70            .ok_or_else(|| self.required_error(key, "an integer"))
71    }
72
73    /// Borrows a list field.
74    pub fn list(&self, key: &str) -> Option<&'a [Expr]> {
75        match self.get(key) {
76            Some(Expr::List(items)) => Some(items),
77            _ => None,
78        }
79    }
80
81    /// Borrows a nested table field.
82    pub fn table(&self, key: &str) -> Option<&'a [(Expr, Expr)]> {
83        match self.get(key) {
84            Some(Expr::Map(entries)) => Some(entries),
85            _ => None,
86        }
87    }
88
89    /// Reads an optional list of string values.
90    ///
91    /// Missing fields read as an empty vector so callers can treat absent
92    /// arrays as the natural zero value. A present non-list field, or any
93    /// non-string list item, is reported as a type mismatch.
94    pub fn string_array(&self, key: &str) -> ConfigResult<Vec<String>> {
95        let Some(value) = self.get(key) else {
96            return Ok(Vec::new());
97        };
98        let Expr::List(items) = value else {
99            return Err(ConfigError::TypeMismatch {
100                key: key.to_owned(),
101                expected: "a string list",
102            });
103        };
104        items
105            .iter()
106            .enumerate()
107            .map(|(index, item)| match item {
108                Expr::String(value) => Ok(value.clone()),
109                _ => Err(ConfigError::TypeMismatch {
110                    key: format!("{key}[{index}]"),
111                    expected: "a string",
112                }),
113            })
114            .collect()
115    }
116
117    /// Reads an optional repeated table field as borrowed table views.
118    ///
119    /// Config codecs represent `[[name]]` repeated tables as a list of map
120    /// expressions. Missing fields read as an empty vector. A present field must
121    /// be a list whose items are maps.
122    pub fn tables(&self, key: &str) -> ConfigResult<Vec<ConfigView<'a>>> {
123        let Some(value) = self.get(key) else {
124            return Ok(Vec::new());
125        };
126        let Expr::List(items) = value else {
127            return Err(ConfigError::TypeMismatch {
128                key: key.to_owned(),
129                expected: "a table list",
130            });
131        };
132        items
133            .iter()
134            .enumerate()
135            .map(|(index, item)| match item {
136                Expr::Map(entries) => Ok(ConfigView::from_entries(entries)),
137                _ => Err(ConfigError::TypeMismatch {
138                    key: format!("{key}[{index}]"),
139                    expected: "a table",
140                }),
141            })
142            .collect()
143    }
144
145    fn required_error(&self, key: &str, expected: &'static str) -> ConfigError {
146        if self.get(key).is_some() {
147            ConfigError::TypeMismatch {
148                key: key.to_owned(),
149                expected,
150            }
151        } else {
152            ConfigError::MissingField {
153                key: key.to_owned(),
154            }
155        }
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use sim_kernel::Symbol;
162    use sim_value::build::{int, list, map, text};
163
164    use super::*;
165
166    #[test]
167    fn view_reads_typed_fields() {
168        let table = ConfigTable::new(
169            Symbol::qualified("model", "defaults"),
170            map(vec![
171                ("provider", text("modeled")),
172                ("enabled", Expr::Bool(true)),
173                ("limit", int(3)),
174            ]),
175        )
176        .unwrap();
177        let view = ConfigView::new(&table);
178
179        assert_eq!(view.required_string("provider").unwrap(), "modeled");
180        assert!(view.required_bool("enabled").unwrap());
181        assert_eq!(view.required_i64("limit").unwrap(), 3);
182        assert_eq!(
183            view.required_string("limit").unwrap_err(),
184            ConfigError::TypeMismatch {
185                key: "limit".to_owned(),
186                expected: "a string"
187            }
188        );
189    }
190
191    #[test]
192    fn view_reads_string_keyed_config_fields() {
193        let table = ConfigTable::new(
194            Symbol::qualified("model", "defaults"),
195            Expr::Map(vec![
196                (Expr::String("provider".to_owned()), text("modeled")),
197                (Expr::String("limit".to_owned()), int(3)),
198            ]),
199        )
200        .unwrap();
201        let view = ConfigView::new(&table);
202
203        assert_eq!(view.required_string("provider").unwrap(), "modeled");
204        assert_eq!(view.required_i64("limit").unwrap(), 3);
205    }
206
207    #[test]
208    fn view_reads_string_arrays_and_repeated_tables() {
209        let table = ConfigTable::new(
210            Symbol::qualified("sim", "cookbook"),
211            map(vec![
212                (
213                    "minimum_loaded",
214                    list(vec![text("codec/lisp"), text("numbers/i64")]),
215                ),
216                (
217                    "loadable_lib",
218                    list(vec![
219                        map(vec![
220                            ("id", text("numbers/i64")),
221                            ("source", text("symbol:numbers/i64")),
222                        ]),
223                        map(vec![
224                            ("id", text("numbers/cas")),
225                            ("source", text("symbol:numbers/cas")),
226                        ]),
227                    ]),
228                ),
229            ]),
230        )
231        .unwrap();
232        let view = ConfigView::new(&table);
233
234        assert_eq!(
235            view.string_array("minimum_loaded").unwrap(),
236            ["codec/lisp", "numbers/i64"]
237        );
238        let loadable = view.tables("loadable_lib").unwrap();
239        assert_eq!(loadable.len(), 2);
240        assert_eq!(loadable[0].string("id"), Some("numbers/i64"));
241        assert_eq!(loadable[1].string("source"), Some("symbol:numbers/cas"));
242        assert!(view.string_array("missing").unwrap().is_empty());
243        assert!(view.tables("missing").unwrap().is_empty());
244    }
245
246    #[test]
247    fn view_reports_array_shape_errors() {
248        let table = ConfigTable::new(
249            Symbol::qualified("sim", "cookbook"),
250            map(vec![
251                ("minimum_loaded", text("codec/lisp")),
252                ("loadable_lib", list(vec![text("bad")])),
253            ]),
254        )
255        .unwrap();
256        let view = ConfigView::new(&table);
257
258        assert_eq!(
259            view.string_array("minimum_loaded").unwrap_err(),
260            ConfigError::TypeMismatch {
261                key: "minimum_loaded".to_owned(),
262                expected: "a string list"
263            }
264        );
265        assert_eq!(
266            view.tables("loadable_lib").unwrap_err(),
267            ConfigError::TypeMismatch {
268                key: "loadable_lib[0]".to_owned(),
269                expected: "a table"
270            }
271        );
272    }
273}