story-kit: merge 78_story_create_spike_mcp_tool

This commit is contained in:
Dave
2026-02-23 20:27:09 +00:00
parent 2b3063ace2
commit a25553f1bc
2 changed files with 261 additions and 2 deletions

View File

@@ -269,6 +269,70 @@ pub fn create_bug_file(
Ok(bug_id)
}
// ── Spike file helpers ────────────────────────────────────────────
/// Create a spike file in `work/1_upcoming/` with a deterministic filename.
///
/// Returns the spike_id (e.g. `"4_spike_filesystem_watcher_architecture"`).
pub fn create_spike_file(
root: &Path,
name: &str,
description: Option<&str>,
) -> Result<String, String> {
let spike_number = next_item_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!("{spike_number}_spike_{slug}.md");
let upcoming_dir = root.join(".story_kit").join("work").join("1_upcoming");
fs::create_dir_all(&upcoming_dir)
.map_err(|e| format!("Failed to create upcoming directory: {e}"))?;
let filepath = upcoming_dir.join(&filename);
if filepath.exists() {
return Err(format!("Spike file already exists: {filename}"));
}
let spike_id = filepath
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or_default()
.to_string();
let mut content = String::new();
content.push_str("---\n");
content.push_str(&format!("name: \"{}\"\n", name.replace('"', "\\\"")));
content.push_str("---\n\n");
content.push_str(&format!("# Spike {spike_number}: {name}\n\n"));
content.push_str("## Question\n\n");
if let Some(desc) = description {
content.push_str(desc);
content.push('\n');
} else {
content.push_str("- TBD\n");
}
content.push('\n');
content.push_str("## Hypothesis\n\n");
content.push_str("- TBD\n\n");
content.push_str("## Timebox\n\n");
content.push_str("- TBD\n\n");
content.push_str("## Investigation Plan\n\n");
content.push_str("- TBD\n\n");
content.push_str("## Findings\n\n");
content.push_str("- TBD\n\n");
content.push_str("## Recommendation\n\n");
content.push_str("- TBD\n");
fs::write(&filepath, &content).map_err(|e| format!("Failed to write spike file: {e}"))?;
// Watcher handles the git commit asynchronously.
Ok(spike_id)
}
/// Returns true if the item stem (filename without extension) is a bug item.
/// Bug items follow the pattern: {N}_bug_{slug}
fn is_bug_item(stem: &str) -> bool {
@@ -1179,4 +1243,92 @@ mod tests {
);
assert!(contents.contains("- [ ] Bug is fixed and verified"));
}
// ── create_spike_file tests ────────────────────────────────────────────────
#[test]
fn create_spike_file_writes_correct_content() {
let tmp = tempfile::tempdir().unwrap();
let spike_id =
create_spike_file(tmp.path(), "Filesystem Watcher Architecture", None).unwrap();
assert_eq!(spike_id, "1_spike_filesystem_watcher_architecture");
let filepath = tmp
.path()
.join(".story_kit/work/1_upcoming/1_spike_filesystem_watcher_architecture.md");
assert!(filepath.exists());
let contents = fs::read_to_string(&filepath).unwrap();
assert!(
contents.starts_with("---\nname: \"Filesystem Watcher Architecture\"\n---"),
"spike file must start with YAML front matter"
);
assert!(contents.contains("# Spike 1: Filesystem Watcher Architecture"));
assert!(contents.contains("## Question"));
assert!(contents.contains("## Hypothesis"));
assert!(contents.contains("## Timebox"));
assert!(contents.contains("## Investigation Plan"));
assert!(contents.contains("## Findings"));
assert!(contents.contains("## Recommendation"));
}
#[test]
fn create_spike_file_uses_description_when_provided() {
let tmp = tempfile::tempdir().unwrap();
let description = "What is the best approach for watching filesystem events?";
create_spike_file(tmp.path(), "FS Watcher Spike", Some(description)).unwrap();
let filepath =
tmp.path().join(".story_kit/work/1_upcoming/1_spike_fs_watcher_spike.md");
let contents = fs::read_to_string(&filepath).unwrap();
assert!(contents.contains(description));
}
#[test]
fn create_spike_file_uses_placeholder_when_no_description() {
let tmp = tempfile::tempdir().unwrap();
create_spike_file(tmp.path(), "My Spike", None).unwrap();
let filepath = tmp.path().join(".story_kit/work/1_upcoming/1_spike_my_spike.md");
let contents = fs::read_to_string(&filepath).unwrap();
// Should have placeholder TBD in Question section
assert!(contents.contains("## Question\n\n- TBD\n"));
}
#[test]
fn create_spike_file_rejects_empty_name() {
let tmp = tempfile::tempdir().unwrap();
let result = create_spike_file(tmp.path(), "!!!", None);
assert!(result.is_err());
assert!(result.unwrap_err().contains("alphanumeric"));
}
#[test]
fn create_spike_file_with_special_chars_in_name_produces_valid_yaml() {
let tmp = tempfile::tempdir().unwrap();
let name = "Spike: compare \"fast\" vs slow encoders";
let result = create_spike_file(tmp.path(), name, None);
assert!(result.is_ok(), "create_spike_file failed: {result:?}");
let upcoming = tmp.path().join(".story_kit/work/1_upcoming");
let spike_id = result.unwrap();
let filename = format!("{spike_id}.md");
let contents = fs::read_to_string(upcoming.join(&filename)).unwrap();
let meta = parse_front_matter(&contents).expect("front matter should be valid YAML");
assert_eq!(meta.name.as_deref(), Some(name));
}
#[test]
fn create_spike_file_increments_from_existing_items() {
let tmp = tempfile::tempdir().unwrap();
let upcoming = tmp.path().join(".story_kit/work/1_upcoming");
fs::create_dir_all(&upcoming).unwrap();
fs::write(upcoming.join("5_story_existing.md"), "").unwrap();
let spike_id = create_spike_file(tmp.path(), "My Spike", None).unwrap();
assert!(spike_id.starts_with("6_spike_"), "expected spike number 6, got: {spike_id}");
}
}