storkit: merge 429_story_interactive_project_setup_wizard_for_new_storkit_projects
This commit is contained in:
@@ -0,0 +1,303 @@
|
||||
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(".storkit")).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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user