Story 49: Deterministic Bug Lifecycle Management
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -699,6 +699,157 @@ fn git_commit_story_file(root: &Path, filepath: &Path, story_id: &str) -> Result
|
||||
git_stage_and_commit(root, &[filepath], &msg)
|
||||
}
|
||||
|
||||
// ── Bug file helpers ──────────────────────────────────────────────
|
||||
|
||||
/// Determine the next bug number by scanning `.story_kit/bugs/` and `.story_kit/bugs/archive/`.
|
||||
fn next_bug_number(root: &Path) -> Result<u32, String> {
|
||||
let bugs_base = root.join(".story_kit").join("bugs");
|
||||
let mut max_num: u32 = 0;
|
||||
|
||||
for dir in [bugs_base.clone(), bugs_base.join("archive")] {
|
||||
if !dir.exists() {
|
||||
continue;
|
||||
}
|
||||
for entry in
|
||||
fs::read_dir(dir).map_err(|e| format!("Failed to read bugs 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();
|
||||
// Bug filenames: bug-N-slug.md — extract the N after "bug-"
|
||||
if let Some(rest) = name_str.strip_prefix("bug-") {
|
||||
let num_str: String = rest.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)
|
||||
}
|
||||
|
||||
/// Create a bug file in `.story_kit/bugs/` with a deterministic filename and auto-commit.
|
||||
///
|
||||
/// Returns the bug_id (e.g. `"bug-3-login_crash"`).
|
||||
pub fn create_bug_file(
|
||||
root: &Path,
|
||||
name: &str,
|
||||
description: &str,
|
||||
steps_to_reproduce: &str,
|
||||
actual_result: &str,
|
||||
expected_result: &str,
|
||||
acceptance_criteria: Option<&[String]>,
|
||||
) -> Result<String, String> {
|
||||
let bug_number = next_bug_number(root)?;
|
||||
let slug = slugify_name(name);
|
||||
|
||||
if slug.is_empty() {
|
||||
return Err("Name must contain at least one alphanumeric character.".to_string());
|
||||
}
|
||||
|
||||
let filename = format!("bug-{bug_number}-{slug}.md");
|
||||
let bugs_dir = root.join(".story_kit").join("bugs");
|
||||
fs::create_dir_all(&bugs_dir)
|
||||
.map_err(|e| format!("Failed to create bugs directory: {e}"))?;
|
||||
|
||||
let filepath = bugs_dir.join(&filename);
|
||||
if filepath.exists() {
|
||||
return Err(format!("Bug file already exists: {filename}"));
|
||||
}
|
||||
|
||||
let bug_id = filepath
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
|
||||
let mut content = String::new();
|
||||
content.push_str(&format!("# Bug {bug_number}: {name}\n\n"));
|
||||
content.push_str("## Description\n\n");
|
||||
content.push_str(description);
|
||||
content.push_str("\n\n");
|
||||
content.push_str("## How to Reproduce\n\n");
|
||||
content.push_str(steps_to_reproduce);
|
||||
content.push_str("\n\n");
|
||||
content.push_str("## Actual Result\n\n");
|
||||
content.push_str(actual_result);
|
||||
content.push_str("\n\n");
|
||||
content.push_str("## Expected Result\n\n");
|
||||
content.push_str(expected_result);
|
||||
content.push_str("\n\n");
|
||||
content.push_str("## Acceptance Criteria\n\n");
|
||||
if let Some(criteria) = acceptance_criteria {
|
||||
for criterion in criteria {
|
||||
content.push_str(&format!("- [ ] {criterion}\n"));
|
||||
}
|
||||
} else {
|
||||
content.push_str("- [ ] Bug is fixed and verified\n");
|
||||
}
|
||||
|
||||
fs::write(&filepath, &content).map_err(|e| format!("Failed to write bug file: {e}"))?;
|
||||
|
||||
let msg = format!("story-kit: create bug {bug_id}");
|
||||
git_stage_and_commit(root, &[filepath.as_path()], &msg)?;
|
||||
|
||||
Ok(bug_id)
|
||||
}
|
||||
|
||||
/// Extract the human-readable name from a bug file's first heading.
|
||||
fn extract_bug_name(path: &Path) -> Option<String> {
|
||||
let contents = fs::read_to_string(path).ok()?;
|
||||
for line in contents.lines() {
|
||||
if let Some(rest) = line.strip_prefix("# Bug ") {
|
||||
// Format: "N: Name"
|
||||
if let Some(colon_pos) = rest.find(": ") {
|
||||
return Some(rest[colon_pos + 2..].to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// List all open bugs — files directly in `.story_kit/bugs/` (excluding `archive/` subdir).
|
||||
///
|
||||
/// Returns a sorted list of `(bug_id, name)` pairs.
|
||||
pub fn list_bug_files(root: &Path) -> Result<Vec<(String, String)>, String> {
|
||||
let bugs_dir = root.join(".story_kit").join("bugs");
|
||||
if !bugs_dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut bugs = Vec::new();
|
||||
for entry in
|
||||
fs::read_dir(&bugs_dir).map_err(|e| format!("Failed to read bugs directory: {e}"))?
|
||||
{
|
||||
let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
|
||||
let path = entry.path();
|
||||
|
||||
// Skip subdirectories (archive/)
|
||||
if path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if path.extension().and_then(|ext| ext.to_str()) != Some("md") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let bug_id = path
|
||||
.file_stem()
|
||||
.and_then(|stem| stem.to_str())
|
||||
.ok_or_else(|| "Invalid bug file name.".to_string())?
|
||||
.to_string();
|
||||
|
||||
let name = extract_bug_name(&path).unwrap_or_else(|| bug_id.clone());
|
||||
bugs.push((bug_id, name));
|
||||
}
|
||||
|
||||
bugs.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
Ok(bugs)
|
||||
}
|
||||
|
||||
/// Locate a story file by searching .story_kit/current/ then stories/upcoming/.
|
||||
fn find_story_file(project_root: &Path, story_id: &str) -> Result<PathBuf, String> {
|
||||
let filename = format!("{story_id}.md");
|
||||
@@ -917,7 +1068,7 @@ pub fn validate_story_dirs(
|
||||
continue;
|
||||
}
|
||||
for entry in
|
||||
fs::read_dir(&dir).map_err(|e| format!("Failed to read {subdir} directory: {e}"))?
|
||||
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();
|
||||
@@ -1540,4 +1691,146 @@ mod tests {
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("not found"));
|
||||
}
|
||||
|
||||
// ── Bug file helper tests ──────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn next_bug_number_starts_at_1_when_empty() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
assert_eq!(next_bug_number(tmp.path()).unwrap(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_bug_number_increments_from_existing() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let bugs_dir = tmp.path().join(".story_kit/bugs");
|
||||
fs::create_dir_all(&bugs_dir).unwrap();
|
||||
fs::write(bugs_dir.join("bug-1-crash.md"), "").unwrap();
|
||||
fs::write(bugs_dir.join("bug-3-another.md"), "").unwrap();
|
||||
assert_eq!(next_bug_number(tmp.path()).unwrap(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_bug_number_scans_archive_too() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let bugs_dir = tmp.path().join(".story_kit/bugs");
|
||||
let archive_dir = bugs_dir.join("archive");
|
||||
fs::create_dir_all(&bugs_dir).unwrap();
|
||||
fs::create_dir_all(&archive_dir).unwrap();
|
||||
fs::write(archive_dir.join("bug-5-old.md"), "").unwrap();
|
||||
assert_eq!(next_bug_number(tmp.path()).unwrap(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_bug_files_empty_when_no_bugs_dir() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let result = list_bug_files(tmp.path()).unwrap();
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_bug_files_excludes_archive_subdir() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let bugs_dir = tmp.path().join(".story_kit/bugs");
|
||||
let archive_dir = bugs_dir.join("archive");
|
||||
fs::create_dir_all(&bugs_dir).unwrap();
|
||||
fs::create_dir_all(&archive_dir).unwrap();
|
||||
fs::write(bugs_dir.join("bug-1-open.md"), "# Bug 1: Open Bug\n").unwrap();
|
||||
fs::write(archive_dir.join("bug-2-closed.md"), "# Bug 2: Closed Bug\n").unwrap();
|
||||
|
||||
let result = list_bug_files(tmp.path()).unwrap();
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].0, "bug-1-open");
|
||||
assert_eq!(result[0].1, "Open Bug");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_bug_files_sorted_by_id() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let bugs_dir = tmp.path().join(".story_kit/bugs");
|
||||
fs::create_dir_all(&bugs_dir).unwrap();
|
||||
fs::write(bugs_dir.join("bug-3-third.md"), "# Bug 3: Third\n").unwrap();
|
||||
fs::write(bugs_dir.join("bug-1-first.md"), "# Bug 1: First\n").unwrap();
|
||||
fs::write(bugs_dir.join("bug-2-second.md"), "# Bug 2: Second\n").unwrap();
|
||||
|
||||
let result = list_bug_files(tmp.path()).unwrap();
|
||||
assert_eq!(result.len(), 3);
|
||||
assert_eq!(result[0].0, "bug-1-first");
|
||||
assert_eq!(result[1].0, "bug-2-second");
|
||||
assert_eq!(result[2].0, "bug-3-third");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_bug_name_parses_heading() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("bug-1-crash.md");
|
||||
fs::write(&path, "# Bug 1: Login page crashes\n\n## Description\n").unwrap();
|
||||
let name = extract_bug_name(&path).unwrap();
|
||||
assert_eq!(name, "Login page crashes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_bug_file_writes_correct_content() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
setup_git_repo(tmp.path());
|
||||
|
||||
let bug_id = create_bug_file(
|
||||
tmp.path(),
|
||||
"Login Crash",
|
||||
"The login page crashes on submit.",
|
||||
"1. Go to /login\n2. Click submit",
|
||||
"Page crashes with 500 error",
|
||||
"Login succeeds",
|
||||
Some(&["Login form submits without error".to_string()]),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(bug_id, "bug-1-login_crash");
|
||||
|
||||
let filepath = tmp
|
||||
.path()
|
||||
.join(".story_kit/bugs/bug-1-login_crash.md");
|
||||
assert!(filepath.exists());
|
||||
let contents = fs::read_to_string(&filepath).unwrap();
|
||||
assert!(contents.contains("# Bug 1: Login Crash"));
|
||||
assert!(contents.contains("## Description"));
|
||||
assert!(contents.contains("The login page crashes on submit."));
|
||||
assert!(contents.contains("## How to Reproduce"));
|
||||
assert!(contents.contains("1. Go to /login"));
|
||||
assert!(contents.contains("## Actual Result"));
|
||||
assert!(contents.contains("Page crashes with 500 error"));
|
||||
assert!(contents.contains("## Expected Result"));
|
||||
assert!(contents.contains("Login succeeds"));
|
||||
assert!(contents.contains("## Acceptance Criteria"));
|
||||
assert!(contents.contains("- [ ] Login form submits without error"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_bug_file_rejects_empty_name() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let result = create_bug_file(tmp.path(), "!!!", "desc", "steps", "actual", "expected", None);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("alphanumeric"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_bug_file_uses_default_acceptance_criterion() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
setup_git_repo(tmp.path());
|
||||
|
||||
create_bug_file(
|
||||
tmp.path(),
|
||||
"Some Bug",
|
||||
"desc",
|
||||
"steps",
|
||||
"actual",
|
||||
"expected",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let filepath = tmp.path().join(".story_kit/bugs/bug-1-some_bug.md");
|
||||
let contents = fs::read_to_string(&filepath).unwrap();
|
||||
assert!(contents.contains("- [ ] Bug is fixed and verified"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user