Skip to main content

figue/
builder.rs

1//! Builder API for layered configuration.
2//!
3//! This module provides the [`builder`] function and [`ConfigBuilder`] type for
4//! constructing layered configuration parsers. Use this when you need to combine
5//! multiple configuration sources (CLI, environment variables, config files).
6//!
7//! # Overview
8//!
9//! The builder pattern allows you to:
10//! - Configure CLI argument parsing
11//! - Set up environment variable parsing with custom prefixes
12//! - Load configuration files in various formats
13//! - Customize help text and version information
14//!
15//! # Example
16//!
17//! ```rust
18//! use facet::Facet;
19//! use figue::{self as args, builder, Driver};
20//!
21//! #[derive(Facet, Debug)]
22//! struct Args {
23//!     #[facet(args::config, args::env_prefix = "MYAPP")]
24//!     config: Config,
25//! }
26//!
27//! #[derive(Facet, Debug)]
28//! struct Config {
29//!     #[facet(default = 8080)]
30//!     port: u16,
31//!     #[facet(default = "localhost")]
32//!     host: String,
33//! }
34//!
35//! // Build the configuration
36//! let config = builder::<Args>()
37//!     .unwrap()
38//!     .cli(|cli| cli.args(["--config.port", "3000"]))
39//!     .help(|h| h.program_name("myapp").version("1.0.0"))
40//!     .build();
41//!
42//! // Run the driver to get the parsed value
43//! let output = Driver::new(config).run().into_result().unwrap();
44//! assert_eq!(output.value.config.port, 3000);
45//! ```
46//!
47//! # Layer Priority
48//!
49//! When the same field is set in multiple sources, the priority order is:
50//! 1. CLI arguments (highest)
51//! 2. Environment variables
52//! 3. Config files
53//! 4. Code defaults (lowest)
54#![allow(private_interfaces)]
55
56use std::marker::PhantomData;
57use std::string::String;
58
59use camino::Utf8PathBuf;
60use facet::Facet;
61use facet_reflect::ReflectError;
62
63use crate::{
64    config_format::{ConfigFormat, ConfigFormatError},
65    help::HelpConfig,
66    layers::{
67        cli::{CliConfig, CliConfigBuilder},
68        env::{EnvConfig, EnvConfigBuilder},
69        file::FileConfig,
70    },
71    schema::{Schema, error::SchemaError},
72};
73
74/// Start configuring an args/config parser for a given type.
75///
76/// This is the main entry point for building layered configuration. The type `T`
77/// must implement [`Facet`] and be properly annotated with figue attributes.
78///
79/// # Example
80///
81/// ```rust
82/// use facet::Facet;
83/// use figue::{self as args, builder, Driver};
84///
85/// #[derive(Facet)]
86/// struct Args {
87///     #[facet(args::named, default)]
88///     verbose: bool,
89///     #[facet(args::positional)]
90///     file: String,
91/// }
92///
93/// let config = builder::<Args>()
94///     .expect("schema should be valid")
95///     .cli(|cli| cli.args(["--verbose", "input.txt"]))
96///     .build();
97///
98/// let args: Args = Driver::new(config).run().unwrap();
99/// assert!(args.verbose);
100/// ```
101///
102/// # Errors
103///
104/// # Errors
105///
106/// Returns an error if:
107/// - The type is not a struct (enums cannot be root types)
108/// - Fields are missing required annotations (`args::positional`, `args::named`, etc.)
109/// - Schema validation fails
110pub fn builder<T>() -> Result<ConfigBuilder<T>, BuilderError>
111where
112    T: Facet<'static>,
113{
114    let schema = Schema::from_shape(T::SHAPE)?;
115    Ok(ConfigBuilder {
116        _phantom: PhantomData,
117        schema,
118        cli_config: None,
119        help_config: None,
120        env_config: None,
121        file_config: None,
122    })
123}
124
125/// Builder for layered configuration parsing.
126///
127/// Use the fluent API to configure each layer:
128/// - [`.cli()`](Self::cli) - Configure CLI argument parsing
129/// - [`.env()`](Self::env) - Configure environment variable parsing
130/// - [`.file()`](Self::file) - Configure config file loading
131/// - [`.help()`](Self::help) - Configure help text generation
132/// - [`.build()`](Self::build) - Finalize and create the config
133///
134/// # Example
135///
136/// ```rust
137/// use facet::Facet;
138/// use figue::{self as args, builder, Driver};
139///
140/// #[derive(Facet)]
141/// struct Args {
142///     #[facet(args::config, args::env_prefix = "APP")]
143///     config: AppConfig,
144/// }
145///
146/// #[derive(Facet)]
147/// struct AppConfig {
148///     #[facet(default = 8080)]
149///     port: u16,
150/// }
151///
152/// let config = builder::<Args>()
153///     .unwrap()
154///     .cli(|cli| cli.args(["--config.port", "9000"]))  // CLI takes priority
155///     .help(|h| h.program_name("myapp"))
156///     .build();
157///
158/// let output = Driver::new(config).run().into_result().unwrap();
159/// assert_eq!(output.value.config.port, 9000);
160/// ```
161pub struct ConfigBuilder<T> {
162    _phantom: PhantomData<T>,
163    /// Parsed schema for the target type.
164    schema: Schema,
165    /// CLI parsing settings, if the user configured that layer.
166    cli_config: Option<CliConfig>,
167    /// Help text settings, if provided.
168    help_config: Option<HelpConfig>,
169    /// Environment parsing settings, if provided.
170    env_config: Option<EnvConfig>,
171    /// File parsing settings for the file layer.
172    file_config: Option<FileConfig>,
173}
174
175/// Fully built configuration (schema + sources) for the driver.
176pub struct Config<T> {
177    /// Parsed schema for the target type.
178    pub schema: Schema,
179    /// CLI parsing settings, if the user configured that layer.
180    pub cli_config: Option<CliConfig>,
181    /// Help text settings, if provided.
182    pub help_config: Option<HelpConfig>,
183    /// Environment parsing settings, if provided.
184    pub env_config: Option<EnvConfig>,
185    /// File parsing settings for the file layer.
186    pub file_config: Option<FileConfig>,
187    /// Type marker.
188    _phantom: PhantomData<T>,
189}
190
191impl<T> ConfigBuilder<T> {
192    /// Configure CLI argument parsing.
193    ///
194    /// Use this to specify where CLI arguments come from and how they're parsed.
195    ///
196    /// # Example
197    ///
198    /// ```rust
199    /// use facet::Facet;
200    /// use figue::{self as args, builder, Driver};
201    ///
202    /// #[derive(Facet)]
203    /// struct Args {
204    ///     #[facet(args::named)]
205    ///     verbose: bool,
206    /// }
207    ///
208    /// // Parse specific arguments (useful for testing)
209    /// let config = builder::<Args>()
210    ///     .unwrap()
211    ///     .cli(|cli| cli.args(["--verbose"]))
212    ///     .build();
213    ///
214    /// let args: Args = Driver::new(config).run().unwrap();
215    /// assert!(args.verbose);
216    /// ```
217    ///
218    /// For production use, parse from `std::env::args()`:
219    ///
220    /// ```rust,no_run
221    /// # use facet::Facet;
222    /// # use figue::{self as args, builder, Driver};
223    /// # #[derive(Facet)]
224    /// # struct Args { #[facet(args::named)] verbose: bool }
225    /// let config = builder::<Args>()
226    ///     .unwrap()
227    ///     .cli(|cli| cli.args(std::env::args().skip(1)))
228    ///     .build();
229    /// ```
230    pub fn cli<F>(mut self, f: F) -> Self
231    where
232        F: FnOnce(CliConfigBuilder) -> CliConfigBuilder,
233    {
234        self.cli_config = Some(f(CliConfigBuilder::new()).build());
235        self
236    }
237
238    /// Configure help text generation.
239    ///
240    /// Use this to set the program name, version, and additional description
241    /// shown in help output and version output.
242    ///
243    /// # Example
244    ///
245    /// ```rust
246    /// use facet::Facet;
247    /// use figue::{self as args, builder, Driver, DriverError};
248    ///
249    /// #[derive(Facet)]
250    /// struct Args {
251    ///     #[facet(args::named, args::help, default)]
252    ///     help: bool,
253    /// }
254    ///
255    /// let config = builder::<Args>()
256    ///     .unwrap()
257    ///     .cli(|cli| cli.args(["--help"]))
258    ///     .help(|h| h
259    ///         .program_name("myapp")
260    ///         .version("1.2.3")
261    ///         .description("A helpful description"))
262    ///     .build();
263    ///
264    /// let result = Driver::new(config).run().into_result();
265    /// match result {
266    ///     Err(DriverError::Help { text }) => {
267    ///         assert!(text.contains("myapp"));
268    ///     }
269    ///     _ => panic!("expected help"),
270    /// }
271    /// ```
272    pub fn help<F>(mut self, f: F) -> Self
273    where
274        F: FnOnce(HelpConfigBuilder) -> HelpConfigBuilder,
275    {
276        self.help_config = Some(f(HelpConfigBuilder::new()).build());
277        self
278    }
279
280    /// Configure environment variable parsing.
281    ///
282    /// Environment variables are parsed according to the schema's `args::env_prefix`
283    /// attribute. For example, with prefix "MYAPP" and a field `port`, the env var
284    /// `MYAPP__PORT` will be read.
285    ///
286    /// # Example
287    ///
288    /// ```rust
289    /// use facet::Facet;
290    /// use figue::{self as args, builder, Driver, MockEnv};
291    ///
292    /// #[derive(Facet)]
293    /// struct Args {
294    ///     #[facet(args::config, args::env_prefix = "APP")]
295    ///     config: Config,
296    /// }
297    ///
298    /// #[derive(Facet)]
299    /// struct Config {
300    ///     #[facet(default = 8080)]
301    ///     port: u16,
302    /// }
303    ///
304    /// // Use MockEnv for testing (to avoid modifying real environment)
305    /// let config = builder::<Args>()
306    ///     .unwrap()
307    ///     .env(|env| env.source(MockEnv::from_pairs([
308    ///         ("APP__PORT", "9000"),
309    ///     ])))
310    ///     .build();
311    ///
312    /// let output = Driver::new(config).run().into_result().unwrap();
313    /// assert_eq!(output.value.config.port, 9000);
314    /// ```
315    pub fn env<F>(mut self, f: F) -> Self
316    where
317        F: FnOnce(EnvConfigBuilder) -> EnvConfigBuilder,
318    {
319        self.env_config = Some(f(EnvConfigBuilder::new()).build());
320        self
321    }
322
323    /// Configure config file parsing.
324    ///
325    /// Load configuration from JSON, or other formats via the format registry.
326    ///
327    /// # Example
328    ///
329    /// ```rust
330    /// use facet::Facet;
331    /// use figue::{self as args, builder, Driver};
332    ///
333    /// #[derive(Facet)]
334    /// struct Args {
335    ///     #[facet(args::config)]
336    ///     config: Config,
337    /// }
338    ///
339    /// #[derive(Facet)]
340    /// struct Config {
341    ///     #[facet(default = 8080)]
342    ///     port: u16,
343    /// }
344    ///
345    /// // Use inline content for testing (avoids file I/O)
346    /// let config = builder::<Args>()
347    ///     .unwrap()
348    ///     .file(|f| f.content(r#"{"port": 9000}"#, "config.json"))
349    ///     .build();
350    ///
351    /// let output = Driver::new(config).run().into_result().unwrap();
352    /// assert_eq!(output.value.config.port, 9000);
353    /// ```
354    pub fn file<F>(mut self, f: F) -> Self
355    where
356        F: FnOnce(FileConfigBuilder) -> FileConfigBuilder,
357    {
358        self.file_config = Some(f(FileConfigBuilder::new()).build());
359        self
360    }
361
362    /// Finalize the builder and return a [`Config`] for use with [`Driver`](crate::Driver).
363    ///
364    /// After calling this, create a `Driver` and call `run()`:
365    ///
366    /// ```rust
367    /// use facet::Facet;
368    /// use figue::{self as args, builder, Driver};
369    ///
370    /// #[derive(Facet)]
371    /// struct Args {
372    ///     #[facet(args::positional)]
373    ///     file: String,
374    /// }
375    ///
376    /// let config = builder::<Args>()
377    ///     .unwrap()
378    ///     .cli(|cli| cli.args(["input.txt"]))
379    ///     .build();
380    ///
381    /// let output = Driver::new(config).run().into_result().unwrap();
382    /// assert_eq!(output.value.file, "input.txt");
383    /// ```
384    pub fn build(self) -> Config<T> {
385        Config {
386            schema: self.schema,
387            cli_config: self.cli_config,
388            help_config: self.help_config,
389            env_config: self.env_config,
390            file_config: self.file_config,
391            _phantom: PhantomData,
392        }
393    }
394}
395
396// ============================================================================
397// Help Configuration
398// ============================================================================
399
400/// Builder for help text configuration.
401///
402/// Configure how help and version information is displayed.
403///
404/// # Example
405///
406/// ```rust
407/// use facet::Facet;
408/// use figue::{self as args, builder, Driver, DriverError};
409///
410/// #[derive(Facet)]
411/// struct Args {
412///     #[facet(args::named, args::version, default)]
413///     version: bool,
414/// }
415///
416/// let config = builder::<Args>()
417///     .unwrap()
418///     .cli(|cli| cli.args(["--version"]))
419///     .help(|h| h.program_name("myapp").version("1.0.0"))
420///     .build();
421///
422/// let result = Driver::new(config).run().into_result();
423/// match result {
424///     Err(DriverError::Version { text }) => {
425///         assert!(text.contains("myapp 1.0.0"));
426///     }
427///     _ => panic!("expected version"),
428/// }
429/// ```
430#[derive(Debug, Default)]
431pub struct HelpConfigBuilder {
432    config: HelpConfig,
433}
434
435impl HelpConfigBuilder {
436    /// Create a new help config builder.
437    pub fn new() -> Self {
438        Self::default()
439    }
440
441    /// Set the program name shown in help and version output.
442    ///
443    /// If not set, defaults to the executable name from `std::env::args()`.
444    pub fn program_name(mut self, name: impl Into<String>) -> Self {
445        self.config.program_name = Some(name.into());
446        self
447    }
448
449    /// Set the program version shown by `--version`.
450    ///
451    /// Use `env!("CARGO_PKG_VERSION")` to capture your crate's version:
452    ///
453    /// ```rust,no_run
454    /// # use figue::builder;
455    /// # use facet::Facet;
456    /// # #[derive(Facet)] struct Args { #[facet(figue::positional)] f: String }
457    /// let config = builder::<Args>()
458    ///     .unwrap()
459    ///     .help(|h| h.version(env!("CARGO_PKG_VERSION")))
460    ///     .build();
461    /// ```
462    ///
463    /// If not set, `--version` will display "unknown".
464    pub fn version(mut self, version: impl Into<String>) -> Self {
465        self.config.version = Some(version.into());
466        self
467    }
468
469    /// Set an additional description shown after the auto-generated help.
470    ///
471    /// This appears below the program name and doc comment, useful for
472    /// additional context or examples.
473    pub fn description(mut self, description: impl Into<String>) -> Self {
474        self.config.description = Some(description.into());
475        self
476    }
477
478    /// Set the text wrapping width for help output.
479    ///
480    /// Set to 0 to disable wrapping. Default is 80 columns.
481    pub fn width(mut self, width: usize) -> Self {
482        self.config.width = width;
483        self
484    }
485
486    /// Build the help configuration.
487    fn build(self) -> HelpConfig {
488        self.config
489    }
490}
491
492// ============================================================================
493// File Configuration Builder
494// ============================================================================
495
496/// Builder for config file parsing configuration.
497///
498/// Configure how configuration files are loaded and parsed.
499///
500/// # Example
501///
502/// ```rust
503/// use facet::Facet;
504/// use figue::{self as args, builder, Driver};
505///
506/// #[derive(Facet)]
507/// struct Args {
508///     #[facet(args::config)]
509///     config: Config,
510/// }
511///
512/// #[derive(Facet)]
513/// struct Config {
514///     #[facet(default = "localhost")]
515///     host: String,
516///     #[facet(default = 8080)]
517///     port: u16,
518/// }
519///
520/// // Load from inline JSON (useful for testing)
521/// let config = builder::<Args>()
522///     .unwrap()
523///     .file(|f| f.content(r#"{"host": "0.0.0.0", "port": 3000}"#, "config.json"))
524///     .build();
525///
526/// let output = Driver::new(config).run().into_result().unwrap();
527/// assert_eq!(output.value.config.host, "0.0.0.0");
528/// assert_eq!(output.value.config.port, 3000);
529/// ```
530#[derive(Default)]
531pub struct FileConfigBuilder {
532    config: FileConfig,
533}
534
535impl FileConfigBuilder {
536    /// Create a new file config builder.
537    pub fn new() -> Self {
538        Self {
539            config: FileConfig::default(),
540        }
541    }
542
543    /// Set default paths to check for config files.
544    ///
545    /// These are checked in order; the first existing file is used.
546    /// Common patterns include `./config.json`, `~/.config/app/config.json`, etc.
547    ///
548    /// # Example
549    ///
550    /// ```rust,no_run
551    /// # use figue::builder;
552    /// # use facet::Facet;
553    /// # #[derive(Facet)] struct Args { #[facet(figue::config)] config: Config }
554    /// # #[derive(Facet)] struct Config { #[facet(default = 0)] port: u16 }
555    /// let config = builder::<Args>()
556    ///     .unwrap()
557    ///     .file(|f| f.default_paths([
558    ///         "./config.json",
559    ///         "~/.config/myapp/config.json",
560    ///         "/etc/myapp/config.json",
561    ///     ]))
562    ///     .build();
563    /// ```
564    pub fn default_paths<I, P>(mut self, paths: I) -> Self
565    where
566        I: IntoIterator<Item = P>,
567        P: Into<Utf8PathBuf>,
568    {
569        self.config.default_paths = paths.into_iter().map(|p| p.into()).collect();
570        self
571    }
572
573    /// Register an additional config file format.
574    ///
575    /// By default, JSON is supported. Use this to add TOML, YAML, or custom formats.
576    /// See [`ConfigFormat`] for implementing custom formats.
577    pub fn format<F: ConfigFormat + 'static>(mut self, format: F) -> Self {
578        self.config.registry.register(format);
579        self
580    }
581
582    /// Enable strict mode - error on unknown keys in config file.
583    ///
584    /// By default, unknown keys are ignored. In strict mode, any key in the
585    /// config file that doesn't match a schema field causes an error.
586    pub fn strict(mut self) -> Self {
587        self.config.strict = true;
588        self
589    }
590
591    /// Set inline content for testing (avoids disk I/O).
592    ///
593    /// The filename is used for format detection (e.g., "config.toml" or "settings.json").
594    /// This is useful for unit tests that don't want to create actual files.
595    ///
596    /// # Example
597    ///
598    /// ```rust
599    /// use facet::Facet;
600    /// use figue::{self as args, builder, Driver};
601    ///
602    /// #[derive(Facet)]
603    /// struct Args {
604    ///     #[facet(args::config)]
605    ///     config: Config,
606    /// }
607    ///
608    /// #[derive(Facet)]
609    /// struct Config {
610    ///     #[facet(default = 8080)]
611    ///     port: u16,
612    /// }
613    ///
614    /// let config = builder::<Args>()
615    ///     .unwrap()
616    ///     .file(|f| f.content(r#"{"port": 9000}"#, "test.json"))
617    ///     .build();
618    ///
619    /// let output = Driver::new(config).run().into_result().unwrap();
620    /// assert_eq!(output.value.config.port, 9000);
621    /// ```
622    pub fn content(mut self, content: impl Into<String>, filename: impl Into<String>) -> Self {
623        self.config.inline_content = Some((content.into(), filename.into()));
624        self
625    }
626
627    /// Build the file configuration.
628    fn build(self) -> FileConfig {
629        self.config
630    }
631}
632
633// ============================================================================
634// Errors
635// ============================================================================
636
637/// Errors that can occur when building configuration.
638///
639/// These errors happen during the setup phase, before actual parsing begins.
640/// They typically indicate problems with the schema definition or file loading.
641#[derive(Facet)]
642#[repr(u8)]
643pub enum BuilderError {
644    /// Schema validation failed.
645    ///
646    /// The type definition has errors, such as missing required attributes
647    /// or invalid combinations of attributes.
648    SchemaError(#[facet(opaque)] SchemaError),
649
650    /// Memory allocation failed when preparing the destination type.
651    Alloc(#[facet(opaque)] ReflectError),
652
653    /// Config file was not found at the specified path.
654    FileNotFound {
655        /// The path that was checked.
656        path: Utf8PathBuf,
657    },
658
659    /// Failed to read the config file.
660    FileRead(Utf8PathBuf, String),
661
662    /// Failed to parse the config file content.
663    FileParse(Utf8PathBuf, ConfigFormatError),
664
665    /// CLI argument parsing failed.
666    CliParse(String),
667
668    /// An unknown key was found in configuration.
669    ///
670    /// Only reported in strict mode.
671    UnknownKey {
672        /// The unknown key.
673        key: String,
674        /// Where the key came from (e.g., "config file", "environment").
675        source: &'static str,
676        /// A suggested correction if the key appears to be a typo.
677        suggestion: Option<String>,
678    },
679
680    /// A required field was not provided.
681    MissingRequired(String),
682}
683
684impl std::fmt::Display for BuilderError {
685    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
686        match self {
687            BuilderError::SchemaError(e) => write!(f, "{e}"),
688            BuilderError::Alloc(e) => write!(f, "allocation failed: {e}"),
689            BuilderError::FileNotFound { path } => {
690                write!(f, "config file not found: {path}")
691            }
692            BuilderError::FileRead(path, msg) => {
693                write!(f, "error reading {path}: {msg}")
694            }
695            BuilderError::FileParse(path, e) => {
696                write!(f, "error parsing {path}: {e}")
697            }
698            BuilderError::CliParse(msg) => write!(f, "{msg}"),
699            BuilderError::UnknownKey {
700                key,
701                source,
702                suggestion,
703            } => {
704                write!(f, "unknown configuration key '{key}' from {source}")?;
705                if let Some(suggestion) = suggestion {
706                    write!(f, " (did you mean '{suggestion}'?)")?;
707                }
708                Ok(())
709            }
710            BuilderError::MissingRequired(field) => {
711                write!(f, "missing required configuration: {field}")
712            }
713        }
714    }
715}
716
717impl std::fmt::Debug for BuilderError {
718    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
719        std::fmt::Display::fmt(self, f)
720    }
721}
722
723impl std::error::Error for BuilderError {
724    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
725        match self {
726            BuilderError::SchemaError(e) => Some(e),
727            BuilderError::Alloc(e) => Some(e),
728            BuilderError::FileParse(_, e) => Some(e),
729            _ => None,
730        }
731    }
732}
733
734impl From<SchemaError> for BuilderError {
735    fn from(e: SchemaError) -> Self {
736        BuilderError::SchemaError(e)
737    }
738}
739
740// ============================================================================
741// Tests
742// ============================================================================
743
744#[cfg(test)]
745mod tests {
746    use super::*;
747    use crate as args;
748    use facet::Facet;
749
750    #[derive(Facet)]
751    struct TestConfig {
752        #[facet(args::config)]
753        config: TestConfigLayer,
754    }
755
756    #[derive(Facet)]
757    struct TestConfigLayer {
758        #[facet(args::named)]
759        port: u16,
760        #[facet(args::named)]
761        host: String,
762    }
763
764    #[test]
765    fn test_cli_config_builder() {
766        let config = CliConfigBuilder::new()
767            .args(["--port", "8080"])
768            .strict()
769            .build();
770
771        assert_eq!(config.resolve_args(), vec!["--port", "8080"]);
772        assert!(config.strict());
773    }
774
775    #[test]
776    fn test_env_config_builder() {
777        let config = EnvConfigBuilder::new().prefix("MYAPP").strict().build();
778
779        assert_eq!(config.prefix, "MYAPP");
780        assert!(config.strict);
781    }
782
783    #[test]
784    fn test_file_config_builder() {
785        let config = FileConfigBuilder::new()
786            .default_paths(["./config.json", "~/.config/app.json"])
787            .strict()
788            .build();
789
790        // explicit_path is set by the driver when CLI provides --config <path>
791        assert_eq!(config.explicit_path, None);
792        assert_eq!(config.default_paths.len(), 2);
793        assert!(config.strict);
794    }
795}