huskies: merge 846
This commit is contained in:
@@ -142,596 +142,4 @@ pub(crate) fn scaffold_story_kit(root: &Path, port: u16) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user