//! 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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, "\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)); } }