From feb349ee09aa2151c421840bec521719c6149dcb Mon Sep 17 00:00:00 2001 From: tv Date: Sat, 24 Oct 2026 15:30:04 +0200 Subject: add API and CLI to print effective config --- src/config/mod.rs | 90 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 89 insertions(+), 1 deletion(-) (limited to 'src/config/mod.rs') diff --git a/src/config/mod.rs b/src/config/mod.rs index f042fb8..e62b2b6 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -436,6 +436,7 @@ fn culprit(merged: &Layer) -> Option<(&String, &Entry)> { // --- command line -------------------------------------------------------------- const CONFIG: &str = "config"; +const PRINT_CONFIG: &str = "print-config"; fn command() -> Command { let mut command = Command::new("continuum") @@ -453,6 +454,12 @@ fn command() -> Command { .value_name("PATH") .action(ArgAction::Append) .help("Configuration file (.toml or .json); repeatable, later files win."), + ) + .arg( + Arg::new(PRINT_CONFIG) + .long(PRINT_CONFIG) + .action(ArgAction::SetTrue) + .help("Print the effective configuration as JSON (with each value's source) and exit."), ); for spec in OPTIONS { let help = match spec.default { @@ -529,12 +536,69 @@ pub(crate) struct Loaded { pub(crate) settings: Settings, pub(crate) origins: BTreeMap, pub(crate) files: Vec, + /// `--print-config`: print [`Loaded::report`] and exit. + pub(crate) print_only: bool, +} + +impl Loaded { + /// The effective configuration with each value's source, for `--print-config` and + /// `GET /admin/config`. Imported values are left out (they may be secret); their + /// importers are listed instead. + pub(crate) fn report(&self) -> JsonValue { + let values = match serde_json::to_value(&self.settings) { + Ok(JsonValue::Object(values)) => values, + _ => serde_json::Map::new(), + }; + let options: serde_json::Map = OPTIONS + .iter() + .filter(|spec| !spec.hidden || self.origins.contains_key(spec.name)) + .map(|spec| { + let mut entry = serde_json::Map::new(); + match self.origins.get(spec.name) { + None => { + entry.insert("source".into(), "default".into()); + } + Some(origin) => { + match &origin.provenance { + Provenance::File(path) => { + entry.insert("source".into(), "file".into()); + entry.insert("file".into(), path.display().to_string().into()); + } + Provenance::Argument => { + entry.insert("source".into(), "argument".into()); + } + } + if origin.is_imported() { + entry.insert("imported".into(), origin.imports.clone().into()); + } + } + } + let imported = self.origins.get(spec.name).is_some_and(Origin::is_imported); + if !imported { + entry.insert( + "value".into(), + values.get(spec.name).cloned().unwrap_or(JsonValue::Null), + ); + } + (spec.name.to_owned(), JsonValue::Object(entry)) + }) + .collect(); + serde_json::json!({ + "files": self.files.iter().map(|path| path.display().to_string()).collect::>(), + "options": options, + }) + } } /// Reads the command line and the configuration files it names. Exits on `--help`, /// `--version` and command-line syntax errors, like any command. pub(crate) fn load() -> Result { let matches = command().get_matches(); + let print_only = matches.get_flag(PRINT_CONFIG); + if print_only { + // Logs go to stdout too; the report says where each value came from anyway. + let _ = crate::logging::set_filter("off"); + } let files: Vec = matches .get_many::(CONFIG) .into_iter() @@ -568,7 +632,9 @@ pub(crate) fn load() -> Result { } merged.insert(name, entry); } - resolve(merged, files) + let mut loaded = resolve(merged, files)?; + loaded.print_only = print_only; + Ok(loaded) } fn resolve(merged: Layer, files: Vec) -> Result { @@ -595,6 +661,7 @@ fn resolve(merged: Layer, files: Vec) -> Result { .map(|(name, entry)| (name, entry.origin)) .collect(), files, + print_only: false, }) } @@ -650,6 +717,27 @@ mod tests { command().debug_assert(); } + #[test] + fn report_hides_imported_values() { + let mut values = layer(json!({ "htpasswd": "users", "alias": "secret-ish" })); + values.get_mut("alias").unwrap().origin.imports = vec!["env:HOSTNAME".into()]; + let report = resolve(values, Vec::new()).unwrap().report(); + let options = &report["options"]; + assert_eq!( + options["htpasswd"], + json!({ "source": "argument", "value": "users" }) + ); + assert_eq!( + options["alias"], + json!({ "source": "argument", "imported": ["env:HOSTNAME"] }) + ); + assert_eq!( + options["neighbours"], + json!({ "source": "default", "value": 12 }) + ); + assert!(options.get("debug-epoch").is_none()); + } + #[test] fn text_values() { assert_eq!(from_text(Kind::Integer, " 30 ").unwrap(), json!(30)); -- cgit v1.3.1