kellnr-settings 6.10.1

Kellnr is a self-hosted registry for Rust crates with support for rustdocs and crates.io caching.
Documentation
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;

use clap::{CommandFactory, Parser, Subcommand};

use crate::compile_time_config;
use crate::settings::{
    Settings, SettingsArgs, SettingsError, SettingsPartial, SettingsProv, build_prov_with_cli,
};

#[derive(Parser)]
#[command(name = "kellnr", version, about)]
pub struct Cli {
    /// Path to configuration file
    #[arg(id = "config", short = 'c', long = "config", global = true)]
    pub config_file: Option<PathBuf>,

    #[command(subcommand)]
    pub command: Option<Command>,
}

#[derive(Subcommand)]
#[allow(clippy::large_enum_variant)]
pub enum Command {
    /// Start the kellnr server
    Start {
        /// All section flags live on `SettingsArgs`, which is generated by
        /// `provcfg-clap::ClapArgs`. Each section is flattened in, so flags
        /// like `--registry-data-dir` and `--log-level` appear under `start`.
        #[command(flatten)]
        server: SettingsArgs,
    },
    /// Configuration management commands
    Config {
        #[command(subcommand)]
        action: ConfigAction,
    },
}

#[derive(Subcommand)]
pub enum ConfigAction {
    /// Show the current configuration
    Show {
        /// Hide settings that have their default value
        #[arg(long = "no-defaults")]
        no_defaults: bool,

        /// Show the source (toml, env, cli) for each setting
        #[arg(long = "sources")]
        show_sources: bool,
    },
    /// Initialize a new configuration file with default values
    Init {
        /// Output file path (default: ./kellnr.toml)
        #[arg(short = 'o', long = "output")]
        output: Option<PathBuf>,
    },
}

/// Options for the `config show` command
#[derive(Debug, Clone, Default)]
pub struct ShowConfigOptions {
    /// Hide settings that have their default value
    pub no_defaults: bool,
    /// Show the source (toml, env, cli) for each setting
    pub show_sources: bool,
}

/// A `Settings` paired with its per-leaf `SettingsProv`. Bundled because every
/// non-init `CliResult` variant needs both, the active values for runtime use
/// and the provenance for source attribution (`/settings` API and
/// `config show`). `prov` is `Arc` so it can move into the app state without a
/// re-allocation; `SettingsProv` is also large enough that an inline owned
/// value would balloon `CliResult`.
pub struct ResolvedSettings {
    pub settings: Settings,
    pub prov: Arc<SettingsProv>,
}

pub enum CliResult {
    RunServer(ResolvedSettings),
    ShowConfig {
        resolved: ResolvedSettings,
        options: ShowConfigOptions,
    },
    /// `config init` writes a fresh `Settings::default()` to `output`; no
    /// settings field is carried because there's nothing to vary per call.
    InitConfig {
        output: PathBuf,
    },
    ShowHelp,
}

pub fn parse_cli() -> Result<CliResult, SettingsError> {
    let cli = Cli::parse();

    match cli.command {
        // No subcommand → print help and exit without touching the config
        // pipeline. Doing this before any file/env resolution means
        // `kellnr` with no args always succeeds, even if `KELLNR_CONFIG_FILE`
        // happens to point at a missing path.
        None => {
            Cli::command().print_help().ok();
            Ok(CliResult::ShowHelp)
        }

        // `config init` doesn't need to merge sources, it writes the
        // compiled-in defaults.
        Some(Command::Config {
            action: ConfigAction::Init { output },
        }) => Ok(CliResult::InitConfig {
            output: output.unwrap_or_else(|| PathBuf::from("kellnr.toml")),
        }),

        Some(ref command) => build_from_command(cli.config_file, command),
    }
}

