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 #[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 {
28 #[command(flatten)]
32 server: SettingsArgs,
33 },
34 Config {
36 #[command(subcommand)]
37 action: ConfigAction,
38 },
39 #[command(name = DOC_BUILD_SUBCOMMAND, hide = true)]
42 DocBuild {
43 crate_path: PathBuf,
45
46 #[arg(long = DOC_BUILD_CRATESIO_INDEX_ARG)]
48 cratesio_index: Option<String>,
49 },
50}
51
52pub const DOC_BUILD_SUBCOMMAND: &str = "doc-build";
54pub const DOC_BUILD_CRATESIO_INDEX_ARG: &str = "cratesio-index";
56
57#[derive(Subcommand)]
58pub enum ConfigAction {
59 Show {
61 #[arg(long = "no-defaults")]
63 no_defaults: bool,
64
65 #[arg(long = "sources")]
67 show_sources: bool,
68 },
69 Init {
71 #[arg(short = 'o', long = "output")]
73 output: Option<PathBuf>,
74 },
75}
76
77#[derive(Debug, Clone, Default)]
79pub struct ShowConfigOptions {
80 pub no_defaults: bool,
82 pub show_sources: bool,
84}
85
86pub 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 InitConfig {
106 output: PathBuf,
107 },
108 ShowHelp,
109 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 None => {
125 Cli::command().print_help().ok();
126 Ok(CliResult::ShowHelp)
127 }
128
129 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 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
150fn build_from_command(
151 cli_config_file: Option<PathBuf>,
152 command: &Command,
153) -> Result<CliResult, SettingsError> {
154 let env_config_file = std::env::var("KELLNR_CONFIG_FILE").ok();
156 let config_file: Option<PathBuf> = cli_config_file
157 .or(env_config_file.map(PathBuf::from))
158 .or(compile_time_config::KELLNR_COMPTIME__CONFIG_FILE.map(PathBuf::from));
159
160 let cli_partial: Option<SettingsPartial> = match command {
165 Command::Start { server } => Some(SettingsPartial::from(server)),
166 Command::Config { .. } | Command::DocBuild { .. } => None,
167 };
168
169 let prov = build_prov_with_cli(config_file.as_deref(), cli_partial)?;
170 let resolved = ResolvedSettings {
171 settings: Settings::from(&prov),
172 prov: Arc::new(prov),
173 };
174
175 match command {
176 Command::Start { .. } => Ok(CliResult::RunServer(resolved)),
177 Command::Config {
178 action:
179 ConfigAction::Show {
180 no_defaults,
181 show_sources,
182 },
183 } => Ok(CliResult::ShowConfig {
184 resolved,
185 options: ShowConfigOptions {
186 no_defaults: *no_defaults,
187 show_sources: *show_sources,
188 },
189 }),
190 Command::Config {
191 action: ConfigAction::Init { .. },
192 } => unreachable!("`Init` is handled by `parse_cli` before this point"),
193 Command::DocBuild { .. } => {
194 unreachable!("`DocBuild` is handled by `parse_cli` before this point")
195 }
196 }
197}
198
199#[must_use]
208pub fn cli_flag_map() -> HashMap<String, String> {
209 let cmd = Cli::command();
210 let Some(start) = cmd.find_subcommand("start") else {
211 return HashMap::new();
212 };
213
214 let mut map = HashMap::new();
215 for arg in start.get_arguments() {
216 let id = arg.get_id().as_str();
217 let Some(path) = id_to_dotted_path(id) else {
218 continue;
219 };
220 let mut flag = String::new();
221 if let Some(long) = arg.get_long() {
222 flag.push_str("--");
223 flag.push_str(long);
224 }
225 if let Some(short) = arg.get_short() {
226 if !flag.is_empty() {
227 flag.push_str(", ");
228 }
229 flag.push('-');
230 flag.push(short);
231 }
232 if !flag.is_empty() {
233 map.insert(path, flag);
234 }
235 }
236 map
237}
238
239fn id_to_dotted_path(id: &str) -> Option<String> {
244 let (section, rest) = id.split_once('-')?;
245 Some(format!("{section}.{}", rest.replace('-', "_")))
246}
247
248#[cfg(test)]
249mod tests {
250 use std::io::Write;
251 use std::path::Path;
252
253 use provcfg::Category;
254 use tempfile::NamedTempFile;
255
256 use super::*;
257
258 fn write_toml(contents: &str) -> NamedTempFile {
267 let mut file = NamedTempFile::new().expect("tempfile");
268 file.write_all(contents.as_bytes()).expect("write toml");
269 file
270 }
271
272 fn run_start(config_file: Option<&Path>, extra_args: &[&str]) -> CliResult {
275 let mut argv: Vec<&str> = vec!["kellnr", "start"];
276 argv.extend(extra_args.iter().copied());
277 let cli = Cli::try_parse_from(&argv).expect("clap should accept argv");
278 let command = cli.command.expect("start subcommand present");
279 build_from_command(config_file.map(PathBuf::from), &command).expect("build")
280 }
281
282 #[test]
283 fn toml_only_marks_overridden_leaves_with_file_provenance() {
284 let file = write_toml("[registry]\ndata_dir = \"/tmp/from-toml\"\n");
285 let CliResult::RunServer(resolved) = run_start(Some(file.path()), &[]) else {
286 panic!("expected RunServer");
287 };
288
289 assert_eq!(resolved.settings.registry.data_dir, "/tmp/from-toml");
290 assert_eq!(
291 resolved.prov.registry.data_dir.source().category(),
292 Category::File,
293 );
294 }
295
296 #[test]
297 fn cli_flag_overrides_toml_with_cli_provenance() {
298 let file = write_toml("[registry]\ndata_dir = \"/tmp/from-toml\"\n");
299 let CliResult::RunServer(resolved) =
300 run_start(Some(file.path()), &["--registry-data-dir", "/tmp/from-cli"])
301 else {
302 panic!("expected RunServer");
303 };
304
305 assert_eq!(resolved.settings.registry.data_dir, "/tmp/from-cli");
306 assert_eq!(
307 resolved.prov.registry.data_dir.source().category(),
308 Category::Cli,
309 );
310 }
311
312 #[test]
313 fn cookie_secure_defaults_to_unset_and_reads_from_toml_and_cli() {
314 let file = write_toml("[registry]\ndata_dir = \"/tmp/x\"\n");
315 let CliResult::RunServer(resolved) = run_start(Some(file.path()), &[]) else {
316 panic!("expected RunServer");
317 };
318 assert_eq!(resolved.settings.origin.cookie_secure, None);
319
320 let file = write_toml("[origin]\ncookie_secure = true\n");
321 let CliResult::RunServer(resolved) = run_start(Some(file.path()), &[]) else {
322 panic!("expected RunServer");
323 };
324 assert_eq!(resolved.settings.origin.cookie_secure, Some(true));
325
326 let CliResult::RunServer(resolved) =
327 run_start(Some(file.path()), &["--origin-cookie-secure", "false"])
328 else {
329 panic!("expected RunServer");
330 };
331 assert_eq!(resolved.settings.origin.cookie_secure, Some(false));
332 }
333
334 #[test]
335 fn config_show_returns_show_config_variant_with_options() {
336 let file = write_toml("[registry]\ndata_dir = \"/tmp/from-toml\"\n");
339 let argv = ["kellnr", "config", "show", "--sources"];
340 let cli = Cli::try_parse_from(argv).expect("clap parse");
341 let command = cli.command.expect("config subcommand present");
342
343 let result = build_from_command(Some(file.path().to_path_buf()), &command).unwrap();
344 let CliResult::ShowConfig { resolved, options } = result else {
345 panic!("expected ShowConfig variant");
346 };
347 assert!(options.show_sources);
348 assert!(!options.no_defaults);
349 assert_eq!(resolved.settings.registry.data_dir, "/tmp/from-toml");
350 assert_eq!(
351 resolved.prov.registry.data_dir.source().category(),
352 Category::File,
353 );
354 }
355
356 #[test]
357 fn id_to_dotted_path_converts_dashes_after_first_segment() {
358 assert_eq!(
359 id_to_dotted_path("registry-data-dir"),
360 Some("registry.data_dir".to_string())
361 );
362 assert_eq!(
363 id_to_dotted_path("registry-token-cache-ttl-seconds"),
364 Some("registry.token_cache_ttl_seconds".to_string())
365 );
366 assert_eq!(id_to_dotted_path("config"), None);
369 assert_eq!(id_to_dotted_path("help"), None);
370 }
371
372 #[test]
373 fn cli_flag_map_covers_every_section_with_correct_paths() {
374 let map = cli_flag_map();
375
376 assert_eq!(
379 map.get("registry.data_dir").map(String::as_str),
380 Some("--registry-data-dir, -d"),
381 "registry.data_dir must surface its auto-derived long plus the manual `-d` short"
382 );
383 assert_eq!(
387 map.get("registry.token_cache_ttl_seconds")
388 .map(String::as_str),
389 Some("--registry-token-cache-ttl"),
390 );
391 assert_eq!(
394 map.get("docs.enabled").map(String::as_str),
395 Some("--docs-enabled")
396 );
397 assert_eq!(
398 map.get("s3.allow_http").map(String::as_str),
399 Some("--s3-allow-http")
400 );
401 assert_eq!(
402 map.get("local.port").map(String::as_str),
403 Some("--local-port, -p")
404 );
405 assert!(
407 !map.contains_key("config"),
408 "top-level `--config` is not a leaf"
409 );
410 }
411
412 #[test]
413 fn doc_build_subcommand_is_hidden_and_parses_arguments() {
414 let cli = Cli::try_parse_from([
415 "kellnr",
416 DOC_BUILD_SUBCOMMAND,
417 "/tmp/crate",
418 "--cratesio-index",
419 "https://proxy/index/",
420 ])
421 .expect("clap should accept argv");
422
423 let Some(Command::DocBuild {
424 crate_path,
425 cratesio_index,
426 }) = cli.command
427 else {
428 panic!("expected DocBuild");
429 };
430 assert_eq!(crate_path, PathBuf::from("/tmp/crate"));
431 assert_eq!(cratesio_index.as_deref(), Some("https://proxy/index/"));
432 assert!(
433 Cli::command()
434 .find_subcommand(DOC_BUILD_SUBCOMMAND)
435 .unwrap()
436 .is_hide_set()
437 );
438 }
439}