Accept story 36: Enforce Front Matter on All Story Files
Add POST /workflow/stories/create endpoint that auto-assigns story numbers, generates correct front matter, and writes to upcoming/. Add slugify_name and next_story_number helpers with full test coverage. Add frontend createStory API method and types. Update README to recommend creation API for agents. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -92,6 +92,7 @@ struct StoryTodosResponse {
|
||||
pub story_id: String,
|
||||
pub story_name: Option<String>,
|
||||
pub todos: Vec<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Object)]
|
||||
@@ -103,6 +104,7 @@ struct TodoListResponse {
|
||||
struct UpcomingStory {
|
||||
pub story_id: String,
|
||||
pub name: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Object)]
|
||||
@@ -110,6 +112,30 @@ struct UpcomingStoriesResponse {
|
||||
pub stories: Vec<UpcomingStory>,
|
||||
}
|
||||
|
||||
#[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>,
|
||||
}
|
||||
|
||||
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");
|
||||
@@ -134,10 +160,11 @@ fn load_upcoming_stories(ctx: &AppContext) -> Result<Vec<UpcomingStory>, String>
|
||||
.to_string();
|
||||
let contents = fs::read_to_string(&path)
|
||||
.map_err(|e| format!("Failed to read story file {}: {e}", path.display()))?;
|
||||
let name = parse_front_matter(&contents)
|
||||
.ok()
|
||||
.and_then(|meta| meta.name);
|
||||
stories.push(UpcomingStory { story_id, name });
|
||||
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 });
|
||||
}
|
||||
|
||||
stories.sort_by(|a, b| a.story_id.cmp(&b.story_id));
|
||||
@@ -491,14 +518,16 @@ impl WorkflowApi {
|
||||
.to_string();
|
||||
let contents = fs::read_to_string(&path)
|
||||
.map_err(|e| bad_request(format!("Failed to read {}: {e}", path.display())))?;
|
||||
let story_name = parse_front_matter(&contents)
|
||||
.ok()
|
||||
.and_then(|m| m.name);
|
||||
let (story_name, error) = match parse_front_matter(&contents) {
|
||||
Ok(m) => (m.name, None),
|
||||
Err(e) => (None, Some(e.to_string())),
|
||||
};
|
||||
let todos = parse_unchecked_todos(&contents);
|
||||
stories.push(StoryTodosResponse {
|
||||
story_id,
|
||||
story_name,
|
||||
todos,
|
||||
error,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -512,6 +541,80 @@ impl WorkflowApi {
|
||||
Ok(Json(UpcomingStoriesResponse { stories }))
|
||||
}
|
||||
|
||||
/// 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 }))
|
||||
}
|
||||
|
||||
/// Ensure a story can be accepted; returns an error when gates fail.
|
||||
#[oai(path = "/workflow/acceptance/ensure", method = "post")]
|
||||
async fn ensure_acceptance(
|
||||
@@ -541,6 +644,128 @@ impl WorkflowApi {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
fn to_test_case(input: TestCasePayload) -> Result<TestCaseResult, String> {
|
||||
let status = parse_test_status(&input.status)?;
|
||||
Ok(TestCaseResult {
|
||||
@@ -716,4 +941,204 @@ mod tests {
|
||||
assert_eq!(stories.len(), 1);
|
||||
assert_eq!(stories[0].story_id, "31_story");
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user