Skip to main content

configulator/
configulator.rs

1use std::marker::PhantomData;
2#[cfg(feature = "file")]
3use std::path::PathBuf;
4
5#[cfg(feature = "cli")]
6use crate::cli;
7use crate::defaults;
8#[cfg(feature = "env")]
9use crate::environment;
10use crate::error::ConfigulatorError;
11#[cfg(feature = "file")]
12use crate::file;
13#[cfg(feature = "file")]
14use crate::options::FileOptions;
15#[cfg(feature = "env")]
16use crate::options::EnvironmentVariableOptions;
17#[cfg(feature = "cli")]
18use crate::options::CLIFlagOptions;
19use crate::value_map::{merge_value_maps, ValueMap};
20#[cfg(all(feature = "cli", feature = "file"))]
21use crate::value_map::ConfigValue;
22use crate::{ConfigFields, FromValueMap, Validate};
23
24/// Builder for loading configuration from multiple sources into a typed struct.
25///
26/// Sources are applied in precedence order: defaults < file < env vars < CLI flags.
27///
28/// Available sources depend on enabled feature flags (`file`, `env`, `cli`).
29pub struct Configulator<C> {
30    #[cfg(feature = "file")]
31    file_opts: Option<FileOptions>,
32    #[cfg(feature = "env")]
33    env_opts: Option<EnvironmentVariableOptions>,
34    #[cfg(feature = "cli")]
35    cli_opts: Option<CLIFlagOptions>,
36    #[cfg(feature = "testing")]
37    cli_args: Option<Vec<String>>,
38    #[cfg(feature = "cli")]
39    cli_command: Option<clap::Command>,
40    _marker: PhantomData<C>,
41}
42
43impl<C: ConfigFields + FromValueMap + Default> Configulator<C> {
44    /// Create a new builder.
45    #[must_use]
46    pub fn new() -> Self {
47        Self {
48            #[cfg(feature = "file")]
49            file_opts: None,
50            #[cfg(feature = "env")]
51            env_opts: None,
52            #[cfg(feature = "cli")]
53            cli_opts: None,
54            #[cfg(feature = "testing")]
55            cli_args: None,
56            #[cfg(feature = "cli")]
57            cli_command: None,
58            _marker: PhantomData,
59        }
60    }
61
62    /// Enable loading from a config file.
63    ///
64    /// The [`FileOptions`] must include a [`FileLoader`](crate::FileLoader)
65    /// implementation that parses the file contents. Use [`serde_loader`](crate::serde_loader)
66    /// for any serde-compatible format.
67    #[cfg(feature = "file")]
68    #[must_use]
69    pub fn with_file(mut self, opts: FileOptions) -> Self {
70        self.file_opts = Some(opts);
71        self
72    }
73
74    /// Enable loading from environment variables.
75    #[cfg(feature = "env")]
76    #[must_use]
77    pub fn with_environment_variables(mut self, opts: EnvironmentVariableOptions) -> Self {
78        self.env_opts = Some(opts);
79        self
80    }
81
82    /// Enable loading from CLI flags.
83    #[cfg(feature = "cli")]
84    #[must_use]
85    pub fn with_cli_flags(mut self, opts: CLIFlagOptions) -> Self {
86        self.cli_opts = Some(opts);
87        self
88    }
89
90    /// Override CLI args (for testing). If not called, uses `std::env::args()`.
91    #[cfg(feature = "testing")]
92    #[must_use]
93    pub fn with_cli_args(mut self, args: Vec<String>) -> Self {
94        self.cli_args = Some(args);
95        self
96    }
97
98    /// Provide a custom `clap::Command` as the base for CLI flag parsing.
99    ///
100    /// Configulator will add its own config arguments to this command,
101    /// allowing you to define additional flags, set the app name/version,
102    /// or customise help output.
103    ///
104    /// Custom args must not share IDs with config field names — clap will
105    /// error at parse time if an arg ID is registered twice.
106    #[cfg(feature = "cli")]
107    #[must_use]
108    pub fn with_cli_command(mut self, cmd: clap::Command) -> Self {
109        self.cli_command = Some(cmd);
110        self
111    }
112
113    /// Load configuration, applying validation.
114    pub fn load(self) -> Result<C, ConfigulatorError>
115    where
116        C: Validate,
117    {
118        let config = self.load_without_validation()?;
119        config
120            .validate()
121            .map_err(ConfigulatorError::ValidationError)?;
122        Ok(config)
123    }
124
125    /// Load configuration without running validation.
126    pub fn load_without_validation(self) -> Result<C, ConfigulatorError> {
127        let fields = C::configulator_fields();
128        let mut merged = ValueMap::new();
129
130        // 1. Defaults (lowest precedence)
131        let defaults = defaults::load_defaults(&fields);
132        merge_value_maps(&mut merged, &defaults);
133
134        // 2. Parse CLI once (if configured) to get both config path and values
135        #[cfg(feature = "cli")]
136        let cli_values = if let Some(ref opts) = self.cli_opts {
137            let args = self.get_cli_args();
138            let has_file = {
139                #[cfg(feature = "file")]
140                { self.file_opts.is_some() }
141                #[cfg(not(feature = "file"))]
142                { false }
143            };
144            Some(cli::load_from_cli(opts, &fields, &args, has_file, self.cli_command.clone())?)
145        } else {
146            None
147        };
148        #[cfg(not(feature = "cli"))]
149        let cli_values = None::<ValueMap>;
150
151        // 3. File
152        #[cfg(feature = "file")]
153        {
154            let mut file_opts = self.file_opts;
155            // Prepend --config path if CLI provided one and .with_file() was called
156            #[cfg(feature = "cli")]
157            if let Some(ref mut opts) = file_opts {
158                if let Some(ref cli_vals) = cli_values {
159                    if let Some(ConfigValue::Scalar(path)) = cli_vals.get("__config_file__") {
160                        opts.paths.insert(0, PathBuf::from(path));
161                    }
162                }
163            }
164            if let Some(ref opts) = file_opts {
165                let file_values = file::load_from_file(opts)?;
166                merge_value_maps(&mut merged, &file_values);
167            }
168        }
169
170        // 4. Environment variables
171        #[cfg(feature = "env")]
172        if let Some(ref opts) = self.env_opts {
173            let env_values = environment::load_from_env(opts, &fields);
174            merge_value_maps(&mut merged, &env_values);
175        }
176
177        // 5. CLI flags (highest precedence)
178        #[cfg(feature = "cli")]
179        if let Some(mut cli_values) = cli_values {
180            cli_values.remove("__config_file__");
181            merge_value_maps(&mut merged, &cli_values);
182        }
183
184        C::from_value_map(&merged)
185    }
186
187    /// Get the default config (all defaults applied, no other sources).
188    ///
189    /// Validation is **not** performed. Call
190    /// [`Validate::validate`](crate::Validate::validate) on the result if needed.
191    pub fn defaults_only() -> Result<C, ConfigulatorError> {
192        let fields = C::configulator_fields();
193        let defaults = defaults::load_defaults(&fields);
194        C::from_value_map(&defaults)
195    }
196
197    #[cfg(feature = "cli")]
198    fn get_cli_args(&self) -> Vec<String> {
199        #[cfg(feature = "testing")]
200        if let Some(args) = &self.cli_args {
201            return args.clone();
202        }
203        std::env::args().skip(1).collect()
204    }
205}
206
207impl<C: ConfigFields + FromValueMap + Default> Default for Configulator<C> {
208    fn default() -> Self {
209        Self::new()
210    }
211}