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