8f91f55cd1
The 2045-line scaffold.rs is split into a sub-module directory:
- templates.rs: STORY_KIT_* and DEFAULT_* template constants (161 lines)
- detect.rs: detect_components_toml + detect_script_{build,lint,test} + tests (989 lines)
- helpers.rs: write_*_if_missing, generate_project_toml, gitignore helpers (166 lines)
- mod.rs: scaffold_story_kit orchestrator + scaffold tests (756 lines)
include_str! paths in templates.rs are adjusted (one extra ../) for the deeper
nesting. Tests stay co-located with the code they exercise per Rust convention.
No behaviour change. All 77 scaffold tests pass; full suite green
(2635 tests with --test-threads=1).
758 lines
27 KiB
Rust
758 lines
27 KiB
Rust
//! Project scaffolding — creates the `.huskies/` directory structure and default files.
|
|
|
|
use std::fs;
|
|
use std::path::Path;
|
|
|
|
mod detect;
|
|
mod helpers;
|
|
mod templates;
|
|
|
|
use detect::{detect_components_toml, detect_script_build, detect_script_lint, detect_script_test};
|
|
use helpers::{
|
|
append_root_gitignore_entries, generate_project_toml, write_file_if_missing,
|
|
write_script_if_missing, write_story_kit_gitignore,
|
|
};
|
|
use templates::{
|
|
BOT_TOML_MATRIX_EXAMPLE, BOT_TOML_SLACK_EXAMPLE, BOT_TOML_WHATSAPP_META_EXAMPLE,
|
|
BOT_TOML_WHATSAPP_TWILIO_EXAMPLE, DEFAULT_AGENTS_TOML, STORY_KIT_CLAUDE_MD,
|
|
STORY_KIT_CLAUDE_SETTINGS, STORY_KIT_CONTEXT, STORY_KIT_README, STORY_KIT_SCRIPT_TEST,
|
|
STORY_KIT_STACK,
|
|
};
|
|
|
|
pub(crate) fn scaffold_story_kit(root: &Path, port: u16) -> Result<(), String> {
|
|
let story_kit_root = root.join(".huskies");
|
|
let specs_root = story_kit_root.join("specs");
|
|
let tech_root = specs_root.join("tech");
|
|
let functional_root = specs_root.join("functional");
|
|
let script_root = root.join("script");
|
|
|
|
// Create the work/ pipeline directories, each with a .gitkeep so empty dirs survive git clone
|
|
let work_stages = [
|
|
"1_backlog",
|
|
"2_current",
|
|
"3_qa",
|
|
"4_merge",
|
|
"5_done",
|
|
"6_archived",
|
|
];
|
|
for stage in &work_stages {
|
|
let dir = story_kit_root.join("work").join(stage);
|
|
fs::create_dir_all(&dir).map_err(|e| format!("Failed to create work/{}: {}", stage, e))?;
|
|
write_file_if_missing(&dir.join(".gitkeep"), "")?;
|
|
}
|
|
|
|
fs::create_dir_all(&tech_root).map_err(|e| format!("Failed to create specs/tech: {}", e))?;
|
|
fs::create_dir_all(&functional_root)
|
|
.map_err(|e| format!("Failed to create specs/functional: {}", e))?;
|
|
fs::create_dir_all(&script_root)
|
|
.map_err(|e| format!("Failed to create script/ directory: {}", e))?;
|
|
|
|
write_file_if_missing(&story_kit_root.join("README.md"), STORY_KIT_README)?;
|
|
let project_toml_content = generate_project_toml(root);
|
|
write_file_if_missing(&story_kit_root.join("project.toml"), &project_toml_content)?;
|
|
write_file_if_missing(&story_kit_root.join("agents.toml"), DEFAULT_AGENTS_TOML)?;
|
|
write_file_if_missing(&specs_root.join("00_CONTEXT.md"), STORY_KIT_CONTEXT)?;
|
|
write_file_if_missing(&tech_root.join("STACK.md"), STORY_KIT_STACK)?;
|
|
let script_test_content = detect_script_test(root);
|
|
write_script_if_missing(&script_root.join("test"), &script_test_content)?;
|
|
let script_build_content = detect_script_build(root);
|
|
write_script_if_missing(&script_root.join("build"), &script_build_content)?;
|
|
let script_lint_content = detect_script_lint(root);
|
|
write_script_if_missing(&script_root.join("lint"), &script_lint_content)?;
|
|
write_file_if_missing(&root.join("CLAUDE.md"), STORY_KIT_CLAUDE_MD)?;
|
|
|
|
// Write per-transport bot.toml example files so users can see all options.
|
|
write_file_if_missing(
|
|
&story_kit_root.join("bot.toml.matrix.example"),
|
|
BOT_TOML_MATRIX_EXAMPLE,
|
|
)?;
|
|
write_file_if_missing(
|
|
&story_kit_root.join("bot.toml.whatsapp-meta.example"),
|
|
BOT_TOML_WHATSAPP_META_EXAMPLE,
|
|
)?;
|
|
write_file_if_missing(
|
|
&story_kit_root.join("bot.toml.whatsapp-twilio.example"),
|
|
BOT_TOML_WHATSAPP_TWILIO_EXAMPLE,
|
|
)?;
|
|
write_file_if_missing(
|
|
&story_kit_root.join("bot.toml.slack.example"),
|
|
BOT_TOML_SLACK_EXAMPLE,
|
|
)?;
|
|
|
|
// Write .mcp.json at the project root so agents can find the MCP server.
|
|
// Only written when missing — never overwrites an existing file, because
|
|
// the port is environment-specific and must not clobber a running instance.
|
|
let mcp_content = format!(
|
|
"{{\n \"mcpServers\": {{\n \"huskies\": {{\n \"type\": \"http\",\n \"url\": \"http://localhost:{port}/mcp\"\n }}\n }}\n}}\n"
|
|
);
|
|
write_file_if_missing(&root.join(".mcp.json"), &mcp_content)?;
|
|
|
|
// Create .claude/settings.json with sensible permission defaults so that
|
|
// Claude Code (both agents and web UI chat) can operate without constant
|
|
// permission prompts.
|
|
let claude_dir = root.join(".claude");
|
|
fs::create_dir_all(&claude_dir)
|
|
.map_err(|e| format!("Failed to create .claude/ directory: {}", e))?;
|
|
write_file_if_missing(&claude_dir.join("settings.json"), STORY_KIT_CLAUDE_SETTINGS)?;
|
|
|
|
write_story_kit_gitignore(root)?;
|
|
append_root_gitignore_entries(root)?;
|
|
|
|
// Run `git init` if the directory is not already a git repo, then make an initial commit
|
|
if !root.join(".git").exists() {
|
|
let init_status = std::process::Command::new("git")
|
|
.args(["init"])
|
|
.current_dir(root)
|
|
.status()
|
|
.map_err(|e| format!("Failed to run git init: {}", e))?;
|
|
if !init_status.success() {
|
|
return Err("git init failed".to_string());
|
|
}
|
|
|
|
let add_output = std::process::Command::new("git")
|
|
.args([
|
|
"add",
|
|
".huskies",
|
|
"script",
|
|
".gitignore",
|
|
"CLAUDE.md",
|
|
".claude",
|
|
])
|
|
.current_dir(root)
|
|
.output()
|
|
.map_err(|e| format!("Failed to run git add: {}", e))?;
|
|
if !add_output.status.success() {
|
|
return Err(format!(
|
|
"git add failed: {}",
|
|
String::from_utf8_lossy(&add_output.stderr)
|
|
));
|
|
}
|
|
|
|
let commit_output = std::process::Command::new("git")
|
|
.args([
|
|
"-c",
|
|
"user.email=huskies@localhost",
|
|
"-c",
|
|
"user.name=Story Kit",
|
|
"commit",
|
|
"-m",
|
|
"Initial Story Kit scaffold",
|
|
])
|
|
.current_dir(root)
|
|
.output()
|
|
.map_err(|e| format!("Failed to run git commit: {}", e))?;
|
|
if !commit_output.status.success() {
|
|
return Err(format!(
|
|
"git commit failed: {}",
|
|
String::from_utf8_lossy(&commit_output.stderr)
|
|
));
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use tempfile::tempdir;
|
|
|
|
#[test]
|
|
fn scaffold_story_kit_creates_structure() {
|
|
let dir = tempdir().unwrap();
|
|
scaffold_story_kit(dir.path(), 3001).unwrap();
|
|
|
|
assert!(dir.path().join(".huskies/README.md").exists());
|
|
assert!(dir.path().join(".huskies/project.toml").exists());
|
|
assert!(dir.path().join(".huskies/agents.toml").exists());
|
|
assert!(dir.path().join(".huskies/specs/00_CONTEXT.md").exists());
|
|
assert!(dir.path().join(".huskies/specs/tech/STACK.md").exists());
|
|
// Old stories/ dirs should NOT be created
|
|
assert!(!dir.path().join(".huskies/stories").exists());
|
|
assert!(dir.path().join("script/test").exists());
|
|
}
|
|
|
|
#[test]
|
|
fn scaffold_story_kit_creates_work_pipeline_dirs() {
|
|
let dir = tempdir().unwrap();
|
|
scaffold_story_kit(dir.path(), 3001).unwrap();
|
|
|
|
let stages = [
|
|
"1_backlog",
|
|
"2_current",
|
|
"3_qa",
|
|
"4_merge",
|
|
"5_done",
|
|
"6_archived",
|
|
];
|
|
for stage in &stages {
|
|
let path = dir.path().join(".huskies/work").join(stage);
|
|
assert!(path.is_dir(), "work/{} should be a directory", stage);
|
|
assert!(
|
|
path.join(".gitkeep").exists(),
|
|
"work/{} should have a .gitkeep file",
|
|
stage
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn scaffold_story_kit_agents_toml_has_coder_qa_mergemaster() {
|
|
let dir = tempdir().unwrap();
|
|
scaffold_story_kit(dir.path(), 3001).unwrap();
|
|
|
|
// Agent definitions go into agents.toml, not project.toml.
|
|
let agents = fs::read_to_string(dir.path().join(".huskies/agents.toml")).unwrap();
|
|
assert!(agents.contains("[[agent]]"));
|
|
assert!(agents.contains("stage = \"coder\""));
|
|
assert!(agents.contains("stage = \"qa\""));
|
|
assert!(agents.contains("stage = \"mergemaster\""));
|
|
assert!(agents.contains("model = \"sonnet\""));
|
|
|
|
// project.toml should NOT contain [[agent]] blocks.
|
|
let project = fs::read_to_string(dir.path().join(".huskies/project.toml")).unwrap();
|
|
assert!(!project.contains("[[agent]]"));
|
|
}
|
|
|
|
#[test]
|
|
fn scaffold_project_toml_contains_rate_limit_and_timezone_comments() {
|
|
let dir = tempdir().unwrap();
|
|
scaffold_story_kit(dir.path(), 3001).unwrap();
|
|
|
|
let content = fs::read_to_string(dir.path().join(".huskies/project.toml")).unwrap();
|
|
assert!(
|
|
content.contains("rate_limit_notifications"),
|
|
"project.toml scaffold should document rate_limit_notifications"
|
|
);
|
|
assert!(
|
|
content.contains("timezone"),
|
|
"project.toml scaffold should document timezone"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn scaffold_project_toml_contains_max_retries_with_default_value() {
|
|
let dir = tempdir().unwrap();
|
|
scaffold_story_kit(dir.path(), 3001).unwrap();
|
|
|
|
let content = fs::read_to_string(dir.path().join(".huskies/project.toml")).unwrap();
|
|
assert!(
|
|
content.contains("max_retries = 2"),
|
|
"project.toml scaffold should include max_retries with default value 2"
|
|
);
|
|
assert!(
|
|
content.contains("Maximum number of retries"),
|
|
"project.toml scaffold should include a comment explaining max_retries"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn scaffold_project_toml_contains_commented_out_optional_fields() {
|
|
let dir = tempdir().unwrap();
|
|
scaffold_story_kit(dir.path(), 3001).unwrap();
|
|
|
|
let content = fs::read_to_string(dir.path().join(".huskies/project.toml")).unwrap();
|
|
assert!(
|
|
content.contains("# default_coder_model"),
|
|
"project.toml scaffold should include commented-out default_coder_model"
|
|
);
|
|
assert!(
|
|
content.contains("# max_coders"),
|
|
"project.toml scaffold should include commented-out max_coders"
|
|
);
|
|
assert!(
|
|
content.contains("# base_branch"),
|
|
"project.toml scaffold should include commented-out base_branch"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn scaffold_project_toml_round_trips_through_project_config_load() {
|
|
use crate::config::ProjectConfig;
|
|
|
|
let dir = tempdir().unwrap();
|
|
scaffold_story_kit(dir.path(), 3001).unwrap();
|
|
|
|
// The generated project.toml must parse without error.
|
|
let config = ProjectConfig::load(dir.path())
|
|
.expect("Generated project.toml should parse without error");
|
|
|
|
// Key defaults must survive the round-trip.
|
|
assert_eq!(config.default_qa, "server");
|
|
assert_eq!(config.max_retries, 2);
|
|
assert!(
|
|
config.rate_limit_notifications,
|
|
"rate_limit_notifications should default to true"
|
|
);
|
|
assert!(
|
|
config.default_coder_model.is_none(),
|
|
"default_coder_model should be None when commented out"
|
|
);
|
|
assert!(
|
|
config.max_coders.is_none(),
|
|
"max_coders should be None when commented out"
|
|
);
|
|
assert!(
|
|
config.base_branch.is_none(),
|
|
"base_branch should be None when commented out"
|
|
);
|
|
assert!(
|
|
config.timezone.is_none(),
|
|
"timezone should be None when commented out"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn scaffold_context_is_blank_template_not_story_kit_content() {
|
|
let dir = tempdir().unwrap();
|
|
scaffold_story_kit(dir.path(), 3001).unwrap();
|
|
|
|
let content = fs::read_to_string(dir.path().join(".huskies/specs/00_CONTEXT.md")).unwrap();
|
|
assert!(content.contains("<!-- huskies:scaffold-template -->"));
|
|
assert!(content.contains("## High-Level Goal"));
|
|
assert!(content.contains("## Core Features"));
|
|
assert!(content.contains("## Domain Definition"));
|
|
assert!(content.contains("## Glossary"));
|
|
// Must NOT contain Story Kit-specific content
|
|
assert!(!content.contains("Agentic AI Code Assistant"));
|
|
assert!(!content.contains("Poem HTTP server"));
|
|
}
|
|
|
|
#[test]
|
|
fn scaffold_stack_is_blank_template_not_story_kit_content() {
|
|
let dir = tempdir().unwrap();
|
|
scaffold_story_kit(dir.path(), 3001).unwrap();
|
|
|
|
let content = fs::read_to_string(dir.path().join(".huskies/specs/tech/STACK.md")).unwrap();
|
|
assert!(content.contains("<!-- huskies:scaffold-template -->"));
|
|
assert!(content.contains("## Core Stack"));
|
|
assert!(content.contains("## Coding Standards"));
|
|
assert!(content.contains("## Quality Gates"));
|
|
assert!(content.contains("## Libraries"));
|
|
// Must NOT contain Story Kit-specific content
|
|
assert!(!content.contains("Poem HTTP server"));
|
|
assert!(!content.contains("TypeScript + React"));
|
|
}
|
|
|
|
#[test]
|
|
fn scaffold_story_kit_creates_executable_script_test() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
let dir = tempdir().unwrap();
|
|
scaffold_story_kit(dir.path(), 3001).unwrap();
|
|
|
|
let script_test = dir.path().join("script/test");
|
|
assert!(script_test.exists(), "script/test should be created");
|
|
let perms = fs::metadata(&script_test).unwrap().permissions();
|
|
assert!(
|
|
perms.mode() & 0o111 != 0,
|
|
"script/test should be executable"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn scaffold_story_kit_does_not_overwrite_existing() {
|
|
let dir = tempdir().unwrap();
|
|
let readme = dir.path().join(".huskies/README.md");
|
|
fs::create_dir_all(readme.parent().unwrap()).unwrap();
|
|
fs::write(&readme, "custom content").unwrap();
|
|
|
|
scaffold_story_kit(dir.path(), 3001).unwrap();
|
|
|
|
assert_eq!(fs::read_to_string(&readme).unwrap(), "custom content");
|
|
}
|
|
|
|
#[test]
|
|
fn scaffold_story_kit_is_idempotent() {
|
|
let dir = tempdir().unwrap();
|
|
scaffold_story_kit(dir.path(), 3001).unwrap();
|
|
|
|
let readme_content = fs::read_to_string(dir.path().join(".huskies/README.md")).unwrap();
|
|
let toml_content = fs::read_to_string(dir.path().join(".huskies/project.toml")).unwrap();
|
|
|
|
// Run again — must not change content or add duplicate .gitignore entries
|
|
scaffold_story_kit(dir.path(), 3001).unwrap();
|
|
|
|
assert_eq!(
|
|
fs::read_to_string(dir.path().join(".huskies/README.md")).unwrap(),
|
|
readme_content
|
|
);
|
|
assert_eq!(
|
|
fs::read_to_string(dir.path().join(".huskies/project.toml")).unwrap(),
|
|
toml_content
|
|
);
|
|
|
|
let story_kit_gitignore =
|
|
fs::read_to_string(dir.path().join(".huskies/.gitignore")).unwrap();
|
|
let count = story_kit_gitignore
|
|
.lines()
|
|
.filter(|l| l.trim() == "worktrees/")
|
|
.count();
|
|
assert_eq!(
|
|
count, 1,
|
|
".huskies/.gitignore should not have duplicate entries"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn scaffold_story_kit_existing_git_repo_no_commit() {
|
|
let dir = tempdir().unwrap();
|
|
|
|
// Initialize a git repo before scaffold
|
|
std::process::Command::new("git")
|
|
.args(["init"])
|
|
.current_dir(dir.path())
|
|
.status()
|
|
.unwrap();
|
|
std::process::Command::new("git")
|
|
.args([
|
|
"-c",
|
|
"user.email=test@test.com",
|
|
"-c",
|
|
"user.name=Test",
|
|
"commit",
|
|
"--allow-empty",
|
|
"-m",
|
|
"pre-scaffold",
|
|
])
|
|
.current_dir(dir.path())
|
|
.status()
|
|
.unwrap();
|
|
|
|
scaffold_story_kit(dir.path(), 3001).unwrap();
|
|
|
|
// Only 1 commit should exist — scaffold must not commit into an existing repo
|
|
let log_output = std::process::Command::new("git")
|
|
.args(["log", "--oneline"])
|
|
.current_dir(dir.path())
|
|
.output()
|
|
.unwrap();
|
|
let log = String::from_utf8_lossy(&log_output.stdout);
|
|
let commit_count = log.lines().count();
|
|
assert_eq!(
|
|
commit_count, 1,
|
|
"scaffold should not create a commit in an existing git repo"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn scaffold_creates_story_kit_gitignore_with_relative_entries() {
|
|
let dir = tempdir().unwrap();
|
|
scaffold_story_kit(dir.path(), 3001).unwrap();
|
|
|
|
// .huskies/.gitignore must contain relative patterns for files under .huskies/
|
|
let sk_content = fs::read_to_string(dir.path().join(".huskies/.gitignore")).unwrap();
|
|
assert!(sk_content.contains("worktrees/"));
|
|
assert!(sk_content.contains("merge_workspace/"));
|
|
assert!(sk_content.contains("coverage/"));
|
|
assert!(sk_content.contains("matrix_history.json"));
|
|
assert!(sk_content.contains("timers.json"));
|
|
// Must NOT contain absolute .huskies/ prefixed paths
|
|
assert!(!sk_content.contains(".huskies/"));
|
|
|
|
// Root .gitignore must contain root-level huskies entries
|
|
let root_content = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
|
|
assert!(root_content.contains(".huskies_port"));
|
|
// store.json now lives inside .huskies/ and must NOT appear in root .gitignore
|
|
assert!(!root_content.contains("store.json"));
|
|
// Root .gitignore must NOT contain .huskies/ sub-directory patterns
|
|
assert!(!root_content.contains(".huskies/worktrees/"));
|
|
assert!(!root_content.contains(".huskies/merge_workspace/"));
|
|
assert!(!root_content.contains(".huskies/coverage/"));
|
|
// store.json must be in .huskies/.gitignore instead
|
|
assert!(sk_content.contains("store.json"));
|
|
// Database files must be ignored so novice users don't accidentally commit them
|
|
assert!(sk_content.contains("pipeline.db"));
|
|
assert!(sk_content.contains("*.db"));
|
|
}
|
|
|
|
#[test]
|
|
fn scaffold_story_kit_gitignore_does_not_duplicate_existing_entries() {
|
|
let dir = tempdir().unwrap();
|
|
// Pre-create .huskies dir and .gitignore with some entries already present
|
|
fs::create_dir_all(dir.path().join(".huskies")).unwrap();
|
|
fs::write(
|
|
dir.path().join(".huskies/.gitignore"),
|
|
"worktrees/\ncoverage/\n",
|
|
)
|
|
.unwrap();
|
|
|
|
scaffold_story_kit(dir.path(), 3001).unwrap();
|
|
|
|
let content = fs::read_to_string(dir.path().join(".huskies/.gitignore")).unwrap();
|
|
let worktrees_count = content.lines().filter(|l| l.trim() == "worktrees/").count();
|
|
assert_eq!(worktrees_count, 1, "worktrees/ should not be duplicated");
|
|
let coverage_count = content.lines().filter(|l| l.trim() == "coverage/").count();
|
|
assert_eq!(coverage_count, 1, "coverage/ should not be duplicated");
|
|
// The missing entry must have been added
|
|
assert!(content.contains("merge_workspace/"));
|
|
}
|
|
|
|
#[test]
|
|
fn scaffold_creates_claude_md_at_project_root() {
|
|
let dir = tempdir().unwrap();
|
|
scaffold_story_kit(dir.path(), 3001).unwrap();
|
|
|
|
let claude_md = dir.path().join("CLAUDE.md");
|
|
assert!(
|
|
claude_md.exists(),
|
|
"CLAUDE.md should be created at project root"
|
|
);
|
|
|
|
let content = fs::read_to_string(&claude_md).unwrap();
|
|
assert!(
|
|
content.contains("<!-- huskies:scaffold-template -->"),
|
|
"CLAUDE.md should contain the scaffold sentinel"
|
|
);
|
|
assert!(
|
|
content.contains("Read .huskies/README.md"),
|
|
"CLAUDE.md should include directive to read .huskies/README.md"
|
|
);
|
|
assert!(
|
|
content.contains("Never chain shell commands"),
|
|
"CLAUDE.md should include command chaining rule"
|
|
);
|
|
assert!(
|
|
content.contains("wizard_status"),
|
|
"CLAUDE.md should instruct Claude to call wizard_status on first conversation"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn scaffold_does_not_overwrite_existing_claude_md() {
|
|
let dir = tempdir().unwrap();
|
|
let claude_md = dir.path().join("CLAUDE.md");
|
|
fs::write(&claude_md, "custom CLAUDE.md content").unwrap();
|
|
|
|
scaffold_story_kit(dir.path(), 3001).unwrap();
|
|
|
|
assert_eq!(
|
|
fs::read_to_string(&claude_md).unwrap(),
|
|
"custom CLAUDE.md content",
|
|
"scaffold should not overwrite an existing CLAUDE.md"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn scaffold_story_kit_writes_mcp_json_with_port() {
|
|
let dir = tempdir().unwrap();
|
|
scaffold_story_kit(dir.path(), 4242).unwrap();
|
|
|
|
let mcp_path = dir.path().join(".mcp.json");
|
|
assert!(mcp_path.exists(), ".mcp.json should be created by scaffold");
|
|
let content = fs::read_to_string(&mcp_path).unwrap();
|
|
assert!(
|
|
content.contains("4242"),
|
|
".mcp.json should reference the given port"
|
|
);
|
|
assert!(
|
|
content.contains("localhost"),
|
|
".mcp.json should reference localhost"
|
|
);
|
|
assert!(
|
|
content.contains("huskies"),
|
|
".mcp.json should name the huskies server"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn scaffold_story_kit_does_not_overwrite_existing_mcp_json() {
|
|
let dir = tempdir().unwrap();
|
|
let mcp_path = dir.path().join(".mcp.json");
|
|
fs::write(&mcp_path, "{\"custom\": true}").unwrap();
|
|
|
|
scaffold_story_kit(dir.path(), 3001).unwrap();
|
|
|
|
assert_eq!(
|
|
fs::read_to_string(&mcp_path).unwrap(),
|
|
"{\"custom\": true}",
|
|
"scaffold should not overwrite an existing .mcp.json"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn scaffold_gitignore_includes_mcp_json() {
|
|
let dir = tempdir().unwrap();
|
|
scaffold_story_kit(dir.path(), 3001).unwrap();
|
|
|
|
let root_gitignore = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
|
|
assert!(
|
|
root_gitignore.contains(".mcp.json"),
|
|
"root .gitignore should include .mcp.json (port is environment-specific)"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn scaffold_script_test_contains_detected_commands_for_rust() {
|
|
let dir = tempdir().unwrap();
|
|
fs::write(
|
|
dir.path().join("Cargo.toml"),
|
|
"[package]\nname = \"myapp\"\n",
|
|
)
|
|
.unwrap();
|
|
|
|
scaffold_story_kit(dir.path(), 3001).unwrap();
|
|
|
|
let content = fs::read_to_string(dir.path().join("script/test")).unwrap();
|
|
assert!(
|
|
content.contains("cargo test"),
|
|
"Rust project scaffold should set cargo test in script/test"
|
|
);
|
|
assert!(
|
|
!content.contains("No tests configured"),
|
|
"should not use stub when stack is detected"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn scaffold_script_test_fallback_stub_when_no_stack() {
|
|
let dir = tempdir().unwrap();
|
|
scaffold_story_kit(dir.path(), 3001).unwrap();
|
|
|
|
let content = fs::read_to_string(dir.path().join("script/test")).unwrap();
|
|
assert!(
|
|
content.contains("No tests configured"),
|
|
"unknown stack should use the generic stub"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn scaffold_story_kit_creates_script_build_and_lint() {
|
|
let dir = tempdir().unwrap();
|
|
scaffold_story_kit(dir.path(), 3001).unwrap();
|
|
|
|
assert!(
|
|
dir.path().join("script/build").exists(),
|
|
"script/build should be created by scaffold"
|
|
);
|
|
assert!(
|
|
dir.path().join("script/lint").exists(),
|
|
"script/lint should be created by scaffold"
|
|
);
|
|
}
|
|
|
|
#[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"
|
|
);
|
|
}
|
|
}
|