2d8ccb3eb6
Rename all references from storkit to huskies across the codebase: - .storkit/ directory → .huskies/ - Binary name, Cargo package name, Docker image references - Server code, frontend code, config files, scripts - Fix script/test to build frontend before cargo clippy/test so merge worktrees have frontend/dist available for RustEmbed Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
304 lines
11 KiB
Rust
304 lines
11 KiB
Rust
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<String>,
|
|
}
|
|
|
|
/// Full wizard state response.
|
|
#[derive(Serialize, Object)]
|
|
struct WizardResponse {
|
|
steps: Vec<StepResponse>,
|
|
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<String>,
|
|
}
|
|
|
|
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<WizardStep, poem::Error> {
|
|
let quoted = format!("\"{step_str}\"");
|
|
serde_json::from_str::<WizardStep>("ed)
|
|
.map_err(|_| not_found(format!("Unknown wizard step: {step_str}")))
|
|
}
|
|
|
|
pub struct WizardApi {
|
|
pub ctx: Arc<AppContext>,
|
|
}
|
|
|
|
#[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<Json<WizardResponse>> {
|
|
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<String>,
|
|
payload: Json<StepActionPayload>,
|
|
) -> OpenApiResult<Json<WizardResponse>> {
|
|
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<String>) -> OpenApiResult<Json<WizardResponse>> {
|
|
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<String>) -> OpenApiResult<Json<WizardResponse>> {
|
|
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<String>) -> OpenApiResult<Json<WizardResponse>> {
|
|
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<impl poem::Endpoint>) {
|
|
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(), 6);
|
|
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-6 (scaffold is already confirmed)
|
|
let steps = ["context", "stack", "test_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());
|
|
}
|
|
}
|