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}