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 #[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 {
29 #[command(flatten)]
33 server: SettingsArgs,
34 },
35 Config {
37 #[command(subcommand)]
38 action: ConfigAction,
39 },
40 #[command(name = DOC_BUILD_SUBCOMMAND, hide = true)]
43 DocBuild {
44 crate_path: PathBuf,
46
47 #[arg(long = DOC_BUILD_CRATESIO_INDEX_ARG)]
49 cratesio_index: Option<String>,
50 },
51}
52
53pub const DOC_BUILD_SUBCOMMAND: &str = "doc-build";
55pub const DOC_BUILD_CRATESIO_INDEX_ARG: &str = "cratesio-index";
57
58#[derive(Subcommand)]
59pub enum ConfigAction {
60 Show {
62 #[arg(long = "no-defaults")]
64 no_defaults: bool,
65
66 #[arg(long = "sources")]
68 show_sources: bool,
69 },
70 Init {
72 #[arg(short = 'o', long = "output")]
74 output: Option<PathBuf>,
75 },
76}
77
78#[derive(Debug, Clone, Default)]
80pub struct ShowConfigOptions {
81 pub no_defaults: bool,
83 pub show_sources: bool,
85}
86
87pub 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 InitConfig {
107 output: PathBuf,
108 },
109 ShowHelp,
110 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 None => {
126 Cli::command().print_help().ok();
127 Ok(CliResult::ShowHelp)
128 }
129
130 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 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#[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 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 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#[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
259fn id_to_dotted_path(id: &str) -> Option<String> {
264 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 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 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 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 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 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 assert_eq!(
528 map.get("registry.token_cache_ttl_seconds")
529 .map(String::as_str),
530 Some("--registry-token-cache-ttl"),
531 );
532 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 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 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}