use sha2::{Digest, Sha256};
use std::path::{Path, PathBuf};
pub const DEFAULT_RETENTION: usize = 5;
pub const SETUP_TEST_SLUG: &str = "setup";
pub const TEARDOWN_TEST_SLUG: &str = "teardown";
pub const FLAT_TEST_SLUG: &str = "<flat>";
pub const LATEST_PASSED_FILENAME: &str = "latest-passed.json";
pub const INDEX_FILENAME: &str = "_index.json";
pub fn file_path_hash(root: &Path, file_path: &Path) -> String {
let relative = file_path.strip_prefix(root).unwrap_or(file_path);
let mut canonical = String::new();
for comp in relative.components() {
if !canonical.is_empty() {
canonical.push('/');
}
canonical.push_str(&comp.as_os_str().to_string_lossy());
}
let mut hasher = Sha256::new();
hasher.update(canonical.as_bytes());
let digest = hasher.finalize();
let mut out = String::with_capacity(16);
for byte in digest.iter().take(8) {
out.push_str(&format!("{:02x}", byte));
}
out
}
pub fn slugify_name(name: &str) -> String {
let mut out = String::with_capacity(name.len());
let mut prev_hyphen = false;
for c in name.chars() {
let lower = c.to_ascii_lowercase();
if lower.is_ascii_alphanumeric() || lower == '_' {
out.push(lower);
prev_hyphen = false;
} else if !prev_hyphen && !out.is_empty() {
out.push('-');
prev_hyphen = true;
}
}
while out.ends_with('-') {
out.pop();
}
if out.is_empty() {
return "_".to_owned();
}
out
}
pub fn test_fixture_dir(root: &Path, file_path: &Path, test: &str) -> PathBuf {
root.join(".tarn")
.join("fixtures")
.join(file_path_hash(root, file_path))
.join(slugify_name(test))
}
pub fn step_fixture_dir(root: &Path, file_path: &Path, test: &str, step_index: usize) -> PathBuf {
test_fixture_dir(root, file_path, test).join(step_index.to_string())
}
pub fn latest_passed_path(root: &Path, file_path: &Path, test: &str, step_index: usize) -> PathBuf {
step_fixture_dir(root, file_path, test, step_index).join(LATEST_PASSED_FILENAME)
}
pub fn index_path(root: &Path, file_path: &Path, test: &str, step_index: usize) -> PathBuf {
step_fixture_dir(root, file_path, test, step_index).join(INDEX_FILENAME)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slugify_name_lowercases_and_hyphenates() {
assert_eq!(slugify_name("Create User"), "create-user");
assert_eq!(slugify_name("POST /users/:id"), "post-users-id");
}
#[test]
fn slugify_name_keeps_underscores_and_digits() {
assert_eq!(slugify_name("list_2"), "list_2");
}
#[test]
fn slugify_name_empty_or_punctuation_collapses_to_underscore() {
assert_eq!(slugify_name(""), "_");
assert_eq!(slugify_name("!!!"), "_");
}
#[test]
fn file_path_hash_is_deterministic_and_relative() {
let root = Path::new("/tmp/workspace");
let a = root.join("tests").join("users.tarn.yaml");
let b = Path::new("/elsewhere/tests/users.tarn.yaml");
let hash_a = file_path_hash(root, &a);
let hash_b = file_path_hash(root, b);
assert_ne!(hash_a, hash_b);
assert_eq!(hash_a.len(), 16);
}
#[test]
fn file_path_hash_matches_relative_input() {
let root = Path::new("/tmp/workspace");
let absolute = root.join("tests").join("users.tarn.yaml");
let relative = Path::new("tests/users.tarn.yaml");
assert_eq!(
file_path_hash(root, &absolute),
file_path_hash(root, relative)
);
}
#[test]
fn step_fixture_dir_layers_under_dot_tarn() {
let root = Path::new("/tmp/workspace");
let file = root.join("tests/users.tarn.yaml");
let dir = step_fixture_dir(root, &file, "create user", 2);
let display = dir.to_string_lossy().into_owned();
assert!(
display.contains(".tarn/fixtures/"),
"expected .tarn/fixtures/ path, got {display}"
);
assert!(display.ends_with("/create-user/2"));
}
#[test]
fn latest_passed_path_points_inside_step_dir() {
let root = Path::new("/tmp/workspace");
let file = root.join("tests/users.tarn.yaml");
let latest = latest_passed_path(root, &file, "create user", 0);
assert!(latest
.to_string_lossy()
.ends_with("/create-user/0/latest-passed.json"));
}
}