use super::*;
use crate::cli::test_support::TestEnv;
use anyhow::{Context, Result, ensure};
use googletest::prelude::*;
use pretty_assertions::assert_eq;
use rstest::rstest;
use tempfile::{TempDir, tempdir};
use super::event_assertions::{EventAssertion, capture_events, find_event};
use super::layers::collect_file_layers_with_normalizer;
use super::paths::FailingPathNormalizer;
use std::cell::Cell;
use std::ffi::OsString;
#[derive(Debug, Clone, Copy)]
pub(super) enum LayerScenario {
ExplicitConfig,
Discovery,
}
#[derive(Default)]
pub(super) struct CountingEnv {
get_calls: Cell<usize>,
}
impl CountingEnv {
pub(super) fn get_calls(&self) -> usize {
self.get_calls.get()
}
}
impl EnvProvider for CountingEnv {
fn get(&self, _key: &str) -> Option<OsString> {
self.get_calls.set(self.get_calls.get() + 1);
None
}
}
pub(super) fn scenario_cli(scenario: LayerScenario, temp: &TempDir) -> Result<Cli> {
match scenario {
LayerScenario::ExplicitConfig => {
let config_path = temp.path().join("config.toml");
test_support::fs::write(&config_path, "theme = \"ascii\"\n")
.with_context(|| format!("write {}", config_path.display()))?;
Ok(Cli {
config: Some(config_path),
..Cli::default()
})
}
LayerScenario::Discovery => Ok(Cli {
directory: Some(temp.path().to_path_buf()),
..Cli::default()
}),
}
}
pub(super) fn replay_events(discovered: &DiscoveryOutcome) -> Result<Vec<String>> {
let ((), events) = capture_events(|| {
discovered.emit_diagnostics();
Ok::<_, anyhow::Error>(())
})?;
Ok(events)
}
#[test]
fn replay_logs_explicit_config_branch_without_environment_access() -> Result<()> {
let temp = tempdir().context("create temp dir")?;
let cli = scenario_cli(LayerScenario::ExplicitConfig, &temp)?;
let env = CountingEnv::default();
let discovered = collect_diag_file_layers_with_env(&cli, &env);
let events = replay_events(&discovered)?;
find_event(&events, "resolved config path")?;
let branch_event = find_event(&events, "using explicit config path")?;
ensure!(
!discovered.layers().is_empty(),
"explicit layer should be retained for the merge"
);
ensure!(
env.get_calls() == 0,
"explicit selection should not access the environment, including on replay"
);
EventAssertion::new(
branch_event,
cli.config.as_deref().context("explicit config")?,
)
.ensure_bounded_path_fields()?;
Ok(())
}
#[test]
fn replay_logs_discovery_and_appended_project_scope_without_environment_access() -> Result<()> {
let temp = tempdir().context("create temp dir")?;
let cli = scenario_cli(LayerScenario::Discovery, &temp)?;
let env = CountingEnv::default();
let discovered = collect_diag_file_layers_with_env(&cli, &env);
let discovery_get_calls = env.get_calls();
ensure!(
discovery_get_calls > 0,
"discovery should read the injected environment"
);
let events = replay_events(&discovered)?;
ensure!(
env.get_calls() == discovery_get_calls,
"replay must not access the environment again"
);
find_event(&events, "read config path variable")?;
find_event(&events, "resolved config path")?;
find_event(&events, "using config discovery")?;
find_event(&events, "appending project-scope layers")?;
Ok(())
}
#[test]
fn replay_logs_included_project_scope_without_environment_access() -> Result<()> {
let temp = tempdir().context("create temp dir")?;
test_support::fs::write(temp.path().join(".netsuke.toml"), "jobs = 7\n")
.context("write project config")?;
let cli = scenario_cli(LayerScenario::Discovery, &temp)?;
let env = CountingEnv::default();
let discovered = collect_diag_file_layers_with_env(&cli, &env);
let discovery_get_calls = env.get_calls();
ensure!(
discovery_get_calls > 0,
"discovery should read the injected environment"
);
let events = replay_events(&discovered)?;
ensure!(
env.get_calls() == discovery_get_calls,
"replay must not access the environment again"
);
find_event(&events, "using config discovery")?;
find_event(&events, "discovery included project-scope layers")?;
Ok(())
}
#[test]
fn injected_automatic_discovery_uses_xdg_config_home() -> Result<()> {
let temp = tempdir().context("create temp dir")?;
let xdg_config_home = temp.path().join("xdg-config");
let config_path = xdg_config_home.join("netsuke/config.toml");
test_support::fs::create_dir(&xdg_config_home).context("create injected XDG directory")?;
test_support::fs::create_dir(config_path.parent().context("config parent")?)
.context("create injected config directory")?;
test_support::fs::write(&config_path, "json = true\n").context("write injected config")?;
let env = TestEnv::default().with_var("XDG_CONFIG_HOME", xdg_config_home.as_os_str());
let discovered = discover_file_layers(&Cli::default(), &env);
ensure!(
discovered.first_error().is_none(),
"injected configuration discovery should succeed"
);
let layers = discovered.layers();
let paths = layers
.iter()
.filter_map(|layer| layer.path().map(|path| path.as_str().to_owned()))
.collect::<Vec<_>>();
assert_eq!(paths, vec![config_path.to_string_lossy().into_owned()]);
Ok(())
}
#[rstest]
#[case::no_candidate(None, None, 0)]
#[case::valid_candidate(Some("emoji = \"always\"\n"), None, 1)]
#[case::malformed_candidate(Some("emoji = \"always\n"), Some(".netsuke.toml"), 0)]
#[case::missing_parent(
Some("extends = \"missing-parent.toml\"\n"),
Some("missing-parent.toml"),
0
)]
fn discovered_project_config_retains_load_outcome(
#[case] contents: Option<&str>,
#[case] expected_error_fragment: Option<&str>,
#[case] expected_layer_count: usize,
) -> Result<()> {
let temp = tempdir().context("create temp dir")?;
if let Some(config_contents) = contents {
test_support::fs::write(temp.path().join(".netsuke.toml"), config_contents)
.context("write project config")?;
}
let cli = Cli {
directory: Some(temp.path().to_path_buf()),
..Cli::default()
};
let env = TestEnv::default();
let discovered = discover_file_layers(&cli, &env);
if let Some(fragment) = expected_error_fragment {
let error = discovered
.first_error()
.context("invalid discovered config must fail")?;
assert_that!(error.to_string(), contains_substring(fragment));
} else {
ensure!(
discovered.first_error().is_none(),
"valid discovered config must load"
);
let layers = discovered.layers();
assert_eq!(layers.len(), expected_layer_count);
}
Ok(())
}
#[test]
fn existing_project_scope_layer_is_not_appended_twice() -> Result<()> {
let temp = tempdir().context("create temp dir")?;
let project_dir = temp.path().join("project");
test_support::fs::create_dir(&project_dir).context("create project dir")?;
test_support::fs::write(
project_dir.join(".netsuke.toml"),
"default_targets = [\"alpha\"]\n",
)
.context("write project config")?;
let non_canonical = project_dir.join(".");
let cli = Cli {
directory: Some(non_canonical),
..Cli::default()
};
let discovered = discover_file_layers(&cli, &TestEnv::default());
let layers = discovered.layers();
let project_layers = layers
.iter()
.filter(|layer| {
layer
.path()
.is_some_and(|path| path.as_str().ends_with(".netsuke.toml"))
})
.count();
ensure!(
project_layers == 1,
"project-scope layer should appear exactly once, found {project_layers}: {layers:?}"
);
let events = replay_events(&discovered)?;
find_event(&events, "discovery included project-scope layers")?;
Ok(())
}
#[test]
fn normalization_failure_does_not_fail_discovery() -> Result<()> {
let temp = tempdir().context("create temp dir")?;
let project_dir = temp.path().join("project");
test_support::fs::create_dir(&project_dir).context("create project dir")?;
test_support::fs::write(
project_dir.join(".netsuke.toml"),
"default_targets = [\"alpha\"]\n",
)
.context("write project config")?;
let layers =
collect_file_layers_with_normalizer(Some(project_dir.as_path()), &FailingPathNormalizer)
.context("discovery must succeed despite normalization failure")?;
ensure!(layers.len() == 1, "expected one project layer: {layers:?}");
Ok(())
}
#[cfg(unix)]
#[test]
fn non_utf8_directory_does_not_duplicate_project_layer() -> Result<()> {
use std::os::unix::ffi::OsStringExt;
let temp = tempdir().context("create temp dir")?;
let project_dir = temp
.path()
.join(OsString::from_vec(b"project-\xff".to_vec()));
test_support::fs::create_dir(&project_dir).context("create project dir")?;
let lossy_project_dir = temp.path().join("project-\u{fffd}");
test_support::fs::symlink(&project_dir, &lossy_project_dir)
.context("create lossy project alias")?;
test_support::fs::write(
project_dir.join(".netsuke.toml"),
"default_targets = [\"alpha\"]\n",
)
.context("write project config")?;
let cli = Cli {
directory: Some(project_dir),
..Cli::default()
};
let discovered = discover_file_layers(&cli, &TestEnv::default());
let layers = discovered.layers();
ensure!(layers.len() == 1, "expected one project layer: {layers:?}");
Ok(())
}