Skip to main content

sim_config/
table.rs

1//! Config Table and Dir data types.
2
3use sim_kernel::{Expr, Symbol};
4
5use crate::{ConfigError, ConfigResult, config_field_name, same_config_field};
6
7/// One library's configuration table.
8#[derive(Clone, Debug, PartialEq, Eq)]
9pub struct ConfigTable {
10    /// Library id whose configuration this table carries.
11    pub lib: Symbol,
12    /// Configuration data; always an `Expr::Map` when built by this crate.
13    pub table: Expr,
14}
15
16impl ConfigTable {
17    /// Creates a table, rejecting non-map expressions.
18    pub fn new(lib: Symbol, table: Expr) -> ConfigResult<Self> {
19        if !matches!(table, Expr::Map(_)) {
20            return Err(ConfigError::NonTableExpr);
21        }
22        reject_duplicate_fields(&table)?;
23        Ok(Self {
24            lib: canonical_lib_symbol(&lib)?,
25            table,
26        })
27    }
28
29    /// Borrows this table's map entries.
30    pub fn entries(&self) -> ConfigResult<&[(Expr, Expr)]> {
31        match &self.table {
32            Expr::Map(entries) => Ok(entries),
33            _ => Err(ConfigError::NonTableExpr),
34        }
35    }
36}
37
38/// A configuration Dir: library id to table.
39#[derive(Clone, Debug, Default, PartialEq, Eq)]
40pub struct ConfigDir {
41    /// Tables carried by this Dir.
42    pub entries: Vec<ConfigTable>,
43}
44
45impl ConfigDir {
46    /// Creates an empty Dir.
47    pub fn new() -> Self {
48        Self::default()
49    }
50
51    /// Creates a Dir with one library table.
52    pub fn one(lib: Symbol, table: Expr) -> ConfigResult<Self> {
53        Ok(Self {
54            entries: vec![ConfigTable::new(lib, table)?],
55        })
56    }
57
58    /// Finds a table by library id.
59    pub fn table(&self, lib: &Symbol) -> Option<&ConfigTable> {
60        self.entries.iter().find(|entry| &entry.lib == lib)
61    }
62
63    /// Finds a mutable table by library id.
64    pub fn table_mut(&mut self, lib: &Symbol) -> Option<&mut ConfigTable> {
65        self.entries.iter_mut().find(|entry| &entry.lib == lib)
66    }
67
68    /// Inserts or replaces a table for its library id.
69    pub fn upsert(&mut self, table: ConfigTable) {
70        if let Some(existing) = self.table_mut(&table.lib) {
71            *existing = table;
72        } else {
73            self.entries.push(table);
74        }
75    }
76
77    /// Builds a Dir from a map of library id keys to table values.
78    pub fn from_dir_expr(expr: &Expr) -> ConfigResult<Self> {
79        let Expr::Map(pairs) = expr else {
80            return Err(ConfigError::NonDirExpr);
81        };
82        let mut entries = Vec::new();
83        for (key, value) in pairs {
84            let lib = symbol_key(key)?;
85            if !matches!(value, Expr::Map(_)) {
86                return Err(ConfigError::NonTableConfig { lib });
87            }
88            let table = ConfigTable::new(lib, value.clone())?;
89            if entries
90                .iter()
91                .any(|prior: &ConfigTable| prior.lib == table.lib)
92            {
93                return Err(ConfigError::DuplicateLibId {
94                    lib: table.lib.clone(),
95                });
96            }
97            entries.push(table);
98        }
99        Ok(Self { entries })
100    }
101
102    /// Converts this Dir back to a kernel map expression.
103    pub fn to_expr(&self) -> Expr {
104        Expr::Map(
105            self.entries
106                .iter()
107                .map(|entry| (Expr::Symbol(entry.lib.clone()), entry.table.clone()))
108                .collect(),
109        )
110    }
111}
112
113/// Parses a library id string into a [`Symbol`].
114pub fn lib_symbol_from_str(id: &str) -> ConfigResult<Symbol> {
115    if id.is_empty() {
116        return Err(ConfigError::InvalidLibId { id: id.to_owned() });
117    }
118    match id.split_once('/') {
119        Some((namespace, name)) if !namespace.is_empty() && !name.is_empty() => {
120            if name.contains('/') {
121                return Err(ConfigError::InvalidLibId { id: id.to_owned() });
122            }
123            Ok(Symbol::qualified(
124                Symbol::checked(namespace)?.name,
125                Symbol::checked(name)?.name,
126            ))
127        }
128        Some(_) => Err(ConfigError::InvalidLibId { id: id.to_owned() }),
129        None => Ok(Symbol::checked(id)?),
130    }
131}
132
133fn canonical_lib_symbol(symbol: &Symbol) -> ConfigResult<Symbol> {
134    lib_symbol_from_str(&symbol.as_qualified_str())
135}
136
137fn symbol_key(key: &Expr) -> ConfigResult<Symbol> {
138    match key {
139        Expr::Symbol(symbol) => canonical_lib_symbol(symbol),
140        Expr::String(text) => lib_symbol_from_str(text),
141        other => Err(ConfigError::UnsupportedDirKey { key: other.clone() }),
142    }
143}
144
145fn reject_duplicate_fields(expr: &Expr) -> ConfigResult<()> {
146    match expr {
147        Expr::Map(entries) => {
148            for (index, (key, value)) in entries.iter().enumerate() {
149                if entries[..index]
150                    .iter()
151                    .any(|(prior_key, _)| same_config_field(prior_key, key))
152                {
153                    return Err(ConfigError::DuplicateField {
154                        key: config_field_label(key),
155                    });
156                }
157                reject_duplicate_fields(value)?;
158            }
159        }
160        Expr::List(items) => {
161            for item in items {
162                reject_duplicate_fields(item)?;
163            }
164        }
165        _ => {}
166    }
167    Ok(())
168}
169
170fn config_field_label(key: &Expr) -> String {
171    config_field_name(key)
172        .map(ToOwned::to_owned)
173        .unwrap_or_else(|| format!("{key:?}"))
174}
175
176#[cfg(test)]
177mod tests {
178    use sim_value::build::{map, text};
179
180    use super::*;
181
182    #[test]
183    fn dir_expr_accepts_symbol_and_string_keys() {
184        let expr = Expr::Map(vec![
185            (
186                Expr::Symbol(Symbol::qualified("sim", "cookbook")),
187                map(vec![("minimum_loaded", Expr::List(vec![]))]),
188            ),
189            (
190                Expr::String("stream/host".to_owned()),
191                map(vec![("audio_backend_regex", text("modeled"))]),
192            ),
193        ]);
194
195        let dir = ConfigDir::from_dir_expr(&expr).unwrap();
196
197        assert!(dir.table(&Symbol::qualified("sim", "cookbook")).is_some());
198        assert!(dir.table(&Symbol::qualified("stream", "host")).is_some());
199    }
200
201    #[test]
202    fn dir_expr_canonicalizes_single_symbol_slash_spelling() {
203        let expr = Expr::Map(vec![(
204            Expr::Symbol(Symbol::new("sim/cookbook")),
205            map(vec![("minimum_loaded", Expr::List(vec![]))]),
206        )]);
207
208        let dir = ConfigDir::from_dir_expr(&expr).unwrap();
209
210        assert_eq!(dir.entries[0].lib, Symbol::qualified("sim", "cookbook"));
211    }
212
213    #[test]
214    fn config_dir_one_emits_canonical_symbol_key() {
215        let dir = ConfigDir::one(
216            Symbol::new("sim/cookbook"),
217            map(vec![("minimum_loaded", Expr::List(vec![]))]),
218        )
219        .unwrap();
220
221        assert_eq!(
222            dir.to_expr(),
223            Expr::Map(vec![(
224                Expr::Symbol(Symbol::qualified("sim", "cookbook")),
225                map(vec![("minimum_loaded", Expr::List(vec![]))]),
226            )])
227        );
228    }
229
230    #[test]
231    fn dir_expr_rejects_non_table_entries() {
232        let expr = Expr::Map(vec![(Expr::String("sim/cookbook".to_owned()), Expr::Nil)]);
233
234        let error = ConfigDir::from_dir_expr(&expr).unwrap_err();
235
236        assert_eq!(
237            error,
238            ConfigError::NonTableConfig {
239                lib: Symbol::qualified("sim", "cookbook")
240            }
241        );
242    }
243
244    #[test]
245    fn dir_expr_rejects_empty_library_ids() {
246        let expr = Expr::Map(vec![(Expr::String(String::new()), map(vec![]))]);
247
248        let error = ConfigDir::from_dir_expr(&expr).unwrap_err();
249
250        assert_eq!(error, ConfigError::InvalidLibId { id: String::new() });
251    }
252
253    #[test]
254    fn dir_expr_rejects_repeated_slashes() {
255        let expr = Expr::Map(vec![(
256            Expr::String("sim//cookbook".to_owned()),
257            map(vec![("minimum_loaded", Expr::List(vec![]))]),
258        )]);
259
260        let error = ConfigDir::from_dir_expr(&expr).unwrap_err();
261
262        assert_eq!(
263            error,
264            ConfigError::InvalidLibId {
265                id: "sim//cookbook".to_owned(),
266            }
267        );
268    }
269
270    #[test]
271    fn dir_expr_rejects_duplicate_equivalent_library_ids() {
272        let expr = Expr::Map(vec![
273            (
274                Expr::String("sim/cookbook".to_owned()),
275                map(vec![("minimum_loaded", Expr::List(vec![]))]),
276            ),
277            (
278                Expr::Symbol(Symbol::new("sim/cookbook")),
279                map(vec![("minimum_loaded", Expr::List(vec![]))]),
280            ),
281        ]);
282
283        let error = ConfigDir::from_dir_expr(&expr).unwrap_err();
284
285        assert_eq!(
286            error,
287            ConfigError::DuplicateLibId {
288                lib: Symbol::qualified("sim", "cookbook"),
289            }
290        );
291    }
292
293    #[test]
294    fn table_rejects_duplicate_equivalent_field_keys() {
295        let table = Expr::Map(vec![
296            (Expr::Symbol(Symbol::new("mode")), text("built-in")),
297            (Expr::String("mode".to_owned()), text("work")),
298        ]);
299
300        let error = ConfigTable::new(Symbol::qualified("sim", "cookbook"), table).unwrap_err();
301
302        assert_eq!(
303            error,
304            ConfigError::DuplicateField {
305                key: "mode".to_owned()
306            }
307        );
308    }
309
310    #[test]
311    fn table_rejects_duplicate_equivalent_nested_field_keys() {
312        let table = Expr::Map(vec![(
313            Expr::Symbol(Symbol::new("settings")),
314            Expr::Map(vec![
315                (Expr::Symbol(Symbol::new("mode")), text("built-in")),
316                (Expr::String("mode".to_owned()), text("work")),
317            ]),
318        )]);
319
320        let error = ConfigTable::new(Symbol::qualified("sim", "cookbook"), table).unwrap_err();
321
322        assert_eq!(
323            error,
324            ConfigError::DuplicateField {
325                key: "mode".to_owned()
326            }
327        );
328    }
329}