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
150#[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 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 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#[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
258fn id_to_dotted_path(id: &str) -> Option<String> {
263 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 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 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 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 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 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 assert_eq!(
475 map.get("registry.token_cache_ttl_seconds")
476 .map(String::as_str),
477 Some("--registry-token-cache-ttl"),
478 );
479 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 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 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}