netsuke-build 0.1.0-beta1

A YAML-powered Ninja/Jinja hybrid build system.
//! Configuration file discovery and loading helpers.
//!
//! This module locates `OrthoConfig` file layers by scanning for config files
//! through [`ConfigDiscovery`], handling explicit paths from CLI flags and
//! environment variables, and loading TOML chains into [`MergeLayer`] values.

use ortho_config::{MergeComposer, MergeLayer, OrthoResult, load_config_file_as_chain};
use std::borrow::Cow;
use std::ffi::OsString;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tracing::{debug, debug_span};

use super::parser::Cli;

#[path = "discovery_diagnostics.rs"]
mod diagnostics;

#[path = "discovery_paths.rs"]
mod paths;

#[path = "discovery_layers.rs"]
mod layers;
use diagnostics::{
    ConfigLoadFailureKind, debug_config_path, path_hash, trace_config_path_variable,
    warn_explicit_config_load_failed,
};
use layers::collect_file_layers;

const CONFIG_ENV_VAR: &str = "NETSUKE_CONFIG";

/// Provides access to environment variables used during config discovery.
///
/// Production code uses [`StdEnvProvider`]. Tests can provide an in-memory
/// implementation so config-selection logic does not mutate process-global
/// environment state.
pub trait EnvProvider {
    /// Return the value of `key`, or `None` when the key is unset.
    fn get(&self, key: &str) -> Option<OsString>;

    /// Return all values available to the configuration environment layer.
    ///
    /// Providers concerned only with selector lookup may retain the empty
    /// default. Full merge providers override this method.
    fn entries(&self) -> Vec<(OsString, OsString)> {
        Vec::new()
    }
}

/// Environment provider backed by [`std::env::var_os`].
#[derive(Debug, Default, Clone, Copy)]
pub struct StdEnvProvider;

#[expect(
    clippy::disallowed_methods,
    reason = "composition root: StdEnvProvider is the process-backed adapter behind the EnvProvider seam"
)]
impl EnvProvider for StdEnvProvider {
    fn get(&self, key: &str) -> Option<OsString> {
        std::env::var_os(key)
    }

    fn entries(&self) -> Vec<(OsString, OsString)> {
        std::env::vars_os().collect()
    }
}

/// Load configuration layers with environment access supplied by `env`.
///
/// Loading errors are appended to `errors`, matching the normal merge path
/// without requiring callers to mutate the process environment.
pub(crate) fn push_file_layers_with_env(
    cli: &Cli,
    composer: &mut MergeComposer,
    errors: &mut Vec<Arc<ortho_config::OrthoError>>,
    env: &impl EnvProvider,
) {
    match collect_file_layers_with_env(cli, env) {
        Ok(layers) => {
            for layer in layers {
                composer.push_layer(layer);
            }
        }
        Err(err) => errors.push(err),
    }
}

/// Load layers through the shared explicit-config precedence boundary.
///
/// Normal merging and early JSON resolution both use this helper so they
/// select the same file layers while retaining their own error handling.
fn collect_file_layers_with_env(
    cli: &Cli,
    env: &impl EnvProvider,
) -> OrthoResult<Vec<MergeLayer<'static>>> {
    let resolution = resolve_config_selector(cli.config.clone(), env);
    trace_config_path_resolution(&resolution);
    resolution.path.map_or_else(
        || {
            debug!("using config discovery");
            collect_file_layers(cli.directory.as_deref())
        },
        |path| {
            debug_config_path("using explicit config path", &path);
            load_layers_from_path(&path)
        },
    )
}

/// Select an explicit config path, giving `--config` precedence over `env`.
///
/// A thin wrapper over [`resolve_config_selector`] for callers that need only
/// the winning path. Like that query it performs no tracing; orchestration
/// boundaries call [`trace_config_path_resolution`] to emit diagnostics.
///
/// Production code takes the richer [`ConfigPathResolution`] so it can trace the
/// environment lookups, leaving this as a convenience for precedence tests.
#[cfg(test)]
pub(crate) fn explicit_config_path_with_env(cli: &Cli, env: &impl EnvProvider) -> Option<PathBuf> {
    resolve_config_selector(cli.config.clone(), env).path
}

/// Describes the result of the pure explicit-path selection query.
///
/// Records the winning selector, its optional path, and every environment
/// lookup evaluated to reach the decision, so a caller can emit diagnostics
/// afterwards without giving the query tracing side effects.
#[derive(Debug, PartialEq, Eq)]
struct ConfigPathResolution {
    selector: &'static str,
    path: Option<PathBuf>,
    environment_lookups: Vec<(&'static str, Option<PathBuf>)>,
}

/// Select a config path from the CLI flag, then `NETSUKE_CONFIG` via `env`.
///
/// `cli_config` wins when present, in which case no environment lookup is
/// recorded because none is performed. This query emits no tracing.
fn resolve_config_selector(
    cli_config: Option<PathBuf>,
    env: &impl EnvProvider,
) -> ConfigPathResolution {
    if let Some(path) = cli_config {
        return ConfigPathResolution {
            selector: "cli_flag",
            path: Some(path),
            environment_lookups: Vec::new(),
        };
    }

    let primary_path = env_config_path(env, CONFIG_ENV_VAR);
    ConfigPathResolution {
        selector: primary_path.as_ref().map_or("none", |_| CONFIG_ENV_VAR),
        environment_lookups: vec![(CONFIG_ENV_VAR, primary_path.clone())],
        path: primary_path,
    }
}

/// Emit bounded diagnostics for a completed path `resolution`.
///
/// Environment lookups are traced before the selector event. A selected path
/// contributes only a correlation hash and file name, never its full value.
fn trace_config_path_resolution(resolution: &ConfigPathResolution) {
    for (var_name, path) in &resolution.environment_lookups {
        trace_config_path_variable(var_name, path.as_deref());
    }
    debug!(
        selector = resolution.selector,
        path_hash = resolution.path.as_deref().map(path_hash).as_deref(),
        path_file_name = ?resolution.path.as_deref().and_then(Path::file_name),
        path_present = resolution.path.is_some(),
        "resolved config path"
    );
}

/// Read a non-empty config path from `var_name` through `env`.
///
/// Returns `None` when the variable is unset or empty, so discovery still runs.
/// This query emits no tracing.
fn env_config_path(env: &impl EnvProvider, var_name: &str) -> Option<PathBuf> {
    env.get(var_name)
        .filter(|value| !value.is_empty())
        .map(PathBuf::from)
}

/// Load the configuration chain rooted at an explicit file path.
///
/// Unlike discovery, a missing explicit file is an error because the caller
/// selected it deliberately.
pub(crate) fn load_layers_from_path(
    path: &std::path::Path,
) -> OrthoResult<Vec<MergeLayer<'static>>> {
    match load_config_file_as_chain(path) {
        Ok(Some(chain)) => Ok(chain
            .values
            .into_iter()
            .map(|(value, layer_path)| MergeLayer::file(Cow::Owned(value), Some(layer_path)))
            .collect()),
        Ok(None) => {
            let error = Arc::new(ortho_config::OrthoError::File {
                path: path.to_path_buf(),
                source: Box::new(io::Error::new(
                    io::ErrorKind::NotFound,
                    "explicit configuration file not found",
                )),
            });
            warn_explicit_config_load_failed(path, ConfigLoadFailureKind::Missing);
            Err(error)
        }
        Err(error) => {
            warn_explicit_config_load_failed(path, ConfigLoadFailureKind::LoadError);
            Err(error)
        }
    }
}

/// Load file layers for early JSON resolution using injected environment access.
///
/// This delegates to the same precedence boundary as the normal merge path.
pub(crate) fn collect_diag_file_layers_with_env(
    cli: &Cli,
    env: &impl EnvProvider,
) -> OrthoResult<Vec<MergeLayer<'static>>> {
    let _span = debug_span!("collect_diag_file_layers").entered();
    collect_file_layers_with_env(cli, env)
}

#[cfg(test)]
#[path = "discovery_event_assertions.rs"]
mod event_assertions;

#[cfg(test)]
#[path = "discovery_tracing_tests.rs"]
mod tracing_tests;

#[cfg(test)]
#[path = "discovery_layer_tests.rs"]
mod layer_tests;

#[cfg(test)]
#[path = "discovery_helper_proptests.rs"]
mod helper_proptests;

#[cfg(test)]
mod tests {
    //! Unit tests for config discovery through injected environment access.

    use super::*;
    use crate::cli::test_support::TestEnv;
    use anyhow::ensure;
    use cap_std::{ambient_authority, fs::Dir};
    use rstest::rstest;
    use tempfile::tempdir;

    #[test]
    fn env_config_path_returns_none_when_var_unset() {
        let env = TestEnv::default();
        assert!(env_config_path(&env, "__NETSUKE_TEST_VAR").is_none());
    }

    #[test]
    fn env_config_path_returns_none_when_var_empty() {
        let env = TestEnv::default().with_var("__NETSUKE_TEST_VAR", "");
        assert!(env_config_path(&env, "__NETSUKE_TEST_VAR").is_none());
    }

    #[test]
    fn env_config_path_returns_path_when_var_set() {
        let env = TestEnv::default().with_var("__NETSUKE_TEST_VAR", "/tmp/foo.toml");
        let result = env_config_path(&env, "__NETSUKE_TEST_VAR");
        assert_eq!(result, Some(PathBuf::from("/tmp/foo.toml")));
    }

    #[rstest]
    #[case::cli_wins_over_env(
        Some("/env/path.toml"),
        Some("/cli/path.toml"),
        Some("/cli/path.toml")
    )]
    #[case::env_used_without_cli(Some("/env/path.toml"), None, Some("/env/path.toml"))]
    #[case::none_when_sources_missing(None, None, None)]
    fn explicit_config_path_obeys_precedence(
        #[case] env_path: Option<&'static str>,
        #[case] cli_path: Option<&'static str>,
        #[case] expected: Option<&'static str>,
    ) {
        let mut env = TestEnv::default();
        if let Some(path) = env_path {
            env = env.with_var(CONFIG_ENV_VAR, path);
        }
        let cli = Cli {
            config: cli_path.map(PathBuf::from),
            ..Cli::default()
        };

        assert_eq!(
            explicit_config_path_with_env(&cli, &env),
            expected.map(PathBuf::from)
        );
    }

    #[test]
    fn collect_diag_file_layers_uses_injected_explicit_config() -> anyhow::Result<()> {
        let dir = tempdir()?;
        let config_path = dir.path().join("netsuke.toml");
        let config_dir = Dir::open_ambient_dir(dir.path(), ambient_authority())?;
        config_dir.write("netsuke.toml", b"json = true\n")?;

        let env = TestEnv::default().with_var(CONFIG_ENV_VAR, config_path.as_os_str());
        let layers = collect_diag_file_layers_with_env(&Cli::default(), &env)?;
        let expected_path = config_path.to_string_lossy().into_owned();

        ensure!(
            layers.iter().any(|layer| layer
                .path()
                .is_some_and(|path| path.as_str() == expected_path)),
            "should include the injected explicit config layer at {expected_path}"
        );

        Ok(())
    }
}

/// Tests for explicit config-path precedence. Enumerated cases cover every
/// combination of `--config` and `NETSUKE_CONFIG` presence; a proptest property
/// test asserts the invariant for generated path values.
#[cfg(test)]
#[path = "config_path_precedence_tests.rs"]
mod config_path_precedence_tests;