1use sim_kernel::{Expr, Symbol};
4
5use crate::{ConfigError, ConfigResult, config_field_name, same_config_field};
6
7#[derive(Clone, Debug, PartialEq, Eq)]
9pub struct ConfigTable {
10 pub lib: Symbol,
12 pub table: Expr,
14}
15
16impl ConfigTable {
17 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 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#[derive(Clone, Debug, Default, PartialEq, Eq)]
40pub struct ConfigDir {
41 pub entries: Vec<ConfigTable>,
43}
44
45impl ConfigDir {
46 pub fn new() -> Self {
48 Self::default()
49 }
50
51 pub fn one(lib: Symbol, table: Expr) -> ConfigResult<Self> {
53 Ok(Self {
54 entries: vec![ConfigTable::new(lib, table)?],
55 })
56 }
57
58 pub fn table(&self, lib: &Symbol) -> Option<&ConfigTable> {
60 self.entries.iter().find(|entry| &entry.lib == lib)
61 }
62
63 pub fn table_mut(&mut self, lib: &Symbol) -> Option<&mut ConfigTable> {
65 self.entries.iter_mut().find(|entry| &entry.lib == lib)
66 }
67
68 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 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 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
113pub 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}