use super::paths::{FailingPathNormalizer, FsPathNormalizer, PathNormalizer, normalized_path_key};
use super::*;
use crate::cli::test_support::TestEnv;
use anyhow::{Context, Result, ensure};
use camino::Utf8PathBuf;
use googletest::prelude::*;
use pretty_assertions::assert_eq;
use rstest::rstest;
use tempfile::{TempDir, tempdir};
use super::event_assertions::{capture_events, find_event};
use super::layers::collect_file_layers_with_normalizer;
use std::cell::Cell;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
fn utf8_directory(path: &Path) -> Result<Utf8PathBuf> {
Utf8PathBuf::from_path_buf(path.to_path_buf()).map_err(|non_utf8| {
anyhow::anyhow!("test directory is not valid UTF-8: {}", non_utf8.display())
})
}
#[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
}
fn entries(&self) -> Vec<(OsString, OsString)> {
Vec::new()
}
}
#[derive(Default)]
struct CountingPathNormalizer {
calls: Cell<usize>,
}
impl CountingPathNormalizer {
fn calls(&self) -> usize {
self.calls.get()
}
}
impl PathNormalizer for CountingPathNormalizer {
fn normalize(&self, path: &Path) -> std::io::Result<PathBuf> {
self.calls.set(self.calls.get() + 1);
Ok(path.to_path_buf())
}
}
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(utf8_directory(temp.path())?),
..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 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<_>>();
let expected_path = normalized_path_key(&FsPathNormalizer, &config_path.to_string_lossy())
.context("canonicalise injected XDG config path")?
.to_string_lossy()
.into_owned();
assert_eq!(paths, vec![expected_path]);
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(utf8_directory(temp.path())?),
..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(
Utf8PathBuf::from_path_buf(non_canonical).map_err(|non_utf8| {
anyhow::anyhow!("test directory is not valid UTF-8: {}", non_utf8.display())
})?,
),
..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(())
}
#[cfg(unix)]
#[test]
fn project_scope_layer_is_not_appended_twice_via_symlink_alias() -> 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 alias = temp.path().join("project-alias");
test_support::fs::symlink(&project_dir, &alias).context("create project alias")?;
let layers =
collect_file_layers_with_normalizer(Some(alias.as_path()), &paths::FsPathNormalizer)?;
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:?}"
);
Ok(())
}
#[test]
fn project_layer_scan_normalizes_only_the_project_key() -> 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("base.toml"), "jobs = 7\n")
.context("write inherited config")?;
test_support::fs::write(
project_dir.join(".netsuke.toml"),
"extends = \"base.toml\"\njson = true\n",
)
.context("write project config")?;
let normalizer = CountingPathNormalizer::default();
let layers = collect_file_layers_with_normalizer(Some(project_dir.as_path()), &normalizer)
.context("collect inherited project layers")?;
ensure!(
layers.len() == 2,
"expected inherited and project layers: {layers:?}"
);
ensure!(
normalizer.calls() == 1,
"only the expected project path should be normalized, got {} calls",
normalizer.calls()
);
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 alias = project_dir.join(".");
let cli = Cli {
directory: Some(Utf8PathBuf::from_path_buf(alias).map_err(|non_utf8| {
anyhow::anyhow!("test directory is not valid UTF-8: {}", non_utf8.display())
})?),
..Cli::default()
};
let env = CountingEnv::default();
let (discovered, collection_events) = capture_events(|| {
Ok::<_, anyhow::Error>(discover_file_layers_with_normalizer(
&cli,
&env,
&FailingPathNormalizer,
))
})?;
ensure!(
!collection_events
.iter()
.any(|event| event.contains("resolved project-scope layer deduplication")),
"discovery must defer the project-layer count event until replay: {collection_events:?}"
);
let discovery_get_calls = env.get_calls();
let events = replay_events(&discovered)?;
ensure!(
env.get_calls() == discovery_get_calls,
"replay must not access the environment again"
);
ensure!(
discovered.layers().len() == 1,
"expected one project layer: {:?}",
discovered.layers()
);
let deduplication = find_event(&events, "resolved project-scope layer deduplication")?;
ensure!(
deduplication.contains("discovered_layer_count=1")
&& deduplication.contains("project_layer_count=1")
&& deduplication.contains("appended_layer_count=0"),
"fallback de-duplication outcome should record its counts: {deduplication}"
);
Ok(())
}