fn build_from_command(
    cli_config_file: Option<PathBuf>,
    command: &Command,
) -> Result<CliResult, SettingsError> {
    // Config file priority: CLI > env var > compile-time > None.
    let env_config_file = std::env::var("KELLNR_CONFIG_FILE").ok();
    let config_file: Option<PathBuf> = cli_config_file
        .or(env_config_file.map(PathBuf::from))
        .or(compile_time_config::KELLNR_COMPTIME__CONFIG_FILE.map(PathBuf::from));

    // `SettingsPartial::from(&SettingsArgs)` is macro-generated by
    // `provcfg-clap`: it walks every section and copies the user-supplied
    // `Some(...)` values from clap. Unset flags stay `None`, so the CLI
    // source contributes provenance only for fields explicitly passed.
    let cli_partial: Option<SettingsPartial> = match command {
        Command::Start { server } => Some(SettingsPartial::from(server)),
        Command::Config { .. } => None,
    };

    let prov = build_prov_with_cli(config_file.as_deref(), cli_partial)?;
    let resolved = ResolvedSettings {
        settings: Settings::from(&prov),
        prov: Arc::new(prov),
    };

    match command {
        Command::Start { .. } => Ok(CliResult::RunServer(resolved)),
        Command::Config {
            action:
                ConfigAction::Show {
                    no_defaults,
                    show_sources,
                },
        } => Ok(CliResult::ShowConfig {
            resolved,
            options: ShowConfigOptions {
                no_defaults: *no_defaults,
                show_sources: *show_sources,
            },
        }),
        Command::Config {
            action: ConfigAction::Init { .. },
        } => unreachable!("`Init` is handled by `parse_cli` before this point"),
    }
}

/// Map every settings leaf (dotted path) to its user-facing CLI flag(s).
///
/// Walks `start`'s flattened `SettingsArgs` via clap reflection so the
/// `/settings` UI doesn't have to hand-maintain a parallel table that drifts
/// from the actual flags. Output format: `"--registry-data-dir, -d"` (long,
/// then short if any). Args whose id doesn't follow the `<prefix>-<field>`
/// convention (e.g. the top-level `--config` flag, clap's built-in `--help`)
/// are skipped, they don't correspond to a settings leaf.
#[must_use]
pub fn cli_flag_map() -> HashMap<String, String> {
    let cmd = Cli::command();
    let Some(start) = cmd.find_subcommand("start") else {
        return HashMap::new();
    };

    let mut map = HashMap::new();
    for arg in start.get_arguments() {
        let id = arg.get_id().as_str();
        let Some(path) = id_to_dotted_path(id) else {
            continue;
        };
        let mut flag = String::new();
        if let Some(long) = arg.get_long() {
            flag.push_str("--");
            flag.push_str(long);
        }
        if let Some(short) = arg.get_short() {
            if !flag.is_empty() {
                flag.push_str(", ");
            }
            flag.push('-');
            flag.push(short);
        }
        if !flag.is_empty() {
            map.insert(path, flag);
        }
    }
    map
}

/// Convert a clap arg id like `registry-data-dir` into its `SettingsProv`
/// dotted path `registry.data_dir`. Returns `None` for ids without at least
/// one `-` (those don't come from a `clap_prefix`-ed section and so don't
/// map to a leaf).
fn id_to_dotted_path(id: &str) -> Option<String> {
    let (section, rest) = id.split_once('-')?;
    Some(format!("{section}.{}", rest.replace('-', "_")))
}

#[cfg(test)]
mod tests {
    use std::io::Write;
    use std::path::Path;

    use provcfg::Category;
    use tempfile::NamedTempFile;

    use super::*;

    // Env-source precedence (env wins over TOML, CLI wins over env) is covered
    // by provcfg's own suite, `full_layered_stack_defaults_toml_env_cli` in
    // `provcfg/src/tests/defaults.rs`. Re-asserting it here would require
    // mutating `KELLNR_*` process env vars, which means `unsafe` (edition 2024)
    // and is forbidden workspace-wide. These tests instead pin down the
    // kellnr-level wiring, TOML and CLI, through the same `parse_cli` path,
    // and the `build_prov_with_cli` body documents the env layer's position.

    fn write_toml(contents: &str) -> NamedTempFile {
        let mut file = NamedTempFile::new().expect("tempfile");
        file.write_all(contents.as_bytes()).expect("write toml");
        file
    }

    /// Drive `Cli::try_parse_from` + `build_from_command` end-to-end, the
    /// same code path `parse_cli` takes once `Cli::parse()` has read argv.
    fn run_start(config_file: Option<&Path>, extra_args: &[&str]) -> CliResult {
        let mut argv: Vec<&str> = vec!["kellnr", "start"];
        argv.extend(extra_args.iter().copied());
        let cli = Cli::try_parse_from(&argv).expect("clap should accept argv");
        let command = cli.command.expect("start subcommand present");
        build_from_command(config_file.map(PathBuf::from), &command).expect("build")
    }

