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