Files
huskies/server/src/http/mcp/wizard_tools.rs
T

442 lines
17 KiB
Rust

//! MCP tool implementations for the interactive setup wizard.
//!
//! These tools allow Claude Code (and other MCP clients) to drive the setup
//! wizard entirely from the terminal without requiring the web UI or chat bot.
//!
//! Typical flow:
//! 1. `wizard_status` — inspect current state
//! 2. `wizard_generate` — read the codebase and call again with `content` to
//! stage generated text for review
//! 3. `wizard_confirm` — write staged content to disk and advance the wizard
//! 4. `wizard_skip` — skip a step that does not apply
//! 5. `wizard_retry` — discard staged content and regenerate from scratch
use crate::http::context::AppContext;
use crate::io::wizard::WizardStep;
use crate::service::wizard::state_machine;
use crate::service::wizard::{self as svc};
use serde_json::Value;
use std::path::Path;
// ── Thin adapters (kept for callers in chat/commands/setup.rs) ────────────────
/// Return the filesystem path for a step's output file.
///
/// Pure path concatenation — delegates to `service::wizard::state_machine`.
pub(crate) fn step_output_path(
project_root: &Path,
step: WizardStep,
) -> Option<std::path::PathBuf> {
state_machine::step_output_path(project_root, step)
}
/// Return true when `step` produces an executable script file.
pub(crate) fn is_script_step(step: WizardStep) -> bool {
state_machine::is_script_step(step)
}
/// Write `content` to `path`, skipping if the file already has real content.
///
/// Delegates to `service::wizard::write_step_file`.
pub(crate) fn write_if_missing(
path: &Path,
content: &str,
executable: bool,
) -> Result<bool, String> {
svc::write_step_file(path, content, executable).map_err(|e| e.to_string())
}
/// Return true when the project directory has no meaningful source files.
///
/// Delegates to `service::wizard::state_machine::is_bare_project` after
/// reading directory entries via `service::wizard::io`.
#[cfg(test)]
fn is_bare_project(project_root: &Path) -> bool {
use crate::service::wizard::io as wio;
let names = wio::list_dir_names(project_root);
state_machine::is_bare_project(&names)
}
/// Return a generation hint for `step` based on the project at `project_root`.
///
/// Reads filesystem state then delegates pure logic to `state_machine`.
pub(crate) fn generation_hint(step: WizardStep, project_root: &Path) -> String {
use crate::service::wizard::io as wio;
let names = wio::list_dir_names(project_root);
let tools = wio::detect_project_tools(project_root);
let is_bare = state_machine::is_bare_project(&names);
state_machine::generation_hint(step, is_bare, &tools)
}
// ── MCP tool handlers ─────────────────────────────────────────────────────────
/// `wizard_status` — return current wizard state as a human-readable summary.
pub(super) fn tool_wizard_status(ctx: &AppContext) -> Result<String, String> {
let root = ctx.state.get_project_root()?;
svc::status(&root).map_err(|e| e.to_string())
}
/// `wizard_generate` — mark the current step as generating or stage content.
///
/// Call with no `content` argument to mark the step as `Generating` and
/// receive a hint describing what to generate. Call again with a `content`
/// argument (the generated file body) to stage it for review; the step will
/// transition to `AwaitingConfirmation`. Content is **not** written to disk
/// until `wizard_confirm` is called.
pub(super) fn tool_wizard_generate(args: &Value, ctx: &AppContext) -> Result<String, String> {
let root = ctx.state.get_project_root()?;
let content = args.get("content").and_then(|v| v.as_str());
svc::generate(&root, content).map_err(|e| e.to_string())
}
/// `wizard_confirm` — confirm the current step and write its content to disk.
///
/// If the step has staged content, the content is written to its target file
/// (only if that file does not already exist — existing files are never
/// overwritten). The step is then marked as `Confirmed` and the wizard
/// advances to the next pending step.
pub(super) fn tool_wizard_confirm(ctx: &AppContext) -> Result<String, String> {
let root = ctx.state.get_project_root()?;
svc::confirm(&root).map_err(|e| e.to_string())
}
/// `wizard_skip` — skip the current step without writing any file.
pub(super) fn tool_wizard_skip(ctx: &AppContext) -> Result<String, String> {
let root = ctx.state.get_project_root()?;
svc::skip(&root).map_err(|e| e.to_string())
}
/// `wizard_retry` — discard staged content and reset the current step to
/// `Pending` so it can be regenerated.
pub(super) fn tool_wizard_retry(ctx: &AppContext) -> Result<String, String> {
let root = ctx.state.get_project_root()?;
svc::retry(&root).map_err(|e| e.to_string())
}
// ── tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use crate::http::context::AppContext;
use crate::io::wizard::{StepStatus, WizardState, format_wizard_state};
use tempfile::TempDir;
fn setup(dir: &TempDir) -> AppContext {
let root = dir.path().to_path_buf();
std::fs::create_dir_all(root.join(".huskies")).unwrap();
WizardState::init_if_missing(&root);
AppContext::new_test(root)
}
#[test]
fn wizard_status_returns_state() {
let dir = TempDir::new().unwrap();
let ctx = setup(&dir);
let result = tool_wizard_status(&ctx).unwrap();
assert!(result.contains("Setup wizard"));
assert!(result.contains("context"));
}
#[test]
fn wizard_status_no_wizard_returns_error() {
let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join(".huskies")).unwrap();
let ctx = AppContext::new_test(dir.path().to_path_buf());
assert!(tool_wizard_status(&ctx).is_err());
}
#[test]
fn wizard_generate_marks_generating() {
let dir = TempDir::new().unwrap();
let ctx = setup(&dir);
let result = tool_wizard_generate(&serde_json::json!({}), &ctx).unwrap();
assert!(result.contains("generating"));
let state = WizardState::load(dir.path()).unwrap();
assert_eq!(state.steps[1].status, StepStatus::Generating);
}
#[test]
fn wizard_generate_with_content_stages_content() {
let dir = TempDir::new().unwrap();
let ctx = setup(&dir);
let result =
tool_wizard_generate(&serde_json::json!({"content": "# My Project"}), &ctx).unwrap();
assert!(result.contains("staged"));
let state = WizardState::load(dir.path()).unwrap();
assert_eq!(state.steps[1].status, StepStatus::AwaitingConfirmation);
assert_eq!(state.steps[1].content.as_deref(), Some("# My Project"));
}
#[test]
fn wizard_confirm_writes_file_and_advances() {
let dir = TempDir::new().unwrap();
let ctx = setup(&dir);
// Stage content for Context step.
tool_wizard_generate(&serde_json::json!({"content": "# Context content"}), &ctx).unwrap();
let result = tool_wizard_confirm(&ctx).unwrap();
assert!(result.contains("confirmed"));
// File should now exist.
let context_path = dir
.path()
.join(".huskies")
.join("specs")
.join("00_CONTEXT.md");
assert!(context_path.exists());
assert_eq!(
std::fs::read_to_string(&context_path).unwrap(),
"# Context content"
);
// Wizard should have advanced.
let state = WizardState::load(dir.path()).unwrap();
assert_eq!(state.steps[1].status, StepStatus::Confirmed);
assert_eq!(state.current_step_index(), 2);
}
#[test]
fn wizard_confirm_does_not_overwrite_existing_file() {
let dir = TempDir::new().unwrap();
let ctx = setup(&dir);
// Pre-create the specs directory and file with real (non-template) content.
let specs_dir = dir.path().join(".huskies").join("specs");
std::fs::create_dir_all(&specs_dir).unwrap();
let context_path = specs_dir.join("00_CONTEXT.md");
std::fs::write(&context_path, "original content").unwrap();
// Stage and confirm — existing real file should NOT be overwritten.
tool_wizard_generate(&serde_json::json!({"content": "new content"}), &ctx).unwrap();
let result = tool_wizard_confirm(&ctx).unwrap();
assert!(result.contains("already exists"));
assert_eq!(
std::fs::read_to_string(&context_path).unwrap(),
"original content"
);
}
#[test]
fn wizard_confirm_overwrites_scaffold_template_file() {
let dir = TempDir::new().unwrap();
let ctx = setup(&dir);
// Pre-create the file with scaffold template placeholder content.
let specs_dir = dir.path().join(".huskies").join("specs");
std::fs::create_dir_all(&specs_dir).unwrap();
let context_path = specs_dir.join("00_CONTEXT.md");
std::fs::write(
&context_path,
"<!-- huskies:scaffold-template -->\n# Project Context\n\nTODO: Describe...",
)
.unwrap();
// Stage and confirm — template placeholder should be overwritten with generated content.
tool_wizard_generate(
&serde_json::json!({"content": "# My Real Project\n\nThis is a real project."}),
&ctx,
)
.unwrap();
let result = tool_wizard_confirm(&ctx).unwrap();
assert!(result.contains("confirmed"));
assert_eq!(
std::fs::read_to_string(&context_path).unwrap(),
"# My Real Project\n\nThis is a real project."
);
}
#[test]
fn wizard_skip_advances_wizard() {
let dir = TempDir::new().unwrap();
let ctx = setup(&dir);
let result = tool_wizard_skip(&ctx).unwrap();
assert!(result.contains("skipped"));
let state = WizardState::load(dir.path()).unwrap();
assert_eq!(state.steps[1].status, StepStatus::Skipped);
assert_eq!(state.current_step_index(), 2);
}
#[test]
fn wizard_retry_resets_to_pending() {
let dir = TempDir::new().unwrap();
let ctx = setup(&dir);
// Stage content first.
tool_wizard_generate(&serde_json::json!({"content": "some content"}), &ctx).unwrap();
let result = tool_wizard_retry(&ctx).unwrap();
assert!(result.contains("reset"));
let state = WizardState::load(dir.path()).unwrap();
assert_eq!(state.steps[1].status, StepStatus::Pending);
assert!(state.steps[1].content.is_none());
}
#[test]
fn wizard_complete_returns_done_message() {
let dir = TempDir::new().unwrap();
let ctx = setup(&dir);
// Skip all remaining steps (scaffold is pre-confirmed, so 7 remaining).
for _ in 0..7 {
tool_wizard_skip(&ctx).unwrap();
}
let result = tool_wizard_status(&ctx).unwrap();
assert!(result.contains("complete"));
}
#[test]
fn format_wizard_state_shows_all_steps() {
let mut state = WizardState::default();
state.steps[0].status = StepStatus::Confirmed;
let output = format_wizard_state(&state);
assert!(output.contains(""));
assert!(output.contains("Scaffold"));
assert!(output.contains("← current"));
}
#[test]
fn is_bare_project_detects_empty_dir() {
let dir = TempDir::new().unwrap();
assert!(is_bare_project(dir.path()));
}
#[test]
fn is_bare_project_detects_scaffold_only_dir() {
let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join(".huskies")).unwrap();
std::fs::write(dir.path().join("CLAUDE.md"), "# Claude").unwrap();
std::fs::write(dir.path().join("README.md"), "# Readme").unwrap();
std::fs::create_dir_all(dir.path().join("script")).unwrap();
assert!(is_bare_project(dir.path()));
}
#[test]
fn is_bare_project_false_when_source_files_exist() {
let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join(".huskies")).unwrap();
std::fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap();
assert!(!is_bare_project(dir.path()));
}
#[test]
fn is_bare_project_false_with_src_directory() {
let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join("src")).unwrap();
assert!(!is_bare_project(dir.path()));
}
#[test]
fn generation_hint_bare_context_asks_user() {
let dir = TempDir::new().unwrap();
// Bare project — only scaffolding
std::fs::create_dir_all(dir.path().join(".huskies")).unwrap();
let hint = generation_hint(WizardStep::Context, dir.path());
assert!(hint.contains("bare project"));
assert!(hint.contains("Ask the user"));
}
#[test]
fn generation_hint_bare_stack_asks_user() {
let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join(".huskies")).unwrap();
let hint = generation_hint(WizardStep::Stack, dir.path());
assert!(hint.contains("bare project"));
assert!(hint.contains("Ask the user"));
}
#[test]
fn generation_hint_bare_test_script_references_stack() {
let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join(".huskies")).unwrap();
let hint = generation_hint(WizardStep::TestScript, dir.path());
assert!(hint.contains("bare project"));
assert!(hint.contains("STACK.md"));
}
#[test]
fn generation_hint_bare_release_script_references_stack() {
let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join(".huskies")).unwrap();
let hint = generation_hint(WizardStep::ReleaseScript, dir.path());
assert!(hint.contains("bare project"));
assert!(hint.contains("STACK.md"));
}
#[test]
fn generation_hint_bare_test_coverage_references_stack() {
let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join(".huskies")).unwrap();
let hint = generation_hint(WizardStep::TestCoverage, dir.path());
assert!(hint.contains("bare project"));
assert!(hint.contains("STACK.md"));
}
#[test]
fn generation_hint_existing_project_reads_code() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap();
let hint = generation_hint(WizardStep::Context, dir.path());
assert!(hint.contains("Read the project"));
assert!(!hint.contains("bare project"));
}
#[test]
fn generation_hint_existing_project_test_script_detects_cargo() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap();
let hint = generation_hint(WizardStep::TestScript, dir.path());
assert!(hint.contains("cargo nextest"));
assert!(!hint.contains("bare project"));
}
#[test]
fn generation_hint_bare_build_script_references_stack() {
let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join(".huskies")).unwrap();
let hint = generation_hint(WizardStep::BuildScript, dir.path());
assert!(hint.contains("bare project"));
assert!(hint.contains("STACK.md"));
}
#[test]
fn generation_hint_bare_lint_script_references_stack() {
let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join(".huskies")).unwrap();
let hint = generation_hint(WizardStep::LintScript, dir.path());
assert!(hint.contains("bare project"));
assert!(hint.contains("STACK.md"));
}
#[test]
fn generation_hint_existing_project_build_script_detects_cargo() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap();
let hint = generation_hint(WizardStep::BuildScript, dir.path());
assert!(hint.contains("cargo build --release"));
assert!(!hint.contains("bare project"));
}
#[test]
fn generation_hint_existing_project_lint_script_detects_cargo() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap();
let hint = generation_hint(WizardStep::LintScript, dir.path());
assert!(hint.contains("cargo fmt --all --check"));
assert!(hint.contains("cargo clippy -- -D warnings"));
assert!(!hint.contains("bare project"));
}
#[test]
fn step_output_path_build_script_returns_script_build() {
let dir = TempDir::new().unwrap();
let path = step_output_path(dir.path(), WizardStep::BuildScript).unwrap();
assert!(path.ends_with("script/build"));
}
#[test]
fn step_output_path_lint_script_returns_script_lint() {
let dir = TempDir::new().unwrap();
let path = step_output_path(dir.path(), WizardStep::LintScript).unwrap();
assert!(path.ends_with("script/lint"));
}
#[test]
fn is_script_step_includes_build_and_lint() {
assert!(is_script_step(WizardStep::BuildScript));
assert!(is_script_step(WizardStep::LintScript));
}
}