huskies: merge 846

This commit is contained in:
dave
2026-04-29 18:17:04 +00:00
parent 320be659c0
commit 39013be535
2 changed files with 595 additions and 593 deletions
+1 -593
View File
@@ -142,596 +142,4 @@ pub(crate) fn scaffold_story_kit(root: &Path, port: u16) -> Result<(), String> {
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn scaffold_story_kit_creates_structure() {
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
assert!(dir.path().join(".huskies/README.md").exists());
assert!(dir.path().join(".huskies/project.toml").exists());
assert!(dir.path().join(".huskies/agents.toml").exists());
assert!(dir.path().join(".huskies/specs/00_CONTEXT.md").exists());
assert!(dir.path().join(".huskies/specs/tech/STACK.md").exists());
// Old stories/ dirs should NOT be created
assert!(!dir.path().join(".huskies/stories").exists());
assert!(dir.path().join("script/test").exists());
}
#[test]
fn scaffold_story_kit_does_not_create_work_pipeline_dirs() {
// After 763 the `.huskies/work/` tree is no longer scaffolded; CRDT is
// the canonical pipeline state store.
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
let work_root = dir.path().join(".huskies/work");
assert!(
!work_root.exists(),
".huskies/work/ should not be created at scaffold time"
);
}
#[test]
fn scaffold_story_kit_agents_toml_has_coder_qa_mergemaster() {
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
// Agent definitions go into agents.toml, not project.toml.
let agents = fs::read_to_string(dir.path().join(".huskies/agents.toml")).unwrap();
assert!(agents.contains("[[agent]]"));
assert!(agents.contains("stage = \"coder\""));
assert!(agents.contains("stage = \"qa\""));
assert!(agents.contains("stage = \"mergemaster\""));
assert!(agents.contains("model = \"sonnet\""));
// project.toml should NOT contain [[agent]] blocks.
let project = fs::read_to_string(dir.path().join(".huskies/project.toml")).unwrap();
assert!(!project.contains("[[agent]]"));
}
#[test]
fn scaffold_project_toml_contains_rate_limit_and_timezone_comments() {
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
let content = fs::read_to_string(dir.path().join(".huskies/project.toml")).unwrap();
assert!(
content.contains("rate_limit_notifications"),
"project.toml scaffold should document rate_limit_notifications"
);
assert!(
content.contains("timezone"),
"project.toml scaffold should document timezone"
);
}
#[test]
fn scaffold_project_toml_contains_max_retries_with_default_value() {
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
let content = fs::read_to_string(dir.path().join(".huskies/project.toml")).unwrap();
assert!(
content.contains("max_retries = 2"),
"project.toml scaffold should include max_retries with default value 2"
);
assert!(
content.contains("Maximum number of retries"),
"project.toml scaffold should include a comment explaining max_retries"
);
}
#[test]
fn scaffold_project_toml_contains_commented_out_optional_fields() {
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
let content = fs::read_to_string(dir.path().join(".huskies/project.toml")).unwrap();
assert!(
content.contains("# default_coder_model"),
"project.toml scaffold should include commented-out default_coder_model"
);
assert!(
content.contains("# max_coders"),
"project.toml scaffold should include commented-out max_coders"
);
assert!(
content.contains("# base_branch"),
"project.toml scaffold should include commented-out base_branch"
);
}
#[test]
fn scaffold_project_toml_round_trips_through_project_config_load() {
use crate::config::ProjectConfig;
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
// The generated project.toml must parse without error.
let config = ProjectConfig::load(dir.path())
.expect("Generated project.toml should parse without error");
// Key defaults must survive the round-trip.
assert_eq!(config.default_qa, "server");
assert_eq!(config.max_retries, 2);
assert!(
config.rate_limit_notifications,
"rate_limit_notifications should default to true"
);
assert!(
config.default_coder_model.is_none(),
"default_coder_model should be None when commented out"
);
assert!(
config.max_coders.is_none(),
"max_coders should be None when commented out"
);
assert!(
config.base_branch.is_none(),
"base_branch should be None when commented out"
);
assert!(
config.timezone.is_none(),
"timezone should be None when commented out"
);
}
#[test]
fn scaffold_context_is_blank_template_not_story_kit_content() {
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
let content = fs::read_to_string(dir.path().join(".huskies/specs/00_CONTEXT.md")).unwrap();
assert!(content.contains("<!-- huskies:scaffold-template -->"));
assert!(content.contains("## High-Level Goal"));
assert!(content.contains("## Core Features"));
assert!(content.contains("## Domain Definition"));
assert!(content.contains("## Glossary"));
// Must NOT contain Story Kit-specific content
assert!(!content.contains("Agentic AI Code Assistant"));
assert!(!content.contains("Poem HTTP server"));
}
#[test]
fn scaffold_stack_is_blank_template_not_story_kit_content() {
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
let content = fs::read_to_string(dir.path().join(".huskies/specs/tech/STACK.md")).unwrap();
assert!(content.contains("<!-- huskies:scaffold-template -->"));
assert!(content.contains("## Core Stack"));
assert!(content.contains("## Coding Standards"));
assert!(content.contains("## Quality Gates"));
assert!(content.contains("## Libraries"));
// Must NOT contain Story Kit-specific content
assert!(!content.contains("Poem HTTP server"));
assert!(!content.contains("TypeScript + React"));
}
#[cfg(unix)]
#[test]
fn scaffold_story_kit_creates_executable_script_test() {
use std::os::unix::fs::PermissionsExt;
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
let script_test = dir.path().join("script/test");
assert!(script_test.exists(), "script/test should be created");
let perms = fs::metadata(&script_test).unwrap().permissions();
assert!(
perms.mode() & 0o111 != 0,
"script/test should be executable"
);
}
#[test]
fn scaffold_story_kit_does_not_overwrite_existing() {
let dir = tempdir().unwrap();
let readme = dir.path().join(".huskies/README.md");
fs::create_dir_all(readme.parent().unwrap()).unwrap();
fs::write(&readme, "custom content").unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
assert_eq!(fs::read_to_string(&readme).unwrap(), "custom content");
}
#[test]
fn scaffold_story_kit_is_idempotent() {
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
let readme_content = fs::read_to_string(dir.path().join(".huskies/README.md")).unwrap();
let toml_content = fs::read_to_string(dir.path().join(".huskies/project.toml")).unwrap();
// Run again — must not change content or add duplicate .gitignore entries
scaffold_story_kit(dir.path(), 3001).unwrap();
assert_eq!(
fs::read_to_string(dir.path().join(".huskies/README.md")).unwrap(),
readme_content
);
assert_eq!(
fs::read_to_string(dir.path().join(".huskies/project.toml")).unwrap(),
toml_content
);
let story_kit_gitignore =
fs::read_to_string(dir.path().join(".huskies/.gitignore")).unwrap();
let count = story_kit_gitignore
.lines()
.filter(|l| l.trim() == "worktrees/")
.count();
assert_eq!(
count, 1,
".huskies/.gitignore should not have duplicate entries"
);
}
#[test]
fn scaffold_story_kit_existing_git_repo_no_commit() {
let dir = tempdir().unwrap();
// Initialize a git repo before scaffold
std::process::Command::new("git")
.args(["init"])
.current_dir(dir.path())
.status()
.unwrap();
std::process::Command::new("git")
.args([
"-c",
"user.email=test@test.com",
"-c",
"user.name=Test",
"commit",
"--allow-empty",
"-m",
"pre-scaffold",
])
.current_dir(dir.path())
.status()
.unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
// Only 1 commit should exist — scaffold must not commit into an existing repo
let log_output = std::process::Command::new("git")
.args(["log", "--oneline"])
.current_dir(dir.path())
.output()
.unwrap();
let log = String::from_utf8_lossy(&log_output.stdout);
let commit_count = log.lines().count();
assert_eq!(
commit_count, 1,
"scaffold should not create a commit in an existing git repo"
);
}
#[test]
fn scaffold_creates_story_kit_gitignore_with_relative_entries() {
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
// .huskies/.gitignore must contain relative patterns for files under .huskies/
let sk_content = fs::read_to_string(dir.path().join(".huskies/.gitignore")).unwrap();
assert!(sk_content.contains("worktrees/"));
assert!(sk_content.contains("merge_workspace/"));
assert!(sk_content.contains("coverage/"));
assert!(sk_content.contains("matrix_history.json"));
assert!(sk_content.contains("timers.json"));
// Must NOT contain absolute .huskies/ prefixed paths
assert!(!sk_content.contains(".huskies/"));
// Root .gitignore must contain root-level huskies entries
let root_content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
assert!(root_content.contains(".huskies_port"));
// store.json now lives inside .huskies/ and must NOT appear in root .gitignore
assert!(!root_content.contains("store.json"));
// Root .gitignore must NOT contain .huskies/ sub-directory patterns
assert!(!root_content.contains(".huskies/worktrees/"));
assert!(!root_content.contains(".huskies/merge_workspace/"));
assert!(!root_content.contains(".huskies/coverage/"));
// store.json must be in .huskies/.gitignore instead
assert!(sk_content.contains("store.json"));
// Database files must be ignored so novice users don't accidentally commit them
assert!(sk_content.contains("pipeline.db"));
assert!(sk_content.contains("*.db"));
}
#[test]
fn scaffold_story_kit_gitignore_does_not_duplicate_existing_entries() {
let dir = tempdir().unwrap();
// Pre-create .huskies dir and .gitignore with some entries already present
fs::create_dir_all(dir.path().join(".huskies")).unwrap();
fs::write(
dir.path().join(".huskies/.gitignore"),
"worktrees/\ncoverage/\n",
)
.unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
let content = fs::read_to_string(dir.path().join(".huskies/.gitignore")).unwrap();
let worktrees_count = content.lines().filter(|l| l.trim() == "worktrees/").count();
assert_eq!(worktrees_count, 1, "worktrees/ should not be duplicated");
let coverage_count = content.lines().filter(|l| l.trim() == "coverage/").count();
assert_eq!(coverage_count, 1, "coverage/ should not be duplicated");
// The missing entry must have been added
assert!(content.contains("merge_workspace/"));
}
#[test]
fn scaffold_creates_claude_md_at_project_root() {
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
let claude_md = dir.path().join("CLAUDE.md");
assert!(
claude_md.exists(),
"CLAUDE.md should be created at project root"
);
let content = fs::read_to_string(&claude_md).unwrap();
assert!(
content.contains("<!-- huskies:scaffold-template -->"),
"CLAUDE.md should contain the scaffold sentinel"
);
assert!(
content.contains("Read .huskies/README.md"),
"CLAUDE.md should include directive to read .huskies/README.md"
);
assert!(
content.contains("Never chain shell commands"),
"CLAUDE.md should include command chaining rule"
);
assert!(
content.contains("wizard_status"),
"CLAUDE.md should instruct Claude to call wizard_status on first conversation"
);
}
#[test]
fn scaffold_does_not_overwrite_existing_claude_md() {
let dir = tempdir().unwrap();
let claude_md = dir.path().join("CLAUDE.md");
fs::write(&claude_md, "custom CLAUDE.md content").unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
assert_eq!(
fs::read_to_string(&claude_md).unwrap(),
"custom CLAUDE.md content",
"scaffold should not overwrite an existing CLAUDE.md"
);
}
#[test]
fn scaffold_story_kit_writes_mcp_json_with_port() {
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path(), 4242).unwrap();
let mcp_path = dir.path().join(".mcp.json");
assert!(mcp_path.exists(), ".mcp.json should be created by scaffold");
let content = fs::read_to_string(&mcp_path).unwrap();
assert!(
content.contains("4242"),
".mcp.json should reference the given port"
);
assert!(
content.contains("localhost"),
".mcp.json should reference localhost"
);
assert!(
content.contains("huskies"),
".mcp.json should name the huskies server"
);
}
#[test]
fn scaffold_story_kit_does_not_overwrite_existing_mcp_json() {
let dir = tempdir().unwrap();
let mcp_path = dir.path().join(".mcp.json");
fs::write(&mcp_path, "{\"custom\": true}").unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
assert_eq!(
fs::read_to_string(&mcp_path).unwrap(),
"{\"custom\": true}",
"scaffold should not overwrite an existing .mcp.json"
);
}
#[test]
fn scaffold_gitignore_includes_mcp_json() {
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
let root_gitignore = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
assert!(
root_gitignore.contains(".mcp.json"),
"root .gitignore should include .mcp.json (port is environment-specific)"
);
}
#[test]
fn scaffold_script_test_contains_detected_commands_for_rust() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"myapp\"\n",
)
.unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
let content = fs::read_to_string(dir.path().join("script/test")).unwrap();
assert!(
content.contains("cargo test"),
"Rust project scaffold should set cargo test in script/test"
);
assert!(
!content.contains("No tests configured"),
"should not use stub when stack is detected"
);
}
#[test]
fn scaffold_script_test_fallback_stub_when_no_stack() {
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
let content = fs::read_to_string(dir.path().join("script/test")).unwrap();
assert!(
content.contains("No tests configured"),
"unknown stack should use the generic stub"
);
}
#[test]
fn scaffold_story_kit_creates_script_build_and_lint() {
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
assert!(
dir.path().join("script/build").exists(),
"script/build should be created by scaffold"
);
assert!(
dir.path().join("script/lint").exists(),
"script/lint should be created by scaffold"
);
}
#[cfg(unix)]
#[test]
fn scaffold_story_kit_creates_executable_script_build_and_lint() {
use std::os::unix::fs::PermissionsExt;
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
for name in &["build", "lint"] {
let path = dir.path().join("script").join(name);
assert!(path.exists(), "script/{name} should be created");
let perms = fs::metadata(&path).unwrap().permissions();
assert!(
perms.mode() & 0o111 != 0,
"script/{name} should be executable"
);
}
}
#[test]
fn scaffold_script_build_contains_detected_commands_for_rust() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"myapp\"\n",
)
.unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
let content = fs::read_to_string(dir.path().join("script/build")).unwrap();
assert!(
content.contains("cargo build --release"),
"Rust project scaffold should set cargo build --release in script/build"
);
}
#[test]
fn scaffold_script_lint_contains_detected_commands_for_rust() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"myapp\"\n",
)
.unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
let content = fs::read_to_string(dir.path().join("script/lint")).unwrap();
assert!(
content.contains("cargo fmt --all --check"),
"Rust project scaffold should include fmt check in script/lint"
);
assert!(
content.contains("cargo clippy -- -D warnings"),
"Rust project scaffold should include clippy in script/lint"
);
}
#[test]
fn scaffold_project_toml_contains_detected_components() {
let dir = tempdir().unwrap();
// Place a Cargo.toml in the project root before scaffolding
fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"myapp\"\n",
)
.unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
let content = fs::read_to_string(dir.path().join(".huskies/project.toml")).unwrap();
assert!(
content.contains("[[component]]"),
"project.toml should contain a component entry"
);
assert!(
content.contains("name = \"server\""),
"Rust project should have a 'server' component"
);
assert!(
content.contains("cargo check"),
"Rust component should have cargo check setup"
);
}
#[test]
fn scaffold_project_toml_fallback_when_no_stack_detected() {
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
let content = fs::read_to_string(dir.path().join(".huskies/project.toml")).unwrap();
assert!(
content.contains("[[component]]"),
"project.toml should always have at least one component"
);
// Fallback uses generic app component with empty setup — no Rust-specific commands
assert!(
content.contains("name = \"app\""),
"fallback should use generic 'app' component name"
);
assert!(
!content.contains("cargo"),
"fallback must not contain Rust-specific commands for non-Rust projects"
);
}
#[test]
fn scaffold_does_not_overwrite_existing_project_toml_with_components() {
let dir = tempdir().unwrap();
let sk_dir = dir.path().join(".huskies");
fs::create_dir_all(&sk_dir).unwrap();
let existing = "[[component]]\nname = \"custom\"\npath = \".\"\nsetup = [\"make build\"]\n";
fs::write(sk_dir.join("project.toml"), existing).unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
let content = fs::read_to_string(sk_dir.join("project.toml")).unwrap();
assert_eq!(
content, existing,
"scaffold should not overwrite existing project.toml"
);
}
}
mod tests;
+594
View File
@@ -0,0 +1,594 @@
//! Integration tests for the scaffold module — verifies that `scaffold_story_kit`
//! creates the correct directory structure, files, and git state.
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn scaffold_story_kit_creates_structure() {
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
assert!(dir.path().join(".huskies/README.md").exists());
assert!(dir.path().join(".huskies/project.toml").exists());
assert!(dir.path().join(".huskies/agents.toml").exists());
assert!(dir.path().join(".huskies/specs/00_CONTEXT.md").exists());
assert!(dir.path().join(".huskies/specs/tech/STACK.md").exists());
// Old stories/ dirs should NOT be created
assert!(!dir.path().join(".huskies/stories").exists());
assert!(dir.path().join("script/test").exists());
}
#[test]
fn scaffold_story_kit_does_not_create_work_pipeline_dirs() {
// After 763 the `.huskies/work/` tree is no longer scaffolded; CRDT is
// the canonical pipeline state store.
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
let work_root = dir.path().join(".huskies/work");
assert!(
!work_root.exists(),
".huskies/work/ should not be created at scaffold time"
);
}
#[test]
fn scaffold_story_kit_agents_toml_has_coder_qa_mergemaster() {
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
// Agent definitions go into agents.toml, not project.toml.
let agents = fs::read_to_string(dir.path().join(".huskies/agents.toml")).unwrap();
assert!(agents.contains("[[agent]]"));
assert!(agents.contains("stage = \"coder\""));
assert!(agents.contains("stage = \"qa\""));
assert!(agents.contains("stage = \"mergemaster\""));
assert!(agents.contains("model = \"sonnet\""));
// project.toml should NOT contain [[agent]] blocks.
let project = fs::read_to_string(dir.path().join(".huskies/project.toml")).unwrap();
assert!(!project.contains("[[agent]]"));
}
#[test]
fn scaffold_project_toml_contains_rate_limit_and_timezone_comments() {
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
let content = fs::read_to_string(dir.path().join(".huskies/project.toml")).unwrap();
assert!(
content.contains("rate_limit_notifications"),
"project.toml scaffold should document rate_limit_notifications"
);
assert!(
content.contains("timezone"),
"project.toml scaffold should document timezone"
);
}
#[test]
fn scaffold_project_toml_contains_max_retries_with_default_value() {
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
let content = fs::read_to_string(dir.path().join(".huskies/project.toml")).unwrap();
assert!(
content.contains("max_retries = 2"),
"project.toml scaffold should include max_retries with default value 2"
);
assert!(
content.contains("Maximum number of retries"),
"project.toml scaffold should include a comment explaining max_retries"
);
}
#[test]
fn scaffold_project_toml_contains_commented_out_optional_fields() {
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
let content = fs::read_to_string(dir.path().join(".huskies/project.toml")).unwrap();
assert!(
content.contains("# default_coder_model"),
"project.toml scaffold should include commented-out default_coder_model"
);
assert!(
content.contains("# max_coders"),
"project.toml scaffold should include commented-out max_coders"
);
assert!(
content.contains("# base_branch"),
"project.toml scaffold should include commented-out base_branch"
);
}
#[test]
fn scaffold_project_toml_round_trips_through_project_config_load() {
use crate::config::ProjectConfig;
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
// The generated project.toml must parse without error.
let config =
ProjectConfig::load(dir.path()).expect("Generated project.toml should parse without error");
// Key defaults must survive the round-trip.
assert_eq!(config.default_qa, "server");
assert_eq!(config.max_retries, 2);
assert!(
config.rate_limit_notifications,
"rate_limit_notifications should default to true"
);
assert!(
config.default_coder_model.is_none(),
"default_coder_model should be None when commented out"
);
assert!(
config.max_coders.is_none(),
"max_coders should be None when commented out"
);
assert!(
config.base_branch.is_none(),
"base_branch should be None when commented out"
);
assert!(
config.timezone.is_none(),
"timezone should be None when commented out"
);
}
#[test]
fn scaffold_context_is_blank_template_not_story_kit_content() {
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
let content = fs::read_to_string(dir.path().join(".huskies/specs/00_CONTEXT.md")).unwrap();
assert!(content.contains("<!-- huskies:scaffold-template -->"));
assert!(content.contains("## High-Level Goal"));
assert!(content.contains("## Core Features"));
assert!(content.contains("## Domain Definition"));
assert!(content.contains("## Glossary"));
// Must NOT contain Story Kit-specific content
assert!(!content.contains("Agentic AI Code Assistant"));
assert!(!content.contains("Poem HTTP server"));
}
#[test]
fn scaffold_stack_is_blank_template_not_story_kit_content() {
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
let content = fs::read_to_string(dir.path().join(".huskies/specs/tech/STACK.md")).unwrap();
assert!(content.contains("<!-- huskies:scaffold-template -->"));
assert!(content.contains("## Core Stack"));
assert!(content.contains("## Coding Standards"));
assert!(content.contains("## Quality Gates"));
assert!(content.contains("## Libraries"));
// Must NOT contain Story Kit-specific content
assert!(!content.contains("Poem HTTP server"));
assert!(!content.contains("TypeScript + React"));
}
#[cfg(unix)]
#[test]
fn scaffold_story_kit_creates_executable_script_test() {
use std::os::unix::fs::PermissionsExt;
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
let script_test = dir.path().join("script/test");
assert!(script_test.exists(), "script/test should be created");
let perms = fs::metadata(&script_test).unwrap().permissions();
assert!(
perms.mode() & 0o111 != 0,
"script/test should be executable"
);
}
#[test]
fn scaffold_story_kit_does_not_overwrite_existing() {
let dir = tempdir().unwrap();
let readme = dir.path().join(".huskies/README.md");
fs::create_dir_all(readme.parent().unwrap()).unwrap();
fs::write(&readme, "custom content").unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
assert_eq!(fs::read_to_string(&readme).unwrap(), "custom content");
}
#[test]
fn scaffold_story_kit_is_idempotent() {
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
let readme_content = fs::read_to_string(dir.path().join(".huskies/README.md")).unwrap();
let toml_content = fs::read_to_string(dir.path().join(".huskies/project.toml")).unwrap();
// Run again — must not change content or add duplicate .gitignore entries
scaffold_story_kit(dir.path(), 3001).unwrap();
assert_eq!(
fs::read_to_string(dir.path().join(".huskies/README.md")).unwrap(),
readme_content
);
assert_eq!(
fs::read_to_string(dir.path().join(".huskies/project.toml")).unwrap(),
toml_content
);
let story_kit_gitignore = fs::read_to_string(dir.path().join(".huskies/.gitignore")).unwrap();
let count = story_kit_gitignore
.lines()
.filter(|l| l.trim() == "worktrees/")
.count();
assert_eq!(
count, 1,
".huskies/.gitignore should not have duplicate entries"
);
}
#[test]
fn scaffold_story_kit_existing_git_repo_no_commit() {
let dir = tempdir().unwrap();
// Initialize a git repo before scaffold
std::process::Command::new("git")
.args(["init"])
.current_dir(dir.path())
.status()
.unwrap();
std::process::Command::new("git")
.args([
"-c",
"user.email=test@test.com",
"-c",
"user.name=Test",
"commit",
"--allow-empty",
"-m",
"pre-scaffold",
])
.current_dir(dir.path())
.status()
.unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
// Only 1 commit should exist — scaffold must not commit into an existing repo
let log_output = std::process::Command::new("git")
.args(["log", "--oneline"])
.current_dir(dir.path())
.output()
.unwrap();
let log = String::from_utf8_lossy(&log_output.stdout);
let commit_count = log.lines().count();
assert_eq!(
commit_count, 1,
"scaffold should not create a commit in an existing git repo"
);
}
#[test]
fn scaffold_creates_story_kit_gitignore_with_relative_entries() {
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
// .huskies/.gitignore must contain relative patterns for files under .huskies/
let sk_content = fs::read_to_string(dir.path().join(".huskies/.gitignore")).unwrap();
assert!(sk_content.contains("worktrees/"));
assert!(sk_content.contains("merge_workspace/"));
assert!(sk_content.contains("coverage/"));
assert!(sk_content.contains("matrix_history.json"));
assert!(sk_content.contains("timers.json"));
// Must NOT contain absolute .huskies/ prefixed paths
assert!(!sk_content.contains(".huskies/"));
// Root .gitignore must contain root-level huskies entries
let root_content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
assert!(root_content.contains(".huskies_port"));
// store.json now lives inside .huskies/ and must NOT appear in root .gitignore
assert!(!root_content.contains("store.json"));
// Root .gitignore must NOT contain .huskies/ sub-directory patterns
assert!(!root_content.contains(".huskies/worktrees/"));
assert!(!root_content.contains(".huskies/merge_workspace/"));
assert!(!root_content.contains(".huskies/coverage/"));
// store.json must be in .huskies/.gitignore instead
assert!(sk_content.contains("store.json"));
// Database files must be ignored so novice users don't accidentally commit them
assert!(sk_content.contains("pipeline.db"));
assert!(sk_content.contains("*.db"));
}
#[test]
fn scaffold_story_kit_gitignore_does_not_duplicate_existing_entries() {
let dir = tempdir().unwrap();
// Pre-create .huskies dir and .gitignore with some entries already present
fs::create_dir_all(dir.path().join(".huskies")).unwrap();
fs::write(
dir.path().join(".huskies/.gitignore"),
"worktrees/\ncoverage/\n",
)
.unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
let content = fs::read_to_string(dir.path().join(".huskies/.gitignore")).unwrap();
let worktrees_count = content.lines().filter(|l| l.trim() == "worktrees/").count();
assert_eq!(worktrees_count, 1, "worktrees/ should not be duplicated");
let coverage_count = content.lines().filter(|l| l.trim() == "coverage/").count();
assert_eq!(coverage_count, 1, "coverage/ should not be duplicated");
// The missing entry must have been added
assert!(content.contains("merge_workspace/"));
}
#[test]
fn scaffold_creates_claude_md_at_project_root() {
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
let claude_md = dir.path().join("CLAUDE.md");
assert!(
claude_md.exists(),
"CLAUDE.md should be created at project root"
);
let content = fs::read_to_string(&claude_md).unwrap();
assert!(
content.contains("<!-- huskies:scaffold-template -->"),
"CLAUDE.md should contain the scaffold sentinel"
);
assert!(
content.contains("Read .huskies/README.md"),
"CLAUDE.md should include directive to read .huskies/README.md"
);
assert!(
content.contains("Never chain shell commands"),
"CLAUDE.md should include command chaining rule"
);
assert!(
content.contains("wizard_status"),
"CLAUDE.md should instruct Claude to call wizard_status on first conversation"
);
}
#[test]
fn scaffold_does_not_overwrite_existing_claude_md() {
let dir = tempdir().unwrap();
let claude_md = dir.path().join("CLAUDE.md");
fs::write(&claude_md, "custom CLAUDE.md content").unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
assert_eq!(
fs::read_to_string(&claude_md).unwrap(),
"custom CLAUDE.md content",
"scaffold should not overwrite an existing CLAUDE.md"
);
}
#[test]
fn scaffold_story_kit_writes_mcp_json_with_port() {
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path(), 4242).unwrap();
let mcp_path = dir.path().join(".mcp.json");
assert!(mcp_path.exists(), ".mcp.json should be created by scaffold");
let content = fs::read_to_string(&mcp_path).unwrap();
assert!(
content.contains("4242"),
".mcp.json should reference the given port"
);
assert!(
content.contains("localhost"),
".mcp.json should reference localhost"
);
assert!(
content.contains("huskies"),
".mcp.json should name the huskies server"
);
}
#[test]
fn scaffold_story_kit_does_not_overwrite_existing_mcp_json() {
let dir = tempdir().unwrap();
let mcp_path = dir.path().join(".mcp.json");
fs::write(&mcp_path, "{\"custom\": true}").unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
assert_eq!(
fs::read_to_string(&mcp_path).unwrap(),
"{\"custom\": true}",
"scaffold should not overwrite an existing .mcp.json"
);
}
#[test]
fn scaffold_gitignore_includes_mcp_json() {
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
let root_gitignore = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
assert!(
root_gitignore.contains(".mcp.json"),
"root .gitignore should include .mcp.json (port is environment-specific)"
);
}
#[test]
fn scaffold_script_test_contains_detected_commands_for_rust() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"myapp\"\n",
)
.unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
let content = fs::read_to_string(dir.path().join("script/test")).unwrap();
assert!(
content.contains("cargo test"),
"Rust project scaffold should set cargo test in script/test"
);
assert!(
!content.contains("No tests configured"),
"should not use stub when stack is detected"
);
}
#[test]
fn scaffold_script_test_fallback_stub_when_no_stack() {
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
let content = fs::read_to_string(dir.path().join("script/test")).unwrap();
assert!(
content.contains("No tests configured"),
"unknown stack should use the generic stub"
);
}
#[test]
fn scaffold_story_kit_creates_script_build_and_lint() {
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
assert!(
dir.path().join("script/build").exists(),
"script/build should be created by scaffold"
);
assert!(
dir.path().join("script/lint").exists(),
"script/lint should be created by scaffold"
);
}
#[cfg(unix)]
#[test]
fn scaffold_story_kit_creates_executable_script_build_and_lint() {
use std::os::unix::fs::PermissionsExt;
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
for name in &["build", "lint"] {
let path = dir.path().join("script").join(name);
assert!(path.exists(), "script/{name} should be created");
let perms = fs::metadata(&path).unwrap().permissions();
assert!(
perms.mode() & 0o111 != 0,
"script/{name} should be executable"
);
}
}
#[test]
fn scaffold_script_build_contains_detected_commands_for_rust() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"myapp\"\n",
)
.unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
let content = fs::read_to_string(dir.path().join("script/build")).unwrap();
assert!(
content.contains("cargo build --release"),
"Rust project scaffold should set cargo build --release in script/build"
);
}
#[test]
fn scaffold_script_lint_contains_detected_commands_for_rust() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"myapp\"\n",
)
.unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
let content = fs::read_to_string(dir.path().join("script/lint")).unwrap();
assert!(
content.contains("cargo fmt --all --check"),
"Rust project scaffold should include fmt check in script/lint"
);
assert!(
content.contains("cargo clippy -- -D warnings"),
"Rust project scaffold should include clippy in script/lint"
);
}
#[test]
fn scaffold_project_toml_contains_detected_components() {
let dir = tempdir().unwrap();
// Place a Cargo.toml in the project root before scaffolding
fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"myapp\"\n",
)
.unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
let content = fs::read_to_string(dir.path().join(".huskies/project.toml")).unwrap();
assert!(
content.contains("[[component]]"),
"project.toml should contain a component entry"
);
assert!(
content.contains("name = \"server\""),
"Rust project should have a 'server' component"
);
assert!(
content.contains("cargo check"),
"Rust component should have cargo check setup"
);
}
#[test]
fn scaffold_project_toml_fallback_when_no_stack_detected() {
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
let content = fs::read_to_string(dir.path().join(".huskies/project.toml")).unwrap();
assert!(
content.contains("[[component]]"),
"project.toml should always have at least one component"
);
// Fallback uses generic app component with empty setup — no Rust-specific commands
assert!(
content.contains("name = \"app\""),
"fallback should use generic 'app' component name"
);
assert!(
!content.contains("cargo"),
"fallback must not contain Rust-specific commands for non-Rust projects"
);
}
#[test]
fn scaffold_does_not_overwrite_existing_project_toml_with_components() {
let dir = tempdir().unwrap();
let sk_dir = dir.path().join(".huskies");
fs::create_dir_all(&sk_dir).unwrap();
let existing = "[[component]]\nname = \"custom\"\npath = \".\"\nsetup = [\"make build\"]\n";
fs::write(sk_dir.join("project.toml"), existing).unwrap();
scaffold_story_kit(dir.path(), 3001).unwrap();
let content = fs::read_to_string(sk_dir.join("project.toml")).unwrap();
assert_eq!(
content, existing,
"scaffold should not overwrite existing project.toml"
);
}