1use std::path::Path;
13
14use speccoder_core::bug::{BugHome, HomeKind, Triad, normalise, valid_slug};
15
16use super::quick::highest;
17use crate::format::{BugRefusal, BugStep, ReadBug, RecordedBug, Rules};
18
19const NAME: &str = "bugfixes";
21const BUGS: &str = ".specify/bugs";
23const EXTENSION: &str = "bug";
25const ASSESSMENT: &str = "assessment.md";
27
28pub static ASSESS_RULES: Rules = Rules {
31 intent: super::texts::RULES.intent,
32 intent_reason: super::texts::RULES.intent_reason,
33 authoring_path: |p| p.starts_with(".specify/bugs/") && !p.contains(".."),
34 command: |_, _| None,
35 env: &[],
36 found_env: super::python::command_env,
37 authoring_refusal: |p| {
38 format!(
39 "the `bug` extension's assess step writes only its bug's directory (.specify/bugs/<slug>/); `{p}` is not in it"
40 )
41 },
42};
43
44pub(super) const STEP: BugStep = BugStep {
47 skill: "bug-assess",
48 arguments: |triad, slug| format!("{}slug={slug}", triad.labelled()),
49 rules: &ASSESS_RULES,
50};
51
52pub(super) fn unit(root: &Path) -> Result<BugHome, BugRefusal> {
54 let inventory = super::extensions::inventory(root);
55 let listed = inventory
56 .as_ref()
57 .and_then(|i| i.entries.iter().find(|e| e.id == EXTENSION));
58 let from = |i: &crate::format::Inventory| match i.source {
59 crate::format::InventorySource::Cli => "`specify extension list`",
60 crate::format::InventorySource::Files => ".specify/extensions/.registry",
61 };
62 if let (Some(e), Some(i)) = (listed, inventory.as_ref())
63 && e.enabled
64 {
65 return Ok(BugHome {
66 kind: HomeKind::NativeSpeckitBug,
67 location: BUGS.to_owned(),
68 reason: format!(
69 "Spec Kit's `bug` extension {} is installed and enabled ({}): the bug is the extension's own",
70 e.version,
71 from(i)
72 ),
73 });
74 }
75 let reason = match (listed, inventory.as_ref()) {
76 (Some(e), Some(i)) => format!(
77 "Spec Kit's `bug` extension {} is installed but disabled ({}): the standing bug-fix unit",
78 e.version,
79 from(i)
80 ),
81 _ => "Spec Kit without the `bug` extension: the standing bug-fix unit".to_owned(),
82 };
83 Ok(BugHome {
84 kind: HomeKind::StandingSpeckit,
85 location: format!(
86 "{}/{}",
87 speccoder_speckit::features::SPECS,
88 standing_name(root)
89 ),
90 reason,
91 })
92}
93
94fn standing_name(root: &Path) -> String {
96 let specs = root.join(speccoder_speckit::features::SPECS);
97 let names: Vec<String> = std::fs::read_dir(&specs)
98 .into_iter()
99 .flatten()
100 .flatten()
101 .map(|e| e.file_name().to_string_lossy().into_owned())
102 .collect();
103 if let Some(n) = names.iter().find(|n| {
104 n.ends_with(&format!("-{NAME}")) && speccoder_speckit::features::prefix(n).is_some()
105 }) {
106 return n.clone();
107 }
108 let next = names
109 .iter()
110 .filter_map(|n| {
111 let p = speccoder_speckit::features::prefix(n)?.to_owned();
112 (p.len() <= 4).then(|| p.parse::<u32>().ok()).flatten()
113 })
114 .max()
115 .unwrap_or(0)
116 + 1;
117 format!("{next:03}-{NAME}")
118}
119
120pub(super) fn dir(home: &BugHome, slug: &str) -> Option<(String, String)> {
123 (home.kind == HomeKind::NativeSpeckitBug && valid_slug(slug))
124 .then(|| (format!("{BUGS}/{slug}"), format!("bugs/{slug}")))
125}
126
127fn linked(root: &Path, rel: &[&str]) -> bool {
130 let mut p = root.to_path_buf();
131 rel.iter().any(|part| {
132 p.push(part);
133 std::fs::symlink_metadata(&p).is_ok_and(|m| m.file_type().is_symlink())
134 })
135}
136
137pub(super) fn slug(root: &Path, title: &str) -> String {
139 let words: Vec<String> = title
140 .split(|c: char| !c.is_ascii_alphanumeric())
141 .filter(|w| !w.is_empty())
142 .map(str::to_ascii_lowercase)
143 .take(4)
144 .collect();
145 let base = if words.is_empty() {
146 "bug".to_owned()
147 } else {
148 words.join("-")
149 };
150 let taken = |s: &str| root.join(BUGS).join(s).exists();
151 if !taken(&base) {
152 return base;
153 }
154 (2..)
155 .map(|n| format!("{base}-{n}"))
156 .find(|s| !taken(s))
157 .unwrap_or(base)
158}
159
160pub(super) fn record(
162 root: &Path,
163 home: &BugHome,
164 triad: &Triad,
165) -> Result<RecordedBug, BugRefusal> {
166 if home.kind != HomeKind::StandingSpeckit {
167 return Err(BugRefusal::new(
168 "BUG-NO-UNIT",
169 "the bug lives in the `bug` extension's own layout: its assess step records it",
170 ));
171 }
172 let io = |e: String| BugRefusal::new("INTERNAL", e);
173 let name = home
174 .location
175 .rsplit('/')
176 .next()
177 .unwrap_or_default()
178 .to_owned();
179 let dir = root.join(&home.location);
180 std::fs::create_dir_all(&dir).map_err(|e| io(format!("{}: {e}", dir.display())))?;
181 let spec_path = dir.join("spec.md");
182 let tasks_path = dir.join("tasks.md");
183 let mut spec = std::fs::read_to_string(&spec_path).unwrap_or_else(|_| {
184 format!(
185 "# Feature Specification: Bug Fixes\n\n**Feature Branch**: `{name}`\n**Status**: Ongoing\n**Input**: Bugs reported one at a time (`speccoder bug`). Each is a user story whose acceptance scenarios are the reporter's words — Current, Expected, Unchanged — with its own requirement and task, reproduced before it is fixed.\n\n## User Scenarios & Testing\n"
186 )
187 });
188 let mut tasks =
189 std::fs::read_to_string(&tasks_path).unwrap_or_else(|_| "# Tasks: Bug Fixes\n".to_owned());
190 let story = highest(&spec, "### User Story ") + 1;
191 let fr = highest(&spec, "**FR-") + 1;
192 let task = highest(&tasks, "- [ ] T")
193 .max(highest(&tasks, "- [x] T"))
194 .max(highest(&tasks, "- [X] T"))
195 + 1;
196 let t = triad.title();
197 let requirement = format!("FR-{fr:03}");
198 let mut block = format!(
199 "\n### User Story {story} - {t} (Priority: P1)\n\nA bug, in the reporter's words.\n\n**Acceptance Scenarios**:\n\n1. **Current**: {}\n",
200 triad.current.text
201 );
202 for (n, s) in (2..).zip(triad.expected.iter().chain(&triad.unchanged)) {
203 let label = if s.id.starts_with('E') {
204 "Expected"
205 } else {
206 "Unchanged"
207 };
208 block.push_str(&format!("{n}. **{label}**: {}\n", s.text));
209 }
210 let mut line = format!("Current: {}.", triad.current.text.trim_end_matches('.'));
211 for e in &triad.expected {
212 line.push_str(&format!(" Expected: {}.", e.text.trim_end_matches('.')));
213 }
214 for u in &triad.unchanged {
215 line.push_str(&format!(" Unchanged: {}.", u.text.trim_end_matches('.')));
216 }
217 block.push_str(&format!("\n- **{requirement}**: {line}\n"));
218 if !spec.ends_with('\n') {
219 spec.push('\n');
220 }
221 spec.push_str(&block);
222 let task_id = format!("T{task:03}");
223 if !tasks.ends_with('\n') {
224 tasks.push('\n');
225 }
226 tasks.push_str(&format!(
227 "\n## Phase {story}: User Story {story} - {t} (Priority: P1)\n\n- [ ] {task_id} [US{story}] Fix: {t} per {requirement}\n"
228 ));
229 let back = standing_triad(&spec, &requirement).map_err(|_| unfaithful(&requirement))?;
232 if !same_words(&back, triad) {
233 return Err(unfaithful(&requirement));
234 }
235 for (path, new) in [(&spec_path, &spec), (&tasks_path, &tasks)] {
238 if let Ok(now) = std::fs::read_to_string(path)
239 && !new.starts_with(now.trim_end_matches('\n'))
240 {
241 return Err(BugRefusal::new(
242 "INTERNAL",
243 format!(
244 "{} changed while the bug was being recorded; try again",
245 path.display()
246 ),
247 ));
248 }
249 }
250 std::fs::write(&spec_path, spec).map_err(|e| io(format!("{}: {e}", spec_path.display())))?;
251 std::fs::write(&tasks_path, tasks).map_err(|e| io(format!("{}: {e}", tasks_path.display())))?;
252 Ok(RecordedBug {
253 unit: name,
254 dir: home.location.clone(),
255 requirement,
256 task: task_id,
257 title: t,
258 files: vec![
259 format!("{}/spec.md", home.location),
260 format!("{}/tasks.md", home.location),
261 ],
262 })
263}
264
265pub(crate) fn unfaithful(requirement: &str) -> BugRefusal {
267 BugRefusal::new(
268 "BUG-ASSESSMENT-DRIFT",
269 format!(
270 "{requirement} would not read back exactly your words (a statement holds what the file's own markup means); nothing is written — reword it"
271 ),
272 )
273}
274
275pub(crate) fn same_words(read: &ReadBug, triad: &Triad) -> bool {
277 read.current == [triad.current.text.clone()]
278 && read.expected
279 == triad
280 .expected
281 .iter()
282 .map(|s| s.text.clone())
283 .collect::<Vec<_>>()
284 && read.unchanged
285 == triad
286 .unchanged
287 .iter()
288 .map(|s| s.text.clone())
289 .collect::<Vec<_>>()
290}
291
292pub(super) fn read(root: &Path, home: &BugHome, key: &str) -> Result<ReadBug, BugRefusal> {
295 match home.kind {
296 HomeKind::NativeSpeckitBug => {
297 if !valid_slug(key) {
298 return Err(BugRefusal::new(
299 "BUG-ASSESSMENT-UNREADABLE",
300 format!("`{key}` is not a bug slug"),
301 ));
302 }
303 if linked(root, &[".specify", "bugs", key, ASSESSMENT]) {
304 return Err(BugRefusal::new(
305 "BUG-ASSESSMENT-UNREADABLE",
306 format!("{BUGS}/{key} is reached through a symbolic link: it is not read"),
307 ));
308 }
309 let file = root.join(BUGS).join(key).join(ASSESSMENT);
310 let text = std::fs::read_to_string(&file).map_err(|_| {
311 BugRefusal::new(
312 "BUG-ASSESSMENT-UNREADABLE",
313 format!("{BUGS}/{key}/{ASSESSMENT} is missing"),
314 )
315 })?;
316 parse_assessment(&text).map(|mut r| {
317 r.file = format!("{BUGS}/{key}/{ASSESSMENT}");
318 r
319 })
320 }
321 _ => {
322 let text = std::fs::read_to_string(root.join(&home.location).join("spec.md")).map_err(
323 |_| {
324 BugRefusal::new(
325 "BUG-NO-UNIT",
326 format!("{}/spec.md is missing", home.location),
327 )
328 },
329 )?;
330 standing_triad(&text, key).map(|mut r| {
331 r.file = format!("{}/spec.md", home.location);
332 r
333 })
334 }
335 }
336}
337
338fn labelled(line: &str) -> Option<(&'static str, String)> {
344 let mut l = line.trim_start();
345 loop {
346 let before = l;
347 if let Some(r) = l.strip_prefix('>') {
348 l = r.trim_start();
349 } else if let Some(r) = ["- ", "* ", "+ "].iter().find_map(|m| l.strip_prefix(m)) {
350 l = r.trim_start();
351 } else if let Some((n, rest)) = l.split_once(". ")
352 && !n.is_empty()
353 && n.chars().all(|c| c.is_ascii_digit())
354 {
355 l = rest.trim_start();
356 }
357 if l == before {
358 break;
359 }
360 }
361 for label in ["current", "expected", "unchanged"] {
362 for (open, close) in [("**", "**:"), ("**", ":**"), ("", ":")] {
363 let Some(rest) = l.strip_prefix(open) else {
364 continue;
365 };
366 let Some(word) = rest.get(..label.len()) else {
367 continue;
368 };
369 if !word.eq_ignore_ascii_case(label) {
370 continue;
371 }
372 if let Some(text) = rest[label.len()..].strip_prefix(close) {
373 let text = normalise(text);
374 return (!text.is_empty()).then_some((label, text));
375 }
376 }
377 }
378 None
379}
380
381fn collect(lines: impl Iterator<Item = String>) -> ReadBug {
382 let mut r = ReadBug {
383 current: Vec::new(),
384 expected: Vec::new(),
385 unchanged: Vec::new(),
386 title: None,
387 file: String::new(),
388 };
389 for line in lines {
390 if let Some((label, text)) = labelled(&line) {
391 match label {
392 "current" => r.current.push(text),
393 "expected" => r.expected.push(text),
394 _ => r.unchanged.push(text),
395 }
396 }
397 }
398 r
399}
400
401pub fn parse_assessment(text: &str) -> Result<ReadBug, BugRefusal> {
410 let unreadable = |why: &str| {
411 BugRefusal::new(
412 "BUG-ASSESSMENT-UNREADABLE",
413 format!("{ASSESSMENT} is not in the `bug` extension's layout speccoder reads: {why}"),
414 )
415 };
416 let title = text
417 .lines()
418 .find_map(|l| l.strip_prefix("# Bug Assessment:"))
419 .map(|t| t.trim().to_owned())
420 .ok_or_else(|| unreadable("no `# Bug Assessment:` title"))?;
421 let heading = |h: &str| text.lines().any(|l| l.trim_start().starts_with(h));
422 if !heading("## Report") || !heading("## Symptom") {
423 return Err(unreadable("no `## Report` and `## Symptom` sections"));
424 }
425 let report: Vec<String> = text
427 .lines()
428 .skip_while(|l| !l.trim_start().starts_with("## Report"))
429 .skip(1)
430 .take_while(|l| !l.trim_start().starts_with("## "))
431 .map(str::to_owned)
432 .collect();
433 let mut r = collect(report.into_iter());
434 r.title = Some(title);
435 if r.current.is_empty() || r.expected.is_empty() {
436 return Err(unreadable(
437 "its Report section has no `Current:` and `Expected:` lines",
438 ));
439 }
440 Ok(r)
441}
442
443fn standing_triad(spec: &str, requirement: &str) -> Result<ReadBug, BugRefusal> {
445 let mut story: Vec<&str> = Vec::new();
446 let mut found = None;
447 for line in spec.lines() {
448 if line.starts_with("### User Story ") {
449 if found.is_some() {
450 break;
451 }
452 story.clear();
453 }
454 story.push(line);
455 if line.contains(&format!("**{requirement}**")) {
456 found = Some(story.clone());
457 }
458 }
459 let lines = found.ok_or_else(|| {
460 BugRefusal::new(
461 "BUG-NO-UNIT",
462 format!("no requirement {requirement} in the bug-fix unit"),
463 )
464 })?;
465 let r = collect(
467 lines
468 .iter()
469 .filter(|l| !l.contains(&format!("**{requirement}**")))
470 .map(|l| (*l).to_owned()),
471 );
472 if r.current.is_empty() || r.expected.is_empty() {
473 return Err(BugRefusal::new(
474 "BUG-ASSESSMENT-UNREADABLE",
475 format!("{requirement}'s story has no Current and Expected scenarios"),
476 ));
477 }
478 Ok(r)
479}
480
481#[cfg(test)]
482mod tests {
483 use super::*;
484
485 const FIXTURE: &str = include_str!(
486 "../../../../speccoder-speckit/tests/fixtures/compat/1.1.3-bug-ext/.specify/bugs/empty-title-crash/assessment.md"
487 );
488
489 #[test]
490 fn the_fixture_assessment_reads_back_the_triad() {
491 let r = parse_assessment(FIXTURE).unwrap_or_else(|e| panic!("{e:?}"));
492 assert_eq!(r.current, ["saving an empty title crashes"]);
493 assert_eq!(r.expected, ["an empty title is refused with a message"]);
494 assert_eq!(r.unchanged, ["a non-empty title is saved as typed"]);
495 assert_eq!(r.title.as_deref(), Some("Saving an empty title crashes"));
496 }
497
498 #[test]
499 fn another_shape_is_refused_not_guessed() {
500 let e = parse_assessment("# Notes\n\nCurrent: x\nExpected: y\n").err();
501 assert_eq!(e.map(|e| e.code), Some("BUG-ASSESSMENT-UNREADABLE"));
502 let no_lines = FIXTURE.replace("> Expected:", "> Hoped:");
503 assert_eq!(
504 parse_assessment(&no_lines).err().map(|e| e.code),
505 Some("BUG-ASSESSMENT-UNREADABLE")
506 );
507 }
508
509 #[test]
510 fn labels_are_read_in_every_list_form() {
511 for l in [
512 "Current: a b",
513 "> Current: a b",
514 "- **Current**: a b",
515 "1. **Current**: a b",
516 " * current: a b",
517 ] {
518 assert_eq!(labelled(l), Some(("current", "a b".to_owned())), "{l}");
519 }
520 assert_eq!(labelled("Severity: high"), None);
521 assert_eq!(
523 labelled("2. **Expected**: passing **kwargs drops them"),
524 Some(("expected", "passing **kwargs drops them".to_owned()))
525 );
526 assert_eq!(
527 labelled("> Expected: `*args` and **kwargs**: kept"),
528 Some(("expected", "`*args` and **kwargs**: kept".to_owned()))
529 );
530 assert_eq!(
531 labelled("**Current:** x"),
532 Some(("current", "x".to_owned()))
533 );
534 }
535
536 proptest::proptest! {
537 #[test]
538 fn the_standing_unit_reads_back_exactly_what_it_records(
539 current in "[ -~]{1,40}",
540 expected in proptest::collection::vec("[ -~]{1,40}", 1..3),
541 unchanged in proptest::collection::vec("[ -~]{1,40}", 0..2),
542 ) {
543 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
544 let Ok(triad) = Triad::new(¤t, &expected, &unchanged, crate::integrity::sha256_hex) else {
545 return Ok(());
546 };
547 let home = BugHome {
548 kind: HomeKind::StandingSpeckit,
549 location: "specs/001-bugfixes".to_owned(),
550 reason: String::new(),
551 };
552 match record(dir.path(), &home, &triad) {
554 Ok(rec) => {
555 let back = read(dir.path(), &home, &rec.requirement).unwrap_or_else(|e| panic!("{e:?}"));
556 proptest::prop_assert!(same_words(&back, &triad), "{back:?} vs {triad:?}");
557 }
558 Err(e) => {
559 proptest::prop_assert_eq!(e.code, "BUG-ASSESSMENT-DRIFT");
560 proptest::prop_assert!(!dir.path().join("specs/001-bugfixes/spec.md").exists());
561 }
562 }
563 }
564 }
565
566 #[test]
567 #[cfg(unix)]
568 fn a_slug_never_reaches_outside_the_bugs_directory() {
569 let tmp = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
570 let home = BugHome {
571 kind: HomeKind::NativeSpeckitBug,
572 location: BUGS.to_owned(),
573 reason: String::new(),
574 };
575 std::fs::create_dir_all(tmp.path().join("elsewhere")).unwrap_or_else(|e| panic!("{e}"));
576 std::fs::write(tmp.path().join("elsewhere/assessment.md"), FIXTURE)
577 .unwrap_or_else(|e| panic!("{e}"));
578 assert_eq!(dir(&home, "../../elsewhere"), None);
579 assert_eq!(
580 read(tmp.path(), &home, "../../elsewhere")
581 .err()
582 .map(|e| e.code),
583 Some("BUG-ASSESSMENT-UNREADABLE")
584 );
585 std::fs::create_dir_all(tmp.path().join(BUGS)).unwrap_or_else(|e| panic!("{e}"));
587 std::os::unix::fs::symlink(
588 tmp.path().join("elsewhere"),
589 tmp.path().join(BUGS).join("linked"),
590 )
591 .unwrap_or_else(|e| panic!("{e}"));
592 assert_eq!(
593 read(tmp.path(), &home, "linked").err().map(|e| e.code),
594 Some("BUG-ASSESSMENT-UNREADABLE")
595 );
596 }
597
598 #[test]
599 fn slugs_are_kebab_case_and_unique() {
600 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
601 assert_eq!(
602 slug(dir.path(), "Saving an empty title crashes"),
603 "saving-an-empty-title"
604 );
605 std::fs::create_dir_all(dir.path().join(".specify/bugs/saving-an-empty-title"))
606 .unwrap_or_else(|e| panic!("{e}"));
607 assert_eq!(
608 slug(dir.path(), "Saving an empty title crashes"),
609 "saving-an-empty-title-2"
610 );
611 }
612}
613
614#[cfg(test)]
615mod policy_tests {
616 #[test]
617 fn the_assess_step_writes_its_bug_directory_only() {
618 let p = crate::tools::policy::Policy::new(&[], &[])
619 .rules(&super::ASSESS_RULES)
620 .authoring();
621 let d = |path: &str| {
622 p.classify(
623 "write_file",
624 &serde_json::json!({ "path": path, "content": "x" }),
625 None,
626 )
627 };
628 assert_eq!(
629 d(".specify/bugs/x/assessment.md"),
630 crate::tools::policy::Decision::Allow
631 );
632 assert_ne!(d("src/lib.rs"), crate::tools::policy::Decision::Allow);
633 assert_ne!(
634 d("specs/001-x/spec.md"),
635 crate::tools::policy::Decision::Allow
636 );
637 }
638}