story-kit: merge 78_story_create_spike_mcp_tool
This commit is contained in:
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user