ortho_config 0.9.0

A configuration management library for Rust, inspired by esbuild.
Documentation
//! Helpers for sanitizing and merging command-line arguments with
//! configuration defaults.

#[cfg(feature = "serde_json")]
use crate::{OrthoResult, OrthoResultExt};
#[cfg(feature = "serde_json")]
use clap::ArgMatches;
#[cfg(feature = "serde_json")]
use figment::providers::Serialized;
#[cfg(feature = "serde_json")]
use serde::Serialize;
#[cfg(feature = "serde_json")]
use serde_json::Value;

/// Recursively remove all [`Value::Null`] entries, pruning empty objects.
///
/// - Object fields equal to null are removed.
/// - Nested objects containing no non-null fields are also removed so empty
///   `#[clap(flatten)]` groups do not clobber defaults.
/// - Array elements equal to null are removed, dropping `None` entries in
///   `Vec<_>` but retaining empty arrays to allow deliberate clearing.
///
/// Intended for CLI sanitization so unset [`Option`] fields and untouched
/// flattened structs do not override defaults from files or environment
/// variables.
/// Arrays are never removed, even when emptied; this function only removes
/// [`Option::None`] fields.
///
/// Returns `true` if `value` becomes empty after pruning (that is, it is
/// `Null` or an object with no remaining fields). Arrays never return `true`,
/// even when emptied, to preserve explicit clearing semantics.
#[cfg(feature = "serde_json")]
fn strip_nulls(value: &mut Value) -> bool {
    match value {
        Value::Object(map) => {
            map.retain(|_, v| !strip_nulls(v));
            map.is_empty()
        }
        Value::Array(arr) => {
            for v in arr.iter_mut() {
                if strip_nulls(v) {
                    *v = Value::Null;
                }
            }
            arr.retain(|v| !v.is_null());
            false
        }
        Value::Null => true,
        _ => false,
    }
}

/// Serialize a CLI struct to JSON, removing fields set to `None`.
///
/// # Examples
///
/// ```rust
/// use ortho_config::value_without_nones;
/// use serde::Serialize;
///
/// #[derive(Serialize)]
/// struct Args { count: Option<u32> }
///
/// let v = value_without_nones(&Args { count: None })
///     .expect("expected serialization to succeed");
/// assert_eq!(v, serde_json::json!({}));
/// ```
///
/// # Errors
///
/// Returns any [`serde_json::Error`] encountered during serialization.
#[cfg(feature = "serde_json")]
pub fn value_without_nones<T: Serialize>(cli: &T) -> Result<Value, serde_json::Error> {
    let mut value = serde_json::to_value(cli)?;
    let _ = strip_nulls(&mut value);
    Ok(value)
}

/// Serialize `value` to JSON, pruning `None` fields and mapping errors to
/// [`crate::OrthoError`].
///
/// # Examples
///
/// ```rust
/// use ortho_config::sanitize_value;
/// use serde::Serialize;
///
/// #[derive(Serialize)]
/// struct Args { count: Option<u32> }
/// let v = sanitize_value(&Args { count: None })
///     .expect("expected sanitization to succeed");
/// assert_eq!(v, serde_json::json!({}));
/// ```
///
/// # Errors
///
/// Returns an [`crate::OrthoError`] if JSON serialization fails.
#[cfg(feature = "serde_json")]
pub fn sanitize_value<T: Serialize>(value: &T) -> OrthoResult<Value> {
    value_without_nones(value).into_ortho()
}

/// Produce a Figment provider from `value` with `None` fields removed.
///
/// This helper wraps [`sanitize_value`] and avoids repeating the
/// `Serialized::defaults` pattern when layering providers.
///
/// # Examples
///
/// ```rust
/// use figment::Figment;
/// use ortho_config::sanitized_provider;
/// use serde::Serialize;
///
/// #[derive(Serialize)]
/// struct Args { count: Option<u32> }
///
/// let provider = sanitized_provider(&Args { count: None })
///     .expect("expected provider creation to succeed");
/// let value: serde_json::Value = Figment::from(provider)
///     .extract()
///     .expect("expected extraction to succeed");
/// assert_eq!(value, serde_json::json!({}));
/// ```
///
/// # Errors
///
/// Returns an [`crate::OrthoError`] if JSON serialization fails.
#[cfg(feature = "serde_json")]
pub fn sanitized_provider<T: Serialize>(value: &T) -> OrthoResult<Serialized<serde_json::Value>> {
    sanitize_value(value).map(Serialized::defaults)
}

/// Trait for extracting CLI values whilst treating clap defaults as absent.
///
/// Types implementing this trait can distinguish between values explicitly
/// provided on the command line and values filled in by clap's `default_value_t`
/// or similar mechanisms. Fields marked with `#[ortho_config(cli_default_as_absent)]`
/// are only included in the extracted JSON when `value_source()` returns
/// [`clap::parser::ValueSource::CommandLine`].
///
/// This allows file and environment configuration to take precedence over CLI
/// defaults while still honouring explicit CLI overrides.
///
/// # Examples
///
/// ```rust,ignore
/// use clap::{ArgMatches, Parser};
/// use ortho_config::{CliValueExtractor, OrthoConfig, OrthoResult};
/// use serde::{Deserialize, Serialize};
///
/// #[derive(Parser, Deserialize, Serialize, OrthoConfig, Default)]
/// #[ortho_config(prefix = "APP_")]
/// struct MyCommand {
///     #[arg(long, default_value_t = String::from("!"))]
///     #[ortho_config(cli_default_as_absent)]
///     punctuation: String,
/// }
///
/// // When parsed without --punctuation flag, extract_user_provided returns {}
/// // When parsed with --punctuation "?", extract_user_provided returns {"punctuation": "?"}
/// ```
#[cfg(feature = "serde_json")]
#[cfg_attr(docsrs, doc(cfg(feature = "serde_json")))]
pub trait CliValueExtractor: Sized {
    /// Extract only values explicitly provided on the command line.
    ///
    /// Takes the parsed CLI struct (`self`) and the `ArgMatches` from clap.
    /// Fields marked with `cli_default_as_absent` are excluded unless the user
    /// explicitly provided them via the CLI. Other fields are serialised
    /// normally with `None` values stripped.
    ///
    /// This trait is automatically implemented by the `OrthoConfig` derive macro
    /// for types that have at least one field with `#[ortho_config(cli_default_as_absent)]`.
    /// Types without this attribute should use the regular [`sanitized_provider`]
    /// function instead.
    ///
    /// # Errors
    ///
    /// Returns an [`crate::OrthoError`] if serialisation fails.
    fn extract_user_provided(&self, matches: &ArgMatches) -> OrthoResult<Value>;
}