1use 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#[derive(Clone, Debug, PartialEq, Eq)]
14pub struct LoadedLibReport {
15 pub role: String,
17 pub lib: Symbol,
19 pub version: String,
21 pub requested: String,
23 pub resolved: String,
25 pub exports: usize,
27}
28
29impl LoadedLibReport {
30 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
45pub enum SourceStatus {
46 Found,
48 Missing,
50 Ignored,
52 Rejected,
54}
55
56#[derive(Clone, Debug, PartialEq, Eq)]
58pub struct ConfigSourceReport {
59 pub source: ConfigSource,
61 pub status: SourceStatus,
63}
64
65#[derive(Clone, Debug, PartialEq, Eq)]
67pub struct ConfigReportRequest {
68 pub kind: ConfigReportKind,
70 pub json: bool,
72}
73
74#[derive(Clone, Debug, PartialEq, Eq)]
76pub enum ConfigReportKind {
77 Status,
79 Effective {
81 lib: Symbol,
83 },
84 Sources,
86}
87
88#[derive(Clone, Debug, PartialEq, Eq)]
90pub struct LoadedStateReport {
91 pub libs: Vec<LoadedLibReport>,
93 pub config_sources: Vec<ConfigSourceReport>,
95 pub effective: EffectiveConfig,
97 pub secret_fields: Vec<ConfigSecretField>,
99 pub probe_reports: Vec<ConfigProbeReport>,
101 pub diagnostics: Vec<String>,
103}
104
105impl LoadedStateReport {
106 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 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
157pub 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
169pub 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
179pub 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
197pub 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
205pub 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
217pub 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
235pub 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}