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    /// Build the rustdoc of an unpacked crate. Used internally by the docs
40    /// queue to run untrusted builds in a separate process.
41    #[command(name = DOC_BUILD_SUBCOMMAND, hide = true)]
42    DocBuild {
43        /// Path to the unpacked crate
44        crate_path: PathBuf,
45
46        /// Sparse index URL that replaces the crates.io source
47        #[arg(long = DOC_BUILD_CRATESIO_INDEX_ARG)]
48        cratesio_index: Option<String>,
49    },
50}
51
52/// Name of the hidden subcommand that builds the docs of a single crate.
53pub const DOC_BUILD_SUBCOMMAND: &str = "doc-build";
54/// Long flag of [`DOC_BUILD_SUBCOMMAND`] for the crates.io index override.
55pub const DOC_BUILD_CRATESIO_INDEX_ARG: &str = "cratesio-index";
56
57#[derive(Subcommand)]
58pub enum ConfigAction {
59    /// Show the current configuration
60    Show {
61        /// Hide settings that have their default value
62        #[arg(long = "no-defaults")]
63        no_defaults: bool,
64
65        /// Show the source (toml, env, cli) for each setting
66        #[arg(long = "sources")]
67        show_sources: bool,
68    },
69    /// Initialize a new configuration file with default values
70    Init {
71        /// Output file path (default: ./kellnr.toml)
72        #[arg(short = 'o', long = "output")]
73        output: Option<PathBuf>,
74    },
75}
76
77/// Options for the `config show` command
78#[derive(Debug, Clone, Default)]
79pub struct ShowConfigOptions {
80    /// Hide settings that have their default value
81    pub no_defaults: bool,
82    /// Show the source (toml, env, cli) for each setting
83    pub show_sources: bool,
84}
85
86/// A `Settings` paired with its per-leaf `SettingsProv`. Bundled because every
87/// non-init `CliResult` variant needs both, the active values for runtime use
88/// and the provenance for source attribution (`/settings` API and
89/// `config show`). `prov` is `Arc` so it can move into the app state without a
90/// re-allocation; `SettingsProv` is also large enough that an inline owned
91/// value would balloon `CliResult`.
92pub struct ResolvedSettings {
93    pub settings: Settings,
94    pub prov: Arc<SettingsProv>,
95}
96
97pub enum CliResult {
98    RunServer(ResolvedSettings),
99    ShowConfig {
100        resolved: ResolvedSettings,
101        options: ShowConfigOptions,
102    },
103    /// `config init` writes a fresh `Settings::default()` to `output`; no
104    /// settings field is carried because there's nothing to vary per call.
105    InitConfig {
106        output: PathBuf,
107    },
108    ShowHelp,
109    /// Build the docs of a crate in the current process and exit.
110    BuildDocs {
111        crate_path: PathBuf,
112        cratesio_index: Option<String>,
113    },
114}
115
116pub fn parse_cli() -> Result<CliResult, SettingsError> {
117    let cli = Cli::parse();
118
119    match cli.command {
120        // No subcommand → print help and exit without touching the config
121        // pipeline. Doing this before any file/env resolution means
122        // `kellnr` with no args always succeeds, even if `KELLNR_CONFIG_FILE`
123        // happens to point at a missing path.
124        None => {
125            Cli::command().print_help().ok();
126            Ok(CliResult::ShowHelp)
127        }
128
129        // `config init` doesn't need to merge sources, it writes the
130        // compiled-in defaults.
131        Some(Command::Config {
132            action: ConfigAction::Init { output },
133        }) => Ok(CliResult::InitConfig {
134            output: output.unwrap_or_else(|| PathBuf::from("kellnr.toml")),
135        }),
136
137        // The docs build runs untrusted code and gets no settings on purpose.
138        Some(Command::DocBuild {
139            crate_path,
140            cratesio_index,
141        }) => Ok(CliResult::BuildDocs {
142            crate_path,
143            cratesio_index,
144        }),
145
146        Some(ref command) => build_from_command(cli.config_file, command),
147    }
148}
149
150/// Settings of the running `kellnr start`, resolved from the same command
151/// line, environment and config file as at startup. For code that runs before
152/// the settings are passed around, like the database migrations. `None` if the
153/// process was not started with valid `kellnr` arguments, for example in tests.
154#[must_use]
155pub fn settings_from_process() -> Option<Settings> {
156    let cli = Cli::try_parse().ok()?;
157    let command = cli.command?;
158    let prov = build_prov(cli.config_file, &command).ok()?;
159    Some(Settings::from(&prov))
160}
161
162fn build_prov(
163    cli_config_file: Option<PathBuf>,
164    command: &Command,
165) -> Result<SettingsProv, SettingsError> {
166    // Config file priority: CLI > env var > compile-time > None.
167    let env_config_file = std::env::var("KELLNR_CONFIG_FILE").ok();
168    let config_file: Option<PathBuf> = cli_config_file
169        .or(env_config_file.map(PathBuf::from))
170        .or(compile_time_config::KELLNR_COMPTIME__CONFIG_FILE.map(PathBuf::from));
171
172    // `SettingsPartial::from(&SettingsArgs)` is macro-generated by
173    // `provcfg-clap`: it walks every section and copies the user-supplied
174    // `Some(...)` values from clap. Unset flags stay `None`, so the CLI
175    // source contributes provenance only for fields explicitly passed.
176    let cli_partial: Option<SettingsPartial> = match command {
177        Command::Start { server } => Some(SettingsPartial::from(server)),
178        Command::Config { .. } | Command::DocBuild { .. } => None,
179    };
180
181    build_prov_with_cli(config_file.as_deref(), cli_partial)
182}
183
184fn build_from_command(
185    cli_config_file: Option<PathBuf>,
186    command: &Command,
187) -> Result<CliResult, SettingsError> {
188    let prov = build_prov(cli_config_file, command)?;
189    let resolved = ResolvedSettings {
190        settings: Settings::from(&prov),
191        prov: Arc::new(prov),
192    };
193
194    match command {
195        Command::Start { .. } => Ok(CliResult::RunServer(resolved)),
196        Command::Config {
197            action:
198                ConfigAction::Show {
199                    no_defaults,
200                    show_sources,
201                },
202        } => Ok(CliResult::ShowConfig {
203            resolved,
204            options: ShowConfigOptions {
205                no_defaults: *no_defaults,
206                show_sources: *show_sources,
207            },
208        }),
209        Command::Config {
210            action: ConfigAction::Init { .. },
211        } => unreachable!("`Init` is handled by `parse_cli` before this point"),
212        Command::DocBuild { .. } => {
213            unreachable!("`DocBuild` is handled by `parse_cli` before this point")
214        }
215    }
216}
217
218/// Map every settings leaf (dotted path) to its user-facing CLI flag(s).
219///
220/// Walks `start`'s flattened `SettingsArgs` via clap reflection so the
221/// `/settings` UI doesn't have to hand-maintain a parallel table that drifts
222/// from the actual flags. Output format: `"--registry-data-dir, -d"` (long,
223/// then short if any). Args whose id doesn't follow the `<prefix>-<field>`
224/// convention (e.g. the top-level `--config` flag, clap's built-in `--help`)
225/// are skipped, they don't correspond to a settings leaf.
226#[must_use]
227pub fn cli_flag_map() -> HashMap<String, String> {
228    let cmd = Cli::command();
229    let Some(start) = cmd.find_subcommand("start") else {
230        return HashMap::new();
231    };
232
233    let mut map = HashMap::new();
234    for arg in start.get_arguments() {
235        let id = arg.get_id().as_str();
236        let Some(path) = id_to_dotted_path(id) else {
237            continue;
238        };
239        let mut flag = String::new();
240        if let Some(long) = arg.get_long() {
241            flag.push_str("--");
242            flag.push_str(long);
243        }
244        if let Some(short) = arg.get_short() {
245            if !flag.is_empty() {
246                flag.push_str(", ");
247            }
248            flag.push('-');
249            flag.push(short);
250        }
251        if !flag.is_empty() {
252            map.insert(path, flag);
253        }
254    }
255    map
256}
257
258/// Convert a clap arg id like `registry-data-dir` into its `SettingsProv`
259/// dotted path `registry.data_dir`. Returns `None` for ids without at least
260/// one `-` (those don't come from a `clap_prefix`-ed section and so don't
261/// map to a leaf).
262fn id_to_dotted_path(id: &str) -> Option<String> {
263    // Sections whose name contains "_" have a prefix with "-", so the first
264    // "-" does not end the section for them.
265    const MULTI_WORD_SECTIONS: &[&str] = &["cratesio-filter", "registry-filter"];
266
267    let (section, rest) = MULTI_WORD_SECTIONS
268        .iter()
269        .find_map(|s| Some((*s, id.strip_prefix(s)?.strip_prefix('-')?)))
270        .or_else(|| id.split_once('-'))?;
271    Some(format!(
272        "{}.{}",
273        section.replace('-', "_"),
274        rest.replace('-', "_")
275    ))
276}
277
278#[cfg(test)]
279mod tests {
280    use std::io::Write;
281    use std::path::Path;
282
283    use provcfg::Category;
284    use tempfile::NamedTempFile;
285
286    use super::*;
287
288    // Env-source precedence (env wins over TOML, CLI wins over env) is covered
289    // by provcfg's own suite, `full_layered_stack_defaults_toml_env_cli` in
290    // `provcfg/src/tests/defaults.rs`. Re-asserting it here would require
291    // mutating `KELLNR_*` process env vars, which means `unsafe` (edition 2024)
292    // and is forbidden workspace-wide. These tests instead pin down the
293    // kellnr-level wiring, TOML and CLI, through the same `parse_cli` path,
294    // and the `build_prov_with_cli` body documents the env layer's position.
295
296    fn write_toml(contents: &str) -> NamedTempFile {
297        let mut file = NamedTempFile::new().expect("tempfile");
298        file.write_all(contents.as_bytes()).expect("write toml");
299        file
300    }
301
302    /// Drive `Cli::try_parse_from` + `build_from_command` end-to-end, the
303    /// same code path `parse_cli` takes once `Cli::parse()` has read argv.
304    fn run_start(config_file: Option<&Path>, extra_args: &[&str]) -> CliResult {
305        let mut argv: Vec<&str> = vec!["kellnr", "start"];
306        argv.extend(extra_args.iter().copied());
307        let cli = Cli::try_parse_from(&argv).expect("clap should accept argv");
308        let command = cli.command.expect("start subcommand present");
309        build_from_command(config_file.map(PathBuf::from), &command).expect("build")
310    }
311
312    #[test]
313    fn toml_only_marks_overridden_leaves_with_file_provenance() {
314        let file = write_toml("[registry]\ndata_dir = \"/tmp/from-toml\"\n");
315        let CliResult::RunServer(resolved) = run_start(Some(file.path()), &[]) else {
316            panic!("expected RunServer");
317        };
318
319        assert_eq!(resolved.settings.registry.data_dir, "/tmp/from-toml");
320        assert_eq!(
321            resolved.prov.registry.data_dir.source().category(),
322            Category::File,
323        );
324    }
325
326    #[test]
327    fn cli_flag_overrides_toml_with_cli_provenance() {
328        let file = write_toml("[registry]\ndata_dir = \"/tmp/from-toml\"\n");
329        let CliResult::RunServer(resolved) =
330            run_start(Some(file.path()), &["--registry-data-dir", "/tmp/from-cli"])
331        else {
332            panic!("expected RunServer");
333        };
334
335        assert_eq!(resolved.settings.registry.data_dir, "/tmp/from-cli");
336        assert_eq!(
337            resolved.prov.registry.data_dir.source().category(),
338            Category::Cli,
339        );
340    }
341
342    #[test]
343    fn cookie_secure_defaults_to_unset_and_reads_from_toml_and_cli() {
344        let file = write_toml("[registry]\ndata_dir = \"/tmp/x\"\n");
345        let CliResult::RunServer(resolved) = run_start(Some(file.path()), &[]) else {
346            panic!("expected RunServer");
347        };
348        assert_eq!(resolved.settings.origin.cookie_secure, None);
349
350        let file = write_toml("[origin]\ncookie_secure = true\n");
351        let CliResult::RunServer(resolved) = run_start(Some(file.path()), &[]) else {
352            panic!("expected RunServer");
353        };
354        assert_eq!(resolved.settings.origin.cookie_secure, Some(true));
355
356        let CliResult::RunServer(resolved) =
357            run_start(Some(file.path()), &["--origin-cookie-secure", "false"])
358        else {
359            panic!("expected RunServer");
360        };
361        assert_eq!(resolved.settings.origin.cookie_secure, Some(false));
362    }
363
364    #[test]
365    fn filter_flags_reach_the_settings() {
366        let CliResult::RunServer(resolved) = run_start(
367            None,
368            &[
369                "--cratesio-filter-license-enabled",
370                "true",
371                "--cratesio-filter-license-allowed",
372                "MIT,Apache-2.0",
373                "--cratesio-filter-license-unknown",
374                "allow",
375                "--registry-filter-license-exempt-crates",
376                "ring,openssl",
377            ],
378        ) else {
379            panic!("expected RunServer");
380        };
381
382        let cratesio = &resolved.settings.cratesio_filter;
383        assert!(cratesio.license_enabled);
384        assert_eq!(vec!["MIT", "Apache-2.0"], cratesio.license_allowed);
385        assert_eq!(crate::LicenseUnknown::Allow, cratesio.license_unknown);
386        assert_eq!(
387            vec!["ring", "openssl"],
388            resolved.settings.registry_filter.license_exempt_crates
389        );
390        assert_eq!(
391            resolved
392                .prov
393                .cratesio_filter
394                .license_allowed
395                .source()
396                .category(),
397            Category::Cli,
398        );
399        assert_eq!(
400            resolved
401                .prov
402                .registry_filter
403                .license_enabled
404                .source()
405                .category(),
406            Category::Default,
407        );
408    }
409
410    #[test]
411    fn invalid_license_unknown_flag_is_rejected() {
412        let result = Cli::try_parse_from([
413            "kellnr",
414            "start",
415            "--registry-filter-license-unknown",
416            "deneay",
417        ]);
418
419        assert!(result.is_err());
420    }
421
422    #[test]
423    fn config_show_returns_show_config_variant_with_options() {
424        // `config show --sources` flows through `build_from_command` like
425        // `start` does, so it must also produce a usable `SettingsProv`.
426        let file = write_toml("[registry]\ndata_dir = \"/tmp/from-toml\"\n");
427        let argv = ["kellnr", "config", "show", "--sources"];
428        let cli = Cli::try_parse_from(argv).expect("clap parse");
429        let command = cli.command.expect("config subcommand present");
430
431        let result = build_from_command(Some(file.path().to_path_buf()), &command).unwrap();
432        let CliResult::ShowConfig { resolved, options } = result else {
433            panic!("expected ShowConfig variant");
434        };
435        assert!(options.show_sources);
436        assert!(!options.no_defaults);
437        assert_eq!(resolved.settings.registry.data_dir, "/tmp/from-toml");
438        assert_eq!(
439            resolved.prov.registry.data_dir.source().category(),
440            Category::File,
441        );
442    }
443
444    #[test]
445    fn id_to_dotted_path_converts_dashes_after_first_segment() {
446        assert_eq!(
447            id_to_dotted_path("registry-data-dir"),
448            Some("registry.data_dir".to_string())
449        );
450        assert_eq!(
451            id_to_dotted_path("registry-token-cache-ttl-seconds"),
452            Some("registry.token_cache_ttl_seconds".to_string())
453        );
454        // Single-segment ids (top-level flags like `--config`, clap's --help)
455        // don't map to a settings leaf.
456        assert_eq!(id_to_dotted_path("config"), None);
457        assert_eq!(id_to_dotted_path("help"), None);
458    }
459
460    #[test]
461    fn cli_flag_map_covers_every_section_with_correct_paths() {
462        let map = cli_flag_map();
463
464        // A long-only flag: clap auto-derives `--registry-data-dir` from the
465        // `clap_prefix = "registry"` section and field name `data_dir`.
466        assert_eq!(
467            map.get("registry.data_dir").map(String::as_str),
468            Some("--registry-data-dir, -d"),
469            "registry.data_dir must surface its auto-derived long plus the manual `-d` short"
470        );
471        // A user-supplied `#[arg(long = "...")]` overrides the auto-derive
472        // while the id (and therefore our lookup key) still follows the
473        // section.field convention.
474        assert_eq!(
475            map.get("registry.token_cache_ttl_seconds")
476                .map(String::as_str),
477            Some("--registry-token-cache-ttl"),
478        );
479        // Spot-check leaves from other sections so a missing wiring on any
480        // single section shows up here.
481        assert_eq!(
482            map.get("docs.enabled").map(String::as_str),
483            Some("--docs-enabled")
484        );
485        assert_eq!(
486            map.get("s3.allow_http").map(String::as_str),
487            Some("--s3-allow-http")
488        );
489        assert_eq!(
490            map.get("local.port").map(String::as_str),
491            Some("--local-port, -p")
492        );
493        // Sections with a multi-word name keep it together.
494        assert_eq!(
495            map.get("cratesio_filter.license_allowed")
496                .map(String::as_str),
497            Some("--cratesio-filter-license-allowed")
498        );
499        assert_eq!(
500            map.get("registry_filter.license_unknown")
501                .map(String::as_str),
502            Some("--registry-filter-license-unknown")
503        );
504        // The top-level `--config` arg is not a settings leaf, must not leak in.
505        assert!(
506            !map.contains_key("config"),
507            "top-level `--config` is not a leaf"
508        );
509    }
510
511    #[test]
512    fn doc_build_subcommand_is_hidden_and_parses_arguments() {
513        let cli = Cli::try_parse_from([
514            "kellnr",
515            DOC_BUILD_SUBCOMMAND,
516            "/tmp/crate",
517            "--cratesio-index",
518            "https://proxy/index/",
519        ])
520        .expect("clap should accept argv");
521
522        let Some(Command::DocBuild {
523            crate_path,
524            cratesio_index,
525        }) = cli.command
526        else {
527            panic!("expected DocBuild");
528        };
529        assert_eq!(crate_path, PathBuf::from("/tmp/crate"));
530        assert_eq!(cratesio_index.as_deref(), Some("https://proxy/index/"));
531        assert!(
532            Cli::command()
533                .find_subcommand(DOC_BUILD_SUBCOMMAND)
534                .unwrap()
535                .is_hide_set()
536        );
537    }
538}