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}
40
41#[derive(Subcommand)]
42pub enum ConfigAction {
43 Show {
45 #[arg(long = "no-defaults")]
47 no_defaults: bool,
48
49 #[arg(long = "sources")]
51 show_sources: bool,
52 },
53 Init {
55 #[arg(short = 'o', long = "output")]
57 output: Option<PathBuf>,
58 },
59}
60
61#[derive(Debug, Clone, Default)]
63pub struct ShowConfigOptions {
64 pub no_defaults: bool,
66 pub show_sources: bool,
68}
69
70pub struct ResolvedSettings {
77 pub settings: Settings,
78 pub prov: Arc<SettingsProv>,
79}
80
81pub enum CliResult {
82 RunServer(ResolvedSettings),
83 ShowConfig {
84 resolved: ResolvedSettings,
85 options: ShowConfigOptions,
86 },
87 InitConfig {
90 output: PathBuf,
91 },
92 ShowHelp,
93}
94
95pub fn parse_cli() -> Result<CliResult, SettingsError> {
96 let cli = Cli::parse();
97
98 match cli.command {
99 None => {
104 Cli::command().print_help().ok();
105 Ok(CliResult::ShowHelp)
106 }
107
108 Some(Command::Config {
111 action: ConfigAction::Init { output },
112 }) => Ok(CliResult::InitConfig {
113 output: output.unwrap_or_else(|| PathBuf::from("kellnr.toml")),
114 }),
115
116 Some(ref command) => build_from_command(cli.config_file, command),
117 }
118}
119
120fn build_from_command(
121 cli_config_file: Option<PathBuf>,
122 command: &Command,
123) -> Result<CliResult, SettingsError> {
124 let env_config_file = std::env::var("KELLNR_CONFIG_FILE").ok();
126 let config_file: Option<PathBuf> = cli_config_file
127 .or(env_config_file.map(PathBuf::from))
128 .or(compile_time_config::KELLNR_COMPTIME__CONFIG_FILE.map(PathBuf::from));
129
130 let cli_partial: Option<SettingsPartial> = match command {
135 Command::Start { server } => Some(SettingsPartial::from(server)),
136 Command::Config { .. } => None,
137 };
138
139 let prov = build_prov_with_cli(config_file.as_deref(), cli_partial)?;
140 let resolved = ResolvedSettings {
141 settings: Settings::from(&prov),
142 prov: Arc::new(prov),
143 };
144
145 match command {
146 Command::Start { .. } => Ok(CliResult::RunServer(resolved)),
147 Command::Config {
148 action:
149 ConfigAction::Show {
150 no_defaults,
151 show_sources,
152 },
153 } => Ok(CliResult::ShowConfig {
154 resolved,
155 options: ShowConfigOptions {
156 no_defaults: *no_defaults,
157 show_sources: *show_sources,
158 },
159 }),
160 Command::Config {
161 action: ConfigAction::Init { .. },
162 } => unreachable!("`Init` is handled by `parse_cli` before this point"),
163 }
164}
165
166#[must_use]
175pub fn cli_flag_map() -> HashMap<String, String> {
176 let cmd = Cli::command();
177 let Some(start) = cmd.find_subcommand("start") else {
178 return HashMap::new();
179 };
180
181 let mut map = HashMap::new();
182 for arg in start.get_arguments() {
183 let id = arg.get_id().as_str();
184 let Some(path) = id_to_dotted_path(id) else {
185 continue;
186 };
187 let mut flag = String::new();
188 if let Some(long) = arg.get_long() {
189 flag.push_str("--");
190 flag.push_str(long);
191 }
192 if let Some(short) = arg.get_short() {
193 if !flag.is_empty() {
194 flag.push_str(", ");
195 }
196 flag.push('-');
197 flag.push(short);
198 }
199 if !flag.is_empty() {
200 map.insert(path, flag);
201 }
202 }
203 map
204}
205
206fn id_to_dotted_path(id: &str) -> Option<String> {
211 let (section, rest) = id.split_once('-')?;
212 Some(format!("{section}.{}", rest.replace('-', "_")))
213}
214
215#[cfg(test)]
216mod tests {
217 use std::io::Write;
218 use std::path::Path;
219
220 use provcfg::Category;
221 use tempfile::NamedTempFile;
222
223 use super::*;
224
225 fn write_toml(contents: &str) -> NamedTempFile {
234 let mut file = NamedTempFile::new().expect("tempfile");
235 file.write_all(contents.as_bytes()).expect("write toml");
236 file
237 }
238
239 fn run_start(config_file: Option<&Path>, extra_args: &[&str]) -> CliResult {
242 let mut argv: Vec<&str> = vec!["kellnr", "start"];
243 argv.extend(extra_args.iter().copied());
244 let cli = Cli::try_parse_from(&argv).expect("clap should accept argv");
245 let command = cli.command.expect("start subcommand present");
246 build_from_command(config_file.map(PathBuf::from), &command).expect("build")
247 }
248
249 #[test]
250 fn toml_only_marks_overridden_leaves_with_file_provenance() {
251 let file = write_toml("[registry]\ndata_dir = \"/tmp/from-toml\"\n");
252 let CliResult::RunServer(resolved) = run_start(Some(file.path()), &[]) else {
253 panic!("expected RunServer");
254 };
255
256 assert_eq!(resolved.settings.registry.data_dir, "/tmp/from-toml");
257 assert_eq!(
258 resolved.prov.registry.data_dir.source().category(),
259 Category::File,
260 );
261 }
262
263 #[test]
264 fn cli_flag_overrides_toml_with_cli_provenance() {
265 let file = write_toml("[registry]\ndata_dir = \"/tmp/from-toml\"\n");
266 let CliResult::RunServer(resolved) =
267 run_start(Some(file.path()), &["--registry-data-dir", "/tmp/from-cli"])
268 else {
269 panic!("expected RunServer");
270 };
271
272 assert_eq!(resolved.settings.registry.data_dir, "/tmp/from-cli");
273 assert_eq!(
274 resolved.prov.registry.data_dir.source().category(),
275 Category::Cli,
276 );
277 }
278
279 #[test]
280 fn config_show_returns_show_config_variant_with_options() {
281 let file = write_toml("[registry]\ndata_dir = \"/tmp/from-toml\"\n");
284 let argv = ["kellnr", "config", "show", "--sources"];
285 let cli = Cli::try_parse_from(argv).expect("clap parse");
286 let command = cli.command.expect("config subcommand present");
287
288 let result = build_from_command(Some(file.path().to_path_buf()), &command).unwrap();
289 let CliResult::ShowConfig { resolved, options } = result else {
290 panic!("expected ShowConfig variant");
291 };
292 assert!(options.show_sources);
293 assert!(!options.no_defaults);
294 assert_eq!(resolved.settings.registry.data_dir, "/tmp/from-toml");
295 assert_eq!(
296 resolved.prov.registry.data_dir.source().category(),
297 Category::File,
298 );
299 }
300
301 #[test]
302 fn id_to_dotted_path_converts_dashes_after_first_segment() {
303 assert_eq!(
304 id_to_dotted_path("registry-data-dir"),
305 Some("registry.data_dir".to_string())
306 );
307 assert_eq!(
308 id_to_dotted_path("registry-token-cache-ttl-seconds"),
309 Some("registry.token_cache_ttl_seconds".to_string())
310 );
311 assert_eq!(id_to_dotted_path("config"), None);
314 assert_eq!(id_to_dotted_path("help"), None);
315 }
316
317 #[test]
318 fn cli_flag_map_covers_every_section_with_correct_paths() {
319 let map = cli_flag_map();
320
321 assert_eq!(
324 map.get("registry.data_dir").map(String::as_str),
325 Some("--registry-data-dir, -d"),
326 "registry.data_dir must surface its auto-derived long plus the manual `-d` short"
327 );
328 assert_eq!(
332 map.get("registry.token_cache_ttl_seconds")
333 .map(String::as_str),
334 Some("--registry-token-cache-ttl"),
335 );
336 assert_eq!(
339 map.get("docs.enabled").map(String::as_str),
340 Some("--docs-enabled")
341 );
342 assert_eq!(
343 map.get("s3.allow_http").map(String::as_str),
344 Some("--s3-allow-http")
345 );
346 assert_eq!(
347 map.get("local.port").map(String::as_str),
348 Some("--local-port, -p")
349 );
350 assert!(
352 !map.contains_key("config"),
353 "top-level `--config` is not a leaf"
354 );
355 }
356}