huskies: merge 610_story_extract_wizard_service
This commit is contained in:
@@ -12,106 +12,60 @@
|
|||||||
//! 5. `wizard_retry` — discard staged content and regenerate from scratch
|
//! 5. `wizard_retry` — discard staged content and regenerate from scratch
|
||||||
|
|
||||||
use crate::http::context::AppContext;
|
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 serde_json::Value;
|
||||||
use std::fs;
|
|
||||||
use std::path::Path;
|
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
|
/// Pure path concatenation — delegates to `service::wizard::state_machine`.
|
||||||
/// creates the full `.huskies/` directory structure and is handled by
|
|
||||||
/// `huskies init` before the server starts.
|
|
||||||
pub(crate) fn step_output_path(
|
pub(crate) fn step_output_path(
|
||||||
project_root: &Path,
|
project_root: &Path,
|
||||||
step: WizardStep,
|
step: WizardStep,
|
||||||
) -> Option<std::path::PathBuf> {
|
) -> Option<std::path::PathBuf> {
|
||||||
match step {
|
state_machine::step_output_path(project_root, 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,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Return true when `step` produces an executable script file.
|
||||||
pub(crate) fn is_script_step(step: WizardStep) -> bool {
|
pub(crate) fn is_script_step(step: WizardStep) -> bool {
|
||||||
matches!(
|
state_machine::is_script_step(step)
|
||||||
step,
|
|
||||||
WizardStep::TestScript
|
|
||||||
| WizardStep::BuildScript
|
|
||||||
| WizardStep::LintScript
|
|
||||||
| WizardStep::ReleaseScript
|
|
||||||
| WizardStep::TestCoverage
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write `content` to `path`, skipping if the file already exists with real
|
/// Write `content` to `path`, skipping if the file already has real content.
|
||||||
/// (non-template) content.
|
|
||||||
///
|
///
|
||||||
/// Scaffold template files (those containing [`TEMPLATE_SENTINEL`]) are treated
|
/// Delegates to `service::wizard::write_step_file`.
|
||||||
/// 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.
|
|
||||||
pub(crate) fn write_if_missing(
|
pub(crate) fn write_if_missing(
|
||||||
path: &Path,
|
path: &Path,
|
||||||
content: &str,
|
content: &str,
|
||||||
executable: bool,
|
executable: bool,
|
||||||
) -> Result<bool, String> {
|
) -> Result<bool, String> {
|
||||||
use crate::io::onboarding::TEMPLATE_SENTINEL;
|
svc::write_step_file(path, content, executable).map_err(|e| e.to_string())
|
||||||
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)
|
/// 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Serialise a `WizardStep` to its snake_case string (e.g. `"test_script"`).
|
/// Return a generation hint for `step` based on the project at `project_root`.
|
||||||
fn step_slug(step: WizardStep) -> String {
|
///
|
||||||
serde_json::to_value(step)
|
/// Reads filesystem state then delegates pure logic to `state_machine`.
|
||||||
.ok()
|
pub(crate) fn generation_hint(step: WizardStep, project_root: &Path) -> String {
|
||||||
.and_then(|v| v.as_str().map(String::from))
|
use crate::service::wizard::io as wio;
|
||||||
.unwrap_or_default()
|
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 ─────────────────────────────────────────────────────────
|
// ── MCP tool handlers ─────────────────────────────────────────────────────────
|
||||||
@@ -119,9 +73,7 @@ fn step_slug(step: WizardStep) -> String {
|
|||||||
/// `wizard_status` — return current wizard state as a human-readable summary.
|
/// `wizard_status` — return current wizard state as a human-readable summary.
|
||||||
pub(super) fn tool_wizard_status(ctx: &AppContext) -> Result<String, String> {
|
pub(super) fn tool_wizard_status(ctx: &AppContext) -> Result<String, String> {
|
||||||
let root = ctx.state.get_project_root()?;
|
let root = ctx.state.get_project_root()?;
|
||||||
let state =
|
svc::status(&root).map_err(|e| e.to_string())
|
||||||
WizardState::load(&root).ok_or("No wizard active. Run `huskies init` to begin setup.")?;
|
|
||||||
Ok(format_wizard_state(&state))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `wizard_generate` — mark the current step as generating or stage content.
|
/// `wizard_generate` — mark the current step as generating or stage content.
|
||||||
@@ -133,245 +85,8 @@ pub(super) fn tool_wizard_status(ctx: &AppContext) -> Result<String, String> {
|
|||||||
/// until `wizard_confirm` is called.
|
/// until `wizard_confirm` is called.
|
||||||
pub(super) fn tool_wizard_generate(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
pub(super) fn tool_wizard_generate(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
||||||
let root = ctx.state.get_project_root()?;
|
let root = ctx.state.get_project_root()?;
|
||||||
let mut state = WizardState::load(&root).ok_or("No wizard active.")?;
|
let content = args.get("content").and_then(|v| v.as_str());
|
||||||
|
svc::generate(&root, content).map_err(|e| e.to_string())
|
||||||
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<String> = 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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `wizard_confirm` — confirm the current step and write its content to disk.
|
/// `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.
|
/// advances to the next pending step.
|
||||||
pub(super) fn tool_wizard_confirm(ctx: &AppContext) -> Result<String, String> {
|
pub(super) fn tool_wizard_confirm(ctx: &AppContext) -> Result<String, String> {
|
||||||
let root = ctx.state.get_project_root()?;
|
let root = ctx.state.get_project_root()?;
|
||||||
let mut state = WizardState::load(&root).ok_or("No wizard active.")?;
|
svc::confirm(&root).map_err(|e| e.to_string())
|
||||||
|
|
||||||
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()
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `wizard_skip` — skip the current step without writing any file.
|
/// `wizard_skip` — skip the current step without writing any file.
|
||||||
pub(super) fn tool_wizard_skip(ctx: &AppContext) -> Result<String, String> {
|
pub(super) fn tool_wizard_skip(ctx: &AppContext) -> Result<String, String> {
|
||||||
let root = ctx.state.get_project_root()?;
|
let root = ctx.state.get_project_root()?;
|
||||||
let mut state = WizardState::load(&root).ok_or("No wizard active.")?;
|
svc::skip(&root).map_err(|e| e.to_string())
|
||||||
|
|
||||||
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()
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `wizard_retry` — discard staged content and reset the current step to
|
/// `wizard_retry` — discard staged content and reset the current step to
|
||||||
/// `Pending` so it can be regenerated.
|
/// `Pending` so it can be regenerated.
|
||||||
pub(super) fn tool_wizard_retry(ctx: &AppContext) -> Result<String, String> {
|
pub(super) fn tool_wizard_retry(ctx: &AppContext) -> Result<String, String> {
|
||||||
let root = ctx.state.get_project_root()?;
|
let root = ctx.state.get_project_root()?;
|
||||||
let mut state = WizardState::load(&root).ok_or("No wizard active.")?;
|
svc::retry(&root).map_err(|e| e.to_string())
|
||||||
|
|
||||||
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()
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── tests ─────────────────────────────────────────────────────────────────────
|
// ── tests ─────────────────────────────────────────────────────────────────────
|
||||||
@@ -495,6 +119,7 @@ pub(super) fn tool_wizard_retry(ctx: &AppContext) -> Result<String, String> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::http::context::AppContext;
|
use crate::http::context::AppContext;
|
||||||
|
use crate::io::wizard::{StepStatus, WizardState, format_wizard_state};
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
fn setup(dir: &TempDir) -> AppContext {
|
fn setup(dir: &TempDir) -> AppContext {
|
||||||
|
|||||||
+11
-31
@@ -1,6 +1,7 @@
|
|||||||
//! HTTP wizard endpoints — REST API for the project setup wizard.
|
//! HTTP wizard endpoints — REST API for the project setup wizard.
|
||||||
use crate::http::context::{AppContext, OpenApiResult, bad_request, not_found};
|
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 poem_openapi::{Object, OpenApi, Tags, param::Path, payload::Json};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -80,8 +81,7 @@ impl WizardApi {
|
|||||||
#[oai(path = "/wizard", method = "get")]
|
#[oai(path = "/wizard", method = "get")]
|
||||||
async fn get_wizard_state(&self) -> OpenApiResult<Json<WizardResponse>> {
|
async fn get_wizard_state(&self) -> OpenApiResult<Json<WizardResponse>> {
|
||||||
let root = self.ctx.state.get_project_root().map_err(bad_request)?;
|
let root = self.ctx.state.get_project_root().map_err(bad_request)?;
|
||||||
let state =
|
let state = svc::get_state(&root).map_err(|_| not_found("No wizard active".to_string()))?;
|
||||||
WizardState::load(&root).ok_or_else(|| not_found("No wizard active".to_string()))?;
|
|
||||||
Ok(Json(WizardResponse::from(&state)))
|
Ok(Json(WizardResponse::from(&state)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,16 +97,8 @@ impl WizardApi {
|
|||||||
) -> OpenApiResult<Json<WizardResponse>> {
|
) -> OpenApiResult<Json<WizardResponse>> {
|
||||||
let root = self.ctx.state.get_project_root().map_err(bad_request)?;
|
let root = self.ctx.state.get_project_root().map_err(bad_request)?;
|
||||||
let wizard_step = parse_step(&step.0)?;
|
let wizard_step = parse_step(&step.0)?;
|
||||||
let mut state =
|
let state = svc::set_step_content(&root, wizard_step, payload.0.content)
|
||||||
WizardState::load(&root).ok_or_else(|| not_found("No wizard active".to_string()))?;
|
.map_err(|e| bad_request(e.to_string()))?;
|
||||||
|
|
||||||
state.set_step_status(
|
|
||||||
wizard_step,
|
|
||||||
StepStatus::AwaitingConfirmation,
|
|
||||||
payload.0.content,
|
|
||||||
);
|
|
||||||
state.save(&root).map_err(bad_request)?;
|
|
||||||
|
|
||||||
Ok(Json(WizardResponse::from(&state)))
|
Ok(Json(WizardResponse::from(&state)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,12 +109,8 @@ impl WizardApi {
|
|||||||
async fn confirm_step(&self, step: Path<String>) -> OpenApiResult<Json<WizardResponse>> {
|
async fn confirm_step(&self, step: Path<String>) -> OpenApiResult<Json<WizardResponse>> {
|
||||||
let root = self.ctx.state.get_project_root().map_err(bad_request)?;
|
let root = self.ctx.state.get_project_root().map_err(bad_request)?;
|
||||||
let wizard_step = parse_step(&step.0)?;
|
let wizard_step = parse_step(&step.0)?;
|
||||||
let mut state =
|
let state =
|
||||||
WizardState::load(&root).ok_or_else(|| not_found("No wizard active".to_string()))?;
|
svc::mark_step_confirmed(&root, wizard_step).map_err(|e| bad_request(e.to_string()))?;
|
||||||
|
|
||||||
state.confirm_step(wizard_step).map_err(bad_request)?;
|
|
||||||
state.save(&root).map_err(bad_request)?;
|
|
||||||
|
|
||||||
Ok(Json(WizardResponse::from(&state)))
|
Ok(Json(WizardResponse::from(&state)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,12 +121,8 @@ impl WizardApi {
|
|||||||
async fn skip_step(&self, step: Path<String>) -> OpenApiResult<Json<WizardResponse>> {
|
async fn skip_step(&self, step: Path<String>) -> OpenApiResult<Json<WizardResponse>> {
|
||||||
let root = self.ctx.state.get_project_root().map_err(bad_request)?;
|
let root = self.ctx.state.get_project_root().map_err(bad_request)?;
|
||||||
let wizard_step = parse_step(&step.0)?;
|
let wizard_step = parse_step(&step.0)?;
|
||||||
let mut state =
|
let state =
|
||||||
WizardState::load(&root).ok_or_else(|| not_found("No wizard active".to_string()))?;
|
svc::mark_step_skipped(&root, wizard_step).map_err(|e| bad_request(e.to_string()))?;
|
||||||
|
|
||||||
state.skip_step(wizard_step).map_err(bad_request)?;
|
|
||||||
state.save(&root).map_err(bad_request)?;
|
|
||||||
|
|
||||||
Ok(Json(WizardResponse::from(&state)))
|
Ok(Json(WizardResponse::from(&state)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,12 +131,8 @@ impl WizardApi {
|
|||||||
async fn mark_generating(&self, step: Path<String>) -> OpenApiResult<Json<WizardResponse>> {
|
async fn mark_generating(&self, step: Path<String>) -> OpenApiResult<Json<WizardResponse>> {
|
||||||
let root = self.ctx.state.get_project_root().map_err(bad_request)?;
|
let root = self.ctx.state.get_project_root().map_err(bad_request)?;
|
||||||
let wizard_step = parse_step(&step.0)?;
|
let wizard_step = parse_step(&step.0)?;
|
||||||
let mut state =
|
let state = svc::mark_step_generating(&root, wizard_step)
|
||||||
WizardState::load(&root).ok_or_else(|| not_found("No wizard active".to_string()))?;
|
.map_err(|e| bad_request(e.to_string()))?;
|
||||||
|
|
||||||
state.set_step_status(wizard_step, StepStatus::Generating, None);
|
|
||||||
state.save(&root).map_err(bad_request)?;
|
|
||||||
|
|
||||||
Ok(Json(WizardResponse::from(&state)))
|
Ok(Json(WizardResponse::from(&state)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,4 +13,5 @@ pub mod file_io;
|
|||||||
pub mod health;
|
pub mod health;
|
||||||
pub mod oauth;
|
pub mod oauth;
|
||||||
pub mod project;
|
pub mod project;
|
||||||
|
pub mod wizard;
|
||||||
pub mod ws;
|
pub mod ws;
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
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<String> {
|
||||||
|
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<bool, Error> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -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<WizardState, Error> {
|
||||||
|
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<String>,
|
||||||
|
) -> Result<WizardState, Error> {
|
||||||
|
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<WizardState, Error> {
|
||||||
|
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<WizardState, Error> {
|
||||||
|
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<WizardState, Error> {
|
||||||
|
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<String, Error> {
|
||||||
|
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<String, Error> {
|
||||||
|
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<String, Error> {
|
||||||
|
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<String, Error> {
|
||||||
|
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<String, Error> {
|
||||||
|
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<bool, Error> {
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<str>]) -> 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<PathBuf> {
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user