use std::path::Path;
use speccoder_core::bug::{BugHome, HomeKind, Triad, normalise};
use crate::format::{BugRefusal, ReadBug, RecordedBug};
const CHANGE: &str = "bugfixes";
const DIR: &str = "openspec/changes/bugfixes";
pub(super) fn unit() -> BugHome {
BugHome {
kind: HomeKind::StandingOpenspec,
location: DIR.to_owned(),
reason: "OpenSpec: the standing bug-fix change".to_owned(),
}
}
fn highest(text: &str, prefix: &str) -> u32 {
text.match_indices(prefix)
.filter_map(|(i, _)| {
text[i + prefix.len()..]
.chars()
.take_while(char::is_ascii_digit)
.collect::<String>()
.parse()
.ok()
})
.max()
.unwrap_or(0)
}
pub(super) fn record(root: &Path, triad: &Triad) -> Result<RecordedBug, BugRefusal> {
let io = |e: String| BugRefusal::new("INTERNAL", e);
let dir = root.join(DIR);
let spec_dir = dir.join("specs").join(CHANGE);
std::fs::create_dir_all(&spec_dir).map_err(|e| io(format!("{}: {e}", spec_dir.display())))?;
let meta = dir.join(".openspec.yaml");
let proposal_path = dir.join("proposal.md");
let tasks_path = dir.join("tasks.md");
let spec_path = spec_dir.join("spec.md");
let mut proposal = std::fs::read_to_string(&proposal_path).unwrap_or_else(|_| {
"# Proposal\n\n## Why\n\nBugs reported one at a time (`speccoder bug`): each is a requirement in the reporter's words — Current, Expected, Unchanged — reproduced before it is fixed.\n\n## What Changes\n".to_owned()
});
let mut spec = std::fs::read_to_string(&spec_path)
.unwrap_or_else(|_| "## ADDED Requirements\n".to_owned());
let mut tasks = std::fs::read_to_string(&tasks_path).unwrap_or_else(|_| "# Tasks\n".to_owned());
let n = highest(&spec, "### Requirement: B") + 1;
let group = highest(&tasks, "\n## ") + 1;
let title = triad.title();
let requirement = format!("B{n}");
let mut block = format!(
"\n### Requirement: {requirement} {title}\nA bug, in the reporter's words.\n\nCurrent: {}\n",
triad.current.text
);
for e in &triad.expected {
block.push_str(&format!("Expected: {}\n", e.text));
}
for u in &triad.unchanged {
block.push_str(&format!("Unchanged: {}\n", u.text));
}
for s in triad.expected.iter().chain(&triad.unchanged) {
block.push_str(&format!(
"\n#### Scenario: {}\n- **THEN** {}\n",
s.id, s.text
));
}
if !spec.ends_with('\n') {
spec.push('\n');
}
spec.push_str(&block);
let faithful = parse(&spec, &requirement, String::new()).is_ok_and(|back| {
back.current == [triad.current.text.clone()]
&& back.expected
== triad
.expected
.iter()
.map(|s| s.text.clone())
.collect::<Vec<_>>()
&& back.unchanged
== triad
.unchanged
.iter()
.map(|s| s.text.clone())
.collect::<Vec<_>>()
});
if !faithful {
return Err(BugRefusal::new(
"BUG-ASSESSMENT-DRIFT",
format!(
"{requirement} would not read back exactly your words; nothing is written — reword it"
),
));
}
if !proposal.ends_with('\n') {
proposal.push('\n');
}
proposal.push_str(&format!("- {requirement}: {title}\n"));
if !tasks.ends_with('\n') {
tasks.push('\n');
}
let task = format!("{group}.1");
tasks.push_str(&format!(
"\n## {group}. {title}\n\n- [ ] {task} Fix: {title} ({requirement})\n"
));
for (path, new) in [
(&proposal_path, &proposal),
(&spec_path, &spec),
(&tasks_path, &tasks),
] {
if let Ok(now) = std::fs::read_to_string(path)
&& !new.starts_with(now.trim_end_matches('\n'))
{
return Err(BugRefusal::new(
"INTERNAL",
format!(
"{} changed while the bug was being recorded; try again",
path.display()
),
));
}
}
if !meta.exists() {
std::fs::write(&meta, "schema: spec-driven\n").map_err(|e| io(e.to_string()))?;
}
std::fs::write(&proposal_path, proposal).map_err(|e| io(e.to_string()))?;
std::fs::write(&spec_path, spec).map_err(|e| io(e.to_string()))?;
std::fs::write(&tasks_path, tasks).map_err(|e| io(e.to_string()))?;
Ok(RecordedBug {
unit: CHANGE.to_owned(),
dir: DIR.to_owned(),
requirement,
task,
title,
files: vec![
format!("{DIR}/proposal.md"),
format!("{DIR}/specs/{CHANGE}/spec.md"),
format!("{DIR}/tasks.md"),
],
})
}
pub(super) fn read(root: &Path, key: &str) -> Result<ReadBug, BugRefusal> {
let file = format!("{DIR}/specs/{CHANGE}/spec.md");
let text = std::fs::read_to_string(root.join(&file))
.map_err(|_| BugRefusal::new("BUG-NO-UNIT", format!("{file} is missing")))?;
parse(&text, key, file)
}
fn parse(text: &str, key: &str, file: String) -> Result<ReadBug, BugRefusal> {
let head = format!("### Requirement: {key} ");
let mut r = ReadBug {
current: Vec::new(),
expected: Vec::new(),
unchanged: Vec::new(),
title: None,
file,
};
let mut inside = false;
for line in text.lines() {
if line.starts_with("### Requirement: ") {
inside = line.starts_with(&head);
if inside {
r.title = Some(line[head.len()..].trim().to_owned());
}
continue;
}
if !inside || line.starts_with("#### ") {
if line.starts_with("#### ") && inside {
inside = false;
}
continue;
}
if let Some((label, rest)) = line.split_once(':') {
let words = normalise(rest);
match label.trim() {
"Current" => r.current.push(words),
"Expected" => r.expected.push(words),
"Unchanged" => r.unchanged.push(words),
_ => {}
}
}
}
if r.current.is_empty() || r.expected.is_empty() {
return Err(BugRefusal::new(
"BUG-ASSESSMENT-UNREADABLE",
format!("{key} in {} has no Current and Expected lines", r.file),
));
}
Ok(r)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_bug_is_a_requirement_a_line_of_the_proposal_and_a_task_group() {
let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
let t = |c: &str, e: &str| {
Triad::new(
c,
&[e.to_owned()],
&["a title is saved".to_owned()],
crate::integrity::sha256_hex,
)
.unwrap_or_else(|e| panic!("{e:?}"))
};
let a = record(
dir.path(),
&t("saving an empty title crashes", "an empty title is refused"),
)
.unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!((a.requirement.as_str(), a.task.as_str()), ("B1", "1.1"));
let b = record(
dir.path(),
&t("long titles are cut", "a long title is kept whole"),
)
.unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!((b.requirement.as_str(), b.task.as_str()), ("B2", "2.1"));
let read = read(dir.path(), "B2").unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(read.expected, ["a long title is kept whole"]);
assert_eq!(read.unchanged, ["a title is saved"]);
let tasks =
std::fs::read_to_string(dir.path().join(DIR).join("tasks.md")).unwrap_or_default();
assert!(
tasks.contains("- [ ] 2.1 Fix: long titles are cut (B2)"),
"{tasks}"
);
let spec = std::fs::read_to_string(dir.path().join(DIR).join("specs/bugfixes/spec.md"))
.unwrap_or_default();
assert!(
spec.contains("#### Scenario: E1\n- **THEN** an empty title is refused"),
"{spec}"
);
}
}