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