Skip to main content

clap_config_fallback/
lib.rs

1//! `clap-config-fallback` extends clap with configuration-file fallback while preserving clap as
2//! the final parser/validator.
3//!
4//! Typical flow:
5//! 1. Parse CLI args into an optional intermediate struct.
6//! 2. Load config values from `#[config(path)]` (if provided).
7//! 3. Merge with precedence `CLI > config`.
8//! 4. Re-run clap on reconstructed arguments for final validation.
9
10mod arg;
11pub mod format;
12
13use std::{
14    ffi::{OsStr, OsString},
15    fs, iter,
16    path::{Path, PathBuf},
17};
18
19use clap::{CommandFactory, Error, Parser, error::ErrorKind};
20use figment::{Figment, providers::*};
21use serde::{Serialize, de::DeserializeOwned};
22
23#[cfg(feature = "derive")]
24pub use clap_config_fallback_derive::{ConfigArgs, ConfigParser, ConfigSubcommand};
25
26pub use arg::{Arg, FromArgs, IntoArgs};
27
28/// Supported configuration file formats.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum ConfigFormat {
31    Toml,
32    Yaml,
33    Json,
34}
35
36/// Provides configuration path and format discovery for fallback loading.
37pub trait ConfigSource {
38    /// Returns the config file path when fallback should be attempted.
39    fn config_path(&self) -> Option<&Path>;
40
41    /// Returns a config format if it can be resolved.
42    ///
43    /// The default implementation infers from extension:
44    /// - `.toml` (`toml` feature)
45    /// - `.yaml` / `.yml` (`yaml` feature)
46    /// - `.json` (`json` feature)
47    fn config_format(&self) -> Option<ConfigFormat> {
48        self.config_path()
49            .and_then(|path| match path.extension().and_then(OsStr::to_str) {
50                #[cfg(feature = "toml")]
51                Some("toml") => Some(ConfigFormat::Toml),
52                #[cfg(feature = "yaml")]
53                Some("yaml" | "yml") => Some(ConfigFormat::Yaml),
54                #[cfg(feature = "json")]
55                Some("json") => Some(ConfigFormat::Json),
56                _ => None,
57            })
58    }
59}
60
61/// Defines the associated types for config fallback parsing.
62pub trait ConfigFallback {
63    /// Intermediate optional representation used during merge.
64    type Opts: Serialize + DeserializeOwned + IntoArgs + FromArgs;
65    /// Config-only representation loaded from file.
66    type Config: Serialize + DeserializeOwned;
67}
68
69/// Parse a clap struct with optional configuration-file fallback.
70///
71/// Deriving `ConfigParser` generates an internal optional `Opts` type and a config-deserialization
72/// type, then wires them into this trait.
73pub trait ConfigParser: Sized + Parser + ConfigFallback
74where
75    Self::Opts: Parser + Default + ConfigSource,
76{
77    /// Equivalent to [`Parser::parse`], but with config fallback.
78    fn parse_with_config() -> Self {
79        Self::parse_with_config_from(std::env::args_os())
80    }
81
82    /// Equivalent to [`Parser::try_parse`], but with config fallback.
83    fn try_parse_with_config() -> Result<Self, Error> {
84        Self::try_parse_with_config_from(std::env::args_os())
85    }
86
87    /// Equivalent to [`Parser::parse_from`], but with config fallback.
88    fn parse_with_config_from<I, T>(itr: I) -> Self
89    where
90        I: IntoIterator<Item = T>,
91        T: Into<OsString> + Clone,
92    {
93        Self::try_parse_with_config_from(itr).unwrap_or_else(|e| e.exit())
94    }
95
96    /// Performs parsing with config fallback and returns clap errors instead of exiting.
97    ///
98    /// Merge precedence is **CLI > config**. If no config path is available, behavior matches a
99    /// normal clap parse.
100    fn try_parse_with_config_from<I, T>(args: I) -> Result<Self, Error>
101    where
102        I: IntoIterator<Item = T>,
103        T: Into<OsString> + Clone,
104    {
105        let command = <Self::Opts as CommandFactory>::command();
106        let command_name = command.get_name().to_owned().into();
107        let args = args.into_iter().collect::<Vec<_>>();
108
109        for arg in args.iter().cloned() {
110            // short-circuit to have fully functional help/version output
111            if let Some("--help" | "-h" | "--version" | "-V") = arg.into().as_os_str().to_str() {
112                return Self::try_parse_from(args);
113            }
114        }
115
116        let args = command.try_get_matches_from(args)?;
117        let opts = Self::Opts::from_args(&args).unwrap_or_default();
118
119        let config: Option<Self::Config> = opts
120            .config_path()
121            .map(|path| {
122                let path = PathBuf::from(path);
123
124                if !path.exists() {
125                    return Err(
126                        Self::command().error(ErrorKind::Io, "configuration file not found")
127                    );
128                }
129
130                let content = fs::read_to_string(path).map_err(|e| {
131                    Self::command().error(
132                        ErrorKind::Io,
133                        format!("failed to read configuration file: {e}"),
134                    )
135                })?;
136
137                match opts.config_format() {
138                    #[cfg(feature = "toml")]
139                    Some(ConfigFormat::Toml) => toml::from_str(&content).map_err(|e| e.to_string()),
140                    #[cfg(feature = "yaml")]
141                    Some(ConfigFormat::Yaml) => {
142                        noyalib::from_str_borrowing(&content).map_err(|e| e.to_string())
143                    }
144                    #[cfg(feature = "json")]
145                    Some(ConfigFormat::Json) => {
146                        serde_json::from_str(&content).map_err(|e| e.to_string())
147                    }
148                    _ => Err("unsupported format".to_string()),
149                }
150                .map_err(|e| {
151                    Self::command().error(
152                        ErrorKind::InvalidValue,
153                        format!("invalid configuration file: {e}"),
154                    )
155                })
156            })
157            .transpose()?;
158
159        let mut cli = Figment::from(Serialized::defaults(opts));
160
161        if let Some(config) = config {
162            cli = cli.join(Serialized::defaults(config));
163        }
164
165        let cli = cli
166            .extract::<Self::Opts>()
167            .map_err(|e| Self::command().error(ErrorKind::InvalidValue, e.to_string()))?;
168
169        Self::try_parse_from(iter::once(command_name).chain(cli.into_args()))
170    }
171}