Skip to main content

sim_run_core/
config.rs

1//! Runtime configuration discovery for the bootloader.
2
3use std::{
4    collections::BTreeSet,
5    fs,
6    path::{Path, PathBuf},
7};
8
9use sim_codec_config::ConfigDecoder;
10use sim_config::{
11    ConfigDir, ConfigLayer, ConfigProbe, ConfigProbeCaps, ConfigProbeReport, ConfigProbeRequest,
12    ConfigRoots, ConfigSecretField, ConfigSource, ConfigTable, EffectiveConfig, ProbeMode,
13    lib_config_path, lib_symbol_from_str, merge_layers,
14};
15use sim_kernel::{Args, Cx, Symbol};
16
17use crate::report::{ConfigSourceReport, SourceStatus};
18
19/// Source-selection options for runtime configuration discovery.
20#[derive(Clone, Debug, PartialEq, Eq)]
21pub struct ConfigLoadOptions {
22    /// Home and working config roots.
23    pub roots: ConfigRoots,
24    /// Whether filesystem config roots should be read.
25    pub read_files: bool,
26    /// Explicit shared config file to read after root files.
27    pub single_file: Option<PathBuf>,
28    /// Site exports that return config Dir expressions when called with
29    /// `config/dir`.
30    pub site_sources: Vec<Symbol>,
31}
32
33impl ConfigLoadOptions {
34    /// Builds options from explicit roots.
35    pub fn with_roots(roots: ConfigRoots) -> Self {
36        Self {
37            roots,
38            read_files: true,
39            single_file: None,
40            site_sources: Vec::new(),
41        }
42    }
43}
44
45impl Default for ConfigLoadOptions {
46    fn default() -> Self {
47        let work_root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
48        Self::with_roots(ConfigRoots::from_env(work_root))
49    }
50}
51
52/// Configuration layers discovered during a boot session.
53#[derive(Clone, Debug, Default)]
54pub struct RuntimeConfigState {
55    layers: Vec<ConfigLayer>,
56    effective: EffectiveConfig,
57    source_reports: Vec<ConfigSourceReport>,
58    secret_fields: Vec<ConfigSecretField>,
59    probe_reports: Vec<ConfigProbeReport>,
60    diagnostics: Vec<String>,
61}
62
63impl RuntimeConfigState {
64    /// Returns the discovered layers in merge order.
65    pub fn layers(&self) -> &[ConfigLayer] {
66        &self.layers
67    }
68
69    /// Returns the effective config after all discovered layers are merged.
70    pub fn effective(&self) -> &EffectiveConfig {
71        &self.effective
72    }
73
74    /// Returns source status records in discovery order.
75    pub fn source_reports(&self) -> &[ConfigSourceReport] {
76        &self.source_reports
77    }
78
79    /// Returns config fields that must be redacted in reports.
80    pub fn secret_fields(&self) -> &[ConfigSecretField] {
81        &self.secret_fields
82    }
83
84    /// Returns typed probe report records in discovery order.
85    pub fn probe_reports(&self) -> &[ConfigProbeReport] {
86        &self.probe_reports
87    }
88
89    /// Returns non-fatal discovery diagnostics.
90    pub fn diagnostics(&self) -> &[String] {
91        &self.diagnostics
92    }
93
94    /// Adds a layer and recomputes the effective config.
95    pub fn push_layer(&mut self, layer: ConfigLayer) {
96        self.push_source_report(layer.source.clone(), SourceStatus::Found);
97        self.layers.push(layer);
98        self.effective = merge_layers(&self.layers);
99    }
100
101    /// Adds or replaces shape-derived secret field metadata.
102    pub fn extend_secret_fields(&mut self, fields: impl IntoIterator<Item = ConfigSecretField>) {
103        for field in fields {
104            if !self.secret_fields.contains(&field) {
105                self.secret_fields.push(field);
106            }
107        }
108    }
109
110    /// Adds a typed probe report record.
111    pub fn push_probe_report(&mut self, report: ConfigProbeReport) {
112        self.probe_reports.push(report);
113    }
114
115    /// Adds a source status record.
116    pub fn push_source_report(&mut self, source: ConfigSource, status: SourceStatus) {
117        self.source_reports
118            .push(ConfigSourceReport { source, status });
119    }
120
121    pub(crate) fn push_diagnostic(&mut self, diagnostic: String) {
122        self.diagnostics.push(diagnostic);
123    }
124}
125
126/// Loads config layers from files and site exports.
127pub fn load_config_sources(
128    cx: &mut Cx,
129    opts: &ConfigLoadOptions,
130    libs: &[Symbol],
131) -> RuntimeConfigState {
132    load_config_sources_with_probes(cx, opts, libs, &[])
133}
134
135/// Loads config layers from probes, files, and site exports.
136pub fn load_config_sources_with_probes(
137    cx: &mut Cx,
138    opts: &ConfigLoadOptions,
139    libs: &[Symbol],
140    probes: &[&dyn ConfigProbe],
141) -> RuntimeConfigState {
142    let mut state = RuntimeConfigState::default();
143    let libs = unique_libs(libs);
144    run_config_probes(
145        &mut state,
146        &libs,
147        probes,
148        ProbeMode::default(),
149        ConfigProbeCaps::default(),
150    );
151    if opts.read_files {
152        read_root_files(&mut state, &opts.roots.home, RootKind::Home, &libs);
153        read_root_files(
154            &mut state,
155            &Some(opts.roots.work.clone()),
156            RootKind::Work,
157            &libs,
158        );
159        if let Some(path) = opts.single_file.as_ref() {
160            read_single_file(&mut state, path, true);
161        }
162    }
163    for site in &opts.site_sources {
164        read_site_dir(cx, &mut state, site);
165    }
166    state
167}
168
169fn run_config_probes(
170    state: &mut RuntimeConfigState,
171    libs: &[Symbol],
172    probes: &[&dyn ConfigProbe],
173    mode: ProbeMode,
174    caps: ConfigProbeCaps,
175) {
176    for lib in libs {
177        for probe in probes {
178            let request = ConfigProbeRequest {
179                lib: lib.clone(),
180                mode,
181                caps: caps.clone(),
182            };
183            run_config_probe(state, *probe, &request);
184        }
185    }
186}
187
188/// Executes one config probe, applying any emitted layer and recording its report.
189pub fn run_config_probe(
190    state: &mut RuntimeConfigState,
191    probe: &dyn ConfigProbe,
192    request: &ConfigProbeRequest,
193) {
194    let (layer, report) = probe.probe(request);
195    if let Some(layer) = layer {
196        state.push_layer(layer);
197    }
198    state.push_probe_report(report);
199}
200
201fn unique_libs(libs: &[Symbol]) -> Vec<Symbol> {
202    let mut seen = BTreeSet::new();
203    let mut unique = Vec::new();
204    for lib in libs {
205        if seen.insert(lib.clone()) {
206            unique.push(lib.clone());
207        }
208    }
209    unique
210}
211
212#[derive(Clone, Copy)]
213enum RootKind {
214    Home,
215    Work,
216}
217
218fn read_root_files(
219    state: &mut RuntimeConfigState,
220    root: &Option<PathBuf>,
221    kind: RootKind,
222    libs: &[Symbol],
223) {
224    let Some(root) = root.as_ref() else {
225        return;
226    };
227    for lib in libs {
228        read_per_lib_file(state, root, kind, lib);
229    }
230    read_single_file(state, &root.join("sim.toml"), false);
231}
232
233fn read_per_lib_file(state: &mut RuntimeConfigState, root: &Path, kind: RootKind, lib: &Symbol) {
234    let relative = match lib_config_path(lib) {
235        Ok(relative) => relative,
236        Err(err) => {
237            state.push_diagnostic(format!("skip config path for {lib}: {err}"));
238            return;
239        }
240    };
241    let path = root.join(relative);
242    let source = match kind {
243        RootKind::Home => ConfigSource::HomeFile { path: path.clone() },
244        RootKind::Work => ConfigSource::WorkFile { path: path.clone() },
245    };
246    if !path.exists() {
247        state.push_source_report(source, SourceStatus::Missing);
248        return;
249    }
250    let table = match decode_table_file(&path) {
251        Ok(table) => table,
252        Err(err) => {
253            state.push_source_report(source, SourceStatus::Rejected);
254            state.push_diagnostic(err);
255            return;
256        }
257    };
258    match ConfigDir::one(lib.clone(), table) {
259        Ok(dir) => state.push_layer(ConfigLayer::new(source, dir)),
260        Err(err) => {
261            state.push_source_report(source, SourceStatus::Rejected);
262            state.push_diagnostic(format!("read config {}: {err}", path.display()));
263        }
264    }
265}
266
267fn read_single_file(state: &mut RuntimeConfigState, path: &Path, explicit: bool) {
268    let source = ConfigSource::SingleFile {
269        path: path.to_path_buf(),
270    };
271    if !path.exists() {
272        state.push_source_report(source, SourceStatus::Missing);
273        if explicit {
274            state.push_diagnostic(format!("config file not found: {}", path.display()));
275        }
276        return;
277    }
278    let dir = match decode_dir_file(path) {
279        Ok(dir) => dir,
280        Err(err) => {
281            state.push_source_report(source, SourceStatus::Rejected);
282            state.push_diagnostic(err);
283            return;
284        }
285    };
286    state.push_layer(ConfigLayer::new(source, dir));
287}
288
289fn read_site_dir(cx: &mut Cx, state: &mut RuntimeConfigState, site: &Symbol) {
290    let source = ConfigSource::Site { site: site.clone() };
291    let Some(value) = cx.registry().site_by_symbol(site).cloned() else {
292        state.push_source_report(source, SourceStatus::Missing);
293        state.push_diagnostic(format!("config site not found: {site}"));
294        return;
295    };
296    let Some(callable) = value.object().as_callable() else {
297        state.push_source_report(source, SourceStatus::Rejected);
298        state.push_diagnostic(format!("config site {site} is not callable"));
299        return;
300    };
301    let request = match cx.factory().symbol(config_site_dir_request_symbol()) {
302        Ok(request) => request,
303        Err(err) => {
304            state.push_source_report(source, SourceStatus::Rejected);
305            state.push_diagnostic(format!("read config site {site}: {err}"));
306            return;
307        }
308    };
309    let dir_value = match callable.call(cx, Args::new(vec![request])) {
310        Ok(value) => value,
311        Err(err) => {
312            state.push_source_report(source, SourceStatus::Rejected);
313            state.push_diagnostic(format!("read config site {site}: {err}"));
314            return;
315        }
316    };
317    let expr = match dir_value.object().as_expr(cx) {
318        Ok(expr) => expr,
319        Err(err) => {
320            state.push_source_report(source, SourceStatus::Rejected);
321            state.push_diagnostic(format!("read config site {site}: {err}"));
322            return;
323        }
324    };
325    match ConfigDir::from_dir_expr(&expr).and_then(normalize_dir) {
326        Ok(dir) => state.push_layer(ConfigLayer::new(source, dir)),
327        Err(err) => {
328            state.push_source_report(source, SourceStatus::Rejected);
329            state.push_diagnostic(format!("read config site {site}: {err}"));
330        }
331    }
332}
333
334fn config_site_dir_request_symbol() -> Symbol {
335    Symbol::qualified("config", "dir")
336}
337
338fn decode_table_file(path: &Path) -> Result<sim_kernel::Expr, String> {
339    let source = read_ascii(path)?;
340    ConfigDecoder::table()
341        .decode_text(&source)
342        .map_err(|err| format!("decode config table {}: {err}", path.display()))
343}
344
345fn decode_dir_file(path: &Path) -> Result<ConfigDir, String> {
346    let source = read_ascii(path)?;
347    let expr = ConfigDecoder::dir()
348        .decode_text(&source)
349        .map_err(|err| format!("decode config dir {}: {err}", path.display()))?;
350    ConfigDir::from_dir_expr(&expr)
351        .and_then(normalize_dir)
352        .map_err(|err| format!("decode config dir {}: {err}", path.display()))
353}
354
355fn normalize_dir(dir: ConfigDir) -> sim_config::ConfigResult<ConfigDir> {
356    let mut normalized = ConfigDir::new();
357    for table in dir.entries {
358        let lib = lib_symbol_from_str(&table.lib.as_qualified_str())?;
359        normalized.upsert(ConfigTable::new(lib, table.table)?);
360    }
361    Ok(normalized)
362}
363
364fn read_ascii(path: &Path) -> Result<String, String> {
365    fs::read_to_string(path).map_err(|err| format!("read config {}: {err}", path.display()))
366}