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