2026-02-19 12:54:04 +00:00
|
|
|
use crate::http::context::{AppContext, OpenApiResult, bad_request};
|
2026-02-19 15:33:45 +00:00
|
|
|
use crate::io::story_metadata::{StoryMetadata, parse_front_matter, parse_unchecked_todos};
|
2026-02-19 12:54:04 +00:00
|
|
|
use crate::workflow::{
|
2026-02-19 14:45:57 +00:00
|
|
|
CoverageReport, StoryTestResults, TestCaseResult, TestStatus,
|
|
|
|
|
evaluate_acceptance_with_coverage, parse_coverage_json, summarize_results,
|
2026-02-19 12:54:04 +00:00
|
|
|
};
|
|
|
|
|
use poem_openapi::{Object, OpenApi, Tags, payload::Json};
|
|
|
|
|
use serde::Deserialize;
|
|
|
|
|
use std::collections::BTreeSet;
|
|
|
|
|
use std::fs;
|
|
|
|
|
use std::sync::Arc;
|
|
|
|
|
|
|
|
|
|
#[derive(Tags)]
|
|
|
|
|
enum WorkflowTags {
|
|
|
|
|
Workflow,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize, Object)]
|
|
|
|
|
struct TestCasePayload {
|
|
|
|
|
pub name: String,
|
|
|
|
|
pub status: String,
|
|
|
|
|
pub details: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize, Object)]
|
|
|
|
|
struct RecordTestsPayload {
|
|
|
|
|
pub story_id: String,
|
|
|
|
|
pub unit: Vec<TestCasePayload>,
|
|
|
|
|
pub integration: Vec<TestCasePayload>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize, Object)]
|
|
|
|
|
struct AcceptanceRequest {
|
|
|
|
|
pub story_id: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Object)]
|
|
|
|
|
struct TestRunSummaryResponse {
|
|
|
|
|
pub total: usize,
|
|
|
|
|
pub passed: usize,
|
|
|
|
|
pub failed: usize,
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 14:45:57 +00:00
|
|
|
#[derive(Object)]
|
|
|
|
|
struct CoverageReportResponse {
|
|
|
|
|
pub current_percent: f64,
|
|
|
|
|
pub threshold_percent: f64,
|
|
|
|
|
pub baseline_percent: Option<f64>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 12:54:04 +00:00
|
|
|
#[derive(Object)]
|
|
|
|
|
struct AcceptanceResponse {
|
|
|
|
|
pub can_accept: bool,
|
|
|
|
|
pub reasons: Vec<String>,
|
|
|
|
|
pub warning: Option<String>,
|
|
|
|
|
pub summary: TestRunSummaryResponse,
|
|
|
|
|
pub missing_categories: Vec<String>,
|
2026-02-19 14:45:57 +00:00
|
|
|
pub coverage_report: Option<CoverageReportResponse>,
|
2026-02-19 12:54:04 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Object)]
|
|
|
|
|
struct ReviewStory {
|
|
|
|
|
pub story_id: String,
|
|
|
|
|
pub can_accept: bool,
|
|
|
|
|
pub reasons: Vec<String>,
|
|
|
|
|
pub warning: Option<String>,
|
|
|
|
|
pub summary: TestRunSummaryResponse,
|
|
|
|
|
pub missing_categories: Vec<String>,
|
2026-02-19 14:45:57 +00:00
|
|
|
pub coverage_report: Option<CoverageReportResponse>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize, Object)]
|
|
|
|
|
struct RecordCoveragePayload {
|
|
|
|
|
pub story_id: String,
|
|
|
|
|
pub current_percent: f64,
|
|
|
|
|
pub threshold_percent: Option<f64>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize, Object)]
|
|
|
|
|
struct CollectCoverageRequest {
|
|
|
|
|
pub story_id: String,
|
|
|
|
|
pub threshold_percent: Option<f64>,
|
2026-02-19 12:54:04 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Object)]
|
|
|
|
|
struct ReviewListResponse {
|
|
|
|
|
pub stories: Vec<ReviewStory>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 15:33:45 +00:00
|
|
|
#[derive(Object)]
|
|
|
|
|
struct StoryTodosResponse {
|
|
|
|
|
pub story_id: String,
|
|
|
|
|
pub story_name: Option<String>,
|
|
|
|
|
pub todos: Vec<String>,
|
2026-02-19 18:02:48 +00:00
|
|
|
pub error: Option<String>,
|
2026-02-19 15:33:45 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Object)]
|
|
|
|
|
struct TodoListResponse {
|
|
|
|
|
pub stories: Vec<StoryTodosResponse>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 15:51:12 +00:00
|
|
|
#[derive(Object)]
|
|
|
|
|
struct UpcomingStory {
|
|
|
|
|
pub story_id: String,
|
|
|
|
|
pub name: Option<String>,
|
2026-02-19 18:02:48 +00:00
|
|
|
pub error: Option<String>,
|
2026-02-19 15:51:12 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Object)]
|
|
|
|
|
struct UpcomingStoriesResponse {
|
|
|
|
|
pub stories: Vec<UpcomingStory>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 18:02:48 +00:00
|
|
|
#[derive(Deserialize, Object)]
|
|
|
|
|
struct CreateStoryPayload {
|
|
|
|
|
pub name: String,
|
|
|
|
|
pub user_story: Option<String>,
|
|
|
|
|
pub acceptance_criteria: Option<Vec<String>>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Object)]
|
|
|
|
|
struct CreateStoryResponse {
|
|
|
|
|
pub story_id: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Object)]
|
|
|
|
|
struct StoryValidationResult {
|
|
|
|
|
pub story_id: String,
|
|
|
|
|
pub valid: bool,
|
|
|
|
|
pub error: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Object)]
|
|
|
|
|
struct ValidateStoriesResponse {
|
|
|
|
|
pub stories: Vec<StoryValidationResult>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 15:51:12 +00:00
|
|
|
fn load_upcoming_stories(ctx: &AppContext) -> Result<Vec<UpcomingStory>, String> {
|
|
|
|
|
let root = ctx.state.get_project_root()?;
|
|
|
|
|
let upcoming_dir = root.join(".story_kit").join("stories").join("upcoming");
|
|
|
|
|
|
|
|
|
|
if !upcoming_dir.exists() {
|
|
|
|
|
return Ok(Vec::new());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut stories = Vec::new();
|
|
|
|
|
for entry in fs::read_dir(&upcoming_dir)
|
|
|
|
|
.map_err(|e| format!("Failed to read upcoming stories directory: {e}"))?
|
|
|
|
|
{
|
|
|
|
|
let entry = entry.map_err(|e| format!("Failed to read upcoming story entry: {e}"))?;
|
|
|
|
|
let path = entry.path();
|
|
|
|
|
if path.extension().and_then(|ext| ext.to_str()) != Some("md") {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
let story_id = path
|
|
|
|
|
.file_stem()
|
|
|
|
|
.and_then(|stem| stem.to_str())
|
|
|
|
|
.ok_or_else(|| "Invalid story file name.".to_string())?
|
|
|
|
|
.to_string();
|
|
|
|
|
let contents = fs::read_to_string(&path)
|
|
|
|
|
.map_err(|e| format!("Failed to read story file {}: {e}", path.display()))?;
|
2026-02-19 18:02:48 +00:00
|
|
|
let (name, error) = match parse_front_matter(&contents) {
|
|
|
|
|
Ok(meta) => (meta.name, None),
|
|
|
|
|
Err(e) => (None, Some(e.to_string())),
|
|
|
|
|
};
|
|
|
|
|
stories.push(UpcomingStory { story_id, name, error });
|
2026-02-19 15:51:12 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
stories.sort_by(|a, b| a.story_id.cmp(&b.story_id));
|
|
|
|
|
Ok(stories)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 12:54:04 +00:00
|
|
|
fn load_current_story_metadata(ctx: &AppContext) -> Result<Vec<(String, StoryMetadata)>, String> {
|
|
|
|
|
let root = ctx.state.get_project_root()?;
|
|
|
|
|
let current_dir = root.join(".story_kit").join("stories").join("current");
|
|
|
|
|
|
|
|
|
|
if !current_dir.exists() {
|
|
|
|
|
return Ok(Vec::new());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut stories = Vec::new();
|
|
|
|
|
for entry in fs::read_dir(¤t_dir)
|
|
|
|
|
.map_err(|e| format!("Failed to read current stories directory: {e}"))?
|
|
|
|
|
{
|
|
|
|
|
let entry = entry.map_err(|e| format!("Failed to read current story entry: {e}"))?;
|
|
|
|
|
let path = entry.path();
|
|
|
|
|
if path.extension().and_then(|ext| ext.to_str()) != Some("md") {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
let story_id = path
|
|
|
|
|
.file_stem()
|
|
|
|
|
.and_then(|stem| stem.to_str())
|
|
|
|
|
.ok_or_else(|| "Invalid story file name.".to_string())?
|
|
|
|
|
.to_string();
|
|
|
|
|
let contents = fs::read_to_string(&path)
|
|
|
|
|
.map_err(|e| format!("Failed to read story file {}: {e}", path.display()))?;
|
|
|
|
|
let metadata = parse_front_matter(&contents)
|
|
|
|
|
.map_err(|e| format!("Failed to parse front matter for {story_id}: {e:?}"))?;
|
|
|
|
|
stories.push((story_id, metadata));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(stories)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 14:45:57 +00:00
|
|
|
fn to_review_story(
|
|
|
|
|
story_id: &str,
|
|
|
|
|
results: &StoryTestResults,
|
|
|
|
|
coverage: Option<&CoverageReport>,
|
|
|
|
|
) -> ReviewStory {
|
|
|
|
|
let decision = evaluate_acceptance_with_coverage(results, coverage);
|
2026-02-19 12:54:04 +00:00
|
|
|
let summary = summarize_results(results);
|
|
|
|
|
|
|
|
|
|
let mut missing_categories = Vec::new();
|
|
|
|
|
let mut reasons = decision.reasons;
|
|
|
|
|
|
|
|
|
|
if results.unit.is_empty() {
|
|
|
|
|
missing_categories.push("unit".to_string());
|
|
|
|
|
reasons.push("Missing unit test results.".to_string());
|
|
|
|
|
}
|
|
|
|
|
if results.integration.is_empty() {
|
|
|
|
|
missing_categories.push("integration".to_string());
|
|
|
|
|
reasons.push("Missing integration test results.".to_string());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let can_accept = decision.can_accept && missing_categories.is_empty();
|
|
|
|
|
|
2026-02-19 14:45:57 +00:00
|
|
|
let coverage_report = coverage.map(|c| CoverageReportResponse {
|
|
|
|
|
current_percent: c.current_percent,
|
|
|
|
|
threshold_percent: c.threshold_percent,
|
|
|
|
|
baseline_percent: c.baseline_percent,
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-19 12:54:04 +00:00
|
|
|
ReviewStory {
|
|
|
|
|
story_id: story_id.to_string(),
|
|
|
|
|
can_accept,
|
|
|
|
|
reasons,
|
|
|
|
|
warning: decision.warning,
|
|
|
|
|
summary: TestRunSummaryResponse {
|
|
|
|
|
total: summary.total,
|
|
|
|
|
passed: summary.passed,
|
|
|
|
|
failed: summary.failed,
|
|
|
|
|
},
|
|
|
|
|
missing_categories,
|
2026-02-19 14:45:57 +00:00
|
|
|
coverage_report,
|
2026-02-19 12:54:04 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub struct WorkflowApi {
|
|
|
|
|
pub ctx: Arc<AppContext>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[OpenApi(tag = "WorkflowTags::Workflow")]
|
|
|
|
|
impl WorkflowApi {
|
|
|
|
|
/// Record test results for a story (unit + integration).
|
|
|
|
|
#[oai(path = "/workflow/tests/record", method = "post")]
|
|
|
|
|
async fn record_tests(&self, payload: Json<RecordTestsPayload>) -> OpenApiResult<Json<bool>> {
|
|
|
|
|
let unit = payload
|
|
|
|
|
.0
|
|
|
|
|
.unit
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(to_test_case)
|
|
|
|
|
.collect::<Result<Vec<_>, String>>()
|
|
|
|
|
.map_err(bad_request)?;
|
|
|
|
|
let integration = payload
|
|
|
|
|
.0
|
|
|
|
|
.integration
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(to_test_case)
|
|
|
|
|
.collect::<Result<Vec<_>, String>>()
|
|
|
|
|
.map_err(bad_request)?;
|
|
|
|
|
|
|
|
|
|
let mut workflow = self
|
|
|
|
|
.ctx
|
|
|
|
|
.workflow
|
|
|
|
|
.lock()
|
|
|
|
|
.map_err(|e| bad_request(e.to_string()))?;
|
|
|
|
|
workflow
|
|
|
|
|
.record_test_results_validated(payload.0.story_id, unit, integration)
|
|
|
|
|
.map_err(bad_request)?;
|
|
|
|
|
|
|
|
|
|
Ok(Json(true))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Evaluate acceptance readiness for a story.
|
|
|
|
|
#[oai(path = "/workflow/acceptance", method = "post")]
|
|
|
|
|
async fn acceptance(
|
|
|
|
|
&self,
|
|
|
|
|
payload: Json<AcceptanceRequest>,
|
|
|
|
|
) -> OpenApiResult<Json<AcceptanceResponse>> {
|
2026-02-19 14:45:57 +00:00
|
|
|
let (results, coverage) = {
|
2026-02-19 12:54:04 +00:00
|
|
|
let workflow = self
|
|
|
|
|
.ctx
|
|
|
|
|
.workflow
|
|
|
|
|
.lock()
|
|
|
|
|
.map_err(|e| bad_request(e.to_string()))?;
|
2026-02-19 14:45:57 +00:00
|
|
|
let results = workflow
|
2026-02-19 12:54:04 +00:00
|
|
|
.results
|
|
|
|
|
.get(&payload.0.story_id)
|
|
|
|
|
.cloned()
|
2026-02-19 14:45:57 +00:00
|
|
|
.unwrap_or_default();
|
|
|
|
|
let coverage = workflow.coverage.get(&payload.0.story_id).cloned();
|
|
|
|
|
(results, coverage)
|
2026-02-19 12:54:04 +00:00
|
|
|
};
|
|
|
|
|
|
2026-02-19 14:45:57 +00:00
|
|
|
let decision =
|
|
|
|
|
evaluate_acceptance_with_coverage(&results, coverage.as_ref());
|
2026-02-19 12:54:04 +00:00
|
|
|
let summary = summarize_results(&results);
|
|
|
|
|
|
|
|
|
|
let mut missing_categories = Vec::new();
|
|
|
|
|
let mut reasons = decision.reasons;
|
|
|
|
|
|
|
|
|
|
if results.unit.is_empty() {
|
|
|
|
|
missing_categories.push("unit".to_string());
|
|
|
|
|
reasons.push("Missing unit test results.".to_string());
|
|
|
|
|
}
|
|
|
|
|
if results.integration.is_empty() {
|
|
|
|
|
missing_categories.push("integration".to_string());
|
|
|
|
|
reasons.push("Missing integration test results.".to_string());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let can_accept = decision.can_accept && missing_categories.is_empty();
|
|
|
|
|
|
2026-02-19 14:45:57 +00:00
|
|
|
let coverage_report = coverage.map(|c| CoverageReportResponse {
|
|
|
|
|
current_percent: c.current_percent,
|
|
|
|
|
threshold_percent: c.threshold_percent,
|
|
|
|
|
baseline_percent: c.baseline_percent,
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-19 12:54:04 +00:00
|
|
|
Ok(Json(AcceptanceResponse {
|
|
|
|
|
can_accept,
|
|
|
|
|
reasons,
|
|
|
|
|
warning: decision.warning,
|
|
|
|
|
summary: TestRunSummaryResponse {
|
|
|
|
|
total: summary.total,
|
|
|
|
|
passed: summary.passed,
|
|
|
|
|
failed: summary.failed,
|
|
|
|
|
},
|
|
|
|
|
missing_categories,
|
2026-02-19 14:45:57 +00:00
|
|
|
coverage_report,
|
2026-02-19 12:54:04 +00:00
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// List stories that are ready for human review.
|
|
|
|
|
#[oai(path = "/workflow/review", method = "get")]
|
|
|
|
|
async fn review_queue(&self) -> OpenApiResult<Json<ReviewListResponse>> {
|
|
|
|
|
let stories = {
|
|
|
|
|
let workflow = self
|
|
|
|
|
.ctx
|
|
|
|
|
.workflow
|
|
|
|
|
.lock()
|
|
|
|
|
.map_err(|e| bad_request(e.to_string()))?;
|
|
|
|
|
workflow
|
|
|
|
|
.results
|
|
|
|
|
.iter()
|
2026-02-19 14:45:57 +00:00
|
|
|
.map(|(story_id, results)| {
|
|
|
|
|
let coverage = workflow.coverage.get(story_id);
|
|
|
|
|
to_review_story(story_id, results, coverage)
|
|
|
|
|
})
|
2026-02-19 12:54:04 +00:00
|
|
|
.filter(|story| story.can_accept)
|
|
|
|
|
.collect::<Vec<_>>()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
Ok(Json(ReviewListResponse { stories }))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// List stories in the review queue, including blocked items and current stories.
|
|
|
|
|
#[oai(path = "/workflow/review/all", method = "get")]
|
|
|
|
|
async fn review_queue_all(&self) -> OpenApiResult<Json<ReviewListResponse>> {
|
|
|
|
|
let current_stories =
|
|
|
|
|
load_current_story_metadata(self.ctx.as_ref()).map_err(bad_request)?;
|
|
|
|
|
let stories = {
|
|
|
|
|
let mut workflow = self
|
|
|
|
|
.ctx
|
|
|
|
|
.workflow
|
|
|
|
|
.lock()
|
|
|
|
|
.map_err(|e| bad_request(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
if !current_stories.is_empty() {
|
|
|
|
|
workflow.load_story_metadata(current_stories);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut story_ids = BTreeSet::new();
|
|
|
|
|
|
|
|
|
|
for story_id in workflow.results.keys() {
|
|
|
|
|
story_ids.insert(story_id.clone());
|
|
|
|
|
}
|
|
|
|
|
for story_id in workflow.stories.keys() {
|
|
|
|
|
story_ids.insert(story_id.clone());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
story_ids
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(|story_id| {
|
|
|
|
|
let results = workflow.results.get(&story_id).cloned().unwrap_or_default();
|
2026-02-19 14:45:57 +00:00
|
|
|
let coverage = workflow.coverage.get(&story_id);
|
|
|
|
|
to_review_story(&story_id, &results, coverage)
|
2026-02-19 12:54:04 +00:00
|
|
|
})
|
|
|
|
|
.collect::<Vec<_>>()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
Ok(Json(ReviewListResponse { stories }))
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 14:45:57 +00:00
|
|
|
/// Record coverage data for a story.
|
|
|
|
|
#[oai(path = "/workflow/coverage/record", method = "post")]
|
|
|
|
|
async fn record_coverage(
|
|
|
|
|
&self,
|
|
|
|
|
payload: Json<RecordCoveragePayload>,
|
|
|
|
|
) -> OpenApiResult<Json<bool>> {
|
|
|
|
|
let mut workflow = self
|
|
|
|
|
.ctx
|
|
|
|
|
.workflow
|
|
|
|
|
.lock()
|
|
|
|
|
.map_err(|e| bad_request(e.to_string()))?;
|
|
|
|
|
workflow.record_coverage(
|
|
|
|
|
payload.0.story_id,
|
|
|
|
|
payload.0.current_percent,
|
|
|
|
|
payload.0.threshold_percent,
|
|
|
|
|
);
|
|
|
|
|
Ok(Json(true))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Run coverage collection: execute test:coverage, parse output, record result.
|
|
|
|
|
#[oai(path = "/workflow/coverage/collect", method = "post")]
|
|
|
|
|
async fn collect_coverage(
|
|
|
|
|
&self,
|
|
|
|
|
payload: Json<CollectCoverageRequest>,
|
|
|
|
|
) -> OpenApiResult<Json<CoverageReportResponse>> {
|
|
|
|
|
let root = self
|
|
|
|
|
.ctx
|
|
|
|
|
.state
|
|
|
|
|
.get_project_root()
|
|
|
|
|
.map_err(bad_request)?;
|
|
|
|
|
|
|
|
|
|
let frontend_dir = root.join("frontend");
|
|
|
|
|
|
|
|
|
|
// Run pnpm run test:coverage in the frontend directory
|
|
|
|
|
let output = tokio::task::spawn_blocking(move || {
|
|
|
|
|
std::process::Command::new("pnpm")
|
|
|
|
|
.args(["run", "test:coverage"])
|
|
|
|
|
.current_dir(&frontend_dir)
|
|
|
|
|
.output()
|
|
|
|
|
})
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| bad_request(format!("Task join error: {e}")))?
|
|
|
|
|
.map_err(|e| bad_request(format!("Failed to run coverage command: {e}")))?;
|
|
|
|
|
|
|
|
|
|
if !output.status.success() {
|
|
|
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
|
|
|
return Err(bad_request(format!("Coverage command failed: {stderr}")));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Read the coverage summary JSON
|
|
|
|
|
let summary_path = root
|
|
|
|
|
.join("frontend")
|
|
|
|
|
.join("coverage")
|
|
|
|
|
.join("coverage-summary.json");
|
|
|
|
|
let json_str = fs::read_to_string(&summary_path)
|
|
|
|
|
.map_err(|e| bad_request(format!("Failed to read coverage summary: {e}")))?;
|
|
|
|
|
|
|
|
|
|
let current_percent = parse_coverage_json(&json_str).map_err(bad_request)?;
|
|
|
|
|
|
|
|
|
|
// Record coverage in workflow state
|
|
|
|
|
let coverage_report = {
|
|
|
|
|
let mut workflow = self
|
|
|
|
|
.ctx
|
|
|
|
|
.workflow
|
|
|
|
|
.lock()
|
|
|
|
|
.map_err(|e| bad_request(e.to_string()))?;
|
|
|
|
|
workflow.record_coverage(
|
|
|
|
|
payload.0.story_id.clone(),
|
|
|
|
|
current_percent,
|
|
|
|
|
payload.0.threshold_percent,
|
|
|
|
|
);
|
|
|
|
|
workflow
|
|
|
|
|
.coverage
|
|
|
|
|
.get(&payload.0.story_id)
|
|
|
|
|
.cloned()
|
|
|
|
|
.expect("just inserted")
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
Ok(Json(CoverageReportResponse {
|
|
|
|
|
current_percent: coverage_report.current_percent,
|
|
|
|
|
threshold_percent: coverage_report.threshold_percent,
|
|
|
|
|
baseline_percent: coverage_report.baseline_percent,
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 15:33:45 +00:00
|
|
|
/// List unchecked acceptance criteria (TODOs) for all current stories.
|
|
|
|
|
#[oai(path = "/workflow/todos", method = "get")]
|
|
|
|
|
async fn story_todos(&self) -> OpenApiResult<Json<TodoListResponse>> {
|
|
|
|
|
let root = self.ctx.state.get_project_root().map_err(bad_request)?;
|
|
|
|
|
let current_dir = root.join(".story_kit").join("stories").join("current");
|
|
|
|
|
|
|
|
|
|
if !current_dir.exists() {
|
|
|
|
|
return Ok(Json(TodoListResponse {
|
|
|
|
|
stories: Vec::new(),
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut stories = Vec::new();
|
|
|
|
|
let mut entries: Vec<_> = fs::read_dir(¤t_dir)
|
|
|
|
|
.map_err(|e| bad_request(format!("Failed to read current stories: {e}")))?
|
|
|
|
|
.filter_map(|e| e.ok())
|
|
|
|
|
.collect();
|
|
|
|
|
entries.sort_by_key(|e| e.file_name());
|
|
|
|
|
|
|
|
|
|
for entry in entries {
|
|
|
|
|
let path = entry.path();
|
|
|
|
|
if path.extension().and_then(|ext| ext.to_str()) != Some("md") {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
let story_id = path
|
|
|
|
|
.file_stem()
|
|
|
|
|
.and_then(|stem| stem.to_str())
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
|
.to_string();
|
|
|
|
|
let contents = fs::read_to_string(&path)
|
|
|
|
|
.map_err(|e| bad_request(format!("Failed to read {}: {e}", path.display())))?;
|
2026-02-19 18:02:48 +00:00
|
|
|
let (story_name, error) = match parse_front_matter(&contents) {
|
|
|
|
|
Ok(m) => (m.name, None),
|
|
|
|
|
Err(e) => (None, Some(e.to_string())),
|
|
|
|
|
};
|
2026-02-19 15:33:45 +00:00
|
|
|
let todos = parse_unchecked_todos(&contents);
|
|
|
|
|
stories.push(StoryTodosResponse {
|
|
|
|
|
story_id,
|
|
|
|
|
story_name,
|
|
|
|
|
todos,
|
2026-02-19 18:02:48 +00:00
|
|
|
error,
|
2026-02-19 15:33:45 +00:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(Json(TodoListResponse { stories }))
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 15:51:12 +00:00
|
|
|
/// List upcoming stories from .story_kit/stories/upcoming/.
|
|
|
|
|
#[oai(path = "/workflow/upcoming", method = "get")]
|
|
|
|
|
async fn list_upcoming_stories(&self) -> OpenApiResult<Json<UpcomingStoriesResponse>> {
|
|
|
|
|
let stories = load_upcoming_stories(self.ctx.as_ref()).map_err(bad_request)?;
|
|
|
|
|
Ok(Json(UpcomingStoriesResponse { stories }))
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 18:02:48 +00:00
|
|
|
/// Validate front matter on all current and upcoming story files.
|
|
|
|
|
#[oai(path = "/workflow/stories/validate", method = "get")]
|
|
|
|
|
async fn validate_stories(&self) -> OpenApiResult<Json<ValidateStoriesResponse>> {
|
|
|
|
|
let root = self.ctx.state.get_project_root().map_err(bad_request)?;
|
|
|
|
|
let stories = validate_story_dirs(&root).map_err(bad_request)?;
|
|
|
|
|
Ok(Json(ValidateStoriesResponse { stories }))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Create a new story file with correct front matter in upcoming/.
|
|
|
|
|
#[oai(path = "/workflow/stories/create", method = "post")]
|
|
|
|
|
async fn create_story(
|
|
|
|
|
&self,
|
|
|
|
|
payload: Json<CreateStoryPayload>,
|
|
|
|
|
) -> OpenApiResult<Json<CreateStoryResponse>> {
|
|
|
|
|
let root = self.ctx.state.get_project_root().map_err(bad_request)?;
|
|
|
|
|
let story_number = next_story_number(&root).map_err(bad_request)?;
|
|
|
|
|
let slug = slugify_name(&payload.0.name);
|
|
|
|
|
|
|
|
|
|
if slug.is_empty() {
|
|
|
|
|
return Err(bad_request("Name must contain at least one alphanumeric character.".to_string()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let filename = format!("{story_number}_{slug}.md");
|
|
|
|
|
let upcoming_dir = root.join(".story_kit").join("stories").join("upcoming");
|
|
|
|
|
fs::create_dir_all(&upcoming_dir)
|
|
|
|
|
.map_err(|e| bad_request(format!("Failed to create upcoming directory: {e}")))?;
|
|
|
|
|
|
|
|
|
|
let filepath = upcoming_dir.join(&filename);
|
|
|
|
|
if filepath.exists() {
|
|
|
|
|
return Err(bad_request(format!("Story file already exists: {filename}")));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let story_id = filepath
|
|
|
|
|
.file_stem()
|
|
|
|
|
.and_then(|s| s.to_str())
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
|
.to_string();
|
|
|
|
|
|
|
|
|
|
// Build file content
|
|
|
|
|
let mut content = String::new();
|
|
|
|
|
content.push_str("---\n");
|
|
|
|
|
content.push_str(&format!("name: {}\n", payload.0.name));
|
|
|
|
|
content.push_str("test_plan: pending\n");
|
|
|
|
|
content.push_str("---\n\n");
|
|
|
|
|
content.push_str(&format!("# Story {story_number}: {}\n\n", payload.0.name));
|
|
|
|
|
|
|
|
|
|
content.push_str("## User Story\n\n");
|
|
|
|
|
if let Some(ref us) = payload.0.user_story {
|
|
|
|
|
content.push_str(us);
|
|
|
|
|
content.push('\n');
|
|
|
|
|
} else {
|
|
|
|
|
content.push_str("As a ..., I want ..., so that ...\n");
|
|
|
|
|
}
|
|
|
|
|
content.push('\n');
|
|
|
|
|
|
|
|
|
|
content.push_str("## Acceptance Criteria\n\n");
|
|
|
|
|
if let Some(ref criteria) = payload.0.acceptance_criteria {
|
|
|
|
|
for criterion in criteria {
|
|
|
|
|
content.push_str(&format!("- [ ] {criterion}\n"));
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
content.push_str("- [ ] TODO\n");
|
|
|
|
|
}
|
|
|
|
|
content.push('\n');
|
|
|
|
|
|
|
|
|
|
content.push_str("## Out of Scope\n\n");
|
|
|
|
|
content.push_str("- TBD\n");
|
|
|
|
|
|
|
|
|
|
fs::write(&filepath, &content)
|
|
|
|
|
.map_err(|e| bad_request(format!("Failed to write story file: {e}")))?;
|
|
|
|
|
|
|
|
|
|
Ok(Json(CreateStoryResponse { story_id }))
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 12:54:04 +00:00
|
|
|
/// Ensure a story can be accepted; returns an error when gates fail.
|
|
|
|
|
#[oai(path = "/workflow/acceptance/ensure", method = "post")]
|
|
|
|
|
async fn ensure_acceptance(
|
|
|
|
|
&self,
|
|
|
|
|
payload: Json<AcceptanceRequest>,
|
|
|
|
|
) -> OpenApiResult<Json<bool>> {
|
|
|
|
|
let response = self.acceptance(payload).await?.0;
|
|
|
|
|
if response.can_accept {
|
|
|
|
|
return Ok(Json(true));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut parts = Vec::new();
|
|
|
|
|
if !response.reasons.is_empty() {
|
|
|
|
|
parts.push(response.reasons.join("; "));
|
|
|
|
|
}
|
|
|
|
|
if let Some(warning) = response.warning {
|
|
|
|
|
parts.push(warning);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let message = if parts.is_empty() {
|
|
|
|
|
"Acceptance is blocked.".to_string()
|
|
|
|
|
} else {
|
|
|
|
|
format!("Acceptance is blocked: {}", parts.join("; "))
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
Err(bad_request(message))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 18:02:48 +00:00
|
|
|
fn slugify_name(name: &str) -> String {
|
|
|
|
|
let slug: String = name
|
|
|
|
|
.chars()
|
|
|
|
|
.map(|c| {
|
|
|
|
|
if c.is_ascii_alphanumeric() {
|
|
|
|
|
c.to_ascii_lowercase()
|
|
|
|
|
} else {
|
|
|
|
|
'_'
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
// Collapse consecutive underscores and trim edges
|
|
|
|
|
let mut result = String::new();
|
|
|
|
|
let mut prev_underscore = true; // start true to trim leading _
|
|
|
|
|
for ch in slug.chars() {
|
|
|
|
|
if ch == '_' {
|
|
|
|
|
if !prev_underscore {
|
|
|
|
|
result.push('_');
|
|
|
|
|
}
|
|
|
|
|
prev_underscore = true;
|
|
|
|
|
} else {
|
|
|
|
|
result.push(ch);
|
|
|
|
|
prev_underscore = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Trim trailing underscore
|
|
|
|
|
if result.ends_with('_') {
|
|
|
|
|
result.pop();
|
|
|
|
|
}
|
|
|
|
|
result
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn next_story_number(root: &std::path::Path) -> Result<u32, String> {
|
|
|
|
|
let base = root.join(".story_kit").join("stories");
|
|
|
|
|
let mut max_num: u32 = 0;
|
|
|
|
|
|
|
|
|
|
for subdir in &["upcoming", "current", "archived"] {
|
|
|
|
|
let dir = base.join(subdir);
|
|
|
|
|
if !dir.exists() {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
for entry in
|
|
|
|
|
fs::read_dir(&dir).map_err(|e| format!("Failed to read {subdir} directory: {e}"))?
|
|
|
|
|
{
|
|
|
|
|
let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
|
|
|
|
|
let name = entry.file_name();
|
|
|
|
|
let name_str = name.to_string_lossy();
|
|
|
|
|
// Extract leading digits from filename
|
|
|
|
|
let num_str: String = name_str.chars().take_while(|c| c.is_ascii_digit()).collect();
|
|
|
|
|
if let Ok(n) = num_str.parse::<u32>()
|
|
|
|
|
&& n > max_num
|
|
|
|
|
{
|
|
|
|
|
max_num = n;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(max_num + 1)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn validate_story_dirs(
|
|
|
|
|
root: &std::path::Path,
|
|
|
|
|
) -> Result<Vec<StoryValidationResult>, String> {
|
|
|
|
|
let base = root.join(".story_kit").join("stories");
|
|
|
|
|
let mut results = Vec::new();
|
|
|
|
|
|
|
|
|
|
for subdir in &["current", "upcoming"] {
|
|
|
|
|
let dir = base.join(subdir);
|
|
|
|
|
if !dir.exists() {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
for entry in
|
|
|
|
|
fs::read_dir(&dir).map_err(|e| format!("Failed to read {subdir} directory: {e}"))?
|
|
|
|
|
{
|
|
|
|
|
let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
|
|
|
|
|
let path = entry.path();
|
|
|
|
|
if path.extension().and_then(|ext| ext.to_str()) != Some("md") {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
let story_id = path
|
|
|
|
|
.file_stem()
|
|
|
|
|
.and_then(|stem| stem.to_str())
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
|
.to_string();
|
|
|
|
|
let contents = fs::read_to_string(&path)
|
|
|
|
|
.map_err(|e| format!("Failed to read {}: {e}", path.display()))?;
|
|
|
|
|
match parse_front_matter(&contents) {
|
|
|
|
|
Ok(meta) => {
|
|
|
|
|
let mut errors = Vec::new();
|
|
|
|
|
if meta.name.is_none() {
|
|
|
|
|
errors.push("Missing 'name' field".to_string());
|
|
|
|
|
}
|
|
|
|
|
if meta.test_plan.is_none() {
|
|
|
|
|
errors.push("Missing 'test_plan' field".to_string());
|
|
|
|
|
}
|
|
|
|
|
if errors.is_empty() {
|
|
|
|
|
results.push(StoryValidationResult {
|
|
|
|
|
story_id,
|
|
|
|
|
valid: true,
|
|
|
|
|
error: None,
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
results.push(StoryValidationResult {
|
|
|
|
|
story_id,
|
|
|
|
|
valid: false,
|
|
|
|
|
error: Some(errors.join("; ")),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Err(e) => results.push(StoryValidationResult {
|
|
|
|
|
story_id,
|
|
|
|
|
valid: false,
|
|
|
|
|
error: Some(e.to_string()),
|
|
|
|
|
}),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
results.sort_by(|a, b| a.story_id.cmp(&b.story_id));
|
|
|
|
|
Ok(results)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 12:54:04 +00:00
|
|
|
fn to_test_case(input: TestCasePayload) -> Result<TestCaseResult, String> {
|
|
|
|
|
let status = parse_test_status(&input.status)?;
|
|
|
|
|
Ok(TestCaseResult {
|
|
|
|
|
name: input.name,
|
|
|
|
|
status,
|
|
|
|
|
details: input.details,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn parse_test_status(value: &str) -> Result<TestStatus, String> {
|
|
|
|
|
match value {
|
|
|
|
|
"pass" => Ok(TestStatus::Pass),
|
|
|
|
|
"fail" => Ok(TestStatus::Fail),
|
|
|
|
|
other => Err(format!(
|
|
|
|
|
"Invalid test status '{other}'. Use 'pass' or 'fail'."
|
|
|
|
|
)),
|
|
|
|
|
}
|
|
|
|
|
}
|
WIP: Batch 2 — backfill tests for fs, shell, and http/workflow
- io/fs.rs: 20 tests (path resolution, project open/close/get, known projects,
model prefs, file read/write, list dir, validate path, scaffold)
- io/shell.rs: 4 new tests (allowlist, command execution, stdout capture, exit codes)
- http/workflow.rs: 8 tests (parse_test_status, to_test_case, to_review_story)
Coverage: 28.6% → 48.1%
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:52:19 +00:00
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
use crate::workflow::{StoryTestResults, TestCaseResult, TestStatus};
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parse_test_status_pass() {
|
|
|
|
|
assert_eq!(parse_test_status("pass").unwrap(), TestStatus::Pass);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parse_test_status_fail() {
|
|
|
|
|
assert_eq!(parse_test_status("fail").unwrap(), TestStatus::Fail);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parse_test_status_invalid() {
|
|
|
|
|
let result = parse_test_status("unknown");
|
|
|
|
|
assert!(result.is_err());
|
|
|
|
|
assert!(result.unwrap_err().contains("Invalid test status"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn to_test_case_converts_pass() {
|
|
|
|
|
let payload = TestCasePayload {
|
|
|
|
|
name: "my_test".to_string(),
|
|
|
|
|
status: "pass".to_string(),
|
|
|
|
|
details: Some("all good".to_string()),
|
|
|
|
|
};
|
|
|
|
|
let result = to_test_case(payload).unwrap();
|
|
|
|
|
assert_eq!(result.name, "my_test");
|
|
|
|
|
assert_eq!(result.status, TestStatus::Pass);
|
|
|
|
|
assert_eq!(result.details, Some("all good".to_string()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn to_test_case_rejects_invalid_status() {
|
|
|
|
|
let payload = TestCasePayload {
|
|
|
|
|
name: "bad".to_string(),
|
|
|
|
|
status: "maybe".to_string(),
|
|
|
|
|
details: None,
|
|
|
|
|
};
|
|
|
|
|
assert!(to_test_case(payload).is_err());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn to_review_story_all_passing() {
|
|
|
|
|
let results = StoryTestResults {
|
|
|
|
|
unit: vec![TestCaseResult {
|
|
|
|
|
name: "u1".to_string(),
|
|
|
|
|
status: TestStatus::Pass,
|
|
|
|
|
details: None,
|
|
|
|
|
}],
|
|
|
|
|
integration: vec![TestCaseResult {
|
|
|
|
|
name: "i1".to_string(),
|
|
|
|
|
status: TestStatus::Pass,
|
|
|
|
|
details: None,
|
|
|
|
|
}],
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-19 14:47:19 +00:00
|
|
|
let review = to_review_story("story-29", &results, None);
|
WIP: Batch 2 — backfill tests for fs, shell, and http/workflow
- io/fs.rs: 20 tests (path resolution, project open/close/get, known projects,
model prefs, file read/write, list dir, validate path, scaffold)
- io/shell.rs: 4 new tests (allowlist, command execution, stdout capture, exit codes)
- http/workflow.rs: 8 tests (parse_test_status, to_test_case, to_review_story)
Coverage: 28.6% → 48.1%
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:52:19 +00:00
|
|
|
assert!(review.can_accept);
|
|
|
|
|
assert!(review.reasons.is_empty());
|
|
|
|
|
assert!(review.missing_categories.is_empty());
|
|
|
|
|
assert_eq!(review.summary.total, 2);
|
|
|
|
|
assert_eq!(review.summary.passed, 2);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn to_review_story_missing_integration() {
|
|
|
|
|
let results = StoryTestResults {
|
|
|
|
|
unit: vec![TestCaseResult {
|
|
|
|
|
name: "u1".to_string(),
|
|
|
|
|
status: TestStatus::Pass,
|
|
|
|
|
details: None,
|
|
|
|
|
}],
|
|
|
|
|
integration: vec![],
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-19 14:47:19 +00:00
|
|
|
let review = to_review_story("story-29", &results, None);
|
WIP: Batch 2 — backfill tests for fs, shell, and http/workflow
- io/fs.rs: 20 tests (path resolution, project open/close/get, known projects,
model prefs, file read/write, list dir, validate path, scaffold)
- io/shell.rs: 4 new tests (allowlist, command execution, stdout capture, exit codes)
- http/workflow.rs: 8 tests (parse_test_status, to_test_case, to_review_story)
Coverage: 28.6% → 48.1%
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:52:19 +00:00
|
|
|
assert!(!review.can_accept);
|
|
|
|
|
assert!(review.missing_categories.contains(&"integration".to_string()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn to_review_story_with_failures() {
|
|
|
|
|
let results = StoryTestResults {
|
|
|
|
|
unit: vec![TestCaseResult {
|
|
|
|
|
name: "u1".to_string(),
|
|
|
|
|
status: TestStatus::Fail,
|
|
|
|
|
details: None,
|
|
|
|
|
}],
|
|
|
|
|
integration: vec![TestCaseResult {
|
|
|
|
|
name: "i1".to_string(),
|
|
|
|
|
status: TestStatus::Pass,
|
|
|
|
|
details: None,
|
|
|
|
|
}],
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-19 14:47:19 +00:00
|
|
|
let review = to_review_story("story-29", &results, None);
|
WIP: Batch 2 — backfill tests for fs, shell, and http/workflow
- io/fs.rs: 20 tests (path resolution, project open/close/get, known projects,
model prefs, file read/write, list dir, validate path, scaffold)
- io/shell.rs: 4 new tests (allowlist, command execution, stdout capture, exit codes)
- http/workflow.rs: 8 tests (parse_test_status, to_test_case, to_review_story)
Coverage: 28.6% → 48.1%
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:52:19 +00:00
|
|
|
assert!(!review.can_accept);
|
|
|
|
|
assert_eq!(review.summary.failed, 1);
|
|
|
|
|
}
|
2026-02-19 15:51:12 +00:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn load_upcoming_returns_empty_when_no_dir() {
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let root = tmp.path().to_path_buf();
|
|
|
|
|
// No .story_kit directory at all
|
|
|
|
|
let ctx = crate::http::context::AppContext::new_test(root);
|
|
|
|
|
let result = load_upcoming_stories(&ctx).unwrap();
|
|
|
|
|
assert!(result.is_empty());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn load_upcoming_parses_metadata() {
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let upcoming = tmp.path().join(".story_kit/stories/upcoming");
|
|
|
|
|
fs::create_dir_all(&upcoming).unwrap();
|
|
|
|
|
fs::write(
|
|
|
|
|
upcoming.join("31_view_upcoming.md"),
|
|
|
|
|
"---\nname: View Upcoming\ntest_plan: pending\n---\n# Story\n",
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
fs::write(
|
|
|
|
|
upcoming.join("32_worktree.md"),
|
|
|
|
|
"---\nname: Worktree Orchestration\ntest_plan: pending\n---\n# Story\n",
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
let ctx = crate::http::context::AppContext::new_test(tmp.path().to_path_buf());
|
|
|
|
|
let stories = load_upcoming_stories(&ctx).unwrap();
|
|
|
|
|
assert_eq!(stories.len(), 2);
|
|
|
|
|
assert_eq!(stories[0].story_id, "31_view_upcoming");
|
|
|
|
|
assert_eq!(stories[0].name.as_deref(), Some("View Upcoming"));
|
|
|
|
|
assert_eq!(stories[1].story_id, "32_worktree");
|
|
|
|
|
assert_eq!(stories[1].name.as_deref(), Some("Worktree Orchestration"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn load_upcoming_skips_non_md_files() {
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let upcoming = tmp.path().join(".story_kit/stories/upcoming");
|
|
|
|
|
fs::create_dir_all(&upcoming).unwrap();
|
|
|
|
|
fs::write(upcoming.join(".gitkeep"), "").unwrap();
|
|
|
|
|
fs::write(
|
|
|
|
|
upcoming.join("31_story.md"),
|
|
|
|
|
"---\nname: A Story\ntest_plan: pending\n---\n",
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
let ctx = crate::http::context::AppContext::new_test(tmp.path().to_path_buf());
|
|
|
|
|
let stories = load_upcoming_stories(&ctx).unwrap();
|
|
|
|
|
assert_eq!(stories.len(), 1);
|
|
|
|
|
assert_eq!(stories[0].story_id, "31_story");
|
|
|
|
|
}
|
2026-02-19 18:02:48 +00:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn validate_story_dirs_valid_files() {
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let current = tmp.path().join(".story_kit/stories/current");
|
|
|
|
|
let upcoming = tmp.path().join(".story_kit/stories/upcoming");
|
|
|
|
|
fs::create_dir_all(¤t).unwrap();
|
|
|
|
|
fs::create_dir_all(&upcoming).unwrap();
|
|
|
|
|
fs::write(
|
|
|
|
|
current.join("28_todos.md"),
|
|
|
|
|
"---\nname: Show TODOs\ntest_plan: approved\n---\n# Story\n",
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
fs::write(
|
|
|
|
|
upcoming.join("36_front_matter.md"),
|
|
|
|
|
"---\nname: Enforce Front Matter\ntest_plan: pending\n---\n# Story\n",
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
let results = validate_story_dirs(tmp.path()).unwrap();
|
|
|
|
|
assert_eq!(results.len(), 2);
|
|
|
|
|
assert!(results.iter().all(|r| r.valid));
|
|
|
|
|
assert!(results.iter().all(|r| r.error.is_none()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn validate_story_dirs_missing_front_matter() {
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let current = tmp.path().join(".story_kit/stories/current");
|
|
|
|
|
fs::create_dir_all(¤t).unwrap();
|
|
|
|
|
fs::write(current.join("28_todos.md"), "# No front matter\n").unwrap();
|
|
|
|
|
|
|
|
|
|
let results = validate_story_dirs(tmp.path()).unwrap();
|
|
|
|
|
assert_eq!(results.len(), 1);
|
|
|
|
|
assert!(!results[0].valid);
|
|
|
|
|
assert_eq!(results[0].error.as_deref(), Some("Missing front matter"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn validate_story_dirs_missing_required_fields() {
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let current = tmp.path().join(".story_kit/stories/current");
|
|
|
|
|
fs::create_dir_all(¤t).unwrap();
|
|
|
|
|
fs::write(current.join("28_todos.md"), "---\n---\n# Story\n").unwrap();
|
|
|
|
|
|
|
|
|
|
let results = validate_story_dirs(tmp.path()).unwrap();
|
|
|
|
|
assert_eq!(results.len(), 1);
|
|
|
|
|
assert!(!results[0].valid);
|
|
|
|
|
let err = results[0].error.as_deref().unwrap();
|
|
|
|
|
assert!(err.contains("Missing 'name' field"));
|
|
|
|
|
assert!(err.contains("Missing 'test_plan' field"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn validate_story_dirs_missing_test_plan_only() {
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let current = tmp.path().join(".story_kit/stories/current");
|
|
|
|
|
fs::create_dir_all(¤t).unwrap();
|
|
|
|
|
fs::write(
|
|
|
|
|
current.join("28_todos.md"),
|
|
|
|
|
"---\nname: A Story\n---\n# Story\n",
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
let results = validate_story_dirs(tmp.path()).unwrap();
|
|
|
|
|
assert_eq!(results.len(), 1);
|
|
|
|
|
assert!(!results[0].valid);
|
|
|
|
|
let err = results[0].error.as_deref().unwrap();
|
|
|
|
|
assert!(err.contains("Missing 'test_plan' field"));
|
|
|
|
|
assert!(!err.contains("Missing 'name' field"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn validate_story_dirs_empty_when_no_dirs() {
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let results = validate_story_dirs(tmp.path()).unwrap();
|
|
|
|
|
assert!(results.is_empty());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- slugify_name tests ---
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn slugify_simple_name() {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
slugify_name("Enforce Front Matter on All Story Files"),
|
|
|
|
|
"enforce_front_matter_on_all_story_files"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn slugify_with_special_chars() {
|
|
|
|
|
assert_eq!(slugify_name("Hello, World! (v2)"), "hello_world_v2");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn slugify_leading_trailing_underscores() {
|
|
|
|
|
assert_eq!(slugify_name(" spaces "), "spaces");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn slugify_consecutive_separators() {
|
|
|
|
|
assert_eq!(slugify_name("a--b__c d"), "a_b_c_d");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn slugify_empty_after_strip() {
|
|
|
|
|
assert_eq!(slugify_name("!!!"), "");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn slugify_already_snake_case() {
|
|
|
|
|
assert_eq!(slugify_name("my_story_name"), "my_story_name");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- next_story_number tests ---
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn next_story_number_empty_dirs() {
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let base = tmp.path().join(".story_kit/stories/upcoming");
|
|
|
|
|
fs::create_dir_all(&base).unwrap();
|
|
|
|
|
assert_eq!(next_story_number(tmp.path()).unwrap(), 1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn next_story_number_scans_all_dirs() {
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let upcoming = tmp.path().join(".story_kit/stories/upcoming");
|
|
|
|
|
let current = tmp.path().join(".story_kit/stories/current");
|
|
|
|
|
let archived = tmp.path().join(".story_kit/stories/archived");
|
|
|
|
|
fs::create_dir_all(&upcoming).unwrap();
|
|
|
|
|
fs::create_dir_all(¤t).unwrap();
|
|
|
|
|
fs::create_dir_all(&archived).unwrap();
|
|
|
|
|
fs::write(upcoming.join("10_foo.md"), "").unwrap();
|
|
|
|
|
fs::write(current.join("20_bar.md"), "").unwrap();
|
|
|
|
|
fs::write(archived.join("15_baz.md"), "").unwrap();
|
|
|
|
|
assert_eq!(next_story_number(tmp.path()).unwrap(), 21);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn next_story_number_no_story_dirs() {
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
// No .story_kit at all
|
|
|
|
|
assert_eq!(next_story_number(tmp.path()).unwrap(), 1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- create_story integration tests ---
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn create_story_writes_correct_content() {
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let upcoming = tmp.path().join(".story_kit/stories/upcoming");
|
|
|
|
|
fs::create_dir_all(&upcoming).unwrap();
|
|
|
|
|
fs::write(upcoming.join("36_existing.md"), "").unwrap();
|
|
|
|
|
|
|
|
|
|
let number = next_story_number(tmp.path()).unwrap();
|
|
|
|
|
assert_eq!(number, 37);
|
|
|
|
|
|
|
|
|
|
let slug = slugify_name("My New Feature");
|
|
|
|
|
assert_eq!(slug, "my_new_feature");
|
|
|
|
|
|
|
|
|
|
let filename = format!("{number}_{slug}.md");
|
|
|
|
|
let filepath = upcoming.join(&filename);
|
|
|
|
|
|
|
|
|
|
let mut content = String::new();
|
|
|
|
|
content.push_str("---\n");
|
|
|
|
|
content.push_str("name: My New Feature\n");
|
|
|
|
|
content.push_str("test_plan: pending\n");
|
|
|
|
|
content.push_str("---\n\n");
|
|
|
|
|
content.push_str(&format!("# Story {number}: My New Feature\n\n"));
|
|
|
|
|
content.push_str("## User Story\n\n");
|
|
|
|
|
content.push_str("As a dev, I want this feature\n\n");
|
|
|
|
|
content.push_str("## Acceptance Criteria\n\n");
|
|
|
|
|
content.push_str("- [ ] It works\n");
|
|
|
|
|
content.push_str("- [ ] It is tested\n\n");
|
|
|
|
|
content.push_str("## Out of Scope\n\n");
|
|
|
|
|
content.push_str("- TBD\n");
|
|
|
|
|
|
|
|
|
|
fs::write(&filepath, &content).unwrap();
|
|
|
|
|
|
|
|
|
|
let written = fs::read_to_string(&filepath).unwrap();
|
|
|
|
|
assert!(written.starts_with("---\nname: My New Feature\ntest_plan: pending\n---"));
|
|
|
|
|
assert!(written.contains("# Story 37: My New Feature"));
|
|
|
|
|
assert!(written.contains("- [ ] It works"));
|
|
|
|
|
assert!(written.contains("- [ ] It is tested"));
|
|
|
|
|
assert!(written.contains("## Out of Scope"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn create_story_rejects_duplicate() {
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let upcoming = tmp.path().join(".story_kit/stories/upcoming");
|
|
|
|
|
fs::create_dir_all(&upcoming).unwrap();
|
|
|
|
|
|
|
|
|
|
let filepath = upcoming.join("1_my_feature.md");
|
|
|
|
|
fs::write(&filepath, "existing").unwrap();
|
|
|
|
|
|
|
|
|
|
// Simulate the check
|
|
|
|
|
assert!(filepath.exists());
|
|
|
|
|
}
|
WIP: Batch 2 — backfill tests for fs, shell, and http/workflow
- io/fs.rs: 20 tests (path resolution, project open/close/get, known projects,
model prefs, file read/write, list dir, validate path, scaffold)
- io/shell.rs: 4 new tests (allowlist, command execution, stdout capture, exit codes)
- http/workflow.rs: 8 tests (parse_test_status, to_test_case, to_review_story)
Coverage: 28.6% → 48.1%
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:52:19 +00:00
|
|
|
}
|