Skip to main content

sim_run_core/
report.rs

1//! Loaded-state and effective-config reports.
2
3use sim_codec_config::ConfigEncoder;
4use sim_config::{
5    ConfigProbeReport, ConfigProbeStatus, ConfigSecretField, ConfigSource, EffectiveConfig,
6    ProbeMode,
7};
8use sim_kernel::{Expr, Symbol};
9
10use crate::{CliBoot, CliError, LoadReceipt, LoadReceiptRole, LoadSession};
11
12/// One loaded library as it appears in a boot session report.
13#[derive(Clone, Debug, PartialEq, Eq)]
14pub struct LoadedLibReport {
15    /// Role assigned by the bootloader.
16    pub role: String,
17    /// Library identifier from the manifest.
18    pub lib: Symbol,
19    /// Library version from the manifest.
20    pub version: String,
21    /// Source requested by the operator or bootloader.
22    pub requested: String,
23    /// Concrete source used by the loader after catalog or registry resolution.
24    pub resolved: String,
25    /// Number of export records published by the loaded library.
26    pub exports: usize,
27}
28
29impl LoadedLibReport {
30    /// Builds a loaded-library report row from a load receipt.
31    pub fn from_receipt(receipt: &LoadReceipt) -> Self {
32        Self {
33            role: role_label(&receipt.role).to_owned(),
34            lib: receipt.manifest.id.clone(),
35            version: receipt.manifest.version.0.clone(),
36            requested: receipt.requested_source.to_string(),
37            resolved: receipt.resolved_source.to_string(),
38            exports: receipt.exports.len(),
39        }
40    }
41}
42
43/// Discovery status for one config source.
44#[derive(Clone, Copy, Debug, PartialEq, Eq)]
45pub enum SourceStatus {
46    /// The source existed and produced a config layer.
47    Found,
48    /// The source was checked but was absent.
49    Missing,
50    /// The source was known but skipped by source-selection policy.
51    Ignored,
52    /// The source existed but could not decode or normalize as config.
53    Rejected,
54}
55
56/// One config source and its discovery status.
57#[derive(Clone, Debug, PartialEq, Eq)]
58pub struct ConfigSourceReport {
59    /// Source descriptor that was checked.
60    pub source: ConfigSource,
61    /// Discovery status for the source.
62    pub status: SourceStatus,
63}
64
65/// Request selected by the `sim config ...` CLI surface.
66#[derive(Clone, Debug, PartialEq, Eq)]
67pub struct ConfigReportRequest {
68    /// Report kind to render.
69    pub kind: ConfigReportKind,
70    /// Whether the report should render as stable JSON.
71    pub json: bool,
72}
73
74/// Config report variants supported by the bootloader.
75#[derive(Clone, Debug, PartialEq, Eq)]
76pub enum ConfigReportKind {
77    /// Loaded libraries, sources, probes, and diagnostics.
78    Status,
79    /// Effective config table for one library.
80    Effective {
81        /// Library whose effective table should be rendered.
82        lib: Symbol,
83    },
84    /// Source provenance and diagnostics only.
85    Sources,
86}
87
88/// Loaded-state report built from one [`LoadSession`].
89#[derive(Clone, Debug, PartialEq, Eq)]
90pub struct LoadedStateReport {
91    /// Loaded libraries in receipt order.
92    pub libs: Vec<LoadedLibReport>,
93    /// Config source status rows in discovery order.
94    pub config_sources: Vec<ConfigSourceReport>,
95    /// Effective merged configuration.
96    pub effective: EffectiveConfig,
97    /// Secret-bearing config fields to redact in report renderers.
98    pub secret_fields: Vec<ConfigSecretField>,
99    /// Typed probe report records in discovery order.
100    pub probe_reports: Vec<ConfigProbeReport>,
101    /// Non-fatal config discovery diagnostics.
102    pub diagnostics: Vec<String>,
103}
104
105impl LoadedStateReport {
106    /// Builds a report snapshot from a load session.
107    pub fn from_session(session: &LoadSession) -> Self {
108        Self {
109            libs: session
110                .receipts()
111                .iter()
112                .map(LoadedLibReport::from_receipt)
113                .collect(),
114            config_sources: session.config_state().source_reports().to_vec(),
115            effective: session.config_state().effective().clone(),
116            secret_fields: session.config_state().secret_fields().to_vec(),
117            probe_reports: session.config_state().probe_reports().to_vec(),
118            diagnostics: session.config_state().diagnostics().to_vec(),
119        }
120    }
121}
122
123impl LoadSession {
124    /// Loads enough boot state to render a `sim config ...` report.
125    pub fn run_config_report(&mut self, boot: &CliBoot) -> Result<String, CliError> {
126        let request = boot
127            .config_report
128            .as_ref()
129            .ok_or_else(|| CliError::new("missing config report request"))?;
130        self.load_for_config_report(boot)?;
131        let report = LoadedStateReport::from_session(self);
132        Ok(render_config_report(&report, request))
133    }
134
135    fn load_for_config_report(&mut self, boot: &CliBoot) -> Result<(), CliError> {
136        match self.load_boot(boot).map(|_| ()) {
137            Ok(()) => Ok(()),
138            Err(err) => {
139                if self.receipts().is_empty() {
140                    if boot.loads.is_empty() {
141                        return Ok(());
142                    }
143                    for source in &boot.loads {
144                        self.load_source(source)?;
145                    }
146                    return Ok(());
147                }
148                if is_no_codec_error(&err) {
149                    return Ok(());
150                }
151                Err(err)
152            }
153        }
154    }
155}
156
157/// Renders the selected config report.
158pub fn render_config_report(report: &LoadedStateReport, request: &ConfigReportRequest) -> String {
159    match (&request.kind, request.json) {
160        (ConfigReportKind::Status, false) => format_config_status(report),
161        (ConfigReportKind::Status, true) => format_config_status_json(report),
162        (ConfigReportKind::Sources, false) => format_config_sources(report),
163        (ConfigReportKind::Sources, true) => format_config_sources_json(report),
164        (ConfigReportKind::Effective { lib }, false) => format_effective_config(report, lib),
165        (ConfigReportKind::Effective { lib }, true) => format_effective_config_json(report, lib),
166    }
167}
168
169/// Renders a text status report.
170pub fn format_config_status(report: &LoadedStateReport) -> String {
171    let mut output = String::new();
172    push_loaded_libs(&mut output, &report.libs);
173    push_config_sources(&mut output, &report.config_sources);
174    push_probe_reports(&mut output, &report.probe_reports);
175    push_diagnostics(&mut output, &report.diagnostics);
176    output
177}
178
179/// Renders a stable JSON status report.
180pub fn format_config_status_json(report: &LoadedStateReport) -> String {
181    let mut output = String::new();
182    output.push('{');
183    output.push_str("\"libs\":");
184    push_loaded_libs_json(&mut output, &report.libs);
185    output.push_str(",\"config_sources\":");
186    push_config_sources_json(&mut output, &report.config_sources);
187    output.push_str(",\"effective\":");
188    push_effective_json(&mut output, report);
189    output.push_str(",\"probes\":");
190    push_probe_reports_json(&mut output, &report.probe_reports);
191    output.push_str(",\"diagnostics\":");
192    push_strings_json(&mut output, &report.diagnostics);
193    output.push_str("}\n");
194    output
195}
196
197/// Renders a text config-source report.
198pub fn format_config_sources(report: &LoadedStateReport) -> String {
199    let mut output = String::new();
200    push_config_sources(&mut output, &report.config_sources);
201    push_diagnostics(&mut output, &report.diagnostics);
202    output
203}
204
205/// Renders a stable JSON config-source report.
206pub fn format_config_sources_json(report: &LoadedStateReport) -> String {
207    let mut output = String::new();
208    output.push('{');
209    output.push_str("\"config_sources\":");
210    push_config_sources_json(&mut output, &report.config_sources);
211    output.push_str(",\"diagnostics\":");
212    push_strings_json(&mut output, &report.diagnostics);
213    output.push_str("}\n");
214    output
215}
216
217/// Renders one effective config table as config text.
218pub fn format_effective_config(report: &LoadedStateReport, lib: &Symbol) -> String {
219    let mut output = String::new();
220    output.push_str(&format!("lib {lib}\n"));
221    match report.effective.dir.table(lib) {
222        Some(table) => {
223            let redacted = redact_table_expr(lib, &table.table, &report.secret_fields);
224            match ConfigEncoder::new().encode_text(&redacted) {
225                Ok(text) if !text.is_empty() => output.push_str(&text),
226                Ok(_) => output.push_str("- empty\n"),
227                Err(err) => output.push_str(&format!("- unencodable config: {err}\n")),
228            }
229        }
230        None => output.push_str("- no effective config\n"),
231    }
232    output
233}
234
235/// Renders one effective config table as stable JSON.
236pub fn format_effective_config_json(report: &LoadedStateReport, lib: &Symbol) -> String {
237    let mut output = String::new();
238    output.push('{');
239    output.push_str("\"lib\":");
240    push_json_string(&mut output, &lib.as_qualified_str());
241    output.push_str(",\"table\":");
242    if let Some(table) = report.effective.dir.table(lib) {
243        let redacted = redact_table_expr(lib, &table.table, &report.secret_fields);
244        push_expr_json(&mut output, &redacted);
245    } else {
246        output.push_str("null");
247    }
248    output.push_str("}\n");
249    output
250}
251
252fn push_loaded_libs(output: &mut String, libs: &[LoadedLibReport]) {
253    output.push_str("loaded libs:\n");
254    if libs.is_empty() {
255        output.push_str("- none\n");
256        return;
257    }
258    for lib in libs {
259        output.push_str(&format!(
260            "- role={} lib={} version={} requested={} resolved={} exports={}\n",
261            lib.role, lib.lib, lib.version, lib.requested, lib.resolved, lib.exports
262        ));
263    }
264}
265
266fn push_config_sources(output: &mut String, sources: &[ConfigSourceReport]) {
267    output.push_str("config sources:\n");
268    if sources.is_empty() {
269        output.push_str("- none\n");
270        return;
271    }
272    for source in sources {
273        output.push_str(&format!(
274            "- source={} status={}\n",
275            source_label(&source.source),
276            status_label(source.status)
277        ));
278    }
279}
280
281fn push_probe_reports(output: &mut String, probes: &[ConfigProbeReport]) {
282    output.push_str("probes:\n");
283    if probes.is_empty() {
284        output.push_str("- none\n");
285        return;
286    }
287    for probe in probes {
288        output.push_str(&format!(
289            "- probe={} lib={} mode={} status={}",
290            probe.probe,
291            probe.lib,
292            mode_label(probe.mode),
293            probe_status_label(&probe.status)
294        ));
295        match &probe.status {
296            ConfigProbeStatus::Applied => {}
297            ConfigProbeStatus::Skipped { reason } => {
298                output.push_str(&format!(" reason={reason}"));
299            }
300            ConfigProbeStatus::Denied { capability } => {
301                output.push_str(&format!(" capability={capability}"));
302            }
303            ConfigProbeStatus::Failed { message } => {
304                output.push_str(&format!(" message={message}"));
305            }
306        }
307        output.push_str(&format!(
308            " emitted={}\n",
309            emitted_keys_label(&probe.emitted_keys)
310        ));
311    }
312}
313
314fn push_diagnostics(output: &mut String, diagnostics: &[String]) {
315    if diagnostics.is_empty() {
316        return;
317    }
318    output.push_str("diagnostics:\n");
319    for diagnostic in diagnostics {
320        output.push_str(&format!("- {diagnostic}\n"));
321    }
322}
323
324fn push_loaded_libs_json(output: &mut String, libs: &[LoadedLibReport]) {
325    output.push('[');
326    for (index, lib) in libs.iter().enumerate() {
327        comma(output, index);
328        output.push('{');
329        output.push_str("\"role\":");
330        push_json_string(output, &lib.role);
331        output.push_str(",\"lib\":");
332        push_json_string(output, &lib.lib.as_qualified_str());
333        output.push_str(",\"version\":");
334        push_json_string(output, &lib.version);
335        output.push_str(",\"requested\":");
336        push_json_string(output, &lib.requested);
337        output.push_str(",\"resolved\":");
338        push_json_string(output, &lib.resolved);
339        output.push_str(",\"exports\":");
340        output.push_str(&lib.exports.to_string());
341        output.push('}');
342    }
343    output.push(']');
344}
345
346fn push_config_sources_json(output: &mut String, sources: &[ConfigSourceReport]) {
347    output.push('[');
348    for (index, source) in sources.iter().enumerate() {
349        comma(output, index);
350        output.push('{');
351        output.push_str("\"source\":");
352        push_json_string(output, &source_label(&source.source));
353        output.push_str(",\"status\":");
354        push_json_string(output, status_label(source.status));
355        output.push('}');
356    }
357    output.push(']');
358}
359
360fn push_probe_reports_json(output: &mut String, probes: &[ConfigProbeReport]) {
361    output.push('[');
362    for (index, probe) in probes.iter().enumerate() {
363        comma(output, index);
364        output.push('{');
365        output.push_str("\"probe\":");
366        push_json_string(output, &probe.probe.as_qualified_str());
367        output.push_str(",\"lib\":");
368        push_json_string(output, &probe.lib.as_qualified_str());
369        output.push_str(",\"mode\":");
370        push_json_string(output, mode_label(probe.mode));
371        output.push_str(",\"status\":");
372        push_json_string(output, probe_status_label(&probe.status));
373        match &probe.status {
374            ConfigProbeStatus::Applied => {}
375            ConfigProbeStatus::Skipped { reason } => {
376                output.push_str(",\"reason\":");
377                push_json_string(output, reason);
378            }
379            ConfigProbeStatus::Denied { capability } => {
380                output.push_str(",\"capability\":");
381                push_json_string(output, capability);
382            }
383            ConfigProbeStatus::Failed { message } => {
384                output.push_str(",\"message\":");
385                push_json_string(output, message);
386            }
387        }
388        output.push_str(",\"emitted_keys\":");
389        push_strings_json(output, &probe.emitted_keys);
390        output.push('}');
391    }
392    output.push(']');
393}
394
395fn push_effective_json(output: &mut String, report: &LoadedStateReport) {
396    output.push('{');
397    for (index, table) in report.effective.dir.entries.iter().enumerate() {
398        comma(output, index);
399        push_json_string(output, &table.lib.as_qualified_str());
400        output.push(':');
401        let redacted = redact_table_expr(&table.lib, &table.table, &report.secret_fields);
402        push_expr_json(output, &redacted);
403    }
404    output.push('}');
405}
406
407fn push_strings_json(output: &mut String, values: &[String]) {
408    output.push('[');
409    for (index, value) in values.iter().enumerate() {
410        comma(output, index);
411        push_json_string(output, value);
412    }
413    output.push(']');
414}
415
416fn push_expr_json(output: &mut String, expr: &Expr) {
417    match expr {
418        Expr::Nil => output.push_str("null"),
419        Expr::Bool(value) => output.push_str(if *value { "true" } else { "false" }),
420        Expr::Number(number) if is_json_number(&number.canonical) => {
421            output.push_str(&number.canonical);
422        }
423        Expr::Number(number) => push_json_string(output, &number.canonical),
424        Expr::Symbol(symbol) | Expr::Local(symbol) => {
425            push_json_string(output, &symbol.as_qualified_str());
426        }
427        Expr::String(value) => push_json_string(output, value),
428        Expr::Bytes(bytes) => push_json_string(output, &hex_bytes(bytes)),
429        Expr::List(items) | Expr::Vector(items) | Expr::Set(items) | Expr::Block(items) => {
430            output.push('[');
431            for (index, item) in items.iter().enumerate() {
432                comma(output, index);
433                push_expr_json(output, item);
434            }
435            output.push(']');
436        }
437        Expr::Map(entries) => {
438            output.push('{');
439            for (index, (key, value)) in entries.iter().enumerate() {
440                comma(output, index);
441                push_json_string(output, &expr_key_label(key));
442                output.push(':');
443                push_expr_json(output, value);
444            }
445            output.push('}');
446        }
447        Expr::Call { operator, args } => {
448            output.push_str("{\"$expr\":\"call\",\"operator\":");
449            push_expr_json(output, operator);
450            output.push_str(",\"args\":");
451            push_expr_array_json(output, args);
452            output.push('}');
453        }
454        Expr::Infix {
455            operator,
456            left,
457            right,
458        } => {
459            output.push_str("{\"$expr\":\"infix\",\"operator\":");
460            push_json_string(output, &operator.as_qualified_str());
461            output.push_str(",\"left\":");
462            push_expr_json(output, left);
463            output.push_str(",\"right\":");
464            push_expr_json(output, right);
465            output.push('}');
466        }
467        Expr::Prefix { operator, arg } => {
468            output.push_str("{\"$expr\":\"prefix\",\"operator\":");
469            push_json_string(output, &operator.as_qualified_str());
470            output.push_str(",\"arg\":");
471            push_expr_json(output, arg);
472            output.push('}');
473        }
474        Expr::Postfix { operator, arg } => {
475            output.push_str("{\"$expr\":\"postfix\",\"operator\":");
476            push_json_string(output, &operator.as_qualified_str());
477            output.push_str(",\"arg\":");
478            push_expr_json(output, arg);
479            output.push('}');
480        }
481        Expr::Quote { mode, expr } => {
482            output.push_str("{\"$expr\":\"quote\",\"mode\":");
483            push_json_string(output, &format!("{mode:?}"));
484            output.push_str(",\"expr\":");
485            push_expr_json(output, expr);
486            output.push('}');
487        }
488        Expr::Annotated { expr, annotations } => {
489            output.push_str("{\"$expr\":\"annotated\",\"expr\":");
490            push_expr_json(output, expr);
491            output.push_str(",\"annotations\":{");
492            for (index, (key, value)) in annotations.iter().enumerate() {
493                comma(output, index);
494                push_json_string(output, &key.as_qualified_str());
495                output.push(':');
496                push_expr_json(output, value);
497            }
498            output.push_str("}}");
499        }
500        Expr::Extension { tag, payload } => {
501            output.push_str("{\"$expr\":\"extension\",\"tag\":");
502            push_json_string(output, &tag.as_qualified_str());
503            output.push_str(",\"payload\":");
504            push_expr_json(output, payload);
505            output.push('}');
506        }
507    }
508}
509
510fn push_expr_array_json(output: &mut String, values: &[Expr]) {
511    output.push('[');
512    for (index, value) in values.iter().enumerate() {
513        comma(output, index);
514        push_expr_json(output, value);
515    }
516    output.push(']');
517}
518
519fn redact_table_expr(lib: &Symbol, expr: &Expr, secrets: &[ConfigSecretField]) -> Expr {
520    let Expr::Map(entries) = expr else {
521        return expr.clone();
522    };
523    Expr::Map(
524        entries
525            .iter()
526            .map(|(key, value)| {
527                let key_label = expr_key_label(key);
528                if secret_key(lib, &key_label, secrets) {
529                    (key.clone(), Expr::String("[redacted]".to_owned()))
530                } else {
531                    (key.clone(), value.clone())
532                }
533            })
534            .collect(),
535    )
536}
537
538fn secret_key(lib: &Symbol, key: &str, secrets: &[ConfigSecretField]) -> bool {
539    secrets
540        .iter()
541        .any(|secret| &secret.lib == lib && secret.key == key)
542        || key_suggests_secret(key)
543}
544
545fn key_suggests_secret(key: &str) -> bool {
546    let key = key.to_ascii_lowercase();
547    key == "password"
548        || key == "token"
549        || key == "secret"
550        || key == "api_key"
551        || key.ends_with("_password")
552        || key.ends_with("_token")
553        || key.ends_with("_secret")
554}
555
556fn source_label(source: &ConfigSource) -> String {
557    match source {
558        ConfigSource::BuiltIn { lib } => format!("built-in:{}", lib.as_qualified_str()),
559        ConfigSource::Probe { probe, mode } => {
560            format!("probe:{}:{}", probe.as_qualified_str(), mode_label(*mode))
561        }
562        ConfigSource::HomeFile { path } => format!("home-file:{}", path.display()),
563        ConfigSource::WorkFile { path } => format!("work-file:{}", path.display()),
564        ConfigSource::SingleFile { path } => format!("single-file:{}", path.display()),
565        ConfigSource::Site { site } => format!("site:{}", site.as_qualified_str()),
566        ConfigSource::Explicit { label } => format!("explicit:{label}"),
567    }
568}
569
570fn mode_label(mode: ProbeMode) -> &'static str {
571    match mode {
572        ProbeMode::Modeled => "modeled",
573        ProbeMode::Real => "real",
574    }
575}
576
577fn probe_status_label(status: &ConfigProbeStatus) -> &'static str {
578    match status {
579        ConfigProbeStatus::Applied => "applied",
580        ConfigProbeStatus::Skipped { .. } => "skipped",
581        ConfigProbeStatus::Denied { .. } => "denied",
582        ConfigProbeStatus::Failed { .. } => "failed",
583    }
584}
585
586fn emitted_keys_label(keys: &[String]) -> String {
587    if keys.is_empty() {
588        "-".to_owned()
589    } else {
590        keys.join(",")
591    }
592}
593
594fn status_label(status: SourceStatus) -> &'static str {
595    match status {
596        SourceStatus::Found => "found",
597        SourceStatus::Missing => "missing",
598        SourceStatus::Ignored => "ignored",
599        SourceStatus::Rejected => "rejected",
600    }
601}
602
603fn role_label(role: &LoadReceiptRole) -> &'static str {
604    match role {
605        LoadReceiptRole::Library => "library",
606        LoadReceiptRole::BootCodec { .. } => "boot-codec",
607    }
608}
609
610fn is_no_codec_error(err: &CliError) -> bool {
611    err.to_string().starts_with("no codec '")
612}
613
614fn expr_key_label(key: &Expr) -> String {
615    match key {
616        Expr::Symbol(symbol) | Expr::Local(symbol) => symbol.as_qualified_str(),
617        Expr::String(value) => value.clone(),
618        Expr::Bool(value) => value.to_string(),
619        Expr::Number(number) => number.canonical.clone(),
620        other => format!("{other:?}"),
621    }
622}
623
624fn push_json_string(output: &mut String, value: &str) {
625    output.push('"');
626    for ch in value.chars() {
627        match ch {
628            '"' => output.push_str("\\\""),
629            '\\' => output.push_str("\\\\"),
630            '\n' => output.push_str("\\n"),
631            '\r' => output.push_str("\\r"),
632            '\t' => output.push_str("\\t"),
633            ch if ch.is_control() => output.push_str(&format!("\\u{:04x}", ch as u32)),
634            ch => output.push(ch),
635        }
636    }
637    output.push('"');
638}
639
640fn comma(output: &mut String, index: usize) {
641    if index > 0 {
642        output.push(',');
643    }
644}
645
646fn is_json_number(value: &str) -> bool {
647    let Some(first) = value.chars().next() else {
648        return false;
649    };
650    (first.is_ascii_digit() || first == '-')
651        && value
652            .chars()
653            .all(|ch| ch.is_ascii_digit() || matches!(ch, '-' | '+' | '.' | 'e' | 'E'))
654}
655
656fn hex_bytes(bytes: &[u8]) -> String {
657    const HEX: &[u8; 16] = b"0123456789abcdef";
658    let mut out = String::with_capacity(bytes.len() * 2);
659    for byte in bytes {
660        out.push(HEX[(byte >> 4) as usize] as char);
661        out.push(HEX[(byte & 0x0f) as usize] as char);
662    }
663    out
664}