    #[test]
    fn toml_only_marks_overridden_leaves_with_file_provenance() {
        let file = write_toml("[registry]\ndata_dir = \"/tmp/from-toml\"\n");
        let CliResult::RunServer(resolved) = run_start(Some(file.path()), &[]) else {
            panic!("expected RunServer");
        };

        assert_eq!(resolved.settings.registry.data_dir, "/tmp/from-toml");
        assert_eq!(
            resolved.prov.registry.data_dir.source().category(),
            Category::File,
        );
    }

    #[test]
    fn cli_flag_overrides_toml_with_cli_provenance() {
        let file = write_toml("[registry]\ndata_dir = \"/tmp/from-toml\"\n");
        let CliResult::RunServer(resolved) =
            run_start(Some(file.path()), &["--registry-data-dir", "/tmp/from-cli"])
        else {
            panic!("expected RunServer");
        };

        assert_eq!(resolved.settings.registry.data_dir, "/tmp/from-cli");
        assert_eq!(
            resolved.prov.registry.data_dir.source().category(),
            Category::Cli,
        );
    }

    #[test]
    fn config_show_returns_show_config_variant_with_options() {
        // `config show --sources` flows through `build_from_command` like
        // `start` does, so it must also produce a usable `SettingsProv`.
        let file = write_toml("[registry]\ndata_dir = \"/tmp/from-toml\"\n");
        let argv = ["kellnr", "config", "show", "--sources"];
        let cli = Cli::try_parse_from(argv).expect("clap parse");
        let command = cli.command.expect("config subcommand present");

        let result = build_from_command(Some(file.path().to_path_buf()), &command).unwrap();
        let CliResult::ShowConfig { resolved, options } = result else {
            panic!("expected ShowConfig variant");
        };
        assert!(options.show_sources);
        assert!(!options.no_defaults);
        assert_eq!(resolved.settings.registry.data_dir, "/tmp/from-toml");
        assert_eq!(
            resolved.prov.registry.data_dir.source().category(),
            Category::File,
        );
    }

    #[test]
    fn id_to_dotted_path_converts_dashes_after_first_segment() {
        assert_eq!(
            id_to_dotted_path("registry-data-dir"),
            Some("registry.data_dir".to_string())
        );
        assert_eq!(
            id_to_dotted_path("registry-token-cache-ttl-seconds"),
            Some("registry.token_cache_ttl_seconds".to_string())
        );
        // Single-segment ids (top-level flags like `--config`, clap's --help)
        // don't map to a settings leaf.
        assert_eq!(id_to_dotted_path("config"), None);
        assert_eq!(id_to_dotted_path("help"), None);
    }

    #[test]
    fn cli_flag_map_covers_every_section_with_correct_paths() {
        let map = cli_flag_map();

        // A long-only flag: clap auto-derives `--registry-data-dir` from the
        // `clap_prefix = "registry"` section and field name `data_dir`.
        assert_eq!(
            map.get("registry.data_dir").map(String::as_str),
            Some("--registry-data-dir, -d"),
            "registry.data_dir must surface its auto-derived long plus the manual `-d` short"
        );
        // A user-supplied `#[arg(long = "...")]` overrides the auto-derive
        // while the id (and therefore our lookup key) still follows the
        // section.field convention.
        assert_eq!(
            map.get("registry.token_cache_ttl_seconds")
                .map(String::as_str),
            Some("--registry-token-cache-ttl"),
        );
        // Spot-check leaves from other sections so a missing wiring on any
        // single section shows up here.
        assert_eq!(
            map.get("docs.enabled").map(String::as_str),
            Some("--docs-enabled")
        );
        assert_eq!(
            map.get("s3.allow_http").map(String::as_str),
            Some("--s3-allow-http")
        );
        assert_eq!(
            map.get("local.port").map(String::as_str),
            Some("--local-port, -p")
        );
        // The top-level `--config` arg is not a settings leaf, must not leak in.
        assert!(
            !map.contains_key("config"),
            "top-level `--config` is not a leaf"
        );
    }
}