//! HTTP wizard endpoints — REST API for the project setup wizard. use crate::http::context::{AppContext, OpenApiResult, bad_request, not_found}; use crate::io::wizard::{StepStatus, WizardState, WizardStep}; use poem_openapi::{Object, OpenApi, Tags, param::Path, payload::Json}; use serde::{Deserialize, Serialize}; use std::sync::Arc; #[derive(Tags)] enum WizardTags { Wizard, } /// Response for a single wizard step. #[derive(Serialize, Object)] struct StepResponse { step: String, label: String, status: String, #[oai(skip_serializing_if = "Option::is_none")] content: Option, } /// Full wizard state response. #[derive(Serialize, Object)] struct WizardResponse { steps: Vec, current_step_index: usize, completed: bool, } /// Request body for confirming/skipping a step or submitting content. #[derive(Deserialize, Object)] struct StepActionPayload { /// Optional content to store for the step (e.g., generated spec). #[oai(skip_serializing_if = "Option::is_none")] content: Option, } impl From<&WizardState> for WizardResponse { fn from(state: &WizardState) -> Self { WizardResponse { steps: state .steps .iter() .map(|s| StepResponse { step: serde_json::to_value(s.step) .ok() .and_then(|v| v.as_str().map(String::from)) .unwrap_or_default(), label: s.step.label().to_string(), status: serde_json::to_value(&s.status) .ok() .and_then(|v| v.as_str().map(String::from)) .unwrap_or_default(), content: s.content.clone(), }) .collect(), current_step_index: state.current_step_index(), completed: state.completed, } } } fn parse_step(step_str: &str) -> Result { let quoted = format!("\"{step_str}\""); serde_json::from_str::("ed) .map_err(|_| not_found(format!("Unknown wizard step: {step_str}"))) } pub struct WizardApi { pub ctx: Arc, } #[OpenApi(tag = "WizardTags::Wizard")] impl WizardApi { /// Get the current wizard state. /// /// Returns the full setup wizard progress including all steps and their /// statuses. Returns 404 if no wizard is active. #[oai(path = "/wizard", method = "get")] async fn get_wizard_state(&self) -> OpenApiResult> { let root = self.ctx.state.get_project_root().map_err(bad_request)?; let state = WizardState::load(&root).ok_or_else(|| not_found("No wizard active".to_string()))?; Ok(Json(WizardResponse::from(&state))) } /// Set a step's content and mark it as awaiting confirmation. /// /// Used after the agent generates content for a step. The content is /// stored for preview and the step is marked as awaiting user confirmation. #[oai(path = "/wizard/step/:step/content", method = "put")] async fn set_step_content( &self, step: Path, payload: Json, ) -> OpenApiResult> { let root = self.ctx.state.get_project_root().map_err(bad_request)?; let wizard_step = parse_step(&step.0)?; let mut state = WizardState::load(&root).ok_or_else(|| not_found("No wizard active".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))) } /// Confirm a step and advance to the next. /// /// The step must be the current active step. Returns the updated wizard state. #[oai(path = "/wizard/step/:step/confirm", method = "post")] async fn confirm_step(&self, step: Path) -> OpenApiResult> { let root = self.ctx.state.get_project_root().map_err(bad_request)?; let wizard_step = parse_step(&step.0)?; let mut state = WizardState::load(&root).ok_or_else(|| not_found("No wizard active".to_string()))?; state.confirm_step(wizard_step).map_err(bad_request)?; state.save(&root).map_err(bad_request)?; Ok(Json(WizardResponse::from(&state))) } /// Skip a step and advance to the next. /// /// The step must be the current active step. #[oai(path = "/wizard/step/:step/skip", method = "post")] async fn skip_step(&self, step: Path) -> OpenApiResult> { let root = self.ctx.state.get_project_root().map_err(bad_request)?; let wizard_step = parse_step(&step.0)?; let mut state = WizardState::load(&root).ok_or_else(|| not_found("No wizard active".to_string()))?; state.skip_step(wizard_step).map_err(bad_request)?; state.save(&root).map_err(bad_request)?; Ok(Json(WizardResponse::from(&state))) } /// Mark a step as generating (agent is working on it). #[oai(path = "/wizard/step/:step/generating", method = "post")] async fn mark_generating(&self, step: Path) -> OpenApiResult> { let root = self.ctx.state.get_project_root().map_err(bad_request)?; let wizard_step = parse_step(&step.0)?; let mut state = WizardState::load(&root).ok_or_else(|| not_found("No wizard active".to_string()))?; state.set_step_status(wizard_step, StepStatus::Generating, None); state.save(&root).map_err(bad_request)?; Ok(Json(WizardResponse::from(&state))) } } #[cfg(test)] mod tests { use super::*; use crate::http::context::AppContext; use poem::http::StatusCode; use poem::test::TestClient; use poem_openapi::OpenApiService; use tempfile::TempDir; fn setup() -> (TempDir, TestClient) { let dir = TempDir::new().unwrap(); let root = dir.path().to_path_buf(); std::fs::create_dir_all(root.join(".huskies")).unwrap(); let ctx = Arc::new(AppContext::new_test(root.clone())); let api = WizardApi { ctx }; let service = OpenApiService::new(api, "test", "0.1.0"); let client = TestClient::new(service); (dir, client) } #[tokio::test] async fn get_wizard_returns_404_when_no_wizard() { let (_dir, client) = setup(); let resp = client.get("/wizard").send().await; resp.assert_status(StatusCode::NOT_FOUND); } #[tokio::test] async fn get_wizard_returns_state_when_active() { let (dir, client) = setup(); WizardState::init_if_missing(dir.path()); let resp = client.get("/wizard").send().await; resp.assert_status_is_ok(); let body: serde_json::Value = resp.0.into_body().into_json().await.unwrap(); assert_eq!(body["current_step_index"], 1); assert!(!body["completed"].as_bool().unwrap()); assert_eq!(body["steps"].as_array().unwrap().len(), 8); assert_eq!(body["steps"][0]["status"], "confirmed"); } #[tokio::test] async fn confirm_step_advances_wizard() { let (dir, client) = setup(); WizardState::init_if_missing(dir.path()); let resp = client.post("/wizard/step/context/confirm").send().await; resp.assert_status_is_ok(); let body: serde_json::Value = resp.0.into_body().into_json().await.unwrap(); assert_eq!(body["current_step_index"], 2); assert_eq!(body["steps"][1]["status"], "confirmed"); } #[tokio::test] async fn confirm_wrong_step_returns_error() { let (dir, client) = setup(); WizardState::init_if_missing(dir.path()); // Try to confirm step 3 (stack) when current is step 2 (context) let resp = client.post("/wizard/step/stack/confirm").send().await; resp.assert_status(StatusCode::BAD_REQUEST); } #[tokio::test] async fn skip_step_advances_wizard() { let (dir, client) = setup(); WizardState::init_if_missing(dir.path()); let resp = client.post("/wizard/step/context/skip").send().await; resp.assert_status_is_ok(); let body: serde_json::Value = resp.0.into_body().into_json().await.unwrap(); assert_eq!(body["steps"][1]["status"], "skipped"); assert_eq!(body["current_step_index"], 2); } #[tokio::test] async fn set_step_content_marks_awaiting_confirmation() { let (dir, client) = setup(); WizardState::init_if_missing(dir.path()); let resp = client .put("/wizard/step/context/content") .body_json(&serde_json::json!({ "content": "# My Project\n\nA great project." })) .send() .await; resp.assert_status_is_ok(); let body: serde_json::Value = resp.0.into_body().into_json().await.unwrap(); assert_eq!(body["steps"][1]["status"], "awaiting_confirmation"); assert_eq!( body["steps"][1]["content"], "# My Project\n\nA great project." ); } #[tokio::test] async fn mark_generating_updates_step() { let (dir, client) = setup(); WizardState::init_if_missing(dir.path()); let resp = client.post("/wizard/step/context/generating").send().await; resp.assert_status_is_ok(); let body: serde_json::Value = resp.0.into_body().into_json().await.unwrap(); assert_eq!(body["steps"][1]["status"], "generating"); } #[tokio::test] async fn unknown_step_returns_404() { let (dir, client) = setup(); WizardState::init_if_missing(dir.path()); let resp = client.post("/wizard/step/nonexistent/confirm").send().await; resp.assert_status(StatusCode::NOT_FOUND); } #[tokio::test] async fn full_wizard_flow_completes() { let (dir, client) = setup(); WizardState::init_if_missing(dir.path()); // Steps 2-8 (scaffold is already confirmed) let steps = [ "context", "stack", "test_script", "build_script", "lint_script", "release_script", "test_coverage", ]; for step in steps { let resp = client .post(format!("/wizard/step/{step}/confirm")) .send() .await; resp.assert_status_is_ok(); } // Check final state let resp = client.get("/wizard").send().await; resp.assert_status_is_ok(); let body: serde_json::Value = resp.0.into_body().into_json().await.unwrap(); assert!(body["completed"].as_bool().unwrap()); } }