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 {
#[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 {
#[command(flatten)]
server: SettingsArgs,
},
Config {
#[command(subcommand)]
action: ConfigAction,
},
#[command(name = DOC_BUILD_SUBCOMMAND, hide = true)]
DocBuild {
crate_path: PathBuf,
#[arg(long = DOC_BUILD_CRATESIO_INDEX_ARG)]
cratesio_index: Option<String>,
},
}
pub const DOC_BUILD_SUBCOMMAND: &str = "doc-build";
pub const DOC_BUILD_CRATESIO_INDEX_ARG: &str = "cratesio-index";
#[derive(Subcommand)]
pub enum ConfigAction {
Show {
#[arg(long = "no-defaults")]
no_defaults: bool,
#[arg(long = "sources")]
show_sources: bool,
},
Init {
#[arg(short = 'o', long = "output")]
output: Option<PathBuf>,
},
}
#[derive(Debug, Clone, Default)]
pub struct ShowConfigOptions {
pub no_defaults: bool,
pub show_sources: bool,
}
pub struct ResolvedSettings {
pub settings: Settings,
pub prov: Arc<SettingsProv>,
}
pub enum CliResult {
RunServer(ResolvedSettings),
ShowConfig {
resolved: ResolvedSettings,
options: ShowConfigOptions,
},
InitConfig {
output: PathBuf,
},
ShowHelp,
BuildDocs {
crate_path: PathBuf,
cratesio_index: Option<String>,
},
}
pub fn parse_cli() -> Result<CliResult, SettingsError> {
let cli = Cli::parse();
match cli.command {
None => {
Cli::command().print_help().ok();
Ok(CliResult::ShowHelp)
}
Some(Command::Config {
action: ConfigAction::Init { output },
}) => Ok(CliResult::InitConfig {
output: output.unwrap_or_else(|| PathBuf::from("kellnr.toml")),
}),
Some(Command::DocBuild {
crate_path,
cratesio_index,
}) => Ok(CliResult::BuildDocs {
crate_path,
cratesio_index,
}),
Some(ref command) => build_from_command(cli.config_file, command),
}
}
fn build_from_command(
cli_config_file: Option<PathBuf>,
command: &Command,
) -> Result<CliResult, SettingsError> {
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));
let cli_partial: Option<SettingsPartial> = match command {
Command::Start { server } => Some(SettingsPartial::from(server)),
Command::Config { .. } | Command::DocBuild { .. } => 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"),
Command::DocBuild { .. } => {
unreachable!("`DocBuild` is handled by `parse_cli` before this point")
}
}
}
#[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
}
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::*;
fn write_toml(contents: &str) -> NamedTempFile {
let mut file = NamedTempFile::new().expect("tempfile");
file.write_all(contents.as_bytes()).expect("write toml");
file
}
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 cookie_secure_defaults_to_unset_and_reads_from_toml_and_cli() {
let file = write_toml("[registry]\ndata_dir = \"/tmp/x\"\n");
let CliResult::RunServer(resolved) = run_start(Some(file.path()), &[]) else {
panic!("expected RunServer");
};
assert_eq!(resolved.settings.origin.cookie_secure, None);
let file = write_toml("[origin]\ncookie_secure = true\n");
let CliResult::RunServer(resolved) = run_start(Some(file.path()), &[]) else {
panic!("expected RunServer");
};
assert_eq!(resolved.settings.origin.cookie_secure, Some(true));
let CliResult::RunServer(resolved) =
run_start(Some(file.path()), &["--origin-cookie-secure", "false"])
else {
panic!("expected RunServer");
};
assert_eq!(resolved.settings.origin.cookie_secure, Some(false));
}
#[test]
fn config_show_returns_show_config_variant_with_options() {
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())
);
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();
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"
);
assert_eq!(
map.get("registry.token_cache_ttl_seconds")
.map(String::as_str),
Some("--registry-token-cache-ttl"),
);
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")
);
assert!(
!map.contains_key("config"),
"top-level `--config` is not a leaf"
);
}
#[test]
fn doc_build_subcommand_is_hidden_and_parses_arguments() {
let cli = Cli::try_parse_from([
"kellnr",
DOC_BUILD_SUBCOMMAND,
"/tmp/crate",
"--cratesio-index",
"https://proxy/index/",
])
.expect("clap should accept argv");
let Some(Command::DocBuild {
crate_path,
cratesio_index,
}) = cli.command
else {
panic!("expected DocBuild");
};
assert_eq!(crate_path, PathBuf::from("/tmp/crate"));
assert_eq!(cratesio_index.as_deref(), Some("https://proxy/index/"));
assert!(
Cli::command()
.find_subcommand(DOC_BUILD_SUBCOMMAND)
.unwrap()
.is_hide_set()
);
}
}