Skip to main content

kellnr_settings/
cli.rs

1use std::collections::HashMap;
2use std::path::PathBuf;
3use std::sync::Arc;
4
5use clap::{CommandFactory, Parser, Subcommand};
6
7use crate::compile_time_config;
8use crate::settings::{
9    Settings, SettingsArgs, SettingsError, SettingsPartial, SettingsProv, build_prov_with_cli,
10};
11
12#[derive(Parser)]
13#[command(name = "kellnr", version, about)]
14pub struct Cli {
15    /// Path to configuration file
16    #[arg(id = "config", short = 'c', long = "config", global = true)]
17    pub config_file: Option<PathBuf>,
18
19    #[command(subcommand)]
20    pub command: Option<Command>,
21}
22
23#[derive(Subcommand)]
24#[allow(clippy::large_enum_variant)]
25pub enum Command {
26    /// Start the kellnr server
27    Start {
28        /// All section flags live on `SettingsArgs`, which is generated by
29        /// `provcfg-clap::ClapArgs`. Each section is flattened in, so flags
30        /// like `--registry-data-dir` and `--log-level` appear under `start`.
31        #[command(flatten)]
32        server: SettingsArgs,
33    },
34    /// Configuration management commands
35    Config {
36        #[command(subcommand)]
37        action: ConfigAction,
38    },
39}
40
41#[derive(Subcommand)]
42pub enum ConfigAction {
43    /// Show the current configuration
44    Show {
45        /// Hide settings that have their default value
46        #[arg(long = "no-defaults")]
47        no_defaults: bool,
48
49        /// Show the source (toml, env, cli) for each setting
50        #[arg(long = "sources")]
51        show_sources: bool,
52    },
53    /// Initialize a new configuration file with default values
54    Init {
55        /// Output file path (default: ./kellnr.toml)
56        #[arg(short = 'o', long = "output")]
57        output: Option<PathBuf>,
58    },
59}
60
61/// Options for the `config show` command
62#[derive(Debug, Clone, Default)]
63pub struct ShowConfigOptions {
64    /// Hide settings that have their default value
65    pub no_defaults: bool,
66    /// Show the source (toml, env, cli) for each setting
67    pub show_sources: bool,
68}
69
70/// A `Settings` paired with its per-leaf `SettingsProv`. Bundled because every
71/// non-init `CliResult` variant needs both, the active values for runtime use
72/// and the provenance for source attribution (`/settings` API and
73/// `config show`). `prov` is `Arc` so it can move into the app state without a
74/// re-allocation; `SettingsProv` is also large enough that an inline owned
75/// value would balloon `CliResult`.
76pub struct ResolvedSettings {
77    pub settings: Settings,
78    pub prov: Arc<SettingsProv>,
79}
80
81pub enum CliResult {
82    RunServer(ResolvedSettings),
83    ShowConfig {
84        resolved: ResolvedSettings,
85        options: ShowConfigOptions,
86    },
87    /// `config init` writes a fresh `Settings::default()` to `output`; no
88    /// settings field is carried because there's nothing to vary per call.
89    InitConfig {
90        output: PathBuf,
91    },
92    ShowHelp,
93}
94
95pub fn parse_cli() -> Result<CliResult, SettingsError> {
96    let cli = Cli::parse();
97
98    match cli.command {
99        // No subcommand → print help and exit without touching the config
100        // pipeline. Doing this before any file/env resolution means
101        // `kellnr` with no args always succeeds, even if `KELLNR_CONFIG_FILE`
102        // happens to point at a missing path.
103        None => {
104            Cli::command().print_help().ok();
105            Ok(CliResult::ShowHelp)
106        }
107
108        // `config init` doesn't need to merge sources, it writes the
109        // compiled-in defaults.
110        Some(Command::Config {
111            action: ConfigAction::Init { output },
112        }) => Ok(CliResult::InitConfig {
113            output: output.unwrap_or_else(|| PathBuf::from("kellnr.toml")),
114        }),
115
116        Some(ref command) => build_from_command(cli.config_file, command),
117    }
118}
119
120fn build_from_command(
121    cli_config_file: Option<PathBuf>,
122    command: &Command,
123) -> Result<CliResult, SettingsError> {
124    // Config file priority: CLI > env var > compile-time > None.
125    let env_config_file = std::env::var("KELLNR_CONFIG_FILE").ok();
126    let config_file: Option<PathBuf> = cli_config_file
127        .or(env_config_file.map(PathBuf::from))
128        .or(compile_time_config::KELLNR_COMPTIME__CONFIG_FILE.map(PathBuf::from));
129
130    // `SettingsPartial::from(&SettingsArgs)` is macro-generated by
131    // `provcfg-clap`: it walks every section and copies the user-supplied
132    // `Some(...)` values from clap. Unset flags stay `None`, so the CLI
133    // source contributes provenance only for fields explicitly passed.
134    let cli_partial: Option<SettingsPartial> = match command {
135        Command::Start { server } => Some(SettingsPartial::from(server)),
136        Command::Config { .. } => None,
137    };
138
139    let prov = build_prov_with_cli(config_file.as_deref(), cli_partial)?;
140    let resolved = ResolvedSettings {
141        settings: Settings::from(&prov),
142        prov: Arc::new(prov),
143    };
144
145    match command {
146        Command::Start { .. } => Ok(CliResult::RunServer(resolved)),
147        Command::Config {
148            action:
149                ConfigAction::Show {
150                    no_defaults,
151                    show_sources,
152                },
153        } => Ok(CliResult::ShowConfig {
154            resolved,
155            options: ShowConfigOptions {
156                no_defaults: *no_defaults,
157                show_sources: *show_sources,
158            },
159        }),
160        Command::Config {
161            action: ConfigAction::Init { .. },
162        } => unreachable!("`Init` is handled by `parse_cli` before this point"),
163    }
164}
165
166/// Map every settings leaf (dotted path) to its user-facing CLI flag(s).
167///
168/// Walks `start`'s flattened `SettingsArgs` via clap reflection so the
169/// `/settings` UI doesn't have to hand-maintain a parallel table that drifts
170/// from the actual flags. Output format: `"--registry-data-dir, -d"` (long,
171/// then short if any). Args whose id doesn't follow the `<prefix>-<field>`
172/// convention (e.g. the top-level `--config` flag, clap's built-in `--help`)
173/// are skipped, they don't correspond to a settings leaf.
174#[must_use]
175pub fn cli_flag_map() -> HashMap<String, String> {
176    let cmd = Cli::command();
177    let Some(start) = cmd.find_subcommand("start") else {
178        return HashMap::new();
179    };
180
181    let mut map = HashMap::new();
182    for arg in start.get_arguments() {
183        let id = arg.get_id().as_str();
184        let Some(path) = id_to_dotted_path(id) else {
185            continue;
186        };
187        let mut flag = String::new();
188        if let Some(long) = arg.get_long() {
189            flag.push_str("--");
190            flag.push_str(long);
191        }
192        if let Some(short) = arg.get_short() {
193            if !flag.is_empty() {
194                flag.push_str(", ");
195            }
196            flag.push('-');
197            flag.push(short);
198        }
199        if !flag.is_empty() {
200            map.insert(path, flag);
201        }
202    }
203    map
204}
205
206/// Convert a clap arg id like `registry-data-dir` into its `SettingsProv`
207/// dotted path `registry.data_dir`. Returns `None` for ids without at least
208/// one `-` (those don't come from a `clap_prefix`-ed section and so don't
209/// map to a leaf).
210fn id_to_dotted_path(id: &str) -> Option<String> {
211    let (section, rest) = id.split_once('-')?;
212    Some(format!("{section}.{}", rest.replace('-', "_")))
213}
214
215#[cfg(test)]
216mod tests {
217    use std::io::Write;
218    use std::path::Path;
219
220    use provcfg::Category;
221    use tempfile::NamedTempFile;
222
223    use super::*;
224
225    // Env-source precedence (env wins over TOML, CLI wins over env) is covered
226    // by provcfg's own suite, `full_layered_stack_defaults_toml_env_cli` in
227    // `provcfg/src/tests/defaults.rs`. Re-asserting it here would require
228    // mutating `KELLNR_*` process env vars, which means `unsafe` (edition 2024)
229    // and is forbidden workspace-wide. These tests instead pin down the
230    // kellnr-level wiring, TOML and CLI, through the same `parse_cli` path,
231    // and the `build_prov_with_cli` body documents the env layer's position.
232
233    fn write_toml(contents: &str) -> NamedTempFile {
234        let mut file = NamedTempFile::new().expect("tempfile");
235        file.write_all(contents.as_bytes()).expect("write toml");
236        file
237    }
238
239    /// Drive `Cli::try_parse_from` + `build_from_command` end-to-end, the
240    /// same code path `parse_cli` takes once `Cli::parse()` has read argv.
241    fn run_start(config_file: Option<&Path>, extra_args: &[&str]) -> CliResult {
242        let mut argv: Vec<&str> = vec!["kellnr", "start"];
243        argv.extend(extra_args.iter().copied());
244        let cli = Cli::try_parse_from(&argv).expect("clap should accept argv");
245        let command = cli.command.expect("start subcommand present");
246        build_from_command(config_file.map(PathBuf::from), &command).expect("build")
247    }
248
249    #[test]
250    fn toml_only_marks_overridden_leaves_with_file_provenance() {
251        let file = write_toml("[registry]\ndata_dir = \"/tmp/from-toml\"\n");
252        let CliResult::RunServer(resolved) = run_start(Some(file.path()), &[]) else {
253            panic!("expected RunServer");
254        };
255
256        assert_eq!(resolved.settings.registry.data_dir, "/tmp/from-toml");
257        assert_eq!(
258            resolved.prov.registry.data_dir.source().category(),
259            Category::File,
260        );
261    }
262
263    #[test]
264    fn cli_flag_overrides_toml_with_cli_provenance() {
265        let file = write_toml("[registry]\ndata_dir = \"/tmp/from-toml\"\n");
266        let CliResult::RunServer(resolved) =
267            run_start(Some(file.path()), &["--registry-data-dir", "/tmp/from-cli"])
268        else {
269            panic!("expected RunServer");
270        };
271
272        assert_eq!(resolved.settings.registry.data_dir, "/tmp/from-cli");
273        assert_eq!(
274            resolved.prov.registry.data_dir.source().category(),
275            Category::Cli,
276        );
277    }
278
279    #[test]
280    fn config_show_returns_show_config_variant_with_options() {
281        // `config show --sources` flows through `build_from_command` like
282        // `start` does, so it must also produce a usable `SettingsProv`.
283        let file = write_toml("[registry]\ndata_dir = \"/tmp/from-toml\"\n");
284        let argv = ["kellnr", "config", "show", "--sources"];
285        let cli = Cli::try_parse_from(argv).expect("clap parse");
286        let command = cli.command.expect("config subcommand present");
287
288        let result = build_from_command(Some(file.path().to_path_buf()), &command).unwrap();
289        let CliResult::ShowConfig { resolved, options } = result else {
290            panic!("expected ShowConfig variant");
291        };
292        assert!(options.show_sources);
293        assert!(!options.no_defaults);
294        assert_eq!(resolved.settings.registry.data_dir, "/tmp/from-toml");
295        assert_eq!(
296            resolved.prov.registry.data_dir.source().category(),
297            Category::File,
298        );
299    }
300
301    #[test]
302    fn id_to_dotted_path_converts_dashes_after_first_segment() {
303        assert_eq!(
304            id_to_dotted_path("registry-data-dir"),
305            Some("registry.data_dir".to_string())
306        );
307        assert_eq!(
308            id_to_dotted_path("registry-token-cache-ttl-seconds"),
309            Some("registry.token_cache_ttl_seconds".to_string())
310        );
311        // Single-segment ids (top-level flags like `--config`, clap's --help)
312        // don't map to a settings leaf.
313        assert_eq!(id_to_dotted_path("config"), None);
314        assert_eq!(id_to_dotted_path("help"), None);
315    }
316
317    #[test]
318    fn cli_flag_map_covers_every_section_with_correct_paths() {
319        let map = cli_flag_map();
320
321        // A long-only flag: clap auto-derives `--registry-data-dir` from the
322        // `clap_prefix = "registry"` section and field name `data_dir`.
323        assert_eq!(
324            map.get("registry.data_dir").map(String::as_str),
325            Some("--registry-data-dir, -d"),
326            "registry.data_dir must surface its auto-derived long plus the manual `-d` short"
327        );
328        // A user-supplied `#[arg(long = "...")]` overrides the auto-derive
329        // while the id (and therefore our lookup key) still follows the
330        // section.field convention.
331        assert_eq!(
332            map.get("registry.token_cache_ttl_seconds")
333                .map(String::as_str),
334            Some("--registry-token-cache-ttl"),
335        );
336        // Spot-check leaves from other sections so a missing wiring on any
337        // single section shows up here.
338        assert_eq!(
339            map.get("docs.enabled").map(String::as_str),
340            Some("--docs-enabled")
341        );
342        assert_eq!(
343            map.get("s3.allow_http").map(String::as_str),
344            Some("--s3-allow-http")
345        );
346        assert_eq!(
347            map.get("local.port").map(String::as_str),
348            Some("--local-port, -p")
349        );
350        // The top-level `--config` arg is not a settings leaf, must not leak in.
351        assert!(
352            !map.contains_key("config"),
353            "top-level `--config` is not a leaf"
354        );
355    }
356}