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";
pub trait EnvProvider {
fn get(&self, key: &str) -> Option<OsString>;
fn entries(&self) -> Vec<(OsString, OsString)> {
Vec::new()
}
}
#[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()
}
}
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),
}
}
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)
},
)
}
#[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
}
#[derive(Debug, PartialEq, Eq)]
struct ConfigPathResolution {
selector: &'static str,
path: Option<PathBuf>,
environment_lookups: Vec<(&'static str, Option<PathBuf>)>,
}
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,
}
}
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"
);
}
fn env_config_path(env: &impl EnvProvider, var_name: &str) -> Option<PathBuf> {
env.get(var_name)
.filter(|value| !value.is_empty())
.map(PathBuf::from)
}
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)
}
}
}
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 {
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(())
}
}
#[cfg(test)]
#[path = "config_path_precedence_tests.rs"]
mod config_path_precedence_tests;