diff --git a/server/src/http/mcp/wizard_tools.rs b/server/src/http/mcp/wizard_tools.rs index d11015e2..69fbc36a 100644 --- a/server/src/http/mcp/wizard_tools.rs +++ b/server/src/http/mcp/wizard_tools.rs @@ -12,106 +12,60 @@ //! 5. `wizard_retry` — discard staged content and regenerate from scratch use crate::http::context::AppContext; -use crate::io::wizard::{StepStatus, WizardState, WizardStep, format_wizard_state}; +use crate::io::wizard::WizardStep; +use crate::service::wizard::state_machine; +use crate::service::wizard::{self as svc}; use serde_json::Value; -use std::fs; use std::path::Path; -// ── helpers ─────────────────────────────────────────────────────────────────── +// ── Thin adapters (kept for callers in chat/commands/setup.rs) ──────────────── -/// Return the filesystem path (relative to `project_root`) for a step's output. +/// Return the filesystem path for a step's output file. /// -/// Returns `None` for `Scaffold` since that step has no single output file — it -/// creates the full `.huskies/` directory structure and is handled by -/// `huskies init` before the server starts. +/// Pure path concatenation — delegates to `service::wizard::state_machine`. pub(crate) fn step_output_path( project_root: &Path, step: WizardStep, ) -> Option { - match step { - WizardStep::Context => Some( - project_root - .join(".huskies") - .join("specs") - .join("00_CONTEXT.md"), - ), - WizardStep::Stack => Some( - project_root - .join(".huskies") - .join("specs") - .join("tech") - .join("STACK.md"), - ), - WizardStep::TestScript => Some(project_root.join("script").join("test")), - WizardStep::BuildScript => Some(project_root.join("script").join("build")), - WizardStep::LintScript => Some(project_root.join("script").join("lint")), - WizardStep::ReleaseScript => Some(project_root.join("script").join("release")), - WizardStep::TestCoverage => Some(project_root.join("script").join("test_coverage")), - WizardStep::Scaffold => None, - } + 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 { - matches!( - step, - WizardStep::TestScript - | WizardStep::BuildScript - | WizardStep::LintScript - | WizardStep::ReleaseScript - | WizardStep::TestCoverage - ) + state_machine::is_script_step(step) } -/// Write `content` to `path`, skipping if the file already exists with real -/// (non-template) content. +/// Write `content` to `path`, skipping if the file already has real content. /// -/// Scaffold template files (those containing [`TEMPLATE_SENTINEL`]) are treated -/// as placeholders and will be overwritten with the wizard-generated content. -/// Files with real user content are never overwritten. For script steps the -/// file is also made executable after writing. +/// Delegates to `service::wizard::write_step_file`. pub(crate) fn write_if_missing( path: &Path, content: &str, executable: bool, ) -> Result { - use crate::io::onboarding::TEMPLATE_SENTINEL; - if path.exists() { - // Overwrite scaffold template placeholders; preserve real user content. - let is_template = std::fs::read_to_string(path) - .map(|s| s.contains(TEMPLATE_SENTINEL)) - .unwrap_or(false); - if !is_template { - return Ok(false); // real content already present — skip - } - } - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|e| format!("Failed to create directory {}: {e}", parent.display()))?; - } - fs::write(path, content).map_err(|e| format!("Failed to write {}: {e}", path.display()))?; - - if executable { - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut perms = fs::metadata(path) - .map_err(|e| format!("Failed to read permissions: {e}"))? - .permissions(); - perms.set_mode(0o755); - fs::set_permissions(path, perms) - .map_err(|e| format!("Failed to set permissions: {e}"))?; - } - } - - Ok(true) + svc::write_step_file(path, content, executable).map_err(|e| e.to_string()) } -/// Serialise a `WizardStep` to its snake_case string (e.g. `"test_script"`). -fn step_slug(step: WizardStep) -> String { - serde_json::to_value(step) - .ok() - .and_then(|v| v.as_str().map(String::from)) - .unwrap_or_default() +/// 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 ───────────────────────────────────────────────────────── @@ -119,9 +73,7 @@ fn step_slug(step: WizardStep) -> String { /// `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()?; - let state = - WizardState::load(&root).ok_or("No wizard active. Run `huskies init` to begin setup.")?; - Ok(format_wizard_state(&state)) + svc::status(&root).map_err(|e| e.to_string()) } /// `wizard_generate` — mark the current step as generating or stage content. @@ -133,245 +85,8 @@ pub(super) fn tool_wizard_status(ctx: &AppContext) -> Result { /// until `wizard_confirm` is called. pub(super) fn tool_wizard_generate(args: &Value, ctx: &AppContext) -> Result { let root = ctx.state.get_project_root()?; - let mut state = WizardState::load(&root).ok_or("No wizard active.")?; - - if state.completed { - return Ok("Wizard is already complete.".to_string()); - } - - let current_idx = state.current_step_index(); - let step = state.steps[current_idx].step; - - // If content is provided, stage it for confirmation. - if let Some(content) = args.get("content").and_then(|v| v.as_str()) { - state.set_step_status( - step, - StepStatus::AwaitingConfirmation, - Some(content.to_string()), - ); - state - .save(&root) - .map_err(|e| format!("Failed to save wizard state: {e}"))?; - return Ok(format!( - "Content staged for '{}'. Run `wizard_confirm` to write it to disk, `wizard_retry` to regenerate, or `wizard_skip` to skip.", - step.label() - )); - } - - // No content provided — mark as generating and return a hint. - state.set_step_status(step, StepStatus::Generating, None); - state - .save(&root) - .map_err(|e| format!("Failed to save wizard state: {e}"))?; - - let hint = generation_hint(step, &root); - let slug = step_slug(step); - - Ok(format!( - "Step '{}' marked as generating.\n\n{hint}\n\nOnce you have the content, call `wizard_generate` again with a `content` argument (or PUT /wizard/step/{slug}/content). Then call `wizard_confirm` to write it to disk.", - step.label(), - )) -} - -/// Return true if the project directory has no meaningful source files. -pub(crate) fn is_bare_project(project_root: &Path) -> bool { - std::fs::read_dir(project_root) - .ok() - .map(|entries| { - let names: Vec = entries - .filter_map(|e| e.ok()) - .map(|e| e.file_name().to_string_lossy().to_string()) - .collect(); - // A bare project only has huskies scaffolding and no real code - names.iter().all(|n| { - n.starts_with('.') - || n == "CLAUDE.md" - || n == "LICENSE" - || n == "README.md" - || n == "script" - }) - }) - .unwrap_or(true) -} - -/// Return a generation hint for a step based on the project root. -pub(crate) fn generation_hint(step: WizardStep, project_root: &Path) -> String { - let bare = is_bare_project(project_root); - - match step { - WizardStep::Context => { - if bare { - "This is a bare project with no existing code. Ask the user what they want \ - to build — the project's purpose, goals, target users, and key features. \ - Then generate `.huskies/specs/00_CONTEXT.md` from their answers covering:\n\ - - High-level goal of the project\n\ - - Core features\n\ - - Domain concepts and entities\n\ - - Glossary of abbreviations and technical terms" - .to_string() - } else { - "Read the project source tree and generate a `.huskies/specs/00_CONTEXT.md` describing:\n\ - - High-level goal of the project\n\ - - Core features\n\ - - Domain concepts and entities\n\ - - Glossary of abbreviations and technical terms".to_string() - } - } - WizardStep::Stack => { - if bare { - "This is a bare project with no existing code. Ask the user what language, \ - frameworks, and tools they plan to use. Then generate `.huskies/specs/tech/STACK.md` \ - from their answers covering:\n\ - - Language, frameworks, and runtimes\n\ - - Coding standards and linting rules\n\ - - Quality gates (commands that must pass before merging)\n\ - - Approved libraries and their purpose".to_string() - } else { - "Read the project source tree and generate a `.huskies/specs/tech/STACK.md` describing:\n\ - - Language, frameworks, and runtimes\n\ - - Coding standards and linting rules\n\ - - Quality gates (commands that must pass before merging)\n\ - - Approved libraries and their purpose".to_string() - } - } - WizardStep::TestScript => { - if bare { - "This is a bare project with no existing code. Read the STACK.md generated \ - in the previous step (or ask the user about their stack if it was skipped) \ - and generate a `script/test` shell script (#!/usr/bin/env bash, set -euo pipefail) \ - with appropriate test commands for their chosen language and framework." - .to_string() - } else { - let has_cargo = project_root.join("Cargo.toml").exists(); - let has_pkg = project_root.join("package.json").exists(); - let has_pnpm = project_root.join("pnpm-lock.yaml").exists(); - let mut cmds = Vec::new(); - if has_cargo { - cmds.push("cargo nextest run"); - } - if has_pkg { - cmds.push(if has_pnpm { "pnpm test" } else { "npm test" }); - } - if cmds.is_empty() { - "Generate a `script/test` shell script (#!/usr/bin/env bash, set -euo pipefail) that runs the project's test suite.".to_string() - } else { - format!( - "Generate a `script/test` shell script (#!/usr/bin/env bash, set -euo pipefail) that runs: {}", - cmds.join(", ") - ) - } - } - } - WizardStep::BuildScript => { - if bare { - "This is a bare project with no existing code. Read the STACK.md generated \ - in the previous step (or ask the user about their stack if it was skipped) \ - and generate a `script/build` shell script (#!/usr/bin/env bash, set -euo pipefail) \ - with appropriate build commands for their chosen language and framework." - .to_string() - } else { - let has_cargo = project_root.join("Cargo.toml").exists(); - let has_pkg = project_root.join("package.json").exists(); - let has_pnpm = project_root.join("pnpm-lock.yaml").exists(); - let has_frontend_subdir = - project_root.join("frontend").join("package.json").exists() - || project_root.join("client").join("package.json").exists(); - let has_go = project_root.join("go.mod").exists(); - let mut cmds = Vec::new(); - if has_cargo { - cmds.push("cargo build --release"); - } - if has_pkg { - cmds.push(if has_pnpm { - "pnpm run build" - } else { - "npm run build" - }); - } - if has_frontend_subdir { - cmds.push("(cd frontend && npm run build)"); - } - if has_go { - cmds.push("go build ./..."); - } - if cmds.is_empty() { - "Generate a `script/build` shell script (#!/usr/bin/env bash, set -euo pipefail) that builds the project.".to_string() - } else { - format!( - "Generate a `script/build` shell script (#!/usr/bin/env bash, set -euo pipefail) that runs: {}", - cmds.join(", ") - ) - } - } - } - WizardStep::LintScript => { - if bare { - "This is a bare project with no existing code. Read the STACK.md generated \ - in the previous step (or ask the user about their stack if it was skipped) \ - and generate a `script/lint` shell script (#!/usr/bin/env bash, set -euo pipefail) \ - with appropriate lint commands for their chosen language and framework." - .to_string() - } else { - let has_cargo = project_root.join("Cargo.toml").exists(); - let has_pkg = project_root.join("package.json").exists(); - let has_pnpm = project_root.join("pnpm-lock.yaml").exists(); - let has_python = project_root.join("pyproject.toml").exists() - || project_root.join("requirements.txt").exists(); - let has_go = project_root.join("go.mod").exists(); - let mut cmds = Vec::new(); - if has_cargo { - cmds.push("cargo fmt --all --check"); - cmds.push("cargo clippy -- -D warnings"); - } - if has_pkg { - cmds.push(if has_pnpm { - "pnpm run lint" - } else { - "npm run lint" - }); - } - if has_python { - cmds.push("flake8 . (or ruff check . if ruff is configured)"); - } - if has_go { - cmds.push("go vet ./..."); - } - if cmds.is_empty() { - "Generate a `script/lint` shell script (#!/usr/bin/env bash, set -euo pipefail) that runs the project's linters.".to_string() - } else { - format!( - "Generate a `script/lint` shell script (#!/usr/bin/env bash, set -euo pipefail) that runs: {}", - cmds.join(", ") - ) - } - } - } - WizardStep::ReleaseScript => { - if bare { - "This is a bare project with no existing code. Read the STACK.md generated \ - in the previous step (or ask the user about their stack if it was skipped) \ - and generate a `script/release` shell script (#!/usr/bin/env bash, set -euo pipefail) \ - with appropriate build/release commands for their chosen language and framework." - .to_string() - } else { - "Generate a `script/release` shell script (#!/usr/bin/env bash, set -euo pipefail) that builds and releases the project (e.g. `cargo build --release` or `npm run build`).".to_string() - } - } - WizardStep::TestCoverage => { - if bare { - "This is a bare project with no existing code. Read the STACK.md generated \ - in the previous step (or ask the user about their stack if it was skipped) \ - and generate a `script/test_coverage` shell script (#!/usr/bin/env bash, set -euo pipefail) \ - with appropriate test coverage commands for their chosen language and framework." - .to_string() - } else { - "Generate a `script/test_coverage` shell script (#!/usr/bin/env bash, set -euo pipefail) that generates a test coverage report (e.g. `cargo llvm-cov nextest` or `npm run coverage`).".to_string() - } - } - WizardStep::Scaffold => { - "Scaffold step is handled automatically by `huskies init`.".to_string() - } - } + 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. @@ -382,111 +97,20 @@ pub(crate) fn generation_hint(step: WizardStep, project_root: &Path) -> String { /// advances to the next pending step. pub(super) fn tool_wizard_confirm(ctx: &AppContext) -> Result { let root = ctx.state.get_project_root()?; - let mut state = WizardState::load(&root).ok_or("No wizard active.")?; - - if state.completed { - return Ok("Wizard is already complete.".to_string()); - } - - let current_idx = state.current_step_index(); - let step = state.steps[current_idx].step; - let content = state.steps[current_idx].content.clone(); - - // Write content to disk (only if a file path exists and the file is absent). - let write_msg = if let (Some(c), Some(ref path)) = (&content, step_output_path(&root, step)) { - let executable = is_script_step(step); - match write_if_missing(path, c, executable)? { - true => format!(" File written: `{}`.", path.display()), - false => format!(" File `{}` already exists — skipped.", path.display()), - } - } else { - String::new() - }; - - state - .confirm_step(step) - .map_err(|e| format!("Cannot confirm step: {e}"))?; - state - .save(&root) - .map_err(|e| format!("Failed to save wizard state: {e}"))?; - - let next_idx = state.current_step_index(); - if state.completed { - Ok(format!( - "Step '{}' confirmed.{write_msg}\n\nSetup wizard complete! All steps done.", - step.label() - )) - } else { - let next = &state.steps[next_idx]; - Ok(format!( - "Step '{}' confirmed.{write_msg}\n\nNext: {} — run `wizard_generate` to begin.", - step.label(), - next.step.label() - )) - } + 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()?; - let mut state = WizardState::load(&root).ok_or("No wizard active.")?; - - if state.completed { - return Ok("Wizard is already complete.".to_string()); - } - - let current_idx = state.current_step_index(); - let step = state.steps[current_idx].step; - - state - .skip_step(step) - .map_err(|e| format!("Cannot skip step: {e}"))?; - state - .save(&root) - .map_err(|e| format!("Failed to save wizard state: {e}"))?; - - let next_idx = state.current_step_index(); - if state.completed { - Ok(format!( - "Step '{}' skipped. Setup wizard complete!", - step.label() - )) - } else { - let next = &state.steps[next_idx]; - Ok(format!( - "Step '{}' skipped.\n\nNext: {} — run `wizard_generate` to begin.", - step.label(), - next.step.label() - )) - } + 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()?; - let mut state = WizardState::load(&root).ok_or("No wizard active.")?; - - if state.completed { - return Ok("Wizard is already complete.".to_string()); - } - - let current_idx = state.current_step_index(); - let step = state.steps[current_idx].step; - - // Clear content and reset to pending. - if let Some(s) = state.steps.iter_mut().find(|s| s.step == step) { - s.status = StepStatus::Pending; - s.content = None; - } - state - .save(&root) - .map_err(|e| format!("Failed to save wizard state: {e}"))?; - - Ok(format!( - "Step '{}' reset to pending. Run `wizard_generate` to regenerate content.", - step.label() - )) + svc::retry(&root).map_err(|e| e.to_string()) } // ── tests ───────────────────────────────────────────────────────────────────── @@ -495,6 +119,7 @@ pub(super) fn tool_wizard_retry(ctx: &AppContext) -> Result { 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 { diff --git a/server/src/http/wizard.rs b/server/src/http/wizard.rs index 405e6cd5..bc573b75 100644 --- a/server/src/http/wizard.rs +++ b/server/src/http/wizard.rs @@ -1,6 +1,7 @@ //! HTTP wizard endpoints — REST API for the project setup wizard. use crate::http::context::{AppContext, OpenApiResult, bad_request, not_found}; -use crate::io::wizard::{StepStatus, WizardState, WizardStep}; +use crate::io::wizard::{WizardState, WizardStep}; +use crate::service::wizard as svc; use poem_openapi::{Object, OpenApi, Tags, param::Path, payload::Json}; use serde::{Deserialize, Serialize}; use std::sync::Arc; @@ -80,8 +81,7 @@ impl WizardApi { #[oai(path = "/wizard", method = "get")] async fn get_wizard_state(&self) -> OpenApiResult> { let root = self.ctx.state.get_project_root().map_err(bad_request)?; - let state = - WizardState::load(&root).ok_or_else(|| not_found("No wizard active".to_string()))?; + let state = svc::get_state(&root).map_err(|_| not_found("No wizard active".to_string()))?; Ok(Json(WizardResponse::from(&state))) } @@ -97,16 +97,8 @@ impl WizardApi { ) -> OpenApiResult> { let root = self.ctx.state.get_project_root().map_err(bad_request)?; let wizard_step = parse_step(&step.0)?; - let mut state = - WizardState::load(&root).ok_or_else(|| not_found("No wizard active".to_string()))?; - - state.set_step_status( - wizard_step, - StepStatus::AwaitingConfirmation, - payload.0.content, - ); - state.save(&root).map_err(bad_request)?; - + let state = svc::set_step_content(&root, wizard_step, payload.0.content) + .map_err(|e| bad_request(e.to_string()))?; Ok(Json(WizardResponse::from(&state))) } @@ -117,12 +109,8 @@ impl WizardApi { async fn confirm_step(&self, step: Path) -> OpenApiResult> { let root = self.ctx.state.get_project_root().map_err(bad_request)?; let wizard_step = parse_step(&step.0)?; - let mut state = - WizardState::load(&root).ok_or_else(|| not_found("No wizard active".to_string()))?; - - state.confirm_step(wizard_step).map_err(bad_request)?; - state.save(&root).map_err(bad_request)?; - + let state = + svc::mark_step_confirmed(&root, wizard_step).map_err(|e| bad_request(e.to_string()))?; Ok(Json(WizardResponse::from(&state))) } @@ -133,12 +121,8 @@ impl WizardApi { async fn skip_step(&self, step: Path) -> OpenApiResult> { let root = self.ctx.state.get_project_root().map_err(bad_request)?; let wizard_step = parse_step(&step.0)?; - let mut state = - WizardState::load(&root).ok_or_else(|| not_found("No wizard active".to_string()))?; - - state.skip_step(wizard_step).map_err(bad_request)?; - state.save(&root).map_err(bad_request)?; - + let state = + svc::mark_step_skipped(&root, wizard_step).map_err(|e| bad_request(e.to_string()))?; Ok(Json(WizardResponse::from(&state))) } @@ -147,12 +131,8 @@ impl WizardApi { async fn mark_generating(&self, step: Path) -> OpenApiResult> { let root = self.ctx.state.get_project_root().map_err(bad_request)?; let wizard_step = parse_step(&step.0)?; - let mut state = - WizardState::load(&root).ok_or_else(|| not_found("No wizard active".to_string()))?; - - state.set_step_status(wizard_step, StepStatus::Generating, None); - state.save(&root).map_err(bad_request)?; - + let state = svc::mark_step_generating(&root, wizard_step) + .map_err(|e| bad_request(e.to_string()))?; Ok(Json(WizardResponse::from(&state))) } } diff --git a/server/src/service/mod.rs b/server/src/service/mod.rs index 606c3838..978559a6 100644 --- a/server/src/service/mod.rs +++ b/server/src/service/mod.rs @@ -13,4 +13,5 @@ pub mod file_io; pub mod health; pub mod oauth; pub mod project; +pub mod wizard; pub mod ws; diff --git a/server/src/service/wizard/io.rs b/server/src/service/wizard/io.rs new file mode 100644 index 00000000..c8089e1e --- /dev/null +++ b/server/src/service/wizard/io.rs @@ -0,0 +1,96 @@ +//! Wizard I/O — the ONLY place in `service/wizard/` that may perform side effects. +//! +//! Side effects here: reading/writing `wizard_state.json`, listing directory +//! entries, detecting build tool manifests, and writing step output files. +//! All business logic and branching belong in `mod.rs` or `state_machine.rs`. + +use super::Error; +use super::state_machine::ProjectTools; +use crate::io::wizard::WizardState; +use std::path::Path; + +/// Load wizard state from disk. Returns `None` if the state file is absent. +pub(super) fn load(root: &Path) -> Option { + WizardState::load(root) +} + +/// Save wizard state to disk. +pub(super) fn save(state: &WizardState, root: &Path) -> Result<(), Error> { + state.save(root).map_err(Error::PersistenceFailure) +} + +/// Return the names of all top-level entries in `root`. +/// +/// Used by `state_machine::is_bare_project` to decide whether the project has +/// existing source code. Returns an empty vec if the directory cannot be read. +/// `pub(crate)` so HTTP adapters can read dir names without duplicating logic. +pub(crate) fn list_dir_names(root: &Path) -> Vec { + std::fs::read_dir(root) + .ok() + .map(|entries| { + entries + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().to_string()) + .collect() + }) + .unwrap_or_default() +} + +/// Probe `root` for build tool manifests and return detection flags. +/// `pub(crate)` so HTTP adapters can call this without duplicating detection. +pub(crate) fn detect_project_tools(root: &Path) -> ProjectTools { + ProjectTools { + has_cargo: root.join("Cargo.toml").exists(), + has_pkg: root.join("package.json").exists(), + has_pnpm: root.join("pnpm-lock.yaml").exists(), + has_frontend_subdir: root.join("frontend").join("package.json").exists() + || root.join("client").join("package.json").exists(), + has_go: root.join("go.mod").exists(), + has_python: root.join("pyproject.toml").exists() || root.join("requirements.txt").exists(), + } +} + +/// Write `content` to `path`, skipping if the file already has real (non-template) content. +/// +/// Scaffold template placeholders (files containing `TEMPLATE_SENTINEL`) are +/// treated as empty and will be overwritten. Returns `Ok(true)` when the file +/// was written, `Ok(false)` when an existing real file was preserved. +/// For script steps, the file is also made executable after writing. +pub(super) fn write_step_file(path: &Path, content: &str, executable: bool) -> Result { + use crate::io::onboarding::TEMPLATE_SENTINEL; + if path.exists() { + let is_template = std::fs::read_to_string(path) + .map(|s| s.contains(TEMPLATE_SENTINEL)) + .unwrap_or(false); + if !is_template { + return Ok(false); + } + } + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| { + Error::PersistenceFailure(format!( + "Failed to create directory {}: {e}", + parent.display() + )) + })?; + } + std::fs::write(path, content).map_err(|e| { + Error::PersistenceFailure(format!("Failed to write {}: {e}", path.display())) + })?; + + if executable { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(path) + .map_err(|e| Error::PersistenceFailure(format!("Failed to read permissions: {e}")))? + .permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(path, perms).map_err(|e| { + Error::PersistenceFailure(format!("Failed to set permissions: {e}")) + })?; + } + } + + Ok(true) +} diff --git a/server/src/service/wizard/mod.rs b/server/src/service/wizard/mod.rs new file mode 100644 index 00000000..f5d4694b --- /dev/null +++ b/server/src/service/wizard/mod.rs @@ -0,0 +1,473 @@ +//! Wizard service — domain logic for the multi-step project setup wizard. +//! +//! Follows the conventions from `docs/architecture/service-modules.md`: +//! - `mod.rs` (this file) — public API, typed [`Error`], and orchestration +//! - `io.rs` — the ONLY place that performs side effects (filesystem reads/writes) +//! - `state_machine.rs` — pure helpers: bare-project detection, generation hints, +//! step classification — all unit-testable without tempdirs or an async runtime + +pub(crate) mod io; +pub mod state_machine; + +use crate::io::wizard::{StepStatus, WizardState, WizardStep, format_wizard_state}; +use std::path::Path; + +// ── Error type ──────────────────────────────────────────────────────────────── + +/// Typed errors returned by `service::wizard` functions. +/// +/// HTTP handlers map these to status codes: +/// - [`Error::NotActive`] → 404 Not Found +/// - [`Error::InvalidStateTransition`] → 400 Bad Request +/// - [`Error::MissingInput`] → 400 Bad Request +/// - [`Error::GenerationFailure`] → 500 Internal Server Error +/// - [`Error::PersistenceFailure`] → 500 Internal Server Error +#[derive(Debug)] +#[allow(dead_code)] +pub enum Error { + /// No wizard is active (`wizard_state.json` not found). + NotActive, + /// The requested state transition is invalid (e.g. wrong step order). + InvalidStateTransition(String), + /// Required input was absent (e.g. content not staged before confirming). + MissingInput(String), + /// The LLM or agent failed to generate content for the step. + GenerationFailure(String), + /// A filesystem read or write operation failed. + PersistenceFailure(String), +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NotActive => { + write!(f, "No wizard active. Run `huskies init` to begin setup.") + } + Self::InvalidStateTransition(msg) => write!(f, "Invalid state transition: {msg}"), + Self::MissingInput(msg) => write!(f, "Missing input: {msg}"), + Self::GenerationFailure(msg) => write!(f, "Generation failed: {msg}"), + Self::PersistenceFailure(msg) => write!(f, "Persistence error: {msg}"), + } + } +} + +// ── Public API — used by HTTP handlers ──────────────────────────────────────── + +/// Load and return the current wizard state. +/// +/// # Errors +/// - [`Error::NotActive`] if `wizard_state.json` does not exist. +pub fn get_state(root: &Path) -> Result { + io::load(root).ok_or(Error::NotActive) +} + +/// Set content for `step` and mark it as awaiting confirmation. +/// +/// Content is staged in `wizard_state.json` but **not** written to disk until +/// [`confirm`] is called. +/// +/// # Errors +/// - [`Error::NotActive`] if no wizard is active. +/// - [`Error::PersistenceFailure`] if saving state fails. +pub fn set_step_content( + root: &Path, + step: WizardStep, + content: Option, +) -> Result { + let mut state = io::load(root).ok_or(Error::NotActive)?; + state.set_step_status(step, StepStatus::AwaitingConfirmation, content); + io::save(&state, root)?; + Ok(state) +} + +/// Mark `step` as confirmed and advance the wizard. +/// +/// Enforces sequential ordering — only the current step may be confirmed. +/// +/// # Errors +/// - [`Error::NotActive`] if no wizard is active. +/// - [`Error::InvalidStateTransition`] if `step` is not the current step. +/// - [`Error::PersistenceFailure`] if saving state fails. +pub fn mark_step_confirmed(root: &Path, step: WizardStep) -> Result { + let mut state = io::load(root).ok_or(Error::NotActive)?; + state + .confirm_step(step) + .map_err(Error::InvalidStateTransition)?; + io::save(&state, root)?; + Ok(state) +} + +/// Mark `step` as skipped and advance the wizard. +/// +/// # Errors +/// - [`Error::NotActive`] if no wizard is active. +/// - [`Error::InvalidStateTransition`] if `step` is not the current step. +/// - [`Error::PersistenceFailure`] if saving state fails. +pub fn mark_step_skipped(root: &Path, step: WizardStep) -> Result { + let mut state = io::load(root).ok_or(Error::NotActive)?; + state + .skip_step(step) + .map_err(Error::InvalidStateTransition)?; + io::save(&state, root)?; + Ok(state) +} + +/// Mark `step` as generating (agent is working on it). +/// +/// # Errors +/// - [`Error::NotActive`] if no wizard is active. +/// - [`Error::PersistenceFailure`] if saving state fails. +pub fn mark_step_generating(root: &Path, step: WizardStep) -> Result { + let mut state = io::load(root).ok_or(Error::NotActive)?; + state.set_step_status(step, StepStatus::Generating, None); + io::save(&state, root)?; + Ok(state) +} + +// ── Public API — used by MCP tool handlers ───────────────────────────────── + +/// Return the current wizard state as a human-readable summary. +/// +/// # Errors +/// - [`Error::NotActive`] if no wizard is active. +pub fn status(root: &Path) -> Result { + let state = io::load(root).ok_or(Error::NotActive)?; + Ok(format_wizard_state(&state)) +} + +/// Drive content generation for the current wizard step. +/// +/// Call with `content = None` to mark the step as `Generating` and receive a +/// generation hint. Call with `content = Some(text)` to stage the content for +/// review (transitions to `AwaitingConfirmation`). Content is not written to +/// disk until [`confirm`] is called. +/// +/// # Errors +/// - [`Error::NotActive`] if no wizard is active. +/// - [`Error::PersistenceFailure`] if saving state fails. +pub fn generate(root: &Path, content: Option<&str>) -> Result { + let mut state = io::load(root).ok_or(Error::NotActive)?; + + if state.completed { + return Ok("Wizard is already complete.".to_string()); + } + + let current_idx = state.current_step_index(); + let step = state.steps[current_idx].step; + + if let Some(c) = content { + state.set_step_status(step, StepStatus::AwaitingConfirmation, Some(c.to_string())); + io::save(&state, root)?; + return Ok(format!( + "Content staged for '{}'. Run `wizard_confirm` to write it to disk, \ + `wizard_retry` to regenerate, or `wizard_skip` to skip.", + step.label() + )); + } + + state.set_step_status(step, StepStatus::Generating, None); + io::save(&state, root)?; + + let names = io::list_dir_names(root); + let tools = io::detect_project_tools(root); + let is_bare = state_machine::is_bare_project(&names); + let hint = state_machine::generation_hint(step, is_bare, &tools); + let slug = state_machine::step_slug(step); + + Ok(format!( + "Step '{}' marked as generating.\n\n{hint}\n\nOnce you have the content, call \ + `wizard_generate` again with a `content` argument (or PUT /wizard/step/{slug}/content). \ + Then call `wizard_confirm` to write it to disk.", + step.label(), + )) +} + +/// Confirm the current step: write staged content to disk, then advance. +/// +/// Content is written only when a target path exists for the step and the file +/// does not already contain real (non-template) content. +/// +/// # Errors +/// - [`Error::NotActive`] if no wizard is active. +/// - [`Error::InvalidStateTransition`] if the state transition fails. +/// - [`Error::PersistenceFailure`] if the file write or state save fails. +pub fn confirm(root: &Path) -> Result { + let mut state = io::load(root).ok_or(Error::NotActive)?; + + if state.completed { + return Ok("Wizard is already complete.".to_string()); + } + + let current_idx = state.current_step_index(); + let step = state.steps[current_idx].step; + let content = state.steps[current_idx].content.clone(); + + let write_msg = if let (Some(c), Some(ref path)) = + (&content, state_machine::step_output_path(root, step)) + { + let executable = state_machine::is_script_step(step); + match io::write_step_file(path, c, executable)? { + true => format!(" File written: `{}`.", path.display()), + false => format!(" File `{}` already exists — skipped.", path.display()), + } + } else { + String::new() + }; + + state + .confirm_step(step) + .map_err(Error::InvalidStateTransition)?; + io::save(&state, root)?; + + let next_idx = state.current_step_index(); + if state.completed { + Ok(format!( + "Step '{}' confirmed.{write_msg}\n\nSetup wizard complete! All steps done.", + step.label() + )) + } else { + let next = &state.steps[next_idx]; + Ok(format!( + "Step '{}' confirmed.{write_msg}\n\nNext: {} — run `wizard_generate` to begin.", + step.label(), + next.step.label() + )) + } +} + +/// Skip the current step without writing any file. +/// +/// # Errors +/// - [`Error::NotActive`] if no wizard is active. +/// - [`Error::InvalidStateTransition`] if the state transition fails. +/// - [`Error::PersistenceFailure`] if saving state fails. +pub fn skip(root: &Path) -> Result { + let mut state = io::load(root).ok_or(Error::NotActive)?; + + if state.completed { + return Ok("Wizard is already complete.".to_string()); + } + + let current_idx = state.current_step_index(); + let step = state.steps[current_idx].step; + + state + .skip_step(step) + .map_err(Error::InvalidStateTransition)?; + io::save(&state, root)?; + + let next_idx = state.current_step_index(); + if state.completed { + Ok(format!( + "Step '{}' skipped. Setup wizard complete!", + step.label() + )) + } else { + let next = &state.steps[next_idx]; + Ok(format!( + "Step '{}' skipped.\n\nNext: {} — run `wizard_generate` to begin.", + step.label(), + next.step.label() + )) + } +} + +/// Discard staged content and reset the current step to `Pending`. +/// +/// # Errors +/// - [`Error::NotActive`] if no wizard is active. +/// - [`Error::PersistenceFailure`] if saving state fails. +pub fn retry(root: &Path) -> Result { + let mut state = io::load(root).ok_or(Error::NotActive)?; + + if state.completed { + return Ok("Wizard is already complete.".to_string()); + } + + let current_idx = state.current_step_index(); + let step = state.steps[current_idx].step; + + if let Some(s) = state.steps.iter_mut().find(|s| s.step == step) { + s.status = StepStatus::Pending; + s.content = None; + } + io::save(&state, root)?; + + Ok(format!( + "Step '{}' reset to pending. Run `wizard_generate` to regenerate content.", + step.label() + )) +} + +/// Write `content` to `path` if no real content already exists there. +/// +/// Thin public wrapper around `io::write_step_file` for use by HTTP/chat +/// adapters that need direct file-write access without going through the full +/// confirm flow. +/// +/// Returns `Ok(true)` when written, `Ok(false)` when an existing real file was +/// preserved, `Err(Error::PersistenceFailure)` on I/O error. +pub fn write_step_file(path: &Path, content: &str, executable: bool) -> Result { + io::write_step_file(path, content, executable) +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::io::wizard::WizardState; + use tempfile::TempDir; + + fn init_wizard(dir: &TempDir) { + std::fs::create_dir_all(dir.path().join(".huskies")).unwrap(); + WizardState::init_if_missing(dir.path()); + } + + #[test] + fn get_state_returns_active_state() { + let dir = TempDir::new().unwrap(); + init_wizard(&dir); + let state = get_state(dir.path()).unwrap(); + assert_eq!(state.steps.len(), 8); + } + + #[test] + fn get_state_not_active_when_no_wizard() { + let dir = TempDir::new().unwrap(); + assert!(matches!(get_state(dir.path()), Err(Error::NotActive))); + } + + #[test] + fn mark_step_confirmed_advances_wizard() { + let dir = TempDir::new().unwrap(); + init_wizard(&dir); + let state = mark_step_confirmed(dir.path(), WizardStep::Context).unwrap(); + assert_eq!(state.current_step_index(), 2); + } + + #[test] + fn mark_step_confirmed_wrong_order_returns_error() { + let dir = TempDir::new().unwrap(); + init_wizard(&dir); + assert!(matches!( + mark_step_confirmed(dir.path(), WizardStep::Stack), + Err(Error::InvalidStateTransition(_)) + )); + } + + #[test] + fn mark_step_skipped_advances_wizard() { + let dir = TempDir::new().unwrap(); + init_wizard(&dir); + let state = mark_step_skipped(dir.path(), WizardStep::Context).unwrap(); + assert_eq!(state.current_step_index(), 2); + } + + #[test] + fn set_step_content_stages_content() { + let dir = TempDir::new().unwrap(); + init_wizard(&dir); + let state = + set_step_content(dir.path(), WizardStep::Context, Some("# ctx".to_string())).unwrap(); + assert_eq!(state.steps[1].status, StepStatus::AwaitingConfirmation); + assert_eq!(state.steps[1].content.as_deref(), Some("# ctx")); + } + + #[test] + fn status_returns_formatted_state() { + let dir = TempDir::new().unwrap(); + init_wizard(&dir); + let s = status(dir.path()).unwrap(); + assert!(s.contains("Setup wizard")); + assert!(s.contains("context")); + } + + #[test] + fn generate_with_content_stages_and_returns_staged_message() { + let dir = TempDir::new().unwrap(); + init_wizard(&dir); + let msg = generate(dir.path(), Some("# Context")).unwrap(); + assert!(msg.contains("staged")); + let state = get_state(dir.path()).unwrap(); + assert_eq!(state.steps[1].status, StepStatus::AwaitingConfirmation); + } + + #[test] + fn generate_no_content_marks_generating() { + let dir = TempDir::new().unwrap(); + init_wizard(&dir); + let msg = generate(dir.path(), None).unwrap(); + assert!(msg.contains("generating")); + let state = get_state(dir.path()).unwrap(); + assert_eq!(state.steps[1].status, StepStatus::Generating); + } + + #[test] + fn confirm_writes_file_and_advances() { + let dir = TempDir::new().unwrap(); + init_wizard(&dir); + generate(dir.path(), Some("# Context content")).unwrap(); + let msg = confirm(dir.path()).unwrap(); + assert!(msg.contains("confirmed")); + let path = dir + .path() + .join(".huskies") + .join("specs") + .join("00_CONTEXT.md"); + assert!(path.exists()); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "# Context content"); + } + + #[test] + fn skip_advances_without_file_write() { + let dir = TempDir::new().unwrap(); + init_wizard(&dir); + let msg = skip(dir.path()).unwrap(); + assert!(msg.contains("skipped")); + let state = get_state(dir.path()).unwrap(); + assert_eq!(state.steps[1].status, StepStatus::Skipped); + assert_eq!(state.current_step_index(), 2); + } + + #[test] + fn retry_resets_to_pending() { + let dir = TempDir::new().unwrap(); + init_wizard(&dir); + generate(dir.path(), Some("some content")).unwrap(); + let msg = retry(dir.path()).unwrap(); + assert!(msg.contains("reset")); + let state = get_state(dir.path()).unwrap(); + assert_eq!(state.steps[1].status, StepStatus::Pending); + assert!(state.steps[1].content.is_none()); + } + + #[test] + fn error_display_not_active() { + assert!(Error::NotActive.to_string().contains("No wizard active")); + } + + #[test] + fn error_display_invalid_state_transition() { + let e = Error::InvalidStateTransition("wrong step".to_string()); + assert!(e.to_string().contains("Invalid state transition")); + } + + #[test] + fn error_display_missing_input() { + let e = Error::MissingInput("no content".to_string()); + assert!(e.to_string().contains("Missing input")); + } + + #[test] + fn error_display_generation_failure() { + let e = Error::GenerationFailure("LLM timeout".to_string()); + assert!(e.to_string().contains("Generation failed")); + } + + #[test] + fn error_display_persistence_failure() { + let e = Error::PersistenceFailure("disk full".to_string()); + assert!(e.to_string().contains("Persistence error")); + } +} diff --git a/server/src/service/wizard/state_machine.rs b/server/src/service/wizard/state_machine.rs new file mode 100644 index 00000000..5b6f5800 --- /dev/null +++ b/server/src/service/wizard/state_machine.rs @@ -0,0 +1,435 @@ +//! Pure wizard state machine helpers — no I/O, no side effects. +//! +//! All functions here take data (strings, booleans, enum values), never paths. +//! Filesystem probes belong in `io.rs`; orchestration in `mod.rs`. +//! These functions are unit-testable without tempdirs or async runtimes. + +use crate::io::wizard::WizardStep; +use std::path::{Path, PathBuf}; + +/// Project build-tool detection flags produced by `io::detect_project_tools`. +pub(crate) struct ProjectTools { + pub has_cargo: bool, + pub has_pkg: bool, + pub has_pnpm: bool, + pub has_frontend_subdir: bool, + pub has_go: bool, + pub has_python: bool, +} + +/// Return true when the directory contains no meaningful source files. +/// +/// A "bare" project has only huskies scaffolding — no code, build manifests, +/// or source directories. Takes a slice of filename strings so it can be +/// called without touching the filesystem. +pub(crate) fn is_bare_project(file_names: &[impl AsRef]) -> bool { + file_names.iter().all(|n| { + let n = n.as_ref(); + n.starts_with('.') + || n == "CLAUDE.md" + || n == "LICENSE" + || n == "README.md" + || n == "script" + }) +} + +/// Return true when `step` produces a script file that must be made executable. +pub(crate) fn is_script_step(step: WizardStep) -> bool { + matches!( + step, + WizardStep::TestScript + | WizardStep::BuildScript + | WizardStep::LintScript + | WizardStep::ReleaseScript + | WizardStep::TestCoverage + ) +} + +/// Serialise a `WizardStep` to its snake_case slug (e.g. `"test_script"`). +pub(crate) fn step_slug(step: WizardStep) -> String { + serde_json::to_value(step) + .ok() + .and_then(|v| v.as_str().map(String::from)) + .unwrap_or_default() +} + +/// Return the output path for a step relative to `project_root`. +/// +/// Pure path concatenation — no filesystem access. Returns `None` for +/// `Scaffold` which has no single output file. +pub(crate) fn step_output_path(project_root: &Path, step: WizardStep) -> Option { + match step { + WizardStep::Context => Some( + project_root + .join(".huskies") + .join("specs") + .join("00_CONTEXT.md"), + ), + WizardStep::Stack => Some( + project_root + .join(".huskies") + .join("specs") + .join("tech") + .join("STACK.md"), + ), + WizardStep::TestScript => Some(project_root.join("script").join("test")), + WizardStep::BuildScript => Some(project_root.join("script").join("build")), + WizardStep::LintScript => Some(project_root.join("script").join("lint")), + WizardStep::ReleaseScript => Some(project_root.join("script").join("release")), + WizardStep::TestCoverage => Some(project_root.join("script").join("test_coverage")), + WizardStep::Scaffold => None, + } +} + +/// Generate an agent hint for a wizard step. +/// +/// `is_bare` — true when the project has no source files beyond scaffolding. +/// `tools` — detected build system presence (no filesystem access needed here). +pub(crate) fn generation_hint(step: WizardStep, is_bare: bool, tools: &ProjectTools) -> String { + match step { + WizardStep::Context => { + if is_bare { + "This is a bare project with no existing code. Ask the user what they want \ + to build — the project's purpose, goals, target users, and key features. \ + Then generate `.huskies/specs/00_CONTEXT.md` from their answers covering:\n\ + - High-level goal of the project\n\ + - Core features\n\ + - Domain concepts and entities\n\ + - Glossary of abbreviations and technical terms" + .to_string() + } else { + "Read the project source tree and generate a `.huskies/specs/00_CONTEXT.md` describing:\n\ + - High-level goal of the project\n\ + - Core features\n\ + - Domain concepts and entities\n\ + - Glossary of abbreviations and technical terms" + .to_string() + } + } + WizardStep::Stack => { + if is_bare { + "This is a bare project with no existing code. Ask the user what language, \ + frameworks, and tools they plan to use. Then generate `.huskies/specs/tech/STACK.md` \ + from their answers covering:\n\ + - Language, frameworks, and runtimes\n\ + - Coding standards and linting rules\n\ + - Quality gates (commands that must pass before merging)\n\ + - Approved libraries and their purpose" + .to_string() + } else { + "Read the project source tree and generate a `.huskies/specs/tech/STACK.md` describing:\n\ + - Language, frameworks, and runtimes\n\ + - Coding standards and linting rules\n\ + - Quality gates (commands that must pass before merging)\n\ + - Approved libraries and their purpose" + .to_string() + } + } + WizardStep::TestScript => { + if is_bare { + "This is a bare project with no existing code. Read the STACK.md generated \ + in the previous step (or ask the user about their stack if it was skipped) \ + and generate a `script/test` shell script (#!/usr/bin/env bash, set -euo pipefail) \ + with appropriate test commands for their chosen language and framework." + .to_string() + } else { + let mut cmds = Vec::new(); + if tools.has_cargo { + cmds.push("cargo nextest run"); + } + if tools.has_pkg { + cmds.push(if tools.has_pnpm { + "pnpm test" + } else { + "npm test" + }); + } + if cmds.is_empty() { + "Generate a `script/test` shell script (#!/usr/bin/env bash, set -euo pipefail) that runs the project's test suite.".to_string() + } else { + format!( + "Generate a `script/test` shell script (#!/usr/bin/env bash, set -euo pipefail) that runs: {}", + cmds.join(", ") + ) + } + } + } + WizardStep::BuildScript => { + if is_bare { + "This is a bare project with no existing code. Read the STACK.md generated \ + in the previous step (or ask the user about their stack if it was skipped) \ + and generate a `script/build` shell script (#!/usr/bin/env bash, set -euo pipefail) \ + with appropriate build commands for their chosen language and framework." + .to_string() + } else { + let mut cmds = Vec::new(); + if tools.has_cargo { + cmds.push("cargo build --release"); + } + if tools.has_pkg { + cmds.push(if tools.has_pnpm { + "pnpm run build" + } else { + "npm run build" + }); + } + if tools.has_frontend_subdir { + cmds.push("(cd frontend && npm run build)"); + } + if tools.has_go { + cmds.push("go build ./..."); + } + if cmds.is_empty() { + "Generate a `script/build` shell script (#!/usr/bin/env bash, set -euo pipefail) that builds the project.".to_string() + } else { + format!( + "Generate a `script/build` shell script (#!/usr/bin/env bash, set -euo pipefail) that runs: {}", + cmds.join(", ") + ) + } + } + } + WizardStep::LintScript => { + if is_bare { + "This is a bare project with no existing code. Read the STACK.md generated \ + in the previous step (or ask the user about their stack if it was skipped) \ + and generate a `script/lint` shell script (#!/usr/bin/env bash, set -euo pipefail) \ + with appropriate lint commands for their chosen language and framework." + .to_string() + } else { + let mut cmds = Vec::new(); + if tools.has_cargo { + cmds.push("cargo fmt --all --check"); + cmds.push("cargo clippy -- -D warnings"); + } + if tools.has_pkg { + cmds.push(if tools.has_pnpm { + "pnpm run lint" + } else { + "npm run lint" + }); + } + if tools.has_python { + cmds.push("flake8 . (or ruff check . if ruff is configured)"); + } + if tools.has_go { + cmds.push("go vet ./..."); + } + if cmds.is_empty() { + "Generate a `script/lint` shell script (#!/usr/bin/env bash, set -euo pipefail) that runs the project's linters.".to_string() + } else { + format!( + "Generate a `script/lint` shell script (#!/usr/bin/env bash, set -euo pipefail) that runs: {}", + cmds.join(", ") + ) + } + } + } + WizardStep::ReleaseScript => { + if is_bare { + "This is a bare project with no existing code. Read the STACK.md generated \ + in the previous step (or ask the user about their stack if it was skipped) \ + and generate a `script/release` shell script (#!/usr/bin/env bash, set -euo pipefail) \ + with appropriate build/release commands for their chosen language and framework." + .to_string() + } else { + "Generate a `script/release` shell script (#!/usr/bin/env bash, set -euo pipefail) that builds and releases the project (e.g. `cargo build --release` or `npm run build`).".to_string() + } + } + WizardStep::TestCoverage => { + if is_bare { + "This is a bare project with no existing code. Read the STACK.md generated \ + in the previous step (or ask the user about their stack if it was skipped) \ + and generate a `script/test_coverage` shell script (#!/usr/bin/env bash, set -euo pipefail) \ + with appropriate test coverage commands for their chosen language and framework." + .to_string() + } else { + "Generate a `script/test_coverage` shell script (#!/usr/bin/env bash, set -euo pipefail) that generates a test coverage report (e.g. `cargo llvm-cov nextest` or `npm run coverage`).".to_string() + } + } + WizardStep::Scaffold => { + "Scaffold step is handled automatically by `huskies init`.".to_string() + } + } +} + +// ── tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + fn no_tools() -> ProjectTools { + ProjectTools { + has_cargo: false, + has_pkg: false, + has_pnpm: false, + has_frontend_subdir: false, + has_go: false, + has_python: false, + } + } + + fn cargo_tools() -> ProjectTools { + ProjectTools { + has_cargo: true, + ..no_tools() + } + } + + #[test] + fn is_bare_project_empty_dir() { + let names: Vec<&str> = vec![]; + assert!(is_bare_project(&names)); + } + + #[test] + fn is_bare_project_scaffold_only() { + let names = [".huskies", "CLAUDE.md", "README.md", "script", ".git"]; + assert!(is_bare_project(&names)); + } + + #[test] + fn is_bare_project_false_with_cargo_toml() { + let names = [".huskies", "Cargo.toml"]; + assert!(!is_bare_project(&names)); + } + + #[test] + fn is_bare_project_false_with_src_dir() { + let names = ["src"]; + assert!(!is_bare_project(&names)); + } + + #[test] + fn is_script_step_includes_all_scripts() { + assert!(is_script_step(WizardStep::TestScript)); + assert!(is_script_step(WizardStep::BuildScript)); + assert!(is_script_step(WizardStep::LintScript)); + assert!(is_script_step(WizardStep::ReleaseScript)); + assert!(is_script_step(WizardStep::TestCoverage)); + } + + #[test] + fn is_script_step_excludes_non_scripts() { + assert!(!is_script_step(WizardStep::Scaffold)); + assert!(!is_script_step(WizardStep::Context)); + assert!(!is_script_step(WizardStep::Stack)); + } + + #[test] + fn step_slug_context() { + assert_eq!(step_slug(WizardStep::Context), "context"); + } + + #[test] + fn step_slug_test_script() { + assert_eq!(step_slug(WizardStep::TestScript), "test_script"); + } + + #[test] + fn step_slug_scaffold() { + assert_eq!(step_slug(WizardStep::Scaffold), "scaffold"); + } + + #[test] + fn step_output_path_context() { + let p = step_output_path(Path::new("/proj"), WizardStep::Context).unwrap(); + assert!(p.ends_with(".huskies/specs/00_CONTEXT.md")); + } + + #[test] + fn step_output_path_build_script() { + let p = step_output_path(Path::new("/proj"), WizardStep::BuildScript).unwrap(); + assert!(p.ends_with("script/build")); + } + + #[test] + fn step_output_path_lint_script() { + let p = step_output_path(Path::new("/proj"), WizardStep::LintScript).unwrap(); + assert!(p.ends_with("script/lint")); + } + + #[test] + fn step_output_path_scaffold_is_none() { + assert!(step_output_path(Path::new("/proj"), WizardStep::Scaffold).is_none()); + } + + #[test] + fn generation_hint_bare_context_asks_user() { + let hint = generation_hint(WizardStep::Context, true, &no_tools()); + assert!(hint.contains("bare project")); + assert!(hint.contains("Ask the user")); + } + + #[test] + fn generation_hint_bare_stack_asks_user() { + let hint = generation_hint(WizardStep::Stack, true, &no_tools()); + assert!(hint.contains("bare project")); + assert!(hint.contains("Ask the user")); + } + + #[test] + fn generation_hint_bare_test_script_references_stack() { + let hint = generation_hint(WizardStep::TestScript, true, &no_tools()); + assert!(hint.contains("bare project")); + assert!(hint.contains("STACK.md")); + } + + #[test] + fn generation_hint_bare_build_script_references_stack() { + let hint = generation_hint(WizardStep::BuildScript, true, &no_tools()); + assert!(hint.contains("bare project")); + assert!(hint.contains("STACK.md")); + } + + #[test] + fn generation_hint_bare_lint_script_references_stack() { + let hint = generation_hint(WizardStep::LintScript, true, &no_tools()); + assert!(hint.contains("bare project")); + assert!(hint.contains("STACK.md")); + } + + #[test] + fn generation_hint_bare_release_script_references_stack() { + let hint = generation_hint(WizardStep::ReleaseScript, true, &no_tools()); + assert!(hint.contains("bare project")); + assert!(hint.contains("STACK.md")); + } + + #[test] + fn generation_hint_bare_test_coverage_references_stack() { + let hint = generation_hint(WizardStep::TestCoverage, true, &no_tools()); + assert!(hint.contains("bare project")); + assert!(hint.contains("STACK.md")); + } + + #[test] + fn generation_hint_existing_context_reads_code() { + let hint = generation_hint(WizardStep::Context, false, &cargo_tools()); + assert!(hint.contains("Read the project")); + assert!(!hint.contains("bare project")); + } + + #[test] + fn generation_hint_existing_test_script_detects_cargo() { + let hint = generation_hint(WizardStep::TestScript, false, &cargo_tools()); + assert!(hint.contains("cargo nextest")); + assert!(!hint.contains("bare project")); + } + + #[test] + fn generation_hint_existing_build_script_detects_cargo() { + let hint = generation_hint(WizardStep::BuildScript, false, &cargo_tools()); + assert!(hint.contains("cargo build --release")); + assert!(!hint.contains("bare project")); + } + + #[test] + fn generation_hint_existing_lint_script_detects_cargo() { + let hint = generation_hint(WizardStep::LintScript, false, &cargo_tools()); + assert!(hint.contains("cargo fmt --all --check")); + assert!(hint.contains("cargo clippy -- -D warnings")); + assert!(!hint.contains("bare project")); + } +}