//! Handler for the `setup` bot command. //! //! Drives the setup wizard from any chat transport (Matrix, Slack, WhatsApp). //! //! Usage: //! - `setup` — show wizard progress and current step instructions //! - `setup confirm` — confirm the current step (writes staged content to disk) //! - `setup skip` — skip the current step //! - `setup retry` — discard staged content and reset the current step use super::CommandContext; use crate::http::mcp::wizard_tools::{ generation_hint, is_script_step, step_output_path, write_if_missing, }; use crate::io::wizard::{format_wizard_state, StepStatus, WizardState}; pub(super) fn handle_setup(ctx: &CommandContext) -> Option { let sub = ctx.args.trim().to_ascii_lowercase(); match sub.as_str() { "" => Some(wizard_status_reply(ctx)), "generate" => Some(wizard_generate_reply(ctx)), "confirm" => Some(wizard_confirm_reply(ctx)), "skip" => Some(wizard_skip_reply(ctx)), "retry" => Some(wizard_retry_reply(ctx)), _ => Some(format!( "Unknown sub-command `{sub}`. Usage: `setup`, `setup generate`, `setup confirm`, `setup skip`, `setup retry`." )), } } /// Mark the current step as generating and return the generation hint. /// /// This mirrors `wizard_generate` (with no content) from the MCP tools, making /// the interview flow accessible from chat transports (Matrix, Slack, WhatsApp). fn wizard_generate_reply(ctx: &CommandContext) -> String { let root = ctx.project_root; let mut state = match WizardState::load(root) { Some(s) => s, None => return "No wizard active.".to_string(), }; if state.completed { return "Wizard is already complete.".to_string(); } let idx = state.current_step_index(); let step = state.steps[idx].step; state.set_step_status(step, StepStatus::Generating, None); if let Err(e) = state.save(root) { return format!("Failed to save wizard state: {e}"); } let hint = generation_hint(step, root); format!( "Step '{}' marked as generating.\n\n{hint}\n\nOnce you have the content, stage it via the API and then run `setup confirm` to write it to disk.", step.label() ) } /// Compose a status reply for the `setup` command (no args). fn wizard_status_reply(ctx: &CommandContext) -> String { match WizardState::load(ctx.project_root) { Some(state) => format_wizard_state(&state), None => { "No setup wizard active. Run `huskies init` in the project root to begin.".to_string() } } } /// Confirm the current wizard step, writing any staged content to disk. fn wizard_confirm_reply(ctx: &CommandContext) -> String { let root = ctx.project_root; let mut state = match WizardState::load(root) { Some(s) => s, None => return "No wizard active.".to_string(), }; if state.completed { return "Wizard is already complete.".to_string(); } let idx = state.current_step_index(); let step = state.steps[idx].step; let content = state.steps[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) { Ok(true) => format!(" File written: `{}`.", path.display()), Ok(false) => format!(" File `{}` already exists — skipped.", path.display()), Err(e) => return format!("Error: {e}"), } } else { String::new() }; if let Err(e) = state.confirm_step(step) { return format!("Cannot confirm step: {e}"); } if let Err(e) = state.save(root) { return format!("Failed to save wizard state: {e}"); } if state.completed { format!( "Step '{}' confirmed.{write_msg}\n\nSetup wizard complete!", step.label() ) } else { let next = &state.steps[state.current_step_index()]; format!( "Step '{}' confirmed.{write_msg}\n\nNext: {} — run `wizard_generate` to begin.", step.label(), next.step.label() ) } } /// Skip the current wizard step without writing any file. fn wizard_skip_reply(ctx: &CommandContext) -> String { let root = ctx.project_root; let mut state = match WizardState::load(root) { Some(s) => s, None => return "No wizard active.".to_string(), }; if state.completed { return "Wizard is already complete.".to_string(); } let idx = state.current_step_index(); let step = state.steps[idx].step; if let Err(e) = state.skip_step(step) { return format!("Cannot skip step: {e}"); } if let Err(e) = state.save(root) { return format!("Failed to save wizard state: {e}"); } if state.completed { format!( "Step '{}' skipped. Setup wizard complete!", step.label() ) } else { let next = &state.steps[state.current_step_index()]; 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. fn wizard_retry_reply(ctx: &CommandContext) -> String { let root = ctx.project_root; let mut state = match WizardState::load(root) { Some(s) => s, None => return "No wizard active.".to_string(), }; if state.completed { return "Wizard is already complete.".to_string(); } let idx = state.current_step_index(); let step = state.steps[idx].step; if let Some(s) = state.steps.iter_mut().find(|s| s.step == step) { s.status = StepStatus::Pending; s.content = None; } if let Err(e) = state.save(root) { return format!("Failed to save wizard state: {e}"); } format!( "Step '{}' reset to pending. Run `wizard_generate` to regenerate content.", step.label() ) } // ── tests ───────────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; use crate::io::wizard::WizardState; use std::collections::HashSet; use std::sync::{Arc, Mutex}; use tempfile::TempDir; fn make_ctx<'a>( args: &'a str, project_root: &'a std::path::Path, agents: &'a Arc, ambient_rooms: &'a Arc>>, ) -> CommandContext<'a> { CommandContext { bot_name: "Bot", args, project_root, agents, ambient_rooms, room_id: "!test:example.com", } } #[test] fn setup_no_wizard_returns_helpful_message() { let dir = TempDir::new().unwrap(); let agents = Arc::new(crate::agents::AgentPool::new_test(4000)); let rooms = Arc::new(Mutex::new(HashSet::new())); let ctx = make_ctx("", dir.path(), &agents, &rooms); let result = handle_setup(&ctx).unwrap(); assert!(result.contains("huskies init")); } #[test] fn setup_with_wizard_shows_status() { let dir = TempDir::new().unwrap(); std::fs::create_dir_all(dir.path().join(".huskies")).unwrap(); WizardState::init_if_missing(dir.path()); let agents = Arc::new(crate::agents::AgentPool::new_test(4001)); let rooms = Arc::new(Mutex::new(HashSet::new())); let ctx = make_ctx("", dir.path(), &agents, &rooms); let result = handle_setup(&ctx).unwrap(); assert!(result.contains("Setup wizard")); } #[test] fn setup_skip_advances_wizard() { let dir = TempDir::new().unwrap(); std::fs::create_dir_all(dir.path().join(".huskies")).unwrap(); WizardState::init_if_missing(dir.path()); let agents = Arc::new(crate::agents::AgentPool::new_test(4002)); let rooms = Arc::new(Mutex::new(HashSet::new())); let ctx = make_ctx("skip", dir.path(), &agents, &rooms); let result = handle_setup(&ctx).unwrap(); assert!(result.contains("skipped")); let state = WizardState::load(dir.path()).unwrap(); assert_eq!(state.current_step_index(), 2); } #[test] fn setup_confirm_advances_wizard() { let dir = TempDir::new().unwrap(); std::fs::create_dir_all(dir.path().join(".huskies")).unwrap(); WizardState::init_if_missing(dir.path()); let agents = Arc::new(crate::agents::AgentPool::new_test(4003)); let rooms = Arc::new(Mutex::new(HashSet::new())); let ctx = make_ctx("confirm", dir.path(), &agents, &rooms); let result = handle_setup(&ctx).unwrap(); assert!(result.contains("confirmed")); let state = WizardState::load(dir.path()).unwrap(); assert_eq!(state.current_step_index(), 2); } #[test] fn setup_retry_resets_step() { let dir = TempDir::new().unwrap(); std::fs::create_dir_all(dir.path().join(".huskies")).unwrap(); WizardState::init_if_missing(dir.path()); // Stage some content first. { let mut state = WizardState::load(dir.path()).unwrap(); state.set_step_status( crate::io::wizard::WizardStep::Context, crate::io::wizard::StepStatus::AwaitingConfirmation, Some("content".to_string()), ); state.save(dir.path()).unwrap(); } let agents = Arc::new(crate::agents::AgentPool::new_test(4004)); let rooms = Arc::new(Mutex::new(HashSet::new())); let ctx = make_ctx("retry", dir.path(), &agents, &rooms); let result = handle_setup(&ctx).unwrap(); assert!(result.contains("reset")); let state = WizardState::load(dir.path()).unwrap(); assert_eq!( state.steps[1].status, crate::io::wizard::StepStatus::Pending ); } #[test] fn setup_unknown_sub_command_returns_usage() { let dir = TempDir::new().unwrap(); let agents = Arc::new(crate::agents::AgentPool::new_test(4005)); let rooms = Arc::new(Mutex::new(HashSet::new())); let ctx = make_ctx("foobar", dir.path(), &agents, &rooms); let result = handle_setup(&ctx).unwrap(); assert!(result.contains("Unknown sub-command")); assert!(result.contains("Usage")); } #[test] fn setup_generate_marks_generating_and_returns_hint() { let dir = TempDir::new().unwrap(); std::fs::create_dir_all(dir.path().join(".huskies")).unwrap(); WizardState::init_if_missing(dir.path()); let agents = Arc::new(crate::agents::AgentPool::new_test(4006)); let rooms = Arc::new(Mutex::new(HashSet::new())); let ctx = make_ctx("generate", dir.path(), &agents, &rooms); let result = handle_setup(&ctx).unwrap(); assert!(result.contains("generating")); let state = WizardState::load(dir.path()).unwrap(); assert_eq!( state.steps[1].status, crate::io::wizard::StepStatus::Generating ); } #[test] fn setup_generate_bare_project_asks_user() { let dir = TempDir::new().unwrap(); // Bare project — only scaffolding files std::fs::create_dir_all(dir.path().join(".huskies")).unwrap(); WizardState::init_if_missing(dir.path()); let agents = Arc::new(crate::agents::AgentPool::new_test(4007)); let rooms = Arc::new(Mutex::new(HashSet::new())); let ctx = make_ctx("generate", dir.path(), &agents, &rooms); let result = handle_setup(&ctx).unwrap(); assert!(result.contains("bare project")); assert!(result.contains("Ask the user")); } #[test] fn setup_generate_no_wizard_returns_error() { let dir = TempDir::new().unwrap(); let agents = Arc::new(crate::agents::AgentPool::new_test(4008)); let rooms = Arc::new(Mutex::new(HashSet::new())); let ctx = make_ctx("generate", dir.path(), &agents, &rooms); let result = handle_setup(&ctx).unwrap(); assert!(result.contains("No wizard active")); } }