Files
huskies/server/src/io/wizard.rs
T

414 lines
14 KiB
Rust
Raw Normal View History

//! Setup wizard — multi-step project onboarding flow with per-step status tracking.
use serde::{Deserialize, Serialize};
use serde_json;
use std::fs;
use std::path::Path;
/// Ordered wizard steps for project setup.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WizardStep {
/// Step 1: scaffold .huskies/ directory structure and project.toml
Scaffold,
/// Step 2: generate specs/00_CONTEXT.md
Context,
/// Step 3: generate specs/tech/STACK.md
Stack,
/// Step 4: create script/test
TestScript,
/// Step 5: create script/build
BuildScript,
/// Step 6: create script/lint
LintScript,
/// Step 7: create script/release
ReleaseScript,
/// Step 8: create script/test_coverage
TestCoverage,
}
impl WizardStep {
/// All steps in order.
pub const ALL: &[WizardStep] = &[
WizardStep::Scaffold,
WizardStep::Context,
WizardStep::Stack,
WizardStep::TestScript,
WizardStep::BuildScript,
WizardStep::LintScript,
WizardStep::ReleaseScript,
WizardStep::TestCoverage,
];
/// Human-readable label for this step.
pub fn label(&self) -> &'static str {
match self {
WizardStep::Scaffold => "Scaffold directory structure",
WizardStep::Context => "Generate project context (00_CONTEXT.md)",
WizardStep::Stack => "Generate tech stack spec (STACK.md)",
WizardStep::TestScript => "Create test script (script/test)",
WizardStep::BuildScript => "Create build script (script/build)",
WizardStep::LintScript => "Create lint script (script/lint)",
WizardStep::ReleaseScript => "Create release script (script/release)",
WizardStep::TestCoverage => "Create test coverage script (script/test_coverage)",
}
}
/// Zero-based index of this step.
pub fn index(&self) -> usize {
Self::ALL.iter().position(|s| s == self).unwrap_or(0)
}
}
/// Status of an individual wizard step.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StepStatus {
/// Not yet started.
Pending,
/// Agent is generating content for this step.
Generating,
/// Content generated, awaiting user confirmation.
AwaitingConfirmation,
/// User confirmed this step.
Confirmed,
/// User skipped this step.
Skipped,
}
/// State of a single wizard step.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StepState {
pub step: WizardStep,
pub status: StepStatus,
/// The generated content (if any) for preview.
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
}
/// Persistent wizard state, stored in `.huskies/wizard_state.json`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WizardState {
pub steps: Vec<StepState>,
/// True when all steps are confirmed or skipped.
pub completed: bool,
}
impl Default for WizardState {
fn default() -> Self {
Self {
steps: WizardStep::ALL
.iter()
.map(|&step| StepState {
step,
status: StepStatus::Pending,
content: None,
})
.collect(),
completed: false,
}
}
}
impl WizardState {
/// Path to the wizard state file relative to the project root.
fn state_path(project_root: &Path) -> std::path::PathBuf {
project_root.join(".huskies").join("wizard_state.json")
}
/// Load wizard state from disk, or return None if it doesn't exist.
pub fn load(project_root: &Path) -> Option<Self> {
let path = Self::state_path(project_root);
let content = fs::read_to_string(&path).ok()?;
serde_json::from_str(&content).ok()
}
/// Save wizard state to disk.
pub fn save(&self, project_root: &Path) -> Result<(), String> {
let path = Self::state_path(project_root);
let content =
serde_json::to_string_pretty(self).map_err(|e| format!("Serialize error: {e}"))?;
fs::write(&path, content).map_err(|e| format!("Failed to write wizard state: {e}"))
}
/// Create wizard state file if it doesn't already exist.
/// Step 1 (Scaffold) is automatically confirmed since `huskies init`
/// has already run the scaffold.
pub fn init_if_missing(project_root: &Path) {
if Self::load(project_root).is_some() {
return;
}
let mut state = Self::default();
// Scaffold step is done by the time the server starts.
state.steps[0].status = StepStatus::Confirmed;
let _ = state.save(project_root);
}
/// Get the current step index (0-based).
pub fn current_step_index(&self) -> usize {
self.steps
.iter()
.position(|s| !matches!(s.status, StepStatus::Confirmed | StepStatus::Skipped))
.unwrap_or(self.steps.len())
}
/// Mark a step's status and update completion state.
pub fn set_step_status(
&mut self,
step: WizardStep,
status: StepStatus,
content: Option<String>,
) {
if let Some(s) = self.steps.iter_mut().find(|s| s.step == step) {
s.status = status;
if content.is_some() {
s.content = content;
}
}
self.completed = self
.steps
.iter()
.all(|s| matches!(s.status, StepStatus::Confirmed | StepStatus::Skipped));
}
/// Confirm a step. Returns error if the step is not the current one
/// (enforces sequential progression).
pub fn confirm_step(&mut self, step: WizardStep) -> Result<(), String> {
let current_idx = self.current_step_index();
let target_idx = step.index();
if target_idx != current_idx {
return Err(format!(
"Cannot confirm step {:?}: current step is {}",
step, current_idx
));
}
self.set_step_status(step, StepStatus::Confirmed, None);
Ok(())
}
/// Skip a step. Only the current step can be skipped.
pub fn skip_step(&mut self, step: WizardStep) -> Result<(), String> {
let current_idx = self.current_step_index();
let target_idx = step.index();
if target_idx != current_idx {
return Err(format!(
"Cannot skip step {:?}: current step is {}",
step, current_idx
));
}
self.set_step_status(step, StepStatus::Skipped, None);
Ok(())
}
}
/// Format a `WizardState` as a human-readable Markdown summary for display in
/// bot messages and MCP responses.
pub fn format_wizard_state(state: &WizardState) -> String {
let total = state.steps.len();
let current_idx = state.current_step_index();
let header = if state.completed {
format!("**Setup wizard — complete** ({total}/{total} steps done)")
} else {
format!("**Setup wizard — step {}/{}**", current_idx + 1, total)
};
let mut lines = vec![header, String::new()];
for (i, step) in state.steps.iter().enumerate() {
let marker = match step.status {
StepStatus::Confirmed => "",
StepStatus::Skipped => "~",
StepStatus::Generating => "",
StepStatus::AwaitingConfirmation => "?",
StepStatus::Pending => "",
};
let is_current = !state.completed && i == current_idx;
let suffix = if is_current { " ← current" } else { "" };
let status_str = serde_json::to_value(&step.status)
.ok()
.and_then(|v| v.as_str().map(String::from))
.unwrap_or_default();
lines.push(format!(
" {} {} ({}){suffix}",
marker,
step.step.label(),
status_str
));
}
if state.completed {
lines.push(String::new());
lines.push("All steps done. Your project is fully configured.".to_string());
} else {
let current = &state.steps[current_idx];
lines.push(String::new());
lines.push(format!("**Current:** {}", current.step.label()));
let hint = match current.status {
StepStatus::Pending => {
"Ready to generate. Proceed by calling wizard_generate.".to_string()
}
StepStatus::Generating => "Generating content…".to_string(),
StepStatus::AwaitingConfirmation => {
"Content ready for review. Show it to the user and ask if they're happy with it. Then call wizard_confirm, wizard_retry, or wizard_skip based on their response.".to_string()
}
StepStatus::Confirmed | StepStatus::Skipped => String::new(),
};
if !hint.is_empty() {
lines.push(hint);
}
}
lines.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::io::test_helpers::setup_project;
use tempfile::TempDir;
#[test]
fn default_state_has_all_steps_pending() {
let state = WizardState::default();
assert_eq!(state.steps.len(), 8);
for step in &state.steps {
assert_eq!(step.status, StepStatus::Pending);
}
assert!(!state.completed);
}
#[test]
fn init_if_missing_creates_state_with_scaffold_confirmed() {
let dir = TempDir::new().unwrap();
let root = setup_project(&dir);
WizardState::init_if_missing(&root);
let state = WizardState::load(&root).unwrap();
assert_eq!(state.steps[0].status, StepStatus::Confirmed);
assert_eq!(state.steps[0].step, WizardStep::Scaffold);
// Rest should be pending
for step in &state.steps[1..] {
assert_eq!(step.status, StepStatus::Pending);
}
}
#[test]
fn init_if_missing_does_not_overwrite_existing() {
let dir = TempDir::new().unwrap();
let root = setup_project(&dir);
// Create a custom state
let mut state = WizardState::default();
state.steps[0].status = StepStatus::Confirmed;
state.steps[1].status = StepStatus::Confirmed;
state.save(&root).unwrap();
// init_if_missing should not overwrite
WizardState::init_if_missing(&root);
let loaded = WizardState::load(&root).unwrap();
assert_eq!(loaded.steps[1].status, StepStatus::Confirmed);
}
#[test]
fn save_and_load_round_trip() {
let dir = TempDir::new().unwrap();
let root = setup_project(&dir);
let mut state = WizardState::default();
state.steps[0].status = StepStatus::Confirmed;
state.steps[1].status = StepStatus::AwaitingConfirmation;
state.steps[1].content = Some("# My Project\n\nA cool project.".to_string());
state.save(&root).unwrap();
let loaded = WizardState::load(&root).unwrap();
assert_eq!(loaded.steps[0].status, StepStatus::Confirmed);
assert_eq!(loaded.steps[1].status, StepStatus::AwaitingConfirmation);
assert_eq!(
loaded.steps[1].content.as_deref(),
Some("# My Project\n\nA cool project.")
);
}
#[test]
fn current_step_index_correct() {
let mut state = WizardState::default();
state.steps[0].status = StepStatus::Confirmed;
assert_eq!(state.current_step_index(), 1);
state.steps[1].status = StepStatus::Skipped;
assert_eq!(state.current_step_index(), 2);
}
#[test]
fn confirm_step_enforces_order() {
let mut state = WizardState::default();
state.steps[0].status = StepStatus::Confirmed;
// Can confirm the current step (Context, index 1)
assert!(state.confirm_step(WizardStep::Context).is_ok());
// Cannot confirm a step that's not current
assert!(state.confirm_step(WizardStep::TestScript).is_err());
}
#[test]
fn skip_step_works() {
let mut state = WizardState::default();
state.steps[0].status = StepStatus::Confirmed;
assert!(state.skip_step(WizardStep::Context).is_ok());
assert_eq!(state.steps[1].status, StepStatus::Skipped);
assert_eq!(state.current_step_index(), 2);
}
#[test]
fn completed_when_all_confirmed_or_skipped() {
let mut state = WizardState::default();
for step in WizardStep::ALL {
state.set_step_status(*step, StepStatus::Confirmed, None);
}
assert!(state.completed);
}
#[test]
fn not_completed_when_some_pending() {
let mut state = WizardState::default();
state.set_step_status(WizardStep::Scaffold, StepStatus::Confirmed, None);
assert!(!state.completed);
}
#[test]
fn set_step_status_with_content() {
let mut state = WizardState::default();
state.set_step_status(
WizardStep::Context,
StepStatus::AwaitingConfirmation,
Some("generated content".to_string()),
);
assert_eq!(state.steps[1].status, StepStatus::AwaitingConfirmation);
assert_eq!(state.steps[1].content.as_deref(), Some("generated content"));
}
#[test]
fn load_returns_none_when_no_file() {
let dir = TempDir::new().unwrap();
assert!(WizardState::load(dir.path()).is_none());
}
#[test]
fn step_labels_are_non_empty() {
for step in WizardStep::ALL {
assert!(!step.label().is_empty());
}
}
#[test]
fn step_indices_are_sequential() {
for (i, step) in WizardStep::ALL.iter().enumerate() {
assert_eq!(step.index(), i);
}
}
}