//! 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, } /// Persistent wizard state, stored in `.huskies/wizard_state.json`. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WizardState { pub steps: Vec, /// 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 { 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, ) { 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); } } }