storkit: merge 429_story_interactive_project_setup_wizard_for_new_storkit_projects
This commit is contained in:
@@ -289,6 +289,7 @@ fn write_story_kit_gitignore(root: &Path) -> Result<(), String> {
|
||||
"work/4_merge/",
|
||||
"logs/",
|
||||
"token_usage.jsonl",
|
||||
"wizard_state.json",
|
||||
];
|
||||
|
||||
let gitignore_path = root.join(".storkit").join(".gitignore");
|
||||
|
||||
@@ -4,3 +4,4 @@ pub mod search;
|
||||
pub mod shell;
|
||||
pub mod story_metadata;
|
||||
pub mod watcher;
|
||||
pub mod wizard;
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
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 {
|
||||
/// Step 1: scaffold .storkit/ directory structure and project.toml
|
||||
Scaffold,
|
||||
/// Step 2: generate specs/00_CONTEXT.md
|
||||
Context,
|
||||
/// Step 3: generate specs/tech/STACK.md
|
||||
Stack,
|
||||
/// Step 4: create script/test
|
||||
TestScript,
|
||||
/// Step 5: create script/release
|
||||
ReleaseScript,
|
||||
/// Step 6: create script/test_coverage
|
||||
TestCoverage,
|
||||
}
|
||||
|
||||
impl WizardStep {
|
||||
/// All steps in order.
|
||||
pub const ALL: &[WizardStep] = &[
|
||||
WizardStep::Scaffold,
|
||||
WizardStep::Context,
|
||||
WizardStep::Stack,
|
||||
WizardStep::TestScript,
|
||||
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)",
|
||||
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>,
|
||||
}
|
||||
|
||||
/// Persistent wizard state, stored in `.storkit/wizard_state.json`.
|
||||
#[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 {
|
||||
project_root.join(".storkit").join("wizard_state.json")
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Step 1 (Scaffold) is automatically confirmed since `storkit init`
|
||||
/// 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(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn setup_project(dir: &TempDir) -> std::path::PathBuf {
|
||||
let root = dir.path().to_path_buf();
|
||||
let sk = root.join(".storkit");
|
||||
std::fs::create_dir_all(&sk).unwrap();
|
||||
root
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_state_has_all_steps_pending() {
|
||||
let state = WizardState::default();
|
||||
assert_eq!(state.steps.len(), 6);
|
||||
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\nA 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\nA 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);
|
||||
assert_eq!(
|
||||
state.steps[1].content.as_deref(),
|
||||
Some("generated content")
|
||||
);
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user