//! Project scaffolding — creates the `.huskies/` directory structure and default files. use std::fs; use std::path::Path; mod detect; mod helpers; mod templates; use detect::{detect_components_toml, detect_script_build, detect_script_lint, detect_script_test}; use helpers::{ append_root_gitignore_entries, generate_project_toml, write_file_if_missing, write_script_if_missing, write_story_kit_gitignore, }; use templates::{ BOT_TOML_MATRIX_EXAMPLE, BOT_TOML_SLACK_EXAMPLE, BOT_TOML_WHATSAPP_META_EXAMPLE, BOT_TOML_WHATSAPP_TWILIO_EXAMPLE, DEFAULT_AGENTS_TOML, STORY_KIT_CLAUDE_MD, STORY_KIT_CLAUDE_SETTINGS, STORY_KIT_CONTEXT, STORY_KIT_README, STORY_KIT_SCRIPT_TEST, STORY_KIT_STACK, }; pub(crate) fn scaffold_story_kit(root: &Path, port: u16) -> Result<(), String> { let story_kit_root = root.join(".huskies"); let specs_root = story_kit_root.join("specs"); let tech_root = specs_root.join("tech"); let functional_root = specs_root.join("functional"); let script_root = root.join("script"); // Create the work/ pipeline directories, each with a .gitkeep so empty dirs survive git clone let work_stages = [ "1_backlog", "2_current", "3_qa", "4_merge", "5_done", "6_archived", ]; for stage in &work_stages { let dir = story_kit_root.join("work").join(stage); fs::create_dir_all(&dir).map_err(|e| format!("Failed to create work/{}: {}", stage, e))?; write_file_if_missing(&dir.join(".gitkeep"), "")?; } fs::create_dir_all(&tech_root).map_err(|e| format!("Failed to create specs/tech: {}", e))?; fs::create_dir_all(&functional_root) .map_err(|e| format!("Failed to create specs/functional: {}", e))?; fs::create_dir_all(&script_root) .map_err(|e| format!("Failed to create script/ directory: {}", e))?; write_file_if_missing(&story_kit_root.join("README.md"), STORY_KIT_README)?; let project_toml_content = generate_project_toml(root); write_file_if_missing(&story_kit_root.join("project.toml"), &project_toml_content)?; write_file_if_missing(&story_kit_root.join("agents.toml"), DEFAULT_AGENTS_TOML)?; write_file_if_missing(&specs_root.join("00_CONTEXT.md"), STORY_KIT_CONTEXT)?; write_file_if_missing(&tech_root.join("STACK.md"), STORY_KIT_STACK)?; let script_test_content = detect_script_test(root); write_script_if_missing(&script_root.join("test"), &script_test_content)?; let script_build_content = detect_script_build(root); write_script_if_missing(&script_root.join("build"), &script_build_content)?; let script_lint_content = detect_script_lint(root); write_script_if_missing(&script_root.join("lint"), &script_lint_content)?; write_file_if_missing(&root.join("CLAUDE.md"), STORY_KIT_CLAUDE_MD)?; // Write per-transport bot.toml example files so users can see all options. write_file_if_missing( &story_kit_root.join("bot.toml.matrix.example"), BOT_TOML_MATRIX_EXAMPLE, )?; write_file_if_missing( &story_kit_root.join("bot.toml.whatsapp-meta.example"), BOT_TOML_WHATSAPP_META_EXAMPLE, )?; write_file_if_missing( &story_kit_root.join("bot.toml.whatsapp-twilio.example"), BOT_TOML_WHATSAPP_TWILIO_EXAMPLE, )?; write_file_if_missing( &story_kit_root.join("bot.toml.slack.example"), BOT_TOML_SLACK_EXAMPLE, )?; // Write .mcp.json at the project root so agents can find the MCP server. // Only written when missing — never overwrites an existing file, because // the port is environment-specific and must not clobber a running instance. let mcp_content = format!( "{{\n \"mcpServers\": {{\n \"huskies\": {{\n \"type\": \"http\",\n \"url\": \"http://localhost:{port}/mcp\"\n }}\n }}\n}}\n" ); write_file_if_missing(&root.join(".mcp.json"), &mcp_content)?; // Create .claude/settings.json with sensible permission defaults so that // Claude Code (both agents and web UI chat) can operate without constant // permission prompts. let claude_dir = root.join(".claude"); fs::create_dir_all(&claude_dir) .map_err(|e| format!("Failed to create .claude/ directory: {}", e))?; write_file_if_missing(&claude_dir.join("settings.json"), STORY_KIT_CLAUDE_SETTINGS)?; write_story_kit_gitignore(root)?; append_root_gitignore_entries(root)?; // Run `git init` if the directory is not already a git repo, then make an initial commit if !root.join(".git").exists() { let init_status = std::process::Command::new("git") .args(["init"]) .current_dir(root) .status() .map_err(|e| format!("Failed to run git init: {}", e))?; if !init_status.success() { return Err("git init failed".to_string()); } let add_output = std::process::Command::new("git") .args([ "add", ".huskies", "script", ".gitignore", "CLAUDE.md", ".claude", ]) .current_dir(root) .output() .map_err(|e| format!("Failed to run git add: {}", e))?; if !add_output.status.success() { return Err(format!( "git add failed: {}", String::from_utf8_lossy(&add_output.stderr) )); } let commit_output = std::process::Command::new("git") .args([ "-c", "user.email=huskies@localhost", "-c", "user.name=Story Kit", "commit", "-m", "Initial Story Kit scaffold", ]) .current_dir(root) .output() .map_err(|e| format!("Failed to run git commit: {}", e))?; if !commit_output.status.success() { return Err(format!( "git commit failed: {}", String::from_utf8_lossy(&commit_output.stderr) )); } } Ok(()) } #[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_creates_work_pipeline_dirs() { let dir = tempdir().unwrap(); scaffold_story_kit(dir.path(), 3001).unwrap(); let stages = [ "1_backlog", "2_current", "3_qa", "4_merge", "5_done", "6_archived", ]; for stage in &stages { let path = dir.path().join(".huskies/work").join(stage); assert!(path.is_dir(), "work/{} should be a directory", stage); assert!( path.join(".gitkeep").exists(), "work/{} should have a .gitkeep file", stage ); } } #[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("")); 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("")); 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")); } #[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(""), "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" ); } #[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" ); } }