From 39013be5350a829766cb82f2c1d07a111f066c14 Mon Sep 17 00:00:00 2001 From: dave Date: Wed, 29 Apr 2026 18:17:04 +0000 Subject: [PATCH] huskies: merge 846 --- server/src/io/fs/scaffold/mod.rs | 594 +---------------------------- server/src/io/fs/scaffold/tests.rs | 594 +++++++++++++++++++++++++++++ 2 files changed, 595 insertions(+), 593 deletions(-) create mode 100644 server/src/io/fs/scaffold/tests.rs diff --git a/server/src/io/fs/scaffold/mod.rs b/server/src/io/fs/scaffold/mod.rs index b06c9fa2..55a85201 100644 --- a/server/src/io/fs/scaffold/mod.rs +++ b/server/src/io/fs/scaffold/mod.rs @@ -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("")); - 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")); - } - - #[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(""), - "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; diff --git a/server/src/io/fs/scaffold/tests.rs b/server/src/io/fs/scaffold/tests.rs new file mode 100644 index 00000000..39df273c --- /dev/null +++ b/server/src/io/fs/scaffold/tests.rs @@ -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("")); + 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")); +} + +#[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(""), + "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" + ); +}