use super::MergeLayer;
use super::diagnostics::{path_hash, short_hash};
use super::json::json_from_value;
use super::layers::collect_file_layers_with_normalizer;
use super::paths::{FailingPathNormalizer, FsPathNormalizer, normalized_path_key};
use anyhow::{Context, Result, ensure};
use proptest::prelude::*;
use rstest::rstest;
use serde_json::{Map, Value};
use std::borrow::Cow;
use std::path::{Path, PathBuf};
use tempfile::tempdir;
fn hash_input() -> impl Strategy<Value = Vec<u8>> {
proptest::collection::vec(any::<u8>(), 0..256)
}
fn path_string() -> impl Strategy<Value = String> {
"[A-Za-z0-9._/-]{0,64}"
}
fn project_alias(temp: &Path, project_name: &str, spelling: u8) -> PathBuf {
let project = temp.join(project_name);
match spelling {
0 => project,
1 => project.join("."),
_ => temp.join(project_name).join("..").join(project_name),
}
}
fn ensure_bounded_hash(hash: &str) -> Result<()> {
ensure!(
hash.len() == 16,
"hash should always be 16 characters, got {}: {hash}",
hash.len()
);
ensure!(
hash.chars()
.all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)),
"hash should be lowercase hex: {hash}"
);
Ok(())
}
proptest! {
#[test]
fn short_hash_is_always_bounded_and_hex(value in hash_input()) {
let hash = short_hash(&value);
prop_assert!(ensure_bounded_hash(&hash).is_ok(), "unbounded hash: {hash}");
}
#[test]
fn short_hash_is_deterministic(value in hash_input()) {
prop_assert_eq!(short_hash(&value), short_hash(&value));
}
#[test]
fn short_hash_does_not_echo_input(value in "[A-Za-z0-9._/-]{8,64}") {
let hash = short_hash(value.as_bytes());
prop_assert!(
!hash.contains(&value),
"hash {hash} should not contain input {value}"
);
}
#[test]
fn path_hash_is_always_bounded(value in path_string()) {
let hash = path_hash(Path::new(&value));
prop_assert!(ensure_bounded_hash(&hash).is_ok(), "unbounded hash: {hash}");
}
#[test]
fn normalized_path_key_reports_absent_paths(value in path_string()) {
let absent = format!("/nonexistent-netsuke-proptest/{value}");
prop_assert!(normalized_path_key(&FsPathNormalizer, &absent).is_err());
}
#[test]
fn normalized_path_key_is_idempotent(
value in "[A-Za-z0-9][A-Za-z0-9._-]{0,63}"
) {
let temp = tempdir().expect("create temp dir for resolvable path");
let path = temp.path().join(value);
test_support::fs::create_dir(&path).expect("create generated directory");
let once = normalized_path_key(&FsPathNormalizer, &path.to_string_lossy())
.expect("generated path must normalize");
let twice = normalized_path_key(&FsPathNormalizer, &once.to_string_lossy())
.expect("an already-resolved path must normalize again");
prop_assert_eq!(once, twice);
}
#[test]
fn project_config_aliases_have_one_canonical_layer(
project_name in "[A-Za-z0-9][A-Za-z0-9_-]{0,31}",
spelling in 0_u8..3,
) {
let temp = tempdir().expect("create temp dir for project alias");
let project = temp.path().join(&project_name);
test_support::fs::create_dir(&project).expect("create generated project directory");
let config = project.join(".netsuke.toml");
test_support::fs::write(&config, "default_targets = [\"alpha\"]\n")
.expect("write generated project config");
let layers = collect_file_layers_with_normalizer(
Some(&project_alias(temp.path(), &project_name, spelling)),
&FsPathNormalizer,
)
.expect("discover generated project config");
let discovered = layers
.iter()
.filter_map(|layer| layer.path().map(|path| path.as_str().to_owned()))
.collect::<Vec<_>>();
let canonical = normalized_path_key(&FsPathNormalizer, &config.to_string_lossy())
.expect("canonicalize generated project config")
.to_string_lossy()
.into_owned();
prop_assert_eq!(discovered, vec![canonical]);
}
}
fn layer_value_with_optional_json(has_json: bool) -> impl Strategy<Value = Value> {
let keys = proptest::collection::vec(
(
"[a-z]{1,8}",
proptest::collection::vec("[A-Za-z0-9]{0,8}", 0..2),
),
0..4,
);
let json_value = prop_oneof![
1 => Just(Value::Bool(true)),
1 => Just(Value::Bool(false)),
1 => Just(Value::String("yes".into())),
];
(keys, json_value).prop_map(move |(entries, json_choice)| {
let mut map = Map::new();
for (key, values) in entries {
if has_json && key == "json" {
map.insert(key, json_choice.clone());
} else {
map.insert(
key,
Value::Array(values.into_iter().map(Value::String).collect()),
);
}
}
if has_json && !map.contains_key("json") {
map.insert("json".to_owned(), json_choice);
}
Value::Object(map)
})
}
fn json_script() -> impl Strategy<Value = Vec<Value>> {
proptest::collection::vec(layer_value_with_optional_json(true), 0..6)
}
proptest! {
#[test]
fn folded_json_preference_is_the_last_boolean_in_layer_order(
layers in json_script(),
trailing in layer_value_with_optional_json(false),
) {
let mut source = layers.clone();
source.push(trailing.clone());
let source_len = source.len();
let ordered = source
.into_iter()
.map(|value| MergeLayer::file(Cow::Owned(value), None))
.collect();
let (final_layers, json_preference) = super::layers::retain_layers_and_resolve_json(ordered);
let expected = layers
.iter()
.filter_map(json_from_value)
.next_back()
.unwrap_or_else(|| crate::cli::Cli::default().json);
prop_assert_eq!(json_preference, expected);
prop_assert_eq!(final_layers.len(), source_len);
}
}
#[rstest]
#[case::dot_component("existing", ".")]
#[case::parent_component("existing", "../existing")]
fn normalized_path_key_resolves_non_canonical_forms(
#[case] dir_name: &str,
#[case] suffix: &str,
) -> Result<()> {
let temp = tempdir().context("create temp dir")?;
let target = temp.path().join(dir_name);
test_support::fs::create_dir(&target).context("create target dir")?;
let non_canonical = target.join(suffix);
let normalized = normalized_path_key(&FsPathNormalizer, &non_canonical.to_string_lossy())
.context("normalize non-canonical path")?;
let expected = normalized_path_key(&FsPathNormalizer, &target.to_string_lossy())
.context("normalize target path")?;
ensure!(
normalized == expected,
"non-canonical {non_canonical:?} should normalize to {expected:?}, got {normalized:?}"
);
Ok(())
}
#[cfg(unix)]
#[test]
fn normalized_path_key_follows_cross_directory_symlinks() -> Result<()> {
let temp = tempdir().context("create temp dir")?;
let target = temp.path().join("project");
let aliases = temp.path().join("aliases");
test_support::fs::create_dir(&target).context("create project dir")?;
test_support::fs::create_dir(&aliases).context("create aliases dir")?;
let alias = aliases.join("project-link");
test_support::fs::symlink(&target, &alias).context("create project alias")?;
let normalized = normalized_path_key(&FsPathNormalizer, &alias.to_string_lossy())
.context("normalize project alias")?;
let expected = normalized_path_key(&FsPathNormalizer, &target.to_string_lossy())
.context("normalize project path")?;
ensure!(
normalized == expected,
"project alias {alias:?} should normalize to {expected:?}, got {normalized:?}"
);
Ok(())
}
#[test]
fn normalized_path_key_propagates_normalizer_failure() -> Result<()> {
let error = normalized_path_key(&FailingPathNormalizer, "/any/path")
.expect_err("the failing normalizer must surface its error");
ensure!(
error.kind() == std::io::ErrorKind::PermissionDenied,
"expected the normalizer's own error kind, got {error:?}"
);
Ok(())
}