Skip to main content

kellnr_settings/
cli.rs

1use std::path::PathBuf;
2
3use clap::{CommandFactory, Parser, Subcommand};
4use clap_serde_derive::ClapSerde;
5use config::ConfigError;
6use toml::Value;
7
8use crate::config_source::{ConfigSource, track_env_sources};
9use crate::docs::Docs;
10use crate::local::Local;
11use crate::log::{LogFormat, LogLevel};
12use crate::oauth2::OAuth2;
13use crate::origin::Origin;
14use crate::postgresql::Postgresql;
15use crate::proxy::Proxy;
16use crate::registry::Registry;
17use crate::s3::S3;
18use crate::settings::Settings;
19use crate::setup::Setup;
20use crate::toolchain::Toolchain;
21use crate::{SourceMap, compile_time_config};
22
23/// Compare two Settings structs and mark all differences with the given source.
24///
25/// Uses serde serialization to automatically iterate over all sections and fields.
26/// New sections or fields added to Settings are automatically tracked without
27/// requiring manual updates to this function.
28fn track_settings_differences(
29    sources: &mut SourceMap,
30    current: &Settings,
31    previous: &Settings,
32    source: ConfigSource,
33) {
34    let Ok(current_value) = Value::try_from(current) else {
35        return;
36    };
37    let Ok(previous_value) = Value::try_from(previous) else {
38        return;
39    };
40
41    let (Value::Table(current_table), Value::Table(previous_table)) =
42        (current_value, previous_value)
43    else {
44        return;
45    };
46
47    for (section_name, section_value) in &current_table {
48        // Get the corresponding section from previous settings
49        let Some(previous_section) = previous_table.get(section_name) else {
50            continue;
51        };
52
53        // Both must be tables (config sections)
54        let (Value::Table(current_fields), Value::Table(previous_fields)) =
55            (section_value, previous_section)
56        else {
57            continue;
58        };
59
60        // Compare each field in the section
61        for (field_name, field_value) in current_fields {
62            if let Some(previous_field) = previous_fields.get(field_name)
63                && field_value != previous_field
64            {
65                sources.insert(format!("{section_name}.{field_name}"), source);
66            }
67        }
68    }
69}
70
71#[derive(Parser)]
72#[command(name = "kellnr", version, about)]
73pub struct Cli {
74    /// Path to configuration file
75    #[arg(id = "config", short = 'c', long = "config", global = true)]
76    pub config_file: Option<PathBuf>,
77
78    #[command(subcommand)]
79    pub command: Option<Command>,
80}
81
82/// Server configuration options
83#[derive(Parser, Default)]
84pub struct ServerArgs {
85    #[command(flatten)]
86    pub local: <Local as ClapSerde>::Opt,
87
88    #[command(flatten)]
89    pub origin: <Origin as ClapSerde>::Opt,
90
91    #[command(flatten)]
92    pub registry: <Registry as ClapSerde>::Opt,
93
94    #[command(flatten)]
95    pub docs: <Docs as ClapSerde>::Opt,
96
97    #[command(flatten)]
98    pub proxy: <Proxy as ClapSerde>::Opt,
99
100    #[command(flatten)]
101    pub postgresql: <Postgresql as ClapSerde>::Opt,
102
103    #[command(flatten)]
104    pub s3: <S3 as ClapSerde>::Opt,
105
106    #[command(flatten)]
107    pub setup: <Setup as ClapSerde>::Opt,
108
109    #[command(flatten)]
110    pub oauth2: <OAuth2 as ClapSerde>::Opt,
111
112    #[command(flatten)]
113    pub toolchain: <Toolchain as ClapSerde>::Opt,
114
115    // Log settings (manual, not using ClapSerde due to custom serde deserializers)
116    /// Log output format
117    #[arg(id = "log-format", long = "log-format", value_enum)]
118    pub log_format: Option<LogFormat>,
119
120    /// Log level
121    #[arg(id = "log-level", short = 'l', long = "log-level", value_enum)]
122    pub log_level: Option<LogLevel>,
123
124    /// Log level for web server
125    #[arg(id = "log-level-web-server", long = "log-level-web-server", value_enum)]
126    pub log_level_web_server: Option<LogLevel>,
127}
128
129#[derive(Subcommand)]
130#[allow(clippy::large_enum_variant)]
131pub enum Command {
132    /// Start the kellnr server
133    Start {
134        #[command(flatten)]
135        server: ServerArgs,
136    },
137    /// Configuration management commands
138    Config {
139        #[command(subcommand)]
140        action: ConfigAction,
141    },
142}
143
144#[derive(Subcommand)]
145pub enum ConfigAction {
146    /// Show the current configuration
147    Show {
148        /// Hide settings that have their default value
149        #[arg(long = "no-defaults")]
150        no_defaults: bool,
151
152        /// Show the source (toml, env, cli) for each setting
153        #[arg(long = "sources")]
154        show_sources: bool,
155    },
156    /// Initialize a new configuration file with default values
157    Init {
158        /// Output file path (default: ./kellnr.toml)
159        #[arg(short = 'o', long = "output")]
160        output: Option<PathBuf>,
161    },
162}
163
164/// Options for the `config show` command
165#[derive(Debug, Clone, Default)]
166pub struct ShowConfigOptions {
167    /// Hide settings that have their default value
168    pub no_defaults: bool,
169    /// Show the source (toml, env, cli) for each setting
170    pub show_sources: bool,
171}
172
173pub enum CliResult {
174    RunServer(Settings),
175    ShowConfig {
176        settings: Settings,
177        options: ShowConfigOptions,
178    },
179    InitConfig {
180        settings: Settings,
181        output: PathBuf,
182    },
183    ShowHelp,
184}
185
186pub fn parse_cli() -> Result<CliResult, ConfigError> {
187    let cli = Cli::parse();
188
189    // Handle `config init` early - it doesn't need an existing config file
190    if let Some(Command::Config {
191        action: ConfigAction::Init { output },
192    }) = &cli.command
193    {
194        let output_path = output
195            .clone()
196            .unwrap_or_else(|| PathBuf::from("kellnr.toml"));
197        return Ok(CliResult::InitConfig {
198            settings: Settings::default(),
199            output: output_path,
200        });
201    }
202
203    // Config file priority: CLI > env var > compile-time > None
204    let env_config_file = std::env::var("KELLNR_CONFIG_FILE").ok();
205    let config_file: Option<PathBuf> = cli
206        .config_file
207        .or(env_config_file.map(PathBuf::from))
208        .or(compile_time_config::KELLNR_COMPTIME__CONFIG_FILE.map(PathBuf::from));
209
210    // Load settings from file + env (sources are initialized to Default)
211    let mut settings = Settings::try_from(config_file.as_deref())?;
212
213    // Track TOML sources (compare to defaults)
214    if config_file.is_some() {
215        track_toml_sources(&mut settings);
216    }
217
218    // Track ENV sources (check which env vars are set)
219    // This must come after TOML tracking since ENV overrides TOML
220    track_env_sources(&mut settings.sources);
221
222    // Handle subcommands
223    match cli.command {
224        Some(Command::Start { server }) => {
225            // Track and merge CLI args
226            track_and_merge_cli(&mut settings, server);
227
228            Ok(CliResult::RunServer(settings))
229        }
230        Some(Command::Config { action }) => match action {
231            ConfigAction::Show {
232                no_defaults,
233                show_sources,
234            } => Ok(CliResult::ShowConfig {
235                settings,
236                options: ShowConfigOptions {
237                    no_defaults,
238                    show_sources,
239                },
240            }),
241            ConfigAction::Init { .. } => {
242                // Already handled above
243                unreachable!()
244            }
245        },
246        None => {
247            // No subcommand - show help
248            Cli::command().print_help().ok();
249            Ok(CliResult::ShowHelp)
250        }
251    }
252}
253
254/// Legacy function for backward compatibility
255pub fn get_settings_with_cli() -> Result<Settings, ConfigError> {
256    match parse_cli()? {
257        CliResult::RunServer(settings)
258        | CliResult::ShowConfig { settings, .. }
259        | CliResult::InitConfig { settings, .. } => Ok(settings),
260        CliResult::ShowHelp => Ok(Settings::default()),
261    }
262}
263
264/// Compare current settings to defaults and mark non-default values as TOML-sourced.
265///
266/// Uses serde serialization to automatically iterate over all sections and fields,
267/// so new settings are automatically tracked without manual updates.
268fn track_toml_sources(settings: &mut Settings) {
269    let current = settings.clone();
270    let defaults = Settings::default();
271    track_settings_differences(
272        &mut settings.sources,
273        &current,
274        &defaults,
275        ConfigSource::Toml,
276    );
277}
278
279/// Track CLI arguments and merge them into settings.
280///
281/// Uses a snapshot-and-compare approach: captures settings before merge,
282/// performs all merges, then marks any changed fields as CLI-sourced.
283/// This ensures new fields are automatically tracked without manual updates.
284///
285/// Note: New sections still need to be added to the merge list below,
286/// but tracking of which fields changed is fully automatic via serde.
287fn track_and_merge_cli(settings: &mut Settings, server: ServerArgs) {
288    // Snapshot before any merges
289    let before = settings.clone();
290
291    // Merge all ClapSerde sections
292    settings.local = settings.local.clone().merge(server.local);
293    settings.origin = settings.origin.clone().merge(server.origin);
294    settings.registry = settings.registry.clone().merge(server.registry);
295    settings.docs = settings.docs.clone().merge(server.docs);
296    settings.proxy = settings.proxy.clone().merge(server.proxy);
297    settings.postgresql = settings.postgresql.clone().merge(server.postgresql);
298    settings.s3 = settings.s3.clone().merge(server.s3);
299    settings.setup = settings.setup.clone().merge(server.setup);
300    settings.oauth2 = settings.oauth2.clone().merge(server.oauth2);
301    settings.toolchain = settings.toolchain.clone().merge(server.toolchain);
302
303    // Log settings (manual merge since they don't use ClapSerde)
304    if let Some(format) = server.log_format {
305        settings.log.format = format;
306    }
307    if let Some(level) = server.log_level {
308        settings.log.level = level;
309    }
310    if let Some(level) = server.log_level_web_server {
311        settings.log.level_web_server = level;
312    }
313
314    // Track all changes at once - fully automatic via serde
315    // Clone current state to avoid borrow conflict with settings.sources
316    let current = settings.clone();
317    track_settings_differences(&mut settings.sources, &current, &before, ConfigSource::Cli);
318}