clap-config-fallback 0.1.13

Add config file fallback to clap while preserving parsing, validation and error handling
Documentation
//! `clap-config-fallback` extends clap with configuration-file fallback while preserving clap as
//! the final parser/validator.
//!
//! Typical flow:
//! 1. Parse CLI args into an optional intermediate struct.
//! 2. Load config values from `#[config(path)]` (if provided).
//! 3. Merge with precedence `CLI > config`.
//! 4. Re-run clap on reconstructed arguments for final validation.

mod arg;
pub mod format;

use std::{
    ffi::{OsStr, OsString},
    fs, iter,
    path::{Path, PathBuf},
};

use clap::{CommandFactory, Error, Parser, error::ErrorKind};
use figment::{Figment, providers::*};
use serde::{Serialize, de::DeserializeOwned};

#[cfg(feature = "derive")]
pub use clap_config_fallback_derive::{ConfigArgs, ConfigParser, ConfigSubcommand};

pub use arg::{Arg, FromArgs, IntoArgs};

/// Supported configuration file formats.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfigFormat {
    Toml,
    Yaml,
    Json,
}

/// Provides configuration path and format discovery for fallback loading.
pub trait ConfigSource {
    /// Returns the config file path when fallback should be attempted.
    fn config_path(&self) -> Option<&Path>;

    /// Returns a config format if it can be resolved.
    ///
    /// The default implementation infers from extension:
    /// - `.toml` (`toml` feature)
    /// - `.yaml` / `.yml` (`yaml` feature)
    /// - `.json` (`json` feature)
    fn config_format(&self) -> Option<ConfigFormat> {
        self.config_path()
            .and_then(|path| match path.extension().and_then(OsStr::to_str) {
                #[cfg(feature = "toml")]
                Some("toml") => Some(ConfigFormat::Toml),
                #[cfg(feature = "yaml")]
                Some("yaml" | "yml") => Some(ConfigFormat::Yaml),
                #[cfg(feature = "json")]
                Some("json") => Some(ConfigFormat::Json),
                _ => None,
            })
    }
}

/// Defines the associated types for config fallback parsing.
pub trait ConfigFallback {
    /// Intermediate optional representation used during merge.
    type Opts: Serialize + DeserializeOwned + IntoArgs + FromArgs;
    /// Config-only representation loaded from file.
    type Config: Serialize + DeserializeOwned;
}

/// Parse a clap struct with optional configuration-file fallback.
///
/// Deriving `ConfigParser` generates an internal optional `Opts` type and a config-deserialization
/// type, then wires them into this trait.
pub trait ConfigParser: Sized + Parser + ConfigFallback
where
    Self::Opts: Parser + Default + ConfigSource,
{
    /// Equivalent to [`Parser::parse`], but with config fallback.
    fn parse_with_config() -> Self {
        Self::parse_with_config_from(std::env::args_os())
    }

    /// Equivalent to [`Parser::try_parse`], but with config fallback.
    fn try_parse_with_config() -> Result<Self, Error> {
        Self::try_parse_with_config_from(std::env::args_os())
    }

    /// Equivalent to [`Parser::parse_from`], but with config fallback.
    fn parse_with_config_from<I, T>(itr: I) -> Self
    where
        I: IntoIterator<Item = T>,
        T: Into<OsString> + Clone,
    {
        Self::try_parse_with_config_from(itr).unwrap_or_else(|e| e.exit())
    }

    /// Performs parsing with config fallback and returns clap errors instead of exiting.
    ///
    /// Merge precedence is **CLI > config**. If no config path is available, behavior matches a
    /// normal clap parse.
    fn try_parse_with_config_from<I, T>(args: I) -> Result<Self, Error>
    where
        I: IntoIterator<Item = T>,
        T: Into<OsString> + Clone,
    {
        let command = <Self::Opts as CommandFactory>::command();
        let command_name = command.get_name().to_owned().into();
        let args = args.into_iter().collect::<Vec<_>>();

        for arg in args.iter().cloned() {
            // short-circuit to have fully functional help/version output
            if let Some("--help" | "-h" | "--version" | "-V") = arg.into().as_os_str().to_str() {
                return Self::try_parse_from(args);
            }
        }

        let args = command.try_get_matches_from(args)?;
        let opts = Self::Opts::from_args(&args).unwrap_or_default();

        let config: Option<Self::Config> = opts
            .config_path()
            .map(|path| {
                let path = PathBuf::from(path);

                if !path.exists() {
                    return Err(
                        Self::command().error(ErrorKind::Io, "configuration file not found")
                    );
                }

                let content = fs::read_to_string(path).map_err(|e| {
                    Self::command().error(
                        ErrorKind::Io,
                        format!("failed to read configuration file: {e}"),
                    )
                })?;

                match opts.config_format() {
                    #[cfg(feature = "toml")]
                    Some(ConfigFormat::Toml) => toml::from_str(&content).map_err(|e| e.to_string()),
                    #[cfg(feature = "yaml")]
                    Some(ConfigFormat::Yaml) => {
                        noyalib::from_str_borrowing(&content).map_err(|e| e.to_string())
                    }
                    #[cfg(feature = "json")]
                    Some(ConfigFormat::Json) => {
                        serde_json::from_str(&content).map_err(|e| e.to_string())
                    }
                    _ => Err("unsupported format".to_string()),
                }
                .map_err(|e| {
                    Self::command().error(
                        ErrorKind::InvalidValue,
                        format!("invalid configuration file: {e}"),
                    )
                })
            })
            .transpose()?;

        let mut cli = Figment::from(Serialized::defaults(opts));

        if let Some(config) = config {
            cli = cli.join(Serialized::defaults(config));
        }

        let cli = cli
            .extract::<Self::Opts>()
            .map_err(|e| Self::command().error(ErrorKind::InvalidValue, e.to_string()))?;

        Self::try_parse_from(iter::once(command_name).chain(cli.into_args()))
    }
}