2026-04-12 13:11:23 +00:00
//! Project scaffolding — creates the `.huskies/` directory structure and default files.
2026-03-22 19:07:07 +00:00
use std ::fs ;
2026-03-27 16:05:42 +00:00
use std ::path ::Path ;
2026-03-22 19:07:07 +00:00
2026-04-03 16:12:52 +01:00
const STORY_KIT_README : & str = include_str! ( " ../../../../.huskies/README.md " ) ;
2026-03-22 19:07:07 +00:00
2026-04-13 14:07:08 +00:00
const BOT_TOML_MATRIX_EXAMPLE : & str = include_str! ( " ../../../../.huskies/bot.toml.matrix.example " ) ;
2026-03-24 18:13:32 +00:00
const BOT_TOML_WHATSAPP_META_EXAMPLE : & str =
2026-04-03 16:12:52 +01:00
include_str! ( " ../../../../.huskies/bot.toml.whatsapp-meta.example " ) ;
2026-03-24 18:13:32 +00:00
const BOT_TOML_WHATSAPP_TWILIO_EXAMPLE : & str =
2026-04-03 16:12:52 +01:00
include_str! ( " ../../../../.huskies/bot.toml.whatsapp-twilio.example " ) ;
const BOT_TOML_SLACK_EXAMPLE : & str = include_str! ( " ../../../../.huskies/bot.toml.slack.example " ) ;
2026-03-24 18:13:32 +00:00
2026-04-03 16:12:52 +01:00
const STORY_KIT_CONTEXT : & str = " <!-- huskies:scaffold-template --> \n \
2026-03-22 19:07:07 +00:00
# Project Context \n \
\n \
## High-Level Goal \n \
\n \
TODO: Describe the high-level goal of this project. \n \
\n \
## Core Features \n \
\n \
TODO: List the core features of this project. \n \
\n \
## Domain Definition \n \
\n \
TODO: Define the key domain concepts and entities. \n \
\n \
## Glossary \n \
\n \
TODO: Define abbreviations and technical terms. \n " ;
2026-04-03 16:12:52 +01:00
const STORY_KIT_STACK : & str = " <!-- huskies:scaffold-template --> \n \
2026-03-22 19:07:07 +00:00
# Tech Stack & Constraints \n \
\n \
## Core Stack \n \
\n \
TODO: Describe the language, frameworks, and runtimes. \n \
\n \
## Coding Standards \n \
\n \
TODO: Describe code style, linting rules, and error handling conventions. \n \
\n \
## Quality Gates \n \
\n \
TODO: List the commands that must pass before merging (e.g., cargo test, npm run build). \n \
\n \
## Libraries \n \
\n \
TODO: List approved libraries and their purpose. \n " ;
const STORY_KIT_SCRIPT_TEST : & str = " #!/usr/bin/env bash \n set -euo pipefail \n \n # Add your project's test commands here. \n # Story Kit agents invoke this script as the canonical test runner. \n # Exit 0 on success, non-zero on failure. \n echo \" No tests configured \" \n " ;
2026-04-03 16:12:52 +01:00
const STORY_KIT_CLAUDE_MD : & str = " <!-- huskies:scaffold-template --> \n \
2026-03-22 19:07:07 +00:00
Never chain shell commands with `&&`, `||`, or `;` in a single Bash call. \
The permission system validates the entire command string, and chained commands \
won't match allow rules like `Bash(git *)`. Use separate Bash calls instead — \
parallel calls work fine. \n \
\n \
2026-04-03 16:12:52 +01:00
Read .huskies/README.md to see our dev process. \n \
2026-03-28 14:37:44 +00:00
\n \
2026-03-28 15:12:54 +00:00
IMPORTANT: On your first conversation, call `wizard_status` to check if \
2026-04-03 16:12:52 +01:00
project setup is complete. If not, read .huskies/README.md for the full \
2026-03-28 15:12:54 +00:00
setup wizard instructions and guide the user through it conversationally. \n " ;
2026-03-22 19:07:07 +00:00
const STORY_KIT_CLAUDE_SETTINGS : & str = r # "{
"permissions": {
"allow": [
2026-04-11 19:26:38 +00:00
"Bash(cargo build:*)",
2026-04-11 22:03:53 +00:00
"Bash(cargo check:*)",
2026-04-11 19:26:38 +00:00
"Bash(git *)",
2026-04-11 22:03:53 +00:00
"Bash(ls *)",
"Bash(mkdir *)",
"Bash(mv *)",
"Bash(rm *)",
"Bash(touch *)",
"Bash(echo:*)",
"Bash(pwd *)",
"Bash(grep:*)",
"Bash(find *)",
"Bash(head *)",
"Bash(tail *)",
"Bash(wc *)",
"Bash(cat *)",
2026-04-13 11:44:17 +00:00
"Read",
2026-04-11 22:03:53 +00:00
"Edit",
"Write",
2026-04-13 11:44:17 +00:00
"Glob",
"Grep",
2026-04-03 16:12:52 +01:00
"mcp__huskies__*"
2026-03-22 19:07:07 +00:00
]
},
"enabledMcpjsonServers": [
2026-04-03 16:12:52 +01:00
"huskies"
2026-03-22 19:07:07 +00:00
]
}
"# ;
2026-04-04 21:20:36 +00:00
const DEFAULT_PROJECT_SETTINGS_TOML : & str = r # "# Project-wide default QA mode: "server", "agent", or "human".
2026-03-22 19:07:07 +00:00
# Per-story `qa` front matter overrides this setting.
default_qa = "server"
2026-04-04 11:38:20 +00:00
# Suppress soft rate-limit warning notifications in chat.
# Hard blocks and story-blocked notifications are always sent.
# rate_limit_notifications = true
# IANA timezone for timer scheduling (e.g. "Europe/London", "America/New_York").
# Timer HH:MM inputs are interpreted in this timezone.
# timezone = "America/New_York"
2026-04-04 21:20:36 +00:00
"# ;
2026-04-04 11:38:20 +00:00
2026-04-04 21:20:36 +00:00
const DEFAULT_AGENTS_TOML : & str = r # "[[agent]]
2026-03-22 19:07:07 +00:00
name = "coder-1"
stage = "coder"
role = "Full-stack engineer. Implements features across all components."
model = "sonnet"
max_turns = 50
max_budget_usd = 5.00
2026-04-03 16:12:52 +01:00
prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .huskies/README.md to understand the dev process. Follow the workflow through implementation and verification. The worktree and feature branch already exist - do not create them. Check .mcp.json for MCP tools. Do NOT accept the story or merge - commit your work and stop.\n\nIMPORTANT: Commit all your work before your process exits. The server will automatically run acceptance gates when your process exits.\n\nIf `script/test` still contains the generic 'No tests configured' stub, update it to run the project's actual test suite before starting implementation."
2026-03-22 19:07:07 +00:00
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Follow the Story-Driven Test Workflow strictly. Commit all your work before finishing. Do not accept stories, move them to archived, or merge to master."
[[agent]]
name = "qa"
stage = "qa"
role = "Reviews coder work: runs quality gates, generates testing plans, and reports findings."
model = "sonnet"
max_turns = 40
max_budget_usd = 4.00
prompt = "You are the QA agent for story {{story_id}}. Review the coder's work and produce a structured QA report. Run quality gates (linting, tests), attempt a build, and generate a manual testing plan. Do NOT modify any code."
system_prompt = "You are a QA agent. Your job is read-only: review code quality, run tests, and produce a structured QA report. Do not modify code."
[[agent]]
name = "mergemaster"
stage = "mergemaster"
role = "Merges completed work into master, runs quality gates, and archives stories."
model = "sonnet"
max_turns = 30
max_budget_usd = 5.00
prompt = "You are the mergemaster agent for story {{story_id}}. Call merge_agent_work(story_id='{{story_id}}') to start the merge pipeline. Then poll get_merge_status(story_id='{{story_id}}') every 15 seconds until the status is 'completed' or 'failed'. Report the final result. If the merge fails, call report_merge_failure."
system_prompt = "You are the mergemaster agent. Call merge_agent_work to start the merge, then poll get_merge_status every 15 seconds until done. Never manually move story files. Call report_merge_failure when merges fail."
"# ;
/// Detect the tech stack from the project root and return TOML `[[component]]` entries.
///
/// Inspects well-known marker files at the project root to identify which
/// tech stacks are present, then emits one `[[component]]` entry per detected
/// stack with sensible default `setup` commands. If no markers are found, a
/// single fallback `app` component with an empty `setup` list is returned so
/// that the pipeline never breaks on an unknown stack.
pub fn detect_components_toml ( root : & Path ) -> String {
let mut sections = Vec ::new ( ) ;
if root . join ( " Cargo.toml " ) . exists ( ) {
sections . push (
" [[component]] \n name = \" server \" \n path = \" . \" \n setup = [ \" cargo check \" ] \n "
. to_string ( ) ,
) ;
}
if root . join ( " package.json " ) . exists ( ) {
let setup_cmd = if root . join ( " pnpm-lock.yaml " ) . exists ( ) {
" pnpm install "
} else {
" npm install "
} ;
sections . push ( format! (
" [[component]] \n name = \" frontend \" \n path = \" . \" \n setup = [ \" {setup_cmd} \" ] \n "
) ) ;
}
if root . join ( " pyproject.toml " ) . exists ( ) | | root . join ( " requirements.txt " ) . exists ( ) {
sections . push (
" [[component]] \n name = \" python \" \n path = \" . \" \n setup = [ \" pip install -r requirements.txt \" ] \n "
. to_string ( ) ,
) ;
}
if root . join ( " go.mod " ) . exists ( ) {
sections . push (
" [[component]] \n name = \" go \" \n path = \" . \" \n setup = [ \" go build ./... \" ] \n "
. to_string ( ) ,
) ;
}
if root . join ( " Gemfile " ) . exists ( ) {
sections . push (
" [[component]] \n name = \" ruby \" \n path = \" . \" \n setup = [ \" bundle install \" ] \n "
. to_string ( ) ,
) ;
}
if sections . is_empty ( ) {
2026-03-23 18:28:14 +00:00
// No tech stack markers detected — emit a single generic component
// with an empty setup list. The ONBOARDING_PROMPT instructs the chat
// agent to inspect the project and replace this with real definitions.
2026-04-13 14:07:08 +00:00
sections . push ( " [[component]] \n name = \" app \" \n path = \" . \" \n setup = [] \n " . to_string ( ) ) ;
2026-03-22 19:07:07 +00:00
}
sections . join ( " \n " )
}
2026-04-15 23:53:03 +00:00
/// Detect the appropriate Node.js test command for a directory containing `package.json`.
///
/// Reads the `package.json` content to identify known test runners (vitest, jest).
/// Falls back to `npm test` or `pnpm test` based on which lock file is present.
fn detect_node_test_cmd ( pkg_dir : & Path ) -> String {
let has_pnpm = pkg_dir . join ( " pnpm-lock.yaml " ) . exists ( ) ;
let content = std ::fs ::read_to_string ( pkg_dir . join ( " package.json " ) ) . unwrap_or_default ( ) ;
if content . contains ( " \" vitest \" " ) {
let pm = if has_pnpm { " pnpm " } else { " npx " } ;
return format! ( " {} vitest run " , pm ) ;
}
if content . contains ( " \" jest \" " ) {
let pm = if has_pnpm { " pnpm " } else { " npx " } ;
return format! ( " {} jest " , pm ) ;
}
if has_pnpm {
" pnpm test " . to_string ( )
} else {
" npm test " . to_string ( )
}
}
2026-04-16 00:18:42 +00:00
/// Detect the appropriate Node.js build command for a directory containing `package.json`.
fn detect_node_build_cmd ( pkg_dir : & Path ) -> String {
if pkg_dir . join ( " pnpm-lock.yaml " ) . exists ( ) {
" pnpm run build " . to_string ( )
} else {
" npm run build " . to_string ( )
}
}
/// Detect the appropriate Node.js lint command for a directory containing `package.json`.
///
/// Reads the `package.json` content to identify eslint. Falls back to
/// `npm run lint` or `pnpm run lint` based on which lock file is present.
fn detect_node_lint_cmd ( pkg_dir : & Path ) -> String {
let has_pnpm = pkg_dir . join ( " pnpm-lock.yaml " ) . exists ( ) ;
let content = std ::fs ::read_to_string ( pkg_dir . join ( " package.json " ) ) . unwrap_or_default ( ) ;
if content . contains ( " \" eslint \" " ) {
let pm = if has_pnpm { " pnpm " } else { " npx " } ;
return format! ( " {pm} eslint . " ) ;
}
if has_pnpm {
" pnpm run lint " . to_string ( )
} else {
" npm run lint " . to_string ( )
}
}
/// Generate `script/build` content for a new project at `root`.
///
/// Inspects well-known marker files to identify which tech stacks are present
/// and emits the appropriate build commands. Multi-stack projects get combined
/// commands run sequentially. Falls back to a generic stub when no markers
/// are found so the scaffold is always valid.
///
/// For projects with a frontend in a known subdirectory (`frontend/`, `client/`),
/// the build command is detected from the presence of `pnpm-lock.yaml`.
pub fn detect_script_build ( root : & Path ) -> String {
let mut commands : Vec < String > = Vec ::new ( ) ;
if root . join ( " Cargo.toml " ) . exists ( ) {
commands . push ( " cargo build --release " . to_string ( ) ) ;
}
if root . join ( " package.json " ) . exists ( ) {
commands . push ( detect_node_build_cmd ( root ) ) ;
}
// Detect frontend in known subdirectories (e.g. frontend/, client/)
for subdir in & [ " frontend " , " client " ] {
let sub_path = root . join ( subdir ) ;
if sub_path . join ( " package.json " ) . exists ( ) {
let cmd = detect_node_build_cmd ( & sub_path ) ;
commands . push ( format! ( " (cd {} && {} ) " , subdir , cmd ) ) ;
}
}
if root . join ( " pyproject.toml " ) . exists ( ) {
commands . push ( " python -m build " . to_string ( ) ) ;
}
if root . join ( " go.mod " ) . exists ( ) {
commands . push ( " go build ./... " . to_string ( ) ) ;
}
if commands . is_empty ( ) {
return " #!/usr/bin/env bash \n set -euo pipefail \n \n # Add your project's build commands here. \n echo \" No build configured \" \n " . to_string ( ) ;
}
let mut script = " #!/usr/bin/env bash \n set -euo pipefail \n \n " . to_string ( ) ;
for cmd in commands {
script . push_str ( & cmd ) ;
script . push ( '\n' ) ;
}
script
}
/// Generate `script/lint` content for a new project at `root`.
///
/// Inspects well-known marker files to identify which linters are present
/// and emits the appropriate lint commands. Multi-stack projects get combined
/// commands run sequentially. Falls back to a generic stub when no markers
/// are found so the scaffold is always valid.
///
/// For projects with a frontend in a known subdirectory (`frontend/`, `client/`),
/// the lint command is detected from the `package.json` (eslint, npm, pnpm).
pub fn detect_script_lint ( root : & Path ) -> String {
let mut commands : Vec < String > = Vec ::new ( ) ;
if root . join ( " Cargo.toml " ) . exists ( ) {
commands . push ( " cargo fmt --all --check " . to_string ( ) ) ;
commands . push ( " cargo clippy -- -D warnings " . to_string ( ) ) ;
}
if root . join ( " package.json " ) . exists ( ) {
commands . push ( detect_node_lint_cmd ( root ) ) ;
}
// Detect frontend in known subdirectories (e.g. frontend/, client/)
for subdir in & [ " frontend " , " client " ] {
let sub_path = root . join ( subdir ) ;
if sub_path . join ( " package.json " ) . exists ( ) {
let cmd = detect_node_lint_cmd ( & sub_path ) ;
commands . push ( format! ( " (cd {} && {} ) " , subdir , cmd ) ) ;
}
}
if root . join ( " pyproject.toml " ) . exists ( ) | | root . join ( " requirements.txt " ) . exists ( ) {
let mut content = std ::fs ::read_to_string ( root . join ( " pyproject.toml " ) ) . unwrap_or_default ( ) ;
content
. push_str ( & std ::fs ::read_to_string ( root . join ( " requirements.txt " ) ) . unwrap_or_default ( ) ) ;
if content . contains ( " ruff " ) {
commands . push ( " ruff check . " . to_string ( ) ) ;
} else {
commands . push ( " flake8 . " . to_string ( ) ) ;
}
}
if root . join ( " go.mod " ) . exists ( ) {
commands . push ( " go vet ./... " . to_string ( ) ) ;
}
if commands . is_empty ( ) {
return " #!/usr/bin/env bash \n set -euo pipefail \n \n # Add your project's lint commands here. \n echo \" No linters configured \" \n " . to_string ( ) ;
}
let mut script = " #!/usr/bin/env bash \n set -euo pipefail \n \n " . to_string ( ) ;
for cmd in commands {
script . push_str ( & cmd ) ;
script . push ( '\n' ) ;
}
script
}
2026-03-23 14:23:03 +00:00
/// Generate `script/test` content for a new project at `root`.
///
/// Inspects well-known marker files to identify which tech stacks are present
/// and emits the appropriate test commands. Multi-stack projects get combined
/// commands run sequentially. Falls back to the generic stub when no markers
/// are found so the scaffold is always valid.
2026-04-15 23:53:03 +00:00
///
/// For projects with a frontend in a known subdirectory (`frontend/`, `client/`),
/// the test runner is detected from the `package.json` (vitest, jest, npm, pnpm).
2026-03-23 14:23:03 +00:00
pub fn detect_script_test ( root : & Path ) -> String {
2026-04-15 23:53:03 +00:00
let mut commands : Vec < String > = Vec ::new ( ) ;
2026-03-23 14:23:03 +00:00
if root . join ( " Cargo.toml " ) . exists ( ) {
2026-04-15 23:53:03 +00:00
commands . push ( " cargo test " . to_string ( ) ) ;
2026-03-23 14:23:03 +00:00
}
if root . join ( " package.json " ) . exists ( ) {
if root . join ( " pnpm-lock.yaml " ) . exists ( ) {
2026-04-15 23:53:03 +00:00
commands . push ( " pnpm test " . to_string ( ) ) ;
2026-03-23 14:23:03 +00:00
} else {
2026-04-15 23:53:03 +00:00
commands . push ( " npm test " . to_string ( ) ) ;
}
}
// Detect frontend in known subdirectories (e.g. frontend/, client/)
for subdir in & [ " frontend " , " client " ] {
let sub_path = root . join ( subdir ) ;
if sub_path . join ( " package.json " ) . exists ( ) {
let cmd = detect_node_test_cmd ( & sub_path ) ;
commands . push ( format! ( " (cd {} && {} ) " , subdir , cmd ) ) ;
2026-03-23 14:23:03 +00:00
}
}
if root . join ( " pyproject.toml " ) . exists ( ) | | root . join ( " requirements.txt " ) . exists ( ) {
2026-04-15 23:53:03 +00:00
commands . push ( " pytest " . to_string ( ) ) ;
2026-03-23 14:23:03 +00:00
}
if root . join ( " go.mod " ) . exists ( ) {
2026-04-15 23:53:03 +00:00
commands . push ( " go test ./... " . to_string ( ) ) ;
2026-03-23 14:23:03 +00:00
}
if commands . is_empty ( ) {
return STORY_KIT_SCRIPT_TEST . to_string ( ) ;
}
let mut script = " #!/usr/bin/env bash \n set -euo pipefail \n \n " . to_string ( ) ;
for cmd in commands {
2026-04-15 23:53:03 +00:00
script . push_str ( & cmd ) ;
2026-03-23 14:23:03 +00:00
script . push ( '\n' ) ;
}
script
}
2026-04-04 21:20:36 +00:00
/// Generate a `project.toml` for a new project at `root`.
2026-03-22 19:07:07 +00:00
///
2026-04-04 21:20:36 +00:00
/// Detects the tech stack via [`detect_components_toml`] and combines the
/// resulting `[[component]]` entries with the default project settings.
/// Agent definitions are written to `agents.toml` separately.
2026-03-22 19:07:07 +00:00
fn generate_project_toml ( root : & Path ) -> String {
let components = detect_components_toml ( root ) ;
2026-04-04 21:20:36 +00:00
format! ( " {components} \n {DEFAULT_PROJECT_SETTINGS_TOML} " )
2026-03-22 19:07:07 +00:00
}
fn write_file_if_missing ( path : & Path , content : & str ) -> Result < ( ) , String > {
if path . exists ( ) {
return Ok ( ( ) ) ;
}
fs ::write ( path , content ) . map_err ( | e | format! ( " Failed to write file: {} " , e ) ) ? ;
Ok ( ( ) )
}
/// Write `content` to `path` if missing, then ensure the file is executable.
fn write_script_if_missing ( path : & Path , content : & str ) -> Result < ( ) , String > {
write_file_if_missing ( path , content ) ? ;
#[ cfg(unix) ]
{
use std ::os ::unix ::fs ::PermissionsExt ;
let mut perms = fs ::metadata ( path )
. map_err ( | e | format! ( " Failed to read permissions for {} : {} " , path . display ( ) , e ) ) ?
. permissions ( ) ;
perms . set_mode ( 0o755 ) ;
fs ::set_permissions ( path , perms )
. map_err ( | e | format! ( " Failed to set permissions on {} : {} " , path . display ( ) , e ) ) ? ;
}
Ok ( ( ) )
}
2026-04-03 16:12:52 +01:00
/// Write (or idempotently update) `.huskies/.gitignore` with Story Kit– specific
/// ignore patterns for files that live inside the `.huskies/` directory.
/// Patterns are relative to `.huskies/` as git resolves `.gitignore` files
2026-03-22 19:07:07 +00:00
/// relative to the directory that contains them.
fn write_story_kit_gitignore ( root : & Path ) -> Result < ( ) , String > {
2026-04-03 16:12:52 +01:00
// Entries that belong inside .huskies/.gitignore (relative to .huskies/).
2026-03-22 19:07:07 +00:00
let entries = [
" bot.toml " ,
" matrix_store/ " ,
" matrix_device_id " ,
2026-04-02 17:14:54 +00:00
" matrix_history.json " ,
" timers.json " ,
2026-03-22 19:07:07 +00:00
" worktrees/ " ,
" merge_workspace/ " ,
" coverage/ " ,
2026-03-23 14:13:15 +00:00
" work/2_current/ " ,
" work/3_qa/ " ,
" work/4_merge/ " ,
2026-03-23 14:13:22 +00:00
" logs/ " ,
" token_usage.jsonl " ,
2026-03-28 13:26:29 +00:00
" wizard_state.json " ,
2026-04-02 13:24:15 +00:00
" store.json " ,
2026-04-15 19:42:53 +00:00
" pipeline.db " ,
" *.db " ,
2026-03-22 19:07:07 +00:00
] ;
2026-04-03 16:12:52 +01:00
let gitignore_path = root . join ( " .huskies " ) . join ( " .gitignore " ) ;
2026-03-22 19:07:07 +00:00
let existing = if gitignore_path . exists ( ) {
fs ::read_to_string ( & gitignore_path )
2026-04-03 16:12:52 +01:00
. map_err ( | e | format! ( " Failed to read .huskies/.gitignore: {} " , e ) ) ?
2026-03-22 19:07:07 +00:00
} else {
String ::new ( )
} ;
let missing : Vec < & str > = entries
. iter ( )
. copied ( )
. filter ( | e | ! existing . lines ( ) . any ( | l | l . trim ( ) = = * e ) )
. collect ( ) ;
if missing . is_empty ( ) {
return Ok ( ( ) ) ;
}
let mut new_content = existing ;
if ! new_content . is_empty ( ) & & ! new_content . ends_with ( '\n' ) {
new_content . push ( '\n' ) ;
}
for entry in missing {
new_content . push_str ( entry ) ;
new_content . push ( '\n' ) ;
}
fs ::write ( & gitignore_path , new_content )
2026-04-03 16:12:52 +01:00
. map_err ( | e | format! ( " Failed to write .huskies/.gitignore: {} " , e ) ) ? ;
2026-03-22 19:07:07 +00:00
Ok ( ( ) )
}
/// Append root-level Story Kit entries to the project `.gitignore`.
2026-04-03 16:12:52 +01:00
/// Only `.huskies_port` and `.mcp.json` remain here because they live at
2026-03-22 19:07:07 +00:00
/// the project root and git does not support `../` patterns in `.gitignore`
2026-04-03 16:12:52 +01:00
/// files, so they cannot be expressed in `.huskies/.gitignore`.
/// `store.json` is excluded via `.huskies/.gitignore` since it now lives
/// inside the `.huskies/` directory.
2026-03-22 19:07:07 +00:00
fn append_root_gitignore_entries ( root : & Path ) -> Result < ( ) , String > {
2026-04-03 16:12:52 +01:00
let entries = [ " .huskies_port " , " .mcp.json " ] ;
2026-03-22 19:07:07 +00:00
let gitignore_path = root . join ( " .gitignore " ) ;
let existing = if gitignore_path . exists ( ) {
fs ::read_to_string ( & gitignore_path )
. map_err ( | e | format! ( " Failed to read .gitignore: {} " , e ) ) ?
} else {
String ::new ( )
} ;
let missing : Vec < & str > = entries
. iter ( )
. copied ( )
. filter ( | e | ! existing . lines ( ) . any ( | l | l . trim ( ) = = * e ) )
. collect ( ) ;
if missing . is_empty ( ) {
return Ok ( ( ) ) ;
}
let mut new_content = existing ;
if ! new_content . is_empty ( ) & & ! new_content . ends_with ( '\n' ) {
new_content . push ( '\n' ) ;
}
for entry in missing {
new_content . push_str ( entry ) ;
new_content . push ( '\n' ) ;
}
fs ::write ( & gitignore_path , new_content )
. map_err ( | e | format! ( " Failed to write .gitignore: {} " , e ) ) ? ;
Ok ( ( ) )
}
2026-03-27 16:05:42 +00:00
pub ( crate ) fn scaffold_story_kit ( root : & Path , port : u16 ) -> Result < ( ) , String > {
2026-04-03 16:12:52 +01:00
let story_kit_root = root . join ( " .huskies " ) ;
2026-03-22 19:07:07 +00:00
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 ) ? ;
2026-04-04 21:20:36 +00:00
write_file_if_missing ( & story_kit_root . join ( " agents.toml " ) , DEFAULT_AGENTS_TOML ) ? ;
2026-03-22 19:07:07 +00:00
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 ) ? ;
2026-03-23 14:23:03 +00:00
let script_test_content = detect_script_test ( root ) ;
write_script_if_missing ( & script_root . join ( " test " ) , & script_test_content ) ? ;
2026-04-16 00:18:42 +00:00
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 ) ? ;
2026-03-22 19:07:07 +00:00
write_file_if_missing ( & root . join ( " CLAUDE.md " ) , STORY_KIT_CLAUDE_MD ) ? ;
2026-03-24 18:13:32 +00:00
// 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 ,
) ? ;
2026-03-23 12:57:49 +00:00
// 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! (
2026-04-03 16:12:52 +01:00
" {{ \n \" mcpServers \" : {{ \n \" huskies \" : {{ \n \" type \" : \" http \" , \n \" url \" : \" http://localhost: {port} /mcp \" \n }} \n }} \n }} \n "
2026-03-23 12:57:49 +00:00
) ;
write_file_if_missing ( & root . join ( " .mcp.json " ) , & mcp_content ) ? ;
2026-03-22 19:07:07 +00:00
// 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 " ,
2026-04-03 16:12:52 +01:00
" .huskies " ,
2026-03-22 19:07:07 +00:00
" 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 " ,
2026-04-03 16:12:52 +01:00
" user.email=huskies@localhost " ,
2026-03-22 19:07:07 +00:00
" -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 ;
// --- scaffold ---
#[ test ]
fn scaffold_story_kit_creates_structure ( ) {
let dir = tempdir ( ) . unwrap ( ) ;
2026-03-23 12:57:49 +00:00
scaffold_story_kit ( dir . path ( ) , 3001 ) . unwrap ( ) ;
2026-03-22 19:07:07 +00:00
2026-04-03 16:12:52 +01:00
assert! ( dir . path ( ) . join ( " .huskies/README.md " ) . exists ( ) ) ;
assert! ( dir . path ( ) . join ( " .huskies/project.toml " ) . exists ( ) ) ;
2026-04-07 11:22:05 +00:00
assert! ( dir . path ( ) . join ( " .huskies/agents.toml " ) . exists ( ) ) ;
2026-04-03 16:12:52 +01:00
assert! ( dir . path ( ) . join ( " .huskies/specs/00_CONTEXT.md " ) . exists ( ) ) ;
assert! ( dir . path ( ) . join ( " .huskies/specs/tech/STACK.md " ) . exists ( ) ) ;
2026-03-22 19:07:07 +00:00
// Old stories/ dirs should NOT be created
2026-04-03 16:12:52 +01:00
assert! ( ! dir . path ( ) . join ( " .huskies/stories " ) . exists ( ) ) ;
2026-03-22 19:07:07 +00:00
assert! ( dir . path ( ) . join ( " script/test " ) . exists ( ) ) ;
}
#[ test ]
fn scaffold_story_kit_creates_work_pipeline_dirs ( ) {
let dir = tempdir ( ) . unwrap ( ) ;
2026-03-23 12:57:49 +00:00
scaffold_story_kit ( dir . path ( ) , 3001 ) . unwrap ( ) ;
2026-03-22 19:07:07 +00:00
let stages = [
" 1_backlog " ,
" 2_current " ,
" 3_qa " ,
" 4_merge " ,
" 5_done " ,
" 6_archived " ,
] ;
for stage in & stages {
2026-04-03 16:12:52 +01:00
let path = dir . path ( ) . join ( " .huskies/work " ) . join ( stage ) ;
2026-03-22 19:07:07 +00:00
assert! ( path . is_dir ( ) , " work/{} should be a directory " , stage ) ;
assert! (
path . join ( " .gitkeep " ) . exists ( ) ,
" work/{} should have a .gitkeep file " ,
stage
) ;
}
}
#[ test ]
2026-04-04 21:20:36 +00:00
fn scaffold_story_kit_agents_toml_has_coder_qa_mergemaster ( ) {
2026-03-22 19:07:07 +00:00
let dir = tempdir ( ) . unwrap ( ) ;
2026-03-23 12:57:49 +00:00
scaffold_story_kit ( dir . path ( ) , 3001 ) . unwrap ( ) ;
2026-03-22 19:07:07 +00:00
2026-04-04 21:20:36 +00:00
// 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]] " ) ) ;
2026-03-22 19:07:07 +00:00
}
2026-04-04 11:38:20 +00:00
#[ 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 "
) ;
}
2026-03-22 19:07:07 +00:00
#[ test ]
fn scaffold_context_is_blank_template_not_story_kit_content ( ) {
let dir = tempdir ( ) . unwrap ( ) ;
2026-03-23 12:57:49 +00:00
scaffold_story_kit ( dir . path ( ) , 3001 ) . unwrap ( ) ;
2026-03-22 19:07:07 +00:00
2026-04-03 16:12:52 +01:00
let content = fs ::read_to_string ( dir . path ( ) . join ( " .huskies/specs/00_CONTEXT.md " ) ) . unwrap ( ) ;
assert! ( content . contains ( " <!-- huskies:scaffold-template --> " ) ) ;
2026-03-22 19:07:07 +00:00
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 ( ) ;
2026-03-23 12:57:49 +00:00
scaffold_story_kit ( dir . path ( ) , 3001 ) . unwrap ( ) ;
2026-03-22 19:07:07 +00:00
2026-04-03 16:12:52 +01:00
let content = fs ::read_to_string ( dir . path ( ) . join ( " .huskies/specs/tech/STACK.md " ) ) . unwrap ( ) ;
assert! ( content . contains ( " <!-- huskies:scaffold-template --> " ) ) ;
2026-03-22 19:07:07 +00:00
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 ( ) ;
2026-03-23 12:57:49 +00:00
scaffold_story_kit ( dir . path ( ) , 3001 ) . unwrap ( ) ;
2026-03-22 19:07:07 +00:00
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 ( ) ;
2026-04-03 16:12:52 +01:00
let readme = dir . path ( ) . join ( " .huskies/README.md " ) ;
2026-03-22 19:07:07 +00:00
fs ::create_dir_all ( readme . parent ( ) . unwrap ( ) ) . unwrap ( ) ;
fs ::write ( & readme , " custom content " ) . unwrap ( ) ;
2026-03-23 12:57:49 +00:00
scaffold_story_kit ( dir . path ( ) , 3001 ) . unwrap ( ) ;
2026-03-22 19:07:07 +00:00
assert_eq! ( fs ::read_to_string ( & readme ) . unwrap ( ) , " custom content " ) ;
}
#[ test ]
fn scaffold_story_kit_is_idempotent ( ) {
let dir = tempdir ( ) . unwrap ( ) ;
2026-03-23 12:57:49 +00:00
scaffold_story_kit ( dir . path ( ) , 3001 ) . unwrap ( ) ;
2026-03-22 19:07:07 +00:00
2026-04-03 16:12:52 +01:00
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 ( ) ;
2026-03-22 19:07:07 +00:00
// Run again — must not change content or add duplicate .gitignore entries
2026-03-23 12:57:49 +00:00
scaffold_story_kit ( dir . path ( ) , 3001 ) . unwrap ( ) ;
2026-03-22 19:07:07 +00:00
assert_eq! (
2026-04-03 16:12:52 +01:00
fs ::read_to_string ( dir . path ( ) . join ( " .huskies/README.md " ) ) . unwrap ( ) ,
2026-03-22 19:07:07 +00:00
readme_content
) ;
assert_eq! (
2026-04-03 16:12:52 +01:00
fs ::read_to_string ( dir . path ( ) . join ( " .huskies/project.toml " ) ) . unwrap ( ) ,
2026-03-22 19:07:07 +00:00
toml_content
) ;
let story_kit_gitignore =
2026-04-03 16:12:52 +01:00
fs ::read_to_string ( dir . path ( ) . join ( " .huskies/.gitignore " ) ) . unwrap ( ) ;
2026-03-22 19:07:07 +00:00
let count = story_kit_gitignore
. lines ( )
. filter ( | l | l . trim ( ) = = " worktrees/ " )
. count ( ) ;
assert_eq! (
count , 1 ,
2026-04-03 16:12:52 +01:00
" .huskies/.gitignore should not have duplicate entries "
2026-03-22 19:07:07 +00:00
) ;
}
#[ 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 ( ) ;
2026-03-23 12:57:49 +00:00
scaffold_story_kit ( dir . path ( ) , 3001 ) . unwrap ( ) ;
2026-03-22 19:07:07 +00:00
// 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 ( ) ;
2026-03-23 12:57:49 +00:00
scaffold_story_kit ( dir . path ( ) , 3001 ) . unwrap ( ) ;
2026-03-22 19:07:07 +00:00
2026-04-03 16:12:52 +01:00
// .huskies/.gitignore must contain relative patterns for files under .huskies/
let sk_content = fs ::read_to_string ( dir . path ( ) . join ( " .huskies/.gitignore " ) ) . unwrap ( ) ;
2026-03-22 19:07:07 +00:00
assert! ( sk_content . contains ( " worktrees/ " ) ) ;
assert! ( sk_content . contains ( " merge_workspace/ " ) ) ;
assert! ( sk_content . contains ( " coverage/ " ) ) ;
2026-04-02 17:14:54 +00:00
assert! ( sk_content . contains ( " matrix_history.json " ) ) ;
assert! ( sk_content . contains ( " timers.json " ) ) ;
2026-04-03 16:12:52 +01:00
// Must NOT contain absolute .huskies/ prefixed paths
assert! ( ! sk_content . contains ( " .huskies/ " ) ) ;
2026-03-22 19:07:07 +00:00
2026-04-03 16:12:52 +01:00
// Root .gitignore must contain root-level huskies entries
2026-03-22 19:07:07 +00:00
let root_content = fs ::read_to_string ( dir . path ( ) . join ( " .gitignore " ) ) . unwrap ( ) ;
2026-04-03 16:12:52 +01:00
assert! ( root_content . contains ( " .huskies_port " ) ) ;
// store.json now lives inside .huskies/ and must NOT appear in root .gitignore
2026-04-02 13:24:15 +00:00
assert! ( ! root_content . contains ( " store.json " ) ) ;
2026-04-03 16:12:52 +01:00
// 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
2026-04-02 13:24:15 +00:00
assert! ( sk_content . contains ( " store.json " ) ) ;
2026-04-15 19:42:53 +00:00
// Database files must be ignored so novice users don't accidentally commit them
assert! ( sk_content . contains ( " pipeline.db " ) ) ;
assert! ( sk_content . contains ( " *.db " ) ) ;
2026-03-22 19:07:07 +00:00
}
#[ test ]
fn scaffold_story_kit_gitignore_does_not_duplicate_existing_entries ( ) {
let dir = tempdir ( ) . unwrap ( ) ;
2026-04-03 16:12:52 +01:00
// Pre-create .huskies dir and .gitignore with some entries already present
fs ::create_dir_all ( dir . path ( ) . join ( " .huskies " ) ) . unwrap ( ) ;
2026-03-22 19:07:07 +00:00
fs ::write (
2026-04-03 16:12:52 +01:00
dir . path ( ) . join ( " .huskies/.gitignore " ) ,
2026-03-22 19:07:07 +00:00
" worktrees/ \n coverage/ \n " ,
)
. unwrap ( ) ;
2026-03-23 12:57:49 +00:00
scaffold_story_kit ( dir . path ( ) , 3001 ) . unwrap ( ) ;
2026-03-22 19:07:07 +00:00
2026-04-03 16:12:52 +01:00
let content = fs ::read_to_string ( dir . path ( ) . join ( " .huskies/.gitignore " ) ) . unwrap ( ) ;
2026-03-22 19:07:07 +00:00
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/ " ) ) ;
}
// --- CLAUDE.md scaffold ---
#[ test ]
fn scaffold_creates_claude_md_at_project_root ( ) {
let dir = tempdir ( ) . unwrap ( ) ;
2026-03-23 12:57:49 +00:00
scaffold_story_kit ( dir . path ( ) , 3001 ) . unwrap ( ) ;
2026-03-22 19:07:07 +00:00
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! (
2026-04-03 16:12:52 +01:00
content . contains ( " <!-- huskies:scaffold-template --> " ) ,
2026-03-22 19:07:07 +00:00
" CLAUDE.md should contain the scaffold sentinel "
) ;
assert! (
2026-04-03 16:12:52 +01:00
content . contains ( " Read .huskies/README.md " ) ,
" CLAUDE.md should include directive to read .huskies/README.md "
2026-03-22 19:07:07 +00:00
) ;
assert! (
content . contains ( " Never chain shell commands " ) ,
" CLAUDE.md should include command chaining rule "
) ;
2026-03-28 23:29:57 +00:00
assert! (
content . contains ( " wizard_status " ) ,
" CLAUDE.md should instruct Claude to call wizard_status on first conversation "
) ;
2026-03-22 19:07:07 +00:00
}
#[ 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 ( ) ;
2026-03-23 12:57:49 +00:00
scaffold_story_kit ( dir . path ( ) , 3001 ) . unwrap ( ) ;
2026-03-22 19:07:07 +00:00
assert_eq! (
fs ::read_to_string ( & claude_md ) . unwrap ( ) ,
" custom CLAUDE.md content " ,
" scaffold should not overwrite an existing CLAUDE.md "
) ;
}
2026-03-23 12:57:49 +00:00
#[ 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 ( ) ;
2026-04-13 14:07:08 +00:00
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 "
) ;
2026-03-23 12:57:49 +00:00
}
#[ 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) "
) ;
}
2026-03-22 19:07:07 +00:00
// --- detect_components_toml ---
#[ test ]
fn detect_no_markers_returns_fallback_components ( ) {
let dir = tempdir ( ) . unwrap ( ) ;
let toml = detect_components_toml ( dir . path ( ) ) ;
// At least one [[component]] entry should always be present
assert! (
toml . contains ( " [[component]] " ) ,
" should always emit at least one component "
) ;
2026-03-23 18:28:14 +00:00
// Fallback should use a generic app component with empty setup
assert! (
toml . contains ( " name = \" app \" " ) ,
" fallback should use generic 'app' component name "
) ;
assert! (
toml . contains ( " setup = [] " ) ,
" fallback should have empty setup list "
) ;
// Must not contain Rust-specific commands in a non-Rust project
2026-03-22 19:07:07 +00:00
assert! (
2026-03-23 18:28:14 +00:00
! toml . contains ( " cargo " ) ,
" fallback must not contain Rust-specific commands "
2026-03-22 19:07:07 +00:00
) ;
}
#[ test ]
fn detect_cargo_toml_generates_rust_component ( ) {
let dir = tempdir ( ) . unwrap ( ) ;
fs ::write (
dir . path ( ) . join ( " Cargo.toml " ) ,
" [package] \n name = \" test \" \n " ,
)
. unwrap ( ) ;
let toml = detect_components_toml ( dir . path ( ) ) ;
assert! ( toml . contains ( " name = \" server \" " ) ) ;
assert! ( toml . contains ( " setup = [ \" cargo check \" ] " ) ) ;
}
#[ test ]
fn detect_package_json_with_pnpm_lock_generates_pnpm_component ( ) {
let dir = tempdir ( ) . unwrap ( ) ;
fs ::write ( dir . path ( ) . join ( " package.json " ) , " {} " ) . unwrap ( ) ;
fs ::write ( dir . path ( ) . join ( " pnpm-lock.yaml " ) , " " ) . unwrap ( ) ;
let toml = detect_components_toml ( dir . path ( ) ) ;
assert! ( toml . contains ( " name = \" frontend \" " ) ) ;
assert! ( toml . contains ( " setup = [ \" pnpm install \" ] " ) ) ;
}
#[ test ]
fn detect_package_json_without_pnpm_lock_generates_npm_component ( ) {
let dir = tempdir ( ) . unwrap ( ) ;
fs ::write ( dir . path ( ) . join ( " package.json " ) , " {} " ) . unwrap ( ) ;
let toml = detect_components_toml ( dir . path ( ) ) ;
assert! ( toml . contains ( " name = \" frontend \" " ) ) ;
assert! ( toml . contains ( " setup = [ \" npm install \" ] " ) ) ;
}
#[ test ]
fn detect_pyproject_toml_generates_python_component ( ) {
let dir = tempdir ( ) . unwrap ( ) ;
fs ::write (
dir . path ( ) . join ( " pyproject.toml " ) ,
" [project] \n name = \" test \" \n " ,
)
. unwrap ( ) ;
let toml = detect_components_toml ( dir . path ( ) ) ;
assert! ( toml . contains ( " name = \" python \" " ) ) ;
assert! ( toml . contains ( " pip install " ) ) ;
}
#[ test ]
fn detect_requirements_txt_generates_python_component ( ) {
let dir = tempdir ( ) . unwrap ( ) ;
fs ::write ( dir . path ( ) . join ( " requirements.txt " ) , " flask \n " ) . unwrap ( ) ;
let toml = detect_components_toml ( dir . path ( ) ) ;
assert! ( toml . contains ( " name = \" python \" " ) ) ;
assert! ( toml . contains ( " pip install " ) ) ;
}
#[ test ]
fn detect_go_mod_generates_go_component ( ) {
let dir = tempdir ( ) . unwrap ( ) ;
fs ::write ( dir . path ( ) . join ( " go.mod " ) , " module example.com/app \n " ) . unwrap ( ) ;
let toml = detect_components_toml ( dir . path ( ) ) ;
assert! ( toml . contains ( " name = \" go \" " ) ) ;
assert! ( toml . contains ( " setup = [ \" go build ./... \" ] " ) ) ;
}
#[ test ]
fn detect_gemfile_generates_ruby_component ( ) {
let dir = tempdir ( ) . unwrap ( ) ;
fs ::write (
dir . path ( ) . join ( " Gemfile " ) ,
" source \" https://rubygems.org \" \n " ,
)
. unwrap ( ) ;
let toml = detect_components_toml ( dir . path ( ) ) ;
assert! ( toml . contains ( " name = \" ruby \" " ) ) ;
assert! ( toml . contains ( " setup = [ \" bundle install \" ] " ) ) ;
}
2026-03-23 18:28:14 +00:00
// --- Bug 375: no Rust-specific commands for non-Rust projects ---
#[ test ]
fn no_rust_commands_in_go_project ( ) {
let dir = tempdir ( ) . unwrap ( ) ;
fs ::write ( dir . path ( ) . join ( " go.mod " ) , " module example.com/app \n " ) . unwrap ( ) ;
let toml = detect_components_toml ( dir . path ( ) ) ;
2026-04-13 14:07:08 +00:00
assert! (
! toml . contains ( " cargo " ) ,
" go project must not contain cargo commands "
) ;
2026-03-23 18:28:14 +00:00
assert! ( toml . contains ( " go build " ) , " go project must use Go tooling " ) ;
}
#[ test ]
fn no_rust_commands_in_node_project ( ) {
let dir = tempdir ( ) . unwrap ( ) ;
fs ::write ( dir . path ( ) . join ( " package.json " ) , " {} " ) . unwrap ( ) ;
let toml = detect_components_toml ( dir . path ( ) ) ;
2026-04-13 14:07:08 +00:00
assert! (
! toml . contains ( " cargo " ) ,
" node project must not contain cargo commands "
) ;
assert! (
toml . contains ( " npm install " ) ,
" node project must use npm tooling "
) ;
2026-03-23 18:28:14 +00:00
}
#[ test ]
fn no_rust_commands_when_no_stack_detected ( ) {
let dir = tempdir ( ) . unwrap ( ) ;
let toml = detect_components_toml ( dir . path ( ) ) ;
2026-04-13 14:07:08 +00:00
assert! (
! toml . contains ( " cargo " ) ,
" unknown stack must not contain cargo commands "
) ;
2026-03-23 18:28:14 +00:00
// setup list must be empty
2026-04-13 14:07:08 +00:00
assert! (
toml . contains ( " setup = [] " ) ,
" unknown stack must have empty setup list "
) ;
2026-03-23 18:28:14 +00:00
}
2026-03-22 19:07:07 +00:00
#[ test ]
fn detect_multiple_markers_generates_multiple_components ( ) {
let dir = tempdir ( ) . unwrap ( ) ;
fs ::write (
dir . path ( ) . join ( " Cargo.toml " ) ,
" [package] \n name = \" server \" \n " ,
)
. unwrap ( ) ;
fs ::write ( dir . path ( ) . join ( " package.json " ) , " {} " ) . unwrap ( ) ;
let toml = detect_components_toml ( dir . path ( ) ) ;
assert! ( toml . contains ( " name = \" server \" " ) ) ;
assert! ( toml . contains ( " name = \" frontend \" " ) ) ;
// Both component entries should be present
let component_count = toml . matches ( " [[component]] " ) . count ( ) ;
assert_eq! ( component_count , 2 ) ;
}
#[ test ]
fn detect_no_fallback_when_markers_found ( ) {
let dir = tempdir ( ) . unwrap ( ) ;
fs ::write ( dir . path ( ) . join ( " Cargo.toml " ) , " [package] \n name = \" x \" \n " ) . unwrap ( ) ;
let toml = detect_components_toml ( dir . path ( ) ) ;
// The fallback "app" component should NOT appear when a real stack is detected
assert! ( ! toml . contains ( " name = \" app \" " ) ) ;
}
2026-03-23 14:23:03 +00:00
// --- detect_script_test ---
#[ test ]
fn detect_script_test_no_markers_returns_stub ( ) {
let dir = tempdir ( ) . unwrap ( ) ;
let script = detect_script_test ( dir . path ( ) ) ;
assert! (
script . contains ( " No tests configured " ) ,
" fallback should contain the generic stub message "
) ;
assert! ( script . starts_with ( " #!/usr/bin/env bash " ) ) ;
}
#[ test ]
fn detect_script_test_cargo_toml_adds_cargo_test ( ) {
let dir = tempdir ( ) . unwrap ( ) ;
fs ::write ( dir . path ( ) . join ( " Cargo.toml " ) , " [package] \n name = \" x \" \n " ) . unwrap ( ) ;
let script = detect_script_test ( dir . path ( ) ) ;
2026-04-13 14:07:08 +00:00
assert! (
script . contains ( " cargo test " ) ,
" Rust project should run cargo test "
) ;
2026-03-23 14:23:03 +00:00
assert! ( ! script . contains ( " No tests configured " ) ) ;
}
#[ test ]
fn detect_script_test_package_json_npm_adds_npm_test ( ) {
let dir = tempdir ( ) . unwrap ( ) ;
fs ::write ( dir . path ( ) . join ( " package.json " ) , " {} " ) . unwrap ( ) ;
let script = detect_script_test ( dir . path ( ) ) ;
2026-04-13 14:07:08 +00:00
assert! (
script . contains ( " npm test " ) ,
" Node project without pnpm-lock should run npm test "
) ;
2026-03-23 14:23:03 +00:00
assert! ( ! script . contains ( " No tests configured " ) ) ;
}
#[ test ]
fn detect_script_test_package_json_pnpm_adds_pnpm_test ( ) {
let dir = tempdir ( ) . unwrap ( ) ;
fs ::write ( dir . path ( ) . join ( " package.json " ) , " {} " ) . unwrap ( ) ;
fs ::write ( dir . path ( ) . join ( " pnpm-lock.yaml " ) , " " ) . unwrap ( ) ;
let script = detect_script_test ( dir . path ( ) ) ;
2026-04-13 14:07:08 +00:00
assert! (
script . contains ( " pnpm test " ) ,
" Node project with pnpm-lock should run pnpm test "
) ;
2026-03-23 14:23:03 +00:00
// "pnpm test" is a substring of itself; verify there's no bare "npm test" line
2026-04-13 14:07:08 +00:00
assert! (
! script . lines ( ) . any ( | l | l . trim ( ) = = " npm test " ) ,
" should not use npm when pnpm-lock.yaml is present "
) ;
2026-03-23 14:23:03 +00:00
}
#[ test ]
fn detect_script_test_pyproject_toml_adds_pytest ( ) {
let dir = tempdir ( ) . unwrap ( ) ;
2026-04-13 14:07:08 +00:00
fs ::write (
dir . path ( ) . join ( " pyproject.toml " ) ,
" [project] \n name = \" x \" \n " ,
)
. unwrap ( ) ;
2026-03-23 14:23:03 +00:00
let script = detect_script_test ( dir . path ( ) ) ;
2026-04-13 14:07:08 +00:00
assert! (
script . contains ( " pytest " ) ,
" Python project should run pytest "
) ;
2026-03-23 14:23:03 +00:00
assert! ( ! script . contains ( " No tests configured " ) ) ;
}
#[ test ]
fn detect_script_test_requirements_txt_adds_pytest ( ) {
let dir = tempdir ( ) . unwrap ( ) ;
fs ::write ( dir . path ( ) . join ( " requirements.txt " ) , " flask \n " ) . unwrap ( ) ;
let script = detect_script_test ( dir . path ( ) ) ;
2026-04-13 14:07:08 +00:00
assert! (
script . contains ( " pytest " ) ,
" Python project (requirements.txt) should run pytest "
) ;
2026-03-23 14:23:03 +00:00
}
#[ test ]
fn detect_script_test_go_mod_adds_go_test ( ) {
let dir = tempdir ( ) . unwrap ( ) ;
fs ::write ( dir . path ( ) . join ( " go.mod " ) , " module example.com/app \n " ) . unwrap ( ) ;
let script = detect_script_test ( dir . path ( ) ) ;
2026-04-13 14:07:08 +00:00
assert! (
script . contains ( " go test ./... " ) ,
" Go project should run go test ./... "
) ;
2026-03-23 14:23:03 +00:00
assert! ( ! script . contains ( " No tests configured " ) ) ;
}
#[ test ]
fn detect_script_test_multi_stack_combines_commands ( ) {
let dir = tempdir ( ) . unwrap ( ) ;
fs ::write ( dir . path ( ) . join ( " go.mod " ) , " module example.com/app \n " ) . unwrap ( ) ;
fs ::write ( dir . path ( ) . join ( " package.json " ) , " {} " ) . unwrap ( ) ;
let script = detect_script_test ( dir . path ( ) ) ;
2026-04-13 14:07:08 +00:00
assert! (
script . contains ( " go test ./... " ) ,
" multi-stack should include Go test command "
) ;
assert! (
script . contains ( " npm test " ) ,
" multi-stack should include Node test command "
) ;
2026-03-23 14:23:03 +00:00
}
2026-04-15 23:53:03 +00:00
#[ test ]
fn detect_script_test_frontend_subdir_with_vitest_uses_npx_vitest ( ) {
let dir = tempdir ( ) . unwrap ( ) ;
let frontend = dir . path ( ) . join ( " frontend " ) ;
fs ::create_dir_all ( & frontend ) . unwrap ( ) ;
fs ::write (
frontend . join ( " package.json " ) ,
r # "{"devDependencies":{"vitest":"^1.0.0"},"scripts":{"test":"vitest run"}}"# ,
)
. unwrap ( ) ;
let script = detect_script_test ( dir . path ( ) ) ;
assert! (
script . contains ( " vitest run " ) ,
" frontend with vitest should emit vitest run "
) ;
assert! (
script . contains ( " cd frontend " ) ,
" should cd into the frontend directory "
) ;
assert! (
! script . contains ( " No tests configured " ) ,
" should not use stub when frontend is detected "
) ;
}
#[ test ]
fn detect_script_test_frontend_subdir_with_jest_uses_npx_jest ( ) {
let dir = tempdir ( ) . unwrap ( ) ;
let frontend = dir . path ( ) . join ( " frontend " ) ;
fs ::create_dir_all ( & frontend ) . unwrap ( ) ;
fs ::write (
frontend . join ( " package.json " ) ,
r # "{"devDependencies":{"jest":"^29.0.0"},"scripts":{"test":"jest"}}"# ,
)
. unwrap ( ) ;
let script = detect_script_test ( dir . path ( ) ) ;
assert! (
script . contains ( " jest " ) ,
" frontend with jest should emit jest "
) ;
assert! (
script . contains ( " cd frontend " ) ,
" should cd into the frontend directory "
) ;
}
#[ test ]
fn detect_script_test_frontend_subdir_no_known_runner_uses_npm_test ( ) {
let dir = tempdir ( ) . unwrap ( ) ;
let frontend = dir . path ( ) . join ( " frontend " ) ;
fs ::create_dir_all ( & frontend ) . unwrap ( ) ;
fs ::write (
frontend . join ( " package.json " ) ,
r # "{"scripts":{"test":"mocha"}}"# ,
)
. unwrap ( ) ;
let script = detect_script_test ( dir . path ( ) ) ;
assert! (
script . contains ( " npm test " ) ,
" frontend without known runner should fall back to npm test "
) ;
assert! ( script . contains ( " cd frontend " ) ) ;
}
#[ test ]
fn detect_script_test_frontend_subdir_pnpm_uses_pnpm_vitest ( ) {
let dir = tempdir ( ) . unwrap ( ) ;
let frontend = dir . path ( ) . join ( " frontend " ) ;
fs ::create_dir_all ( & frontend ) . unwrap ( ) ;
fs ::write (
frontend . join ( " package.json " ) ,
r # "{"devDependencies":{"vitest":"^1.0.0"}}"# ,
)
. unwrap ( ) ;
fs ::write ( frontend . join ( " pnpm-lock.yaml " ) , " " ) . unwrap ( ) ;
let script = detect_script_test ( dir . path ( ) ) ;
assert! (
script . contains ( " pnpm vitest run " ) ,
" pnpm frontend with vitest should use pnpm vitest run "
) ;
}
#[ test ]
fn detect_script_test_rust_plus_frontend_subdir_both_included ( ) {
let dir = tempdir ( ) . unwrap ( ) ;
fs ::write (
dir . path ( ) . join ( " Cargo.toml " ) ,
" [package] \n name = \" server \" \n " ,
)
. unwrap ( ) ;
let frontend = dir . path ( ) . join ( " frontend " ) ;
fs ::create_dir_all ( & frontend ) . unwrap ( ) ;
fs ::write (
frontend . join ( " package.json " ) ,
r # "{"devDependencies":{"vitest":"^1.0.0"}}"# ,
)
. unwrap ( ) ;
let script = detect_script_test ( dir . path ( ) ) ;
assert! (
script . contains ( " cargo test " ) ,
" Rust + frontend should include cargo test "
) ;
assert! (
script . contains ( " vitest run " ) ,
" Rust + frontend should include vitest run "
) ;
assert! (
script . contains ( " cd frontend " ) ,
" Rust + frontend should cd into frontend "
) ;
}
#[ test ]
fn detect_script_test_client_subdir_detected ( ) {
let dir = tempdir ( ) . unwrap ( ) ;
let client = dir . path ( ) . join ( " client " ) ;
fs ::create_dir_all ( & client ) . unwrap ( ) ;
fs ::write (
client . join ( " package.json " ) ,
r # "{"scripts":{"test":"jest"}}"# ,
)
. unwrap ( ) ;
let script = detect_script_test ( dir . path ( ) ) ;
assert! (
script . contains ( " cd client " ) ,
" client/ subdir should also be detected "
) ;
}
2026-03-23 14:23:03 +00:00
#[ test ]
fn detect_script_test_output_starts_with_shebang ( ) {
let dir = tempdir ( ) . unwrap ( ) ;
fs ::write ( dir . path ( ) . join ( " Cargo.toml " ) , " [package] \n name = \" x \" \n " ) . unwrap ( ) ;
let script = detect_script_test ( dir . path ( ) ) ;
assert! (
script . starts_with ( " #!/usr/bin/env bash \n set -euo pipefail \n " ) ,
" generated script should start with bash shebang and set -euo pipefail "
) ;
}
#[ test ]
fn scaffold_script_test_contains_detected_commands_for_rust ( ) {
let dir = tempdir ( ) . unwrap ( ) ;
2026-04-13 14:07:08 +00:00
fs ::write (
dir . path ( ) . join ( " Cargo.toml " ) ,
" [package] \n name = \" myapp \" \n " ,
)
. unwrap ( ) ;
2026-03-23 14:23:03 +00:00
scaffold_story_kit ( dir . path ( ) , 3001 ) . unwrap ( ) ;
let content = fs ::read_to_string ( dir . path ( ) . join ( " script/test " ) ) . unwrap ( ) ;
2026-04-13 14:07:08 +00:00
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 "
) ;
2026-03-23 14:23:03 +00:00
}
#[ 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 ( ) ;
2026-04-13 14:07:08 +00:00
assert! (
content . contains ( " No tests configured " ) ,
" unknown stack should use the generic stub "
) ;
2026-03-23 14:23:03 +00:00
}
2026-04-16 00:18:42 +00:00
// --- detect_script_build ---
#[ test ]
fn detect_script_build_no_markers_returns_stub ( ) {
let dir = tempdir ( ) . unwrap ( ) ;
let script = detect_script_build ( dir . path ( ) ) ;
assert! (
script . contains ( " No build configured " ) ,
" fallback should contain the generic stub message "
) ;
assert! ( script . starts_with ( " #!/usr/bin/env bash " ) ) ;
}
#[ test ]
fn detect_script_build_cargo_toml_adds_cargo_build_release ( ) {
let dir = tempdir ( ) . unwrap ( ) ;
fs ::write ( dir . path ( ) . join ( " Cargo.toml " ) , " [package] \n name = \" x \" \n " ) . unwrap ( ) ;
let script = detect_script_build ( dir . path ( ) ) ;
assert! (
script . contains ( " cargo build --release " ) ,
" Rust project should run cargo build --release "
) ;
assert! ( ! script . contains ( " No build configured " ) ) ;
}
#[ test ]
fn detect_script_build_package_json_npm_adds_npm_run_build ( ) {
let dir = tempdir ( ) . unwrap ( ) ;
fs ::write ( dir . path ( ) . join ( " package.json " ) , " {} " ) . unwrap ( ) ;
let script = detect_script_build ( dir . path ( ) ) ;
assert! (
script . contains ( " npm run build " ) ,
" Node project without pnpm-lock should run npm run build "
) ;
}
#[ test ]
fn detect_script_build_package_json_pnpm_adds_pnpm_run_build ( ) {
let dir = tempdir ( ) . unwrap ( ) ;
fs ::write ( dir . path ( ) . join ( " package.json " ) , " {} " ) . unwrap ( ) ;
fs ::write ( dir . path ( ) . join ( " pnpm-lock.yaml " ) , " " ) . unwrap ( ) ;
let script = detect_script_build ( dir . path ( ) ) ;
assert! (
script . contains ( " pnpm run build " ) ,
" Node project with pnpm-lock should run pnpm run build "
) ;
assert! (
! script . lines ( ) . any ( | l | l . trim ( ) = = " npm run build " ) ,
" should not use npm when pnpm-lock.yaml is present "
) ;
}
#[ test ]
fn detect_script_build_go_mod_adds_go_build ( ) {
let dir = tempdir ( ) . unwrap ( ) ;
fs ::write ( dir . path ( ) . join ( " go.mod " ) , " module example.com/app \n " ) . unwrap ( ) ;
let script = detect_script_build ( dir . path ( ) ) ;
assert! (
script . contains ( " go build ./... " ) ,
" Go project should run go build ./... "
) ;
}
#[ test ]
fn detect_script_build_pyproject_toml_adds_python_build ( ) {
let dir = tempdir ( ) . unwrap ( ) ;
fs ::write (
dir . path ( ) . join ( " pyproject.toml " ) ,
" [project] \n name = \" x \" \n " ,
)
. unwrap ( ) ;
let script = detect_script_build ( dir . path ( ) ) ;
assert! (
script . contains ( " python -m build " ) ,
" Python project should run python -m build "
) ;
}
#[ test ]
fn detect_script_build_frontend_subdir_detected ( ) {
let dir = tempdir ( ) . unwrap ( ) ;
let frontend = dir . path ( ) . join ( " frontend " ) ;
fs ::create_dir_all ( & frontend ) . unwrap ( ) ;
fs ::write ( frontend . join ( " package.json " ) , " {} " ) . unwrap ( ) ;
let script = detect_script_build ( dir . path ( ) ) ;
assert! (
script . contains ( " cd frontend " ) ,
" frontend subdir should be detected for build "
) ;
assert! ( script . contains ( " npm run build " ) ) ;
}
#[ test ]
fn detect_script_build_rust_plus_frontend_subdir_both_included ( ) {
let dir = tempdir ( ) . unwrap ( ) ;
fs ::write (
dir . path ( ) . join ( " Cargo.toml " ) ,
" [package] \n name = \" server \" \n " ,
)
. unwrap ( ) ;
let frontend = dir . path ( ) . join ( " frontend " ) ;
fs ::create_dir_all ( & frontend ) . unwrap ( ) ;
fs ::write ( frontend . join ( " package.json " ) , " {} " ) . unwrap ( ) ;
let script = detect_script_build ( dir . path ( ) ) ;
assert! ( script . contains ( " cargo build --release " ) ) ;
assert! ( script . contains ( " cd frontend " ) ) ;
assert! ( script . contains ( " npm run build " ) ) ;
}
// --- detect_script_lint ---
#[ test ]
fn detect_script_lint_no_markers_returns_stub ( ) {
let dir = tempdir ( ) . unwrap ( ) ;
let script = detect_script_lint ( dir . path ( ) ) ;
assert! (
script . contains ( " No linters configured " ) ,
" fallback should contain the generic stub message "
) ;
assert! ( script . starts_with ( " #!/usr/bin/env bash " ) ) ;
}
#[ test ]
fn detect_script_lint_cargo_toml_adds_fmt_and_clippy ( ) {
let dir = tempdir ( ) . unwrap ( ) ;
fs ::write ( dir . path ( ) . join ( " Cargo.toml " ) , " [package] \n name = \" x \" \n " ) . unwrap ( ) ;
let script = detect_script_lint ( dir . path ( ) ) ;
assert! (
script . contains ( " cargo fmt --all --check " ) ,
" Rust project should check formatting "
) ;
assert! (
script . contains ( " cargo clippy -- -D warnings " ) ,
" Rust project should run clippy "
) ;
assert! ( ! script . contains ( " No linters configured " ) ) ;
}
#[ test ]
fn detect_script_lint_package_json_without_eslint_uses_npm_run_lint ( ) {
let dir = tempdir ( ) . unwrap ( ) ;
fs ::write ( dir . path ( ) . join ( " package.json " ) , " {} " ) . unwrap ( ) ;
let script = detect_script_lint ( dir . path ( ) ) ;
assert! (
script . contains ( " npm run lint " ) ,
" Node project without eslint dep should fall back to npm run lint "
) ;
}
#[ test ]
fn detect_script_lint_package_json_with_eslint_uses_npx_eslint ( ) {
let dir = tempdir ( ) . unwrap ( ) ;
fs ::write (
dir . path ( ) . join ( " package.json " ) ,
r # "{"devDependencies":{"eslint":"^8.0.0"}}"# ,
)
. unwrap ( ) ;
let script = detect_script_lint ( dir . path ( ) ) ;
assert! (
script . contains ( " npx eslint . " ) ,
" Node project with eslint should use npx eslint . "
) ;
}
#[ test ]
fn detect_script_lint_pnpm_with_eslint_uses_pnpm_eslint ( ) {
let dir = tempdir ( ) . unwrap ( ) ;
fs ::write (
dir . path ( ) . join ( " package.json " ) ,
r # "{"devDependencies":{"eslint":"^8.0.0"}}"# ,
)
. unwrap ( ) ;
fs ::write ( dir . path ( ) . join ( " pnpm-lock.yaml " ) , " " ) . unwrap ( ) ;
let script = detect_script_lint ( dir . path ( ) ) ;
assert! (
script . contains ( " pnpm eslint . " ) ,
" pnpm project with eslint should use pnpm eslint . "
) ;
}
#[ test ]
fn detect_script_lint_python_requirements_uses_flake8 ( ) {
let dir = tempdir ( ) . unwrap ( ) ;
fs ::write ( dir . path ( ) . join ( " requirements.txt " ) , " flask \n " ) . unwrap ( ) ;
let script = detect_script_lint ( dir . path ( ) ) ;
assert! (
script . contains ( " flake8 . " ) ,
" Python project without ruff should use flake8 "
) ;
}
#[ test ]
fn detect_script_lint_python_with_ruff_uses_ruff ( ) {
let dir = tempdir ( ) . unwrap ( ) ;
fs ::write (
dir . path ( ) . join ( " pyproject.toml " ) ,
" [project] \n name = \" x \" \n \n [tool.ruff] \n " ,
)
. unwrap ( ) ;
let script = detect_script_lint ( dir . path ( ) ) ;
assert! (
script . contains ( " ruff check . " ) ,
" Python project with ruff configured should use ruff "
) ;
assert! (
! script . contains ( " flake8 " ) ,
" should not use flake8 when ruff is configured "
) ;
}
#[ test ]
fn detect_script_lint_go_mod_adds_go_vet ( ) {
let dir = tempdir ( ) . unwrap ( ) ;
fs ::write ( dir . path ( ) . join ( " go.mod " ) , " module example.com/app \n " ) . unwrap ( ) ;
let script = detect_script_lint ( dir . path ( ) ) ;
assert! (
script . contains ( " go vet ./... " ) ,
" Go project should run go vet ./... "
) ;
}
#[ test ]
fn detect_script_lint_frontend_subdir_detected ( ) {
let dir = tempdir ( ) . unwrap ( ) ;
let frontend = dir . path ( ) . join ( " frontend " ) ;
fs ::create_dir_all ( & frontend ) . unwrap ( ) ;
fs ::write ( frontend . join ( " package.json " ) , " {} " ) . unwrap ( ) ;
let script = detect_script_lint ( dir . path ( ) ) ;
assert! (
script . contains ( " cd frontend " ) ,
" frontend subdir should be detected for lint "
) ;
}
#[ test ]
fn detect_script_lint_rust_plus_frontend_subdir_both_included ( ) {
let dir = tempdir ( ) . unwrap ( ) ;
fs ::write (
dir . path ( ) . join ( " Cargo.toml " ) ,
" [package] \n name = \" server \" \n " ,
)
. unwrap ( ) ;
let frontend = dir . path ( ) . join ( " frontend " ) ;
fs ::create_dir_all ( & frontend ) . unwrap ( ) ;
fs ::write ( frontend . join ( " package.json " ) , " {} " ) . unwrap ( ) ;
let script = detect_script_lint ( dir . path ( ) ) ;
assert! ( script . contains ( " cargo fmt --all --check " ) ) ;
assert! ( script . contains ( " cargo clippy -- -D warnings " ) ) ;
assert! ( script . contains ( " cd frontend " ) ) ;
}
#[ 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] \n name = \" 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] \n name = \" 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 "
) ;
}
2026-03-22 19:07:07 +00:00
// --- generate_project_toml ---
#[ test ]
2026-04-04 21:20:36 +00:00
fn generate_project_toml_includes_components_but_not_agents ( ) {
2026-03-22 19:07:07 +00:00
let dir = tempdir ( ) . unwrap ( ) ;
fs ::write ( dir . path ( ) . join ( " Cargo.toml " ) , " [package] \n name = \" x \" \n " ) . unwrap ( ) ;
let toml = generate_project_toml ( dir . path ( ) ) ;
2026-04-04 21:20:36 +00:00
// Component section should be present
2026-03-22 19:07:07 +00:00
assert! ( toml . contains ( " [[component]] " ) ) ;
assert! ( toml . contains ( " name = \" server \" " ) ) ;
2026-04-04 21:20:36 +00:00
// Agent sections must NOT be in project.toml — they go in agents.toml
assert! ( ! toml . contains ( " [[agent]] " ) ) ;
}
#[ test ]
fn default_agents_toml_has_coder_qa_mergemaster ( ) {
assert! ( DEFAULT_AGENTS_TOML . contains ( " [[agent]] " ) ) ;
assert! ( DEFAULT_AGENTS_TOML . contains ( " stage = \" coder \" " ) ) ;
assert! ( DEFAULT_AGENTS_TOML . contains ( " stage = \" qa \" " ) ) ;
assert! ( DEFAULT_AGENTS_TOML . contains ( " stage = \" mergemaster \" " ) ) ;
2026-03-22 19:07:07 +00:00
}
#[ 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] \n name = \" myapp \" \n " ,
)
. unwrap ( ) ;
2026-03-23 12:57:49 +00:00
scaffold_story_kit ( dir . path ( ) , 3001 ) . unwrap ( ) ;
2026-03-22 19:07:07 +00:00
2026-04-03 16:12:52 +01:00
let content = fs ::read_to_string ( dir . path ( ) . join ( " .huskies/project.toml " ) ) . unwrap ( ) ;
2026-03-22 19:07:07 +00:00
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 ( ) ;
2026-03-23 12:57:49 +00:00
scaffold_story_kit ( dir . path ( ) , 3001 ) . unwrap ( ) ;
2026-03-22 19:07:07 +00:00
2026-04-03 16:12:52 +01:00
let content = fs ::read_to_string ( dir . path ( ) . join ( " .huskies/project.toml " ) ) . unwrap ( ) ;
2026-03-22 19:07:07 +00:00
assert! (
content . contains ( " [[component]] " ) ,
" project.toml should always have at least one component "
) ;
2026-03-23 18:28:14 +00:00
// Fallback uses generic app component with empty setup — no Rust-specific commands
assert! (
content . contains ( " name = \" app \" " ) ,
" fallback should use generic 'app' component name "
) ;
2026-03-22 19:07:07 +00:00
assert! (
2026-03-23 18:28:14 +00:00
! content . contains ( " cargo " ) ,
" fallback must not contain Rust-specific commands for non-Rust projects "
2026-03-22 19:07:07 +00:00
) ;
}
#[ test ]
fn scaffold_does_not_overwrite_existing_project_toml_with_components ( ) {
let dir = tempdir ( ) . unwrap ( ) ;
2026-04-03 16:12:52 +01:00
let sk_dir = dir . path ( ) . join ( " .huskies " ) ;
2026-03-22 19:07:07 +00:00
fs ::create_dir_all ( & sk_dir ) . unwrap ( ) ;
let existing = " [[component]] \n name = \" custom \" \n path = \" . \" \n setup = [ \" make build \" ] \n " ;
fs ::write ( sk_dir . join ( " project.toml " ) , existing ) . unwrap ( ) ;
2026-03-23 12:57:49 +00:00
scaffold_story_kit ( dir . path ( ) , 3001 ) . unwrap ( ) ;
2026-03-22 19:07:07 +00:00
let content = fs ::read_to_string ( sk_dir . join ( " project.toml " ) ) . unwrap ( ) ;
assert_eq! (
content , existing ,
" scaffold should not overwrite existing project.toml "
) ;
}
}