ortho_config 0.9.0

A configuration management library for Rust, inspired by esbuild.
Documentation
//! Builder for configuration discovery helpers.
//!
//! The builder lets applications customise environment variables, filenames,
//! and project roots before producing a [`ConfigDiscovery`] instance that
//! drives the search order.

use std::path::{Path, PathBuf};
use std::sync::Arc;

use crate::env_source::{SharedEnvSource, process_env_source};

use super::ConfigDiscovery;
use super::telemetry;

/// Resolver supplying the default project root when none is configured.
///
/// A stored closure rather than a trait: one call site, one signature, and
/// tests only need to substitute a fixed result. `Arc` keeps the builder
/// `Clone`.
type ProjectRootResolver = Arc<dyn Fn() -> std::io::Result<PathBuf> + Send + Sync>;

/// Builder for [`ConfigDiscovery`].
///
/// # Examples
///
/// ```rust,no_run
/// use ortho_config::discovery::ConfigDiscovery;
///
/// # fn run() -> ortho_config::OrthoResult<()> {
/// let discovery = ConfigDiscovery::builder("hello_world")
///     .add_explicit_path("./hello_world.toml")
///     .build();
///
/// if let Some(figment) = discovery.load_first()? {
///     #[derive(serde::Deserialize)]
///     struct Greeting { recipient: String }
///     let config: Greeting = figment
///         .extract()
///         .map_err(ortho_config::OrthoError::gathering_arc)?;
///     println!("Loaded greeting for {}", config.recipient);
/// }
/// # Ok(())
/// # }
/// ```
#[derive(Clone)]
pub struct ConfigDiscoveryBuilder {
    env_var: Option<String>,
    app_name: String,
    config_file_name: String,
    custom_dotfile_name: Option<String>,
    custom_project_file_name: Option<String>,
    project_roots: Vec<PathBuf>,
    explicit_paths: Vec<PathBuf>,
    required_explicit_paths: Vec<PathBuf>,
    env_source: Option<SharedEnvSource>,
    project_root_resolver: ProjectRootResolver,
}

/// Debug output omits the environment source and every path.
///
/// The source may hold secret-shaped values, and this project treats paths
/// as sensitive in diagnostics, so only names, counts, and the injected/
/// process distinction are printed.
impl std::fmt::Debug for ConfigDiscoveryBuilder {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("ConfigDiscoveryBuilder")
            .field("env_var", &self.env_var)
            .field("app_name", &self.app_name)
            .field("config_file_name", &self.config_file_name)
            .field("custom_dotfile_name", &self.custom_dotfile_name)
            .field("custom_project_file_name", &self.custom_project_file_name)
            .field("project_roots", &self.project_roots.len())
            .field("explicit_paths", &self.explicit_paths.len())
            .field(
                "required_explicit_paths",
                &self.required_explicit_paths.len(),
            )
            .field("env_source_injected", &self.env_source.is_some())
            .finish_non_exhaustive()
    }
}

impl ConfigDiscoveryBuilder {
    /// Creates a builder initialised for `app_name`.
    ///
    /// The `app_name` populates platform directories such as
    /// `$XDG_CONFIG_HOME/<app_name>/config.toml` and
    /// `%APPDATA%\\<app_name>\\config.toml`.
    #[must_use]
    pub fn new(app_name: impl Into<String>) -> Self {
        Self {
            env_var: None,
            app_name: app_name.into(),
            config_file_name: String::from("config.toml"),
            custom_dotfile_name: None,
            custom_project_file_name: None,
            project_roots: Vec::new(),
            explicit_paths: Vec::new(),
            required_explicit_paths: Vec::new(),
            env_source: None,
            project_root_resolver: Arc::new(std::env::current_dir),
        }
    }

    /// Substitute the default project-root resolver, for deterministic tests.
    ///
    /// Crate-private: the public contract is behavioural (a missing working
    /// directory is survivable and omits only the implicit project root), and
    /// embedders needing a specific root already have
    /// [`ConfigDiscoveryBuilder::add_project_root`].
    #[cfg(test)]
    pub(crate) fn with_project_root_resolver(mut self, resolver: ProjectRootResolver) -> Self {
        self.project_root_resolver = resolver;
        self
    }

    /// Supplies the environment source consulted during discovery.
    ///
    /// Discovery otherwise reads the live process environment. Injecting a
    /// source lets tests drive the configuration-path selector and the
    /// platform base directories without mutating global state, so they need
    /// no serializing lock and may run concurrently.
    ///
    /// # Examples
    ///
    /// ```rust
    /// use ortho_config::{ConfigDiscovery, MapEnv};
    /// use std::sync::Arc;
    ///
    /// let env = Arc::new(MapEnv::new().with_var("DEMO_CONFIG", "/etc/demo.toml"));
    /// let discovery = ConfigDiscovery::builder("demo")
    ///     .env_var("DEMO_CONFIG")
    ///     .env_source(env)
    ///     .build();
    /// assert!(discovery.candidates().iter().any(|p| p.ends_with("demo.toml")));
    /// ```
    #[must_use]
    pub fn env_source(mut self, env_source: SharedEnvSource) -> Self {
        self.env_source = Some(env_source);
        self
    }

    /// Sets the environment variable consulted for an explicit configuration path.
    #[must_use]
    pub fn env_var(mut self, env_var: impl Into<String>) -> Self {
        self.env_var = Some(env_var.into());
        self
    }

    /// Overrides the canonical configuration file name searched under platform directories.
    #[must_use]
    pub fn config_file_name(mut self, name: impl Into<String>) -> Self {
        self.config_file_name = name.into();
        self
    }

    /// Sets a custom dotfile name used in directories that search for hidden files.
    #[must_use]
    pub fn dotfile_name(mut self, name: impl Into<String>) -> Self {
        self.custom_dotfile_name = Some(name.into());
        self
    }

    /// Overrides the filename searched within project roots.
    #[must_use]
    pub fn project_file_name(mut self, name: impl Into<String>) -> Self {
        self.custom_project_file_name = Some(name.into());
        self
    }

    /// Removes all project roots from the builder.
    #[must_use]
    pub fn clear_project_roots(mut self) -> Self {
        self.project_roots.clear();
        self
    }

    /// Replaces the project roots searched for configuration files.
    ///
    /// # Examples
    ///
    /// ```rust
    /// use ortho_config::discovery::ConfigDiscovery;
    ///
    /// let discovery = ConfigDiscovery::builder("hello_world")
    ///     .project_roots(["./workspace", "./fallback"])
    ///     .build();
    /// let candidates = discovery.candidates();
    /// assert!(candidates.ends_with(&[
    ///     std::path::PathBuf::from("./workspace/.hello_world.toml"),
    ///     std::path::PathBuf::from("./fallback/.hello_world.toml"),
    /// ]));
    /// ```
    #[must_use]
    pub fn project_roots<I, P>(mut self, roots: I) -> Self
    where
        I: IntoIterator<Item = P>,
        P: Into<PathBuf>,
    {
        self.project_roots = roots.into_iter().map(Into::into).collect();
        self
    }

    /// Adds an additional project root searched for configuration files.
    #[must_use]
    pub fn add_project_root(mut self, root: impl Into<PathBuf>) -> Self {
        self.project_roots.push(root.into());
        self
    }

    /// Adds an explicit candidate path that precedes platform discovery.
    #[must_use]
    pub fn add_explicit_path(mut self, path: impl Into<PathBuf>) -> Self {
        self.explicit_paths.push(path.into());
        self
    }

    /// Adds an explicit candidate path that must exist.
    ///
    /// This is primarily used for CLI-specified paths where falling back to
    /// other discovery locations would be surprising.
    #[must_use]
    pub fn add_required_path(mut self, path: impl Into<PathBuf>) -> Self {
        self.required_explicit_paths.push(path.into());
        self
    }

    fn default_dotfile(&self) -> String {
        let stem = self.app_name.trim();
        let extension = Path::new(&self.config_file_name)
            .extension()
            .and_then(|ext| ext.to_str())
            .filter(|ext| !ext.is_empty());

        if stem.is_empty() {
            let mut name = String::from('.');
            name.push_str(extension.unwrap_or("config"));
            return name;
        }

        let mut name = String::from('.');
        name.push_str(stem);
        if let Some(ext) = extension {
            name.push('.');
            name.push_str(ext);
        }
        name
    }

    /// Finalises the builder and returns a [`ConfigDiscovery`].
    #[must_use]
    pub fn build(self) -> ConfigDiscovery {
        let default_dotfile = self.default_dotfile();
        let dotfile_name = self.custom_dotfile_name.unwrap_or(default_dotfile);
        let project_file_name = self
            .custom_project_file_name
            .unwrap_or_else(|| dotfile_name.clone());

        telemetry::source_selected(if self.env_source.is_some() {
            telemetry::SOURCE_INJECTED
        } else {
            telemetry::SOURCE_PROCESS
        });

        let mut project_roots = self.project_roots;
        // Explicit roots suppress the resolver entirely: a caller who named
        // roots has taken responsibility for them, and invoking the resolver
        // anyway would reintroduce the ambient read the seam exists to avoid.
        if project_roots.is_empty() {
            // A missing working directory (deleted cwd, permission denial) is
            // survivable — discovery simply has no default project root — but
            // it must not be survivable *silently*, so the decision is
            // recorded in bounded telemetry rather than discarded with `.ok()`.
            match (self.project_root_resolver)() {
                Ok(dir) => project_roots.push(dir),
                Err(_) => telemetry::project_root_cwd_unavailable(),
            }
        }

        ConfigDiscovery {
            env_var: self.env_var,
            explicit_paths: self.explicit_paths,
            required_explicit_paths: self.required_explicit_paths,
            app_name: self.app_name,
            config_file_name: self.config_file_name,
            dotfile_name,
            project_file_name,
            project_roots,
            env_source: self.env_source.unwrap_or_else(process_env_source),
        }
    }
}