#[cfg(test)]
mod tests_unit {
use super::super::*;
use crate::config::SpexConfig;
use crate::error::describe;
use std::fs;
use tempfile::tempdir;
#[test]
fn parse_layout_defaults_to_multi_repo_on_an_empty_answer() {
assert_eq!(parse_layout("").expect("layout"), Layout::MultiRepo);
}
#[test]
fn parse_layout_accepts_the_number_in_the_listed_order() {
assert_eq!(parse_layout("1").expect("layout"), Layout::MultiRepo);
assert_eq!(parse_layout("2").expect("layout"), Layout::SingleRepo);
assert_eq!(parse_layout("3").expect("layout"), Layout::Monorepo);
}
#[test]
fn parse_layout_accepts_the_name_in_any_case() {
assert_eq!(parse_layout("monorepo").expect("layout"), Layout::Monorepo);
assert_eq!(
parse_layout("Single-Repo").expect("layout"),
Layout::SingleRepo
);
}
#[test]
fn parse_layout_rejects_numbers_outside_the_list_and_unknown_names() {
for answer in ["0", "4", "-1", "polyrepo"] {
let error = parse_layout(answer).expect_err("unknown layout");
assert!(
describe(&error).contains(&format!("unknown layout `{answer}`")),
"unexpected error for `{answer}`: {}",
describe(&error)
);
}
}
#[test]
fn to_kebab_case_lowercases_and_replaces_separators() {
assert_eq!(to_kebab_case("Package Name"), "package-name");
assert_eq!(to_kebab_case("package_name"), "package-name");
assert_eq!(to_kebab_case("Package-Name"), "package-name");
}
#[test]
fn to_kebab_case_strips_non_english_chars_without_splitting() {
assert_eq!(to_kebab_case("répo@näme!"), "rponme");
assert_eq!(to_kebab_case("package.name123"), "packagename123");
assert_eq!(to_kebab_case("package_name.v2"), "package-namev2");
}
#[test]
fn to_kebab_case_collapses_and_trims_separators() {
assert_eq!(to_kebab_case(" package name "), "package-name");
assert_eq!(to_kebab_case("__package__name__"), "package-name");
assert_eq!(to_kebab_case("---"), "");
}
fn parsed_config(layout: Layout) -> SpexConfig {
serde_yaml_ng::from_str(&config_from_template(layout, "pizza-shop")).expect("parse config")
}
#[test]
fn config_lists_only_the_spex_package_in_every_layout() {
for layout in [Layout::MultiRepo, Layout::SingleRepo, Layout::Monorepo] {
let config = parsed_config(layout);
assert_eq!(config.project_name, "pizza-shop");
assert_eq!(config.layout, layout);
assert_eq!(config.agent_root, layout.agent_root());
assert_eq!(config.packages.len(), 1, "{layout}");
let spex = &config.packages[0];
let expected_name = if layout.is_multi_repo() {
"pizza-shop-spex"
} else {
"spex"
};
assert_eq!(spex.package_name, expected_name, "{layout}");
assert_eq!(spex.package_short_name, "spex", "{layout}");
assert_eq!(spex.path, ".", "{layout}");
}
}
#[test]
fn config_from_template_records_installed_cli_version() {
for layout in [Layout::MultiRepo, Layout::SingleRepo, Layout::Monorepo] {
assert_eq!(parsed_config(layout).cli_version, CLI_VERSION);
}
}
#[test]
fn config_from_template_leaves_no_unnamed_placeholders() {
for layout in [Layout::MultiRepo, Layout::SingleRepo, Layout::Monorepo] {
let yaml = config_from_template(layout, "pizza-shop");
for field in [
"project_name",
"layout",
"agent_root",
"package_name",
"package_short_name",
] {
assert!(
!yaml.contains(&format!("{field}: unknown")),
"`{field}` left unnamed for {layout}"
);
}
}
}
#[test]
fn config_from_template_lists_root_fields_in_schema_order() {
let yaml = config_from_template(Layout::SingleRepo, "pizza-shop");
let positions: Vec<usize> = ["project_name:", "cli_version:", "layout:", "agent_root:"]
.iter()
.map(|field| yaml.find(field).expect("root field"))
.collect();
assert!(positions.is_sorted());
}
#[test]
fn write_config_writes_file_to_spex_dir() {
let spex_dir = tempdir().expect("create spex dir");
write_config(spex_dir.path(), Layout::SingleRepo, "pizza-shop").expect("write config");
let config_path = spex_dir.path().join(SPEX_CONFIG_FILE);
let written = fs::read_to_string(&config_path).expect("read config");
assert_eq!(
written,
config_from_template(Layout::SingleRepo, "pizza-shop")
);
}
#[test]
fn write_code_workspace_uses_project_name_file() {
let spex_dir = tempdir().expect("create spex dir");
write_code_workspace(spex_dir.path(), "pizza-shop").expect("write code-workspace");
let path = spex_dir.path().join("pizza-shop.code-workspace");
let written = fs::read_to_string(&path).expect("read code-workspace");
assert_eq!(written, CODE_WORKSPACE_TEMPLATE);
}
#[test]
fn verify_empty_passes_on_a_directory_that_does_not_exist() {
let project = tempdir().expect("create project");
assert!(verify_empty(&project.path().join("spex")).is_ok());
}
#[test]
fn verify_empty_fails_when_directory_has_a_file() {
let spex_dir = tempdir().expect("create spex dir");
fs::write(spex_dir.path().join(SPEX_CONFIG_FILE), "").expect("create config");
assert!(verify_empty(spex_dir.path()).is_err());
}
#[test]
fn verify_empty_fails_when_directory_has_a_subdirectory() {
let spex_dir = tempdir().expect("create spex dir");
fs::create_dir(spex_dir.path().join(".git")).expect("create subdirectory");
assert!(verify_empty(spex_dir.path()).is_err());
}
#[test]
fn verify_empty_passes_on_empty_directory() {
let spex_dir = tempdir().expect("create spex dir");
assert!(verify_empty(spex_dir.path()).is_ok());
}
#[test]
fn copy_templates_writes_rules_and_scripts() {
let spex_dir = tempdir().expect("create spex dir");
copy_templates(spex_dir.path(), spex_dir.path()).expect("copy templates");
assert!(spex_dir.path().join("rules").is_dir());
assert!(spex_dir.path().join("specs").is_dir());
assert!(spex_dir.path().join("rules/commit-message-format").is_dir());
assert!(spex_dir.path().join("plans").is_dir());
assert!(spex_dir.path().join("contracts").is_dir());
}
#[test]
fn copy_templates_writes_schemas() {
let spex_dir = tempdir().expect("create spex dir");
copy_templates(spex_dir.path(), spex_dir.path()).expect("copy templates");
assert!(spex_dir.path().join("schemas").is_dir());
assert!(spex_dir.path().join("schemas/spec.schema.json").is_file());
}
#[test]
fn copy_templates_relocates_skills_into_agents() {
let spex_dir = tempdir().expect("create spex dir");
copy_templates(spex_dir.path(), spex_dir.path()).expect("copy templates");
assert!(!spex_dir.path().join("skills").exists());
assert!(spex_dir.path().join(".agents/skills").is_dir());
assert!(
spex_dir
.path()
.join(".agents/skills/spex-spec/SKILL.md")
.is_file()
);
}
#[cfg(unix)]
#[test]
fn copy_templates_links_claude_skills_to_agents_skills() {
let spex_dir = tempdir().expect("create spex dir");
copy_templates(spex_dir.path(), spex_dir.path()).expect("copy templates");
let claude = spex_dir.path().join(".claude");
assert!(
fs::symlink_metadata(&claude)
.expect("read .claude metadata")
.is_dir(),
".claude should be a real directory"
);
let target = fs::read_link(claude.join("skills")).expect("read .claude/skills symlink");
assert_eq!(target, std::path::Path::new("../.agents/skills"));
assert!(
claude.join("skills/spex-spec/SKILL.md").is_file(),
".claude/skills should resolve through the symlink"
);
}
#[cfg(unix)]
#[test]
fn copy_templates_marks_scripts_executable() {
use std::os::unix::fs::PermissionsExt;
let spex_dir = tempdir().expect("create spex dir");
copy_templates(spex_dir.path(), spex_dir.path()).expect("copy templates");
let script = spex_dir
.path()
.join("rules/commit-message-format/commit-spec-id-trace.sh");
let mode = fs::metadata(&script)
.expect("read metadata")
.permissions()
.mode();
assert_ne!(mode & 0o111, 0, "script is not executable");
}
}