2026-04-12 13:11:23 +00:00
//! Setup wizard — multi-step project onboarding flow with per-step status tracking.
2026-03-28 13:26:29 +00:00
use serde ::{ Deserialize , Serialize } ;
2026-03-28 14:21:13 +00:00
use serde_json ;
2026-03-28 13:26:29 +00:00
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 {
2026-04-03 16:12:52 +01:00
/// Step 1: scaffold .huskies/ directory structure and project.toml
2026-03-28 13:26:29 +00:00
Scaffold ,
/// Step 2: generate specs/00_CONTEXT.md
Context ,
/// Step 3: generate specs/tech/STACK.md
Stack ,
/// Step 4: create script/test
TestScript ,
2026-04-16 00:18:42 +00:00
/// Step 5: create script/build
BuildScript ,
/// Step 6: create script/lint
LintScript ,
/// Step 7: create script/release
2026-03-28 13:26:29 +00:00
ReleaseScript ,
2026-04-16 00:18:42 +00:00
/// Step 8: create script/test_coverage
2026-03-28 13:26:29 +00:00
TestCoverage ,
}
impl WizardStep {
/// All steps in order.
pub const ALL : & [ WizardStep ] = & [
WizardStep ::Scaffold ,
WizardStep ::Context ,
WizardStep ::Stack ,
WizardStep ::TestScript ,
2026-04-16 00:18:42 +00:00
WizardStep ::BuildScript ,
WizardStep ::LintScript ,
2026-03-28 13:26:29 +00:00
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) " ,
2026-04-16 00:18:42 +00:00
WizardStep ::BuildScript = > " Create build script (script/build) " ,
WizardStep ::LintScript = > " Create lint script (script/lint) " ,
2026-03-28 13:26:29 +00:00
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 > ,
}
2026-04-03 16:12:52 +01:00
/// Persistent wizard state, stored in `.huskies/wizard_state.json`.
2026-03-28 13:26:29 +00:00
#[ 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 {
2026-04-03 16:12:52 +01:00
project_root . join ( " .huskies " ) . join ( " wizard_state.json " )
2026-03-28 13:26:29 +00:00
}
/// 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.
2026-04-03 16:12:52 +01:00
/// Step 1 (Scaffold) is automatically confirmed since `huskies init`
2026-03-28 13:26:29 +00:00
/// 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 ( ( ) )
}
}
2026-03-28 14:21:13 +00:00
/// 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 = > {
2026-03-28 15:15:14 +00:00
" Ready to generate. Proceed by calling wizard_generate. " . to_string ( )
2026-03-28 14:21:13 +00:00
}
2026-03-28 15:15:14 +00:00
StepStatus ::Generating = > " Generating content… " . to_string ( ) ,
2026-03-28 14:21:13 +00:00
StepStatus ::AwaitingConfirmation = > {
2026-03-28 15:15:14 +00:00
" 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 ( )
2026-03-28 14:21:13 +00:00
}
StepStatus ::Confirmed | StepStatus ::Skipped = > String ::new ( ) ,
} ;
if ! hint . is_empty ( ) {
lines . push ( hint ) ;
}
}
lines . join ( " \n " )
}
2026-03-28 13:26:29 +00:00
#[ cfg(test) ]
mod tests {
use super ::* ;
2026-03-28 19:47:59 +00:00
use crate ::io ::test_helpers ::setup_project ;
2026-03-28 13:26:29 +00:00
use tempfile ::TempDir ;
#[ test ]
fn default_state_has_all_steps_pending ( ) {
let state = WizardState ::default ( ) ;
2026-04-16 00:18:42 +00:00
assert_eq! ( state . steps . len ( ) , 8 ) ;
2026-03-28 13:26:29 +00:00
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 \n A 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 \n A 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 ) ;
2026-04-13 14:07:08 +00:00
assert_eq! ( state . steps [ 1 ] . content . as_deref ( ) , Some ( " generated content " ) ) ;
2026-03-28 13:26:29 +00:00
}
#[ 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 ) ;
}
}
}