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:
@@ -45,7 +45,15 @@ When the user asks for a feature, follow this 4-step loop strictly:
|
|||||||
|
|
||||||
### Step 1: The Story (Ingest)
|
### Step 1: The Story (Ingest)
|
||||||
* **User Input:** "I want the robot to dance."
|
* **User Input:** "I want the robot to dance."
|
||||||
* **Action:** Create a file in `stories/upcoming/` (e.g., `stories/upcoming/XX_robot_dance.md`).
|
* **Action:** Create a story via `POST /api/workflow/stories/create` (preferred for agents — guarantees correct front matter and auto-assigns the story number). Alternatively, create a file manually in `stories/upcoming/` (e.g., `stories/upcoming/XX_robot_dance.md`).
|
||||||
|
* **Front Matter (Required):** Every story file MUST begin with YAML front matter containing `name` and `test_plan` fields:
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
name: Short Human-Readable Story Name
|
||||||
|
test_plan: pending
|
||||||
|
---
|
||||||
|
```
|
||||||
|
The `test_plan` field tracks approval status: `pending` → `approved` (after Step 2).
|
||||||
* **Move to Current:** Once the story is validated and ready for coding, move it to `stories/current/`.
|
* **Move to Current:** Once the story is validated and ready for coding, move it to `stories/current/`.
|
||||||
* **Tracking:** Mark Acceptance Criteria as tested directly in the story file as tests are completed.
|
* **Tracking:** Mark Acceptance Criteria as tested directly in the story file as tests are completed.
|
||||||
* **Content:**
|
* **Content:**
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export interface StoryTodosResponse {
|
|||||||
story_id: string;
|
story_id: string;
|
||||||
story_name: string | null;
|
story_name: string | null;
|
||||||
todos: string[];
|
todos: string[];
|
||||||
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TodoListResponse {
|
export interface TodoListResponse {
|
||||||
@@ -75,12 +76,33 @@ export interface TodoListResponse {
|
|||||||
export interface UpcomingStory {
|
export interface UpcomingStory {
|
||||||
story_id: string;
|
story_id: string;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpcomingStoriesResponse {
|
export interface UpcomingStoriesResponse {
|
||||||
stories: UpcomingStory[];
|
stories: UpcomingStory[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StoryValidationResult {
|
||||||
|
story_id: string;
|
||||||
|
valid: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValidateStoriesResponse {
|
||||||
|
stories: StoryValidationResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateStoryPayload {
|
||||||
|
name: string;
|
||||||
|
user_story?: string | null;
|
||||||
|
acceptance_criteria?: string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateStoryResponse {
|
||||||
|
story_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_API_BASE = "/api";
|
const DEFAULT_API_BASE = "/api";
|
||||||
|
|
||||||
function buildApiUrl(path: string, baseUrl = DEFAULT_API_BASE): string {
|
function buildApiUrl(path: string, baseUrl = DEFAULT_API_BASE): string {
|
||||||
@@ -160,4 +182,18 @@ export const workflowApi = {
|
|||||||
getStoryTodos(baseUrl?: string) {
|
getStoryTodos(baseUrl?: string) {
|
||||||
return requestJson<TodoListResponse>("/workflow/todos", {}, baseUrl);
|
return requestJson<TodoListResponse>("/workflow/todos", {}, baseUrl);
|
||||||
},
|
},
|
||||||
|
validateStories(baseUrl?: string) {
|
||||||
|
return requestJson<ValidateStoriesResponse>(
|
||||||
|
"/workflow/stories/validate",
|
||||||
|
{},
|
||||||
|
baseUrl,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
createStory(payload: CreateStoryPayload, baseUrl?: string) {
|
||||||
|
return requestJson<CreateStoryResponse>(
|
||||||
|
"/workflow/stories/create",
|
||||||
|
{ method: "POST", body: JSON.stringify(payload) },
|
||||||
|
baseUrl,
|
||||||
|
);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ struct StoryTodosResponse {
|
|||||||
pub story_id: String,
|
pub story_id: String,
|
||||||
pub story_name: Option<String>,
|
pub story_name: Option<String>,
|
||||||
pub todos: Vec<String>,
|
pub todos: Vec<String>,
|
||||||
|
pub error: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Object)]
|
#[derive(Object)]
|
||||||
@@ -103,6 +104,7 @@ struct TodoListResponse {
|
|||||||
struct UpcomingStory {
|
struct UpcomingStory {
|
||||||
pub story_id: String,
|
pub story_id: String,
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
|
pub error: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Object)]
|
#[derive(Object)]
|
||||||
@@ -110,6 +112,30 @@ struct UpcomingStoriesResponse {
|
|||||||
pub stories: Vec<UpcomingStory>,
|
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> {
|
fn load_upcoming_stories(ctx: &AppContext) -> Result<Vec<UpcomingStory>, String> {
|
||||||
let root = ctx.state.get_project_root()?;
|
let root = ctx.state.get_project_root()?;
|
||||||
let upcoming_dir = root.join(".story_kit").join("stories").join("upcoming");
|
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();
|
.to_string();
|
||||||
let contents = fs::read_to_string(&path)
|
let contents = fs::read_to_string(&path)
|
||||||
.map_err(|e| format!("Failed to read story file {}: {e}", path.display()))?;
|
.map_err(|e| format!("Failed to read story file {}: {e}", path.display()))?;
|
||||||
let name = parse_front_matter(&contents)
|
let (name, error) = match parse_front_matter(&contents) {
|
||||||
.ok()
|
Ok(meta) => (meta.name, None),
|
||||||
.and_then(|meta| meta.name);
|
Err(e) => (None, Some(e.to_string())),
|
||||||
stories.push(UpcomingStory { story_id, name });
|
};
|
||||||
|
stories.push(UpcomingStory { story_id, name, error });
|
||||||
}
|
}
|
||||||
|
|
||||||
stories.sort_by(|a, b| a.story_id.cmp(&b.story_id));
|
stories.sort_by(|a, b| a.story_id.cmp(&b.story_id));
|
||||||
@@ -491,14 +518,16 @@ impl WorkflowApi {
|
|||||||
.to_string();
|
.to_string();
|
||||||
let contents = fs::read_to_string(&path)
|
let contents = fs::read_to_string(&path)
|
||||||
.map_err(|e| bad_request(format!("Failed to read {}: {e}", path.display())))?;
|
.map_err(|e| bad_request(format!("Failed to read {}: {e}", path.display())))?;
|
||||||
let story_name = parse_front_matter(&contents)
|
let (story_name, error) = match parse_front_matter(&contents) {
|
||||||
.ok()
|
Ok(m) => (m.name, None),
|
||||||
.and_then(|m| m.name);
|
Err(e) => (None, Some(e.to_string())),
|
||||||
|
};
|
||||||
let todos = parse_unchecked_todos(&contents);
|
let todos = parse_unchecked_todos(&contents);
|
||||||
stories.push(StoryTodosResponse {
|
stories.push(StoryTodosResponse {
|
||||||
story_id,
|
story_id,
|
||||||
story_name,
|
story_name,
|
||||||
todos,
|
todos,
|
||||||
|
error,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -512,6 +541,80 @@ impl WorkflowApi {
|
|||||||
Ok(Json(UpcomingStoriesResponse { stories }))
|
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.
|
/// Ensure a story can be accepted; returns an error when gates fail.
|
||||||
#[oai(path = "/workflow/acceptance/ensure", method = "post")]
|
#[oai(path = "/workflow/acceptance/ensure", method = "post")]
|
||||||
async fn ensure_acceptance(
|
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> {
|
fn to_test_case(input: TestCasePayload) -> Result<TestCaseResult, String> {
|
||||||
let status = parse_test_status(&input.status)?;
|
let status = parse_test_status(&input.status)?;
|
||||||
Ok(TestCaseResult {
|
Ok(TestCaseResult {
|
||||||
@@ -716,4 +941,204 @@ mod tests {
|
|||||||
assert_eq!(stories.len(), 1);
|
assert_eq!(stories.len(), 1);
|
||||||
assert_eq!(stories[0].story_id, "31_story");
|
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