Story 49: Deterministic Bug Lifecycle Management

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dave
2026-02-20 16:34:32 +00:00
parent b76b5df8c9
commit 2d28304a41
3 changed files with 620 additions and 5 deletions

View File

@@ -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"));
}
}