1use std::fmt::Write as _;
16use std::fs;
17use std::path::{Path, PathBuf};
18use std::sync::OnceLock;
19
20use regex::Regex;
21
22use crate::broker::messages::slugify_branch;
23use crate::error::PawError;
24use crate::specs::{SpecBackend, SpecBackendKind, SpecEntry};
25
26pub(crate) const IMPLICIT_PHASE_NUMBER: u32 = 0;
28
29#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct Task {
32 pub id: String,
34 pub p_marker: bool,
36 pub complete: bool,
38 pub description: String,
40 pub phase: u32,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct Phase {
47 pub number: u32,
51 pub name: String,
54 pub tasks: Vec<Task>,
56}
57
58#[derive(Debug, Clone)]
60pub struct Feature {
61 pub dir: PathBuf,
63 pub phases: Vec<Phase>,
65 pub spec_md: Option<String>,
67 pub plan_md: Option<String>,
69 pub checklists: Vec<(String, String)>,
71}
72
73#[derive(Debug)]
75pub struct SpecKitBackend;
76
77impl SpecBackend for SpecKitBackend {
78 fn scan(&self, dir: &Path) -> Result<Vec<SpecEntry>, PawError> {
79 let read = fs::read_dir(dir).map_err(|e| {
80 PawError::SpecError(format!("cannot read directory {}: {e}", dir.display()))
81 })?;
82
83 let mut features: Vec<Feature> = Vec::new();
84 for raw in read {
85 let raw = raw
86 .map_err(|e| PawError::SpecError(format!("error reading directory entry: {e}")))?;
87 let path = raw.path();
88 if !path.is_dir() {
89 continue;
90 }
91
92 let Some(feature) = read_feature(&path)? else {
93 continue;
94 };
95
96 features.push(feature);
97 }
98
99 features.sort_by(|a, b| a.dir.file_name().cmp(&b.dir.file_name()));
101
102 let mut entries: Vec<SpecEntry> = Vec::new();
103 for feature in &features {
104 entries.extend(decompose_feature(feature));
105 }
106 Ok(entries)
107 }
108}
109
110pub(crate) fn read_feature(dir: &Path) -> Result<Option<Feature>, PawError> {
115 let tasks_path = dir.join("tasks.md");
116 if !tasks_path.exists() {
117 eprintln!(
118 "warning: skipping feature {}: no tasks.md found",
119 dir.display()
120 );
121 return Ok(None);
122 }
123
124 let tasks_content = fs::read_to_string(&tasks_path)
125 .map_err(|e| PawError::SpecError(format!("cannot read {}: {e}", tasks_path.display())))?;
126 let phases = parse_tasks_md(&tasks_content);
127
128 let spec_md = read_optional(&dir.join("spec.md"))?;
129 let plan_md = read_optional(&dir.join("plan.md"))?;
130 let checklists = read_checklists(&dir.join("checklists"))?;
131
132 Ok(Some(Feature {
133 dir: dir.to_path_buf(),
134 phases,
135 spec_md,
136 plan_md,
137 checklists,
138 }))
139}
140
141fn read_optional(path: &Path) -> Result<Option<String>, PawError> {
143 if !path.exists() {
144 return Ok(None);
145 }
146 fs::read_to_string(path)
147 .map(Some)
148 .map_err(|e| PawError::SpecError(format!("cannot read {}: {e}", path.display())))
149}
150
151fn read_checklists(dir: &Path) -> Result<Vec<(String, String)>, PawError> {
154 if !dir.is_dir() {
155 return Ok(Vec::new());
156 }
157
158 let read = fs::read_dir(dir)
159 .map_err(|e| PawError::SpecError(format!("read dir {}: {e}", dir.display())))?;
160
161 let mut items: Vec<(String, String)> = Vec::new();
162 for raw in read {
163 let raw = raw.map_err(|e| PawError::SpecError(format!("read entry: {e}")))?;
164 let path = raw.path();
165 if !path.is_file() {
166 continue;
167 }
168 let name = raw.file_name().to_string_lossy().to_string();
169 let content = fs::read_to_string(&path)
170 .map_err(|e| PawError::SpecError(format!("cannot read {}: {e}", path.display())))?;
171 items.push((name, content));
172 }
173 items.sort_by(|a, b| a.0.cmp(&b.0));
174 Ok(items)
175}
176
177fn phase_heading_re() -> &'static Regex {
180 static RE: OnceLock<Regex> = OnceLock::new();
181 RE.get_or_init(|| {
182 Regex::new(r"^##\s+Phase\s+(\d+)\s*[:\-\u{2014}]\s*(.+?)\s*$")
184 .expect("phase heading regex must compile")
185 })
186}
187
188fn incomplete_task_re() -> &'static Regex {
189 static RE: OnceLock<Regex> = OnceLock::new();
190 RE.get_or_init(|| {
191 Regex::new(r"^-\s+\[\s\]\s+(T\d+)(\s+\[P\])?\s+(.+?)\s*$")
192 .expect("incomplete task regex must compile")
193 })
194}
195
196fn complete_task_re() -> &'static Regex {
197 static RE: OnceLock<Regex> = OnceLock::new();
198 RE.get_or_init(|| {
199 Regex::new(r"^-\s+\[[xX]\]\s+(T\d+)(\s+\[P\])?\s+(.+?)\s*$")
200 .expect("complete task regex must compile")
201 })
202}
203
204pub(crate) fn parse_tasks_md(content: &str) -> Vec<Phase> {
210 let mut phases: Vec<Phase> = Vec::new();
211 let mut current_phase_idx: Option<usize> = None;
212
213 let push_phase = |phases: &mut Vec<Phase>, number: u32, name: String| -> usize {
214 phases.push(Phase {
215 number,
216 name,
217 tasks: Vec::new(),
218 });
219 phases.len() - 1
220 };
221
222 let ensure_implicit_phase = |phases: &mut Vec<Phase>, current_idx: &mut Option<usize>| {
223 if current_idx.is_none() {
224 let idx = push_phase(phases, IMPLICIT_PHASE_NUMBER, String::new());
225 *current_idx = Some(idx);
226 }
227 };
228
229 for line in content.lines() {
230 if let Some(caps) = phase_heading_re().captures(line) {
231 let number: u32 = caps
232 .get(1)
233 .and_then(|m| m.as_str().parse().ok())
234 .unwrap_or(0);
235 let name = caps
236 .get(2)
237 .map(|m| m.as_str().to_string())
238 .unwrap_or_default();
239 let idx = push_phase(&mut phases, number, name);
240 current_phase_idx = Some(idx);
241 continue;
242 }
243
244 if let Some(caps) = incomplete_task_re().captures(line) {
245 ensure_implicit_phase(&mut phases, &mut current_phase_idx);
246 let idx = current_phase_idx.expect("ensure_implicit_phase set Some");
247 let phase_number = phases[idx].number;
248 let task = Task {
249 id: caps[1].to_string(),
250 p_marker: caps.get(2).is_some(),
251 complete: false,
252 description: caps[3].to_string(),
253 phase: phase_number,
254 };
255 phases[idx].tasks.push(task);
256 continue;
257 }
258
259 if let Some(caps) = complete_task_re().captures(line) {
260 ensure_implicit_phase(&mut phases, &mut current_phase_idx);
261 let idx = current_phase_idx.expect("ensure_implicit_phase set Some");
262 let phase_number = phases[idx].number;
263 let task = Task {
264 id: caps[1].to_string(),
265 p_marker: caps.get(2).is_some(),
266 complete: true,
267 description: caps[3].to_string(),
268 phase: phase_number,
269 };
270 phases[idx].tasks.push(task);
271 }
272 }
274
275 phases
276}
277
278pub(crate) fn current_phase(phases: &[Phase]) -> Option<&Phase> {
281 phases
282 .iter()
283 .filter(|p| p.tasks.iter().any(|t| !t.complete))
284 .min_by_key(|p| p.number)
285}
286
287pub(crate) enum EntryKind<'a> {
289 Single { task: &'a Task },
291 Consolidated {
293 tasks: Vec<&'a Task>,
294 phase_number: u32,
295 phase_name: &'a str,
296 },
297}
298
299pub(crate) fn decompose_feature(feature: &Feature) -> Vec<SpecEntry> {
307 let feature_dir = feature
308 .dir
309 .file_name()
310 .map(|n| n.to_string_lossy().to_string())
311 .unwrap_or_default();
312
313 let total_tasks: usize = feature.phases.iter().map(|p| p.tasks.len()).sum();
314 let Some(phase) = current_phase(&feature.phases) else {
315 if total_tasks > 0 {
316 eprintln!(
317 "warning: feature {} has no incomplete tasks — skipping",
318 feature.dir.display()
319 );
320 }
321 return Vec::new();
323 };
324
325 let mut entries: Vec<SpecEntry> = Vec::new();
326
327 for task in phase.tasks.iter().filter(|t| !t.complete && t.p_marker) {
329 let id = format!("{feature_dir}-{}", task.id);
330 let branch_input = format!("{}-{}", task.id, task.description);
331 let branch = format!("task/{}", slugify_branch(&branch_input));
332 let prompt = build_prompt(feature, &EntryKind::Single { task });
333 entries.push(SpecEntry {
334 id,
335 backend: SpecBackendKind::SpecKit,
336 branch,
337 cli: None,
338 prompt,
339 owned_files: None,
340 });
341 }
342
343 let non_p: Vec<&Task> = phase
345 .tasks
346 .iter()
347 .filter(|t| !t.complete && !t.p_marker)
348 .collect();
349 if !non_p.is_empty() {
350 let id = format!("{feature_dir}-phase-{}", phase.number);
351 let branch_input = format!("{feature_dir}-{}", phase.name);
352 let branch = format!("phase/{}", slugify_branch(&branch_input));
353 let kind = EntryKind::Consolidated {
354 tasks: non_p,
355 phase_number: phase.number,
356 phase_name: &phase.name,
357 };
358 let prompt = build_prompt(feature, &kind);
359 entries.push(SpecEntry {
360 id,
361 backend: SpecBackendKind::SpecKit,
362 branch,
363 cli: None,
364 prompt,
365 owned_files: None,
366 });
367 }
368
369 entries
370}
371
372const SECTION_DELIM: &str = "\n\n---\n\n";
374
375pub(crate) fn build_prompt(feature: &Feature, kind: &EntryKind<'_>) -> String {
386 let mut sections: Vec<String> = Vec::new();
387
388 if let Some(spec) = feature.spec_md.as_deref() {
389 let trimmed = spec.trim();
390 if !trimmed.is_empty() {
391 sections.push(format!("## Feature Context\n\n{trimmed}"));
392 }
393 }
394
395 if let Some(plan) = feature.plan_md.as_deref() {
396 let trimmed = plan.trim();
397 if !trimmed.is_empty() {
398 sections.push(format!("## Implementation Plan\n\n{trimmed}"));
399 }
400 }
401
402 if !feature.checklists.is_empty() {
403 let mut section = String::from(
404 "## Validation Criteria (advisory)\n\n\
405 The following checklists are advisory context for this release \
406 (full enforcement is planned for v1.0.0).",
407 );
408 for (name, content) in &feature.checklists {
409 let _ = write!(section, "\n\n### {name}\n\n{}", content.trim());
410 }
411 sections.push(section);
412 }
413
414 sections.push(your_task_section(kind));
415
416 sections.join(SECTION_DELIM)
417}
418
419fn your_task_section(kind: &EntryKind<'_>) -> String {
420 let mut out = String::from("## Your Task\n\n");
421 match kind {
422 EntryKind::Single { task } => {
423 let id = &task.id;
424 let desc = &task.description;
425 let _ = write!(out, "{id} — {desc}");
426 }
427 EntryKind::Consolidated {
428 tasks,
429 phase_number,
430 phase_name,
431 } => {
432 let _ = writeln!(
433 out,
434 "Phase {phase_number} ({phase_name}). Complete the following tasks in order:"
435 );
436 for task in tasks {
437 let id = &task.id;
438 let desc = &task.description;
439 let _ = write!(out, "\n- {id} — {desc}");
440 }
441 out.push_str(
442 "\n\nWork through these tasks sequentially in the order listed. \
443 After completing each task, flip its `- [ ]` checkbox to \
444 `- [x]` in this worktree's `tasks.md`. You may commit the \
445 writeback alongside the task's code change or as a separate \
446 commit. Publish `agent.done` only when every task above \
447 shows `- [x]` in `tasks.md`.",
448 );
449 }
450 }
451 out
452}
453
454pub fn detect_constitution(specs_dir: &Path) -> Option<PathBuf> {
460 let parent = specs_dir.parent()?;
461 let candidate = parent.join("memory").join("constitution.md");
462 if candidate.is_file() {
463 Some(candidate)
464 } else {
465 None
466 }
467}
468
469#[cfg(test)]
470mod tests {
471 use super::*;
472 use std::fs;
473
474 #[test]
477 fn backend_constructs() {
478 let backend = SpecKitBackend;
479 let dbg = format!("{backend:?}");
480 assert!(dbg.contains("SpecKitBackend"));
481 }
482
483 #[test]
484 fn scan_empty_directory() {
485 let tmp = tempfile::tempdir().unwrap();
486 let backend = SpecKitBackend;
487 let result = backend.scan(tmp.path()).unwrap();
488 assert!(result.is_empty());
489 }
490
491 #[test]
492 fn scan_skips_non_directory_children() {
493 let tmp = tempfile::tempdir().unwrap();
494 fs::write(tmp.path().join("loose-file.md"), "hello").unwrap();
495 let backend = SpecKitBackend;
496 let result = backend.scan(tmp.path()).unwrap();
497 assert!(result.is_empty());
498 }
499
500 #[test]
501 fn scan_skips_feature_without_tasks_md() {
502 let tmp = tempfile::tempdir().unwrap();
503 fs::create_dir(tmp.path().join("001-no-tasks")).unwrap();
504 let backend = SpecKitBackend;
505 let result = backend.scan(tmp.path()).unwrap();
506 assert!(result.is_empty());
507 }
508
509 #[test]
512 fn read_feature_loads_optional_files() {
513 let tmp = tempfile::tempdir().unwrap();
514 let feat = tmp.path().join("002-onboarding");
515 fs::create_dir(&feat).unwrap();
516 fs::write(
517 feat.join("tasks.md"),
518 "## Phase 1: Setup\n- [ ] T001 do thing\n",
519 )
520 .unwrap();
521 fs::write(feat.join("spec.md"), "the spec").unwrap();
522 fs::write(feat.join("plan.md"), "the plan").unwrap();
523 fs::create_dir(feat.join("checklists")).unwrap();
524 fs::write(feat.join("checklists/security.md"), "sec criteria").unwrap();
525 fs::write(feat.join("checklists/perf.md"), "perf criteria").unwrap();
526
527 let feature = read_feature(&feat).unwrap().expect("feature should load");
528 assert_eq!(feature.dir, feat);
529 assert_eq!(feature.spec_md.as_deref(), Some("the spec"));
530 assert_eq!(feature.plan_md.as_deref(), Some("the plan"));
531 assert_eq!(feature.checklists.len(), 2);
532 assert_eq!(feature.checklists[0].0, "perf.md");
533 assert_eq!(feature.checklists[1].0, "security.md");
534 assert_eq!(feature.phases.len(), 1);
535 assert_eq!(feature.phases[0].number, 1);
536 assert_eq!(feature.phases[0].name, "Setup");
537 assert_eq!(feature.phases[0].tasks.len(), 1);
538 }
539
540 #[test]
541 fn read_feature_optional_files_absent() {
542 let tmp = tempfile::tempdir().unwrap();
543 let feat = tmp.path().join("004-bare");
544 fs::create_dir(&feat).unwrap();
545 fs::write(feat.join("tasks.md"), "## Phase 1: Setup\n").unwrap();
546
547 let feature = read_feature(&feat).unwrap().expect("feature should load");
548 assert!(feature.spec_md.is_none());
549 assert!(feature.plan_md.is_none());
550 assert!(feature.checklists.is_empty());
551 }
552
553 #[test]
554 fn read_feature_returns_none_when_tasks_md_missing() {
555 let tmp = tempfile::tempdir().unwrap();
556 let feat = tmp.path().join("005-empty");
557 fs::create_dir(&feat).unwrap();
558
559 let result = read_feature(&feat).unwrap();
560 assert!(result.is_none());
561 }
562
563 #[test]
566 fn parses_standard_task_line() {
567 let phases = parse_tasks_md("## Phase 1: Setup\n- [ ] T001 Create project structure\n");
568 assert_eq!(phases.len(), 1);
569 let t = &phases[0].tasks[0];
570 assert_eq!(t.id, "T001");
571 assert!(!t.p_marker);
572 assert!(!t.complete);
573 assert_eq!(t.description, "Create project structure");
574 }
575
576 #[test]
577 fn parses_p_marker() {
578 let phases = parse_tasks_md(
579 "## Phase 2: Build\n- [ ] T009 [P] Contract test POST /api/v1/auth/otp/request\n",
580 );
581 let t = &phases[0].tasks[0];
582 assert_eq!(t.id, "T009");
583 assert!(t.p_marker);
584 assert_eq!(t.description, "Contract test POST /api/v1/auth/otp/request");
585 }
586
587 #[test]
588 fn parses_complete_task_lowercase_and_uppercase_x() {
589 let phases = parse_tasks_md("## Phase 1: Setup\n- [x] T001 lower\n- [X] T002 upper\n");
590 assert_eq!(phases[0].tasks.len(), 2);
591 assert!(phases[0].tasks[0].complete);
592 assert!(phases[0].tasks[1].complete);
593 }
594
595 #[test]
596 fn parses_phase_heading_separator_variants() {
597 let phases = parse_tasks_md(
598 "## Phase 1: Setup\n\
599 - [ ] T001 a\n\
600 ## Phase 2 — Foundational\n\
601 - [ ] T002 b\n\
602 ## Phase 3 - User Story 1\n\
603 - [ ] T003 c\n",
604 );
605 assert_eq!(phases.len(), 3);
606 assert_eq!(phases[0].number, 1);
607 assert_eq!(phases[0].name, "Setup");
608 assert_eq!(phases[1].number, 2);
609 assert_eq!(phases[1].name, "Foundational");
610 assert_eq!(phases[2].number, 3);
611 assert_eq!(phases[2].name, "User Story 1");
612 }
613
614 #[test]
615 fn tasks_attach_to_preceding_phase() {
616 let phases = parse_tasks_md(
617 "## Phase 1: Setup\n\
618 - [ ] T001 a\n\
619 - [ ] T002 b\n\
620 ## Phase 2: Foundational\n\
621 - [ ] T003 c\n\
622 - [ ] T004 d\n\
623 - [ ] T005 e\n",
624 );
625 assert_eq!(phases.len(), 2);
626 assert_eq!(phases[0].tasks.len(), 2);
627 assert_eq!(phases[1].tasks.len(), 3);
628 }
629
630 #[test]
631 fn unrecognised_lines_are_ignored() {
632 let phases = parse_tasks_md(
633 "## Phase 1: Setup\n\
634 Some prose paragraph.\n\
635 - [ ] T001 real task\n\
636 Another commentary line.\n\
637 > a quote\n",
638 );
639 assert_eq!(phases.len(), 1);
640 assert_eq!(phases[0].tasks.len(), 1);
641 }
642
643 #[test]
644 fn phase_less_file_uses_implicit_phase() {
645 let phases = parse_tasks_md("- [ ] T001 first\n- [ ] T002 [P] second\n");
646 assert_eq!(phases.len(), 1);
647 assert_eq!(phases[0].number, IMPLICIT_PHASE_NUMBER);
648 assert!(phases[0].name.is_empty());
649 assert_eq!(phases[0].tasks.len(), 2);
650 }
651
652 #[test]
653 fn duplicate_task_ids_are_kept_as_separate_records() {
654 let phases = parse_tasks_md("## Phase 1: Setup\n- [ ] T001 first\n- [ ] T001 dup\n");
656 assert_eq!(phases[0].tasks.len(), 2);
657 }
658
659 #[test]
662 fn current_phase_skips_fully_complete_phases() {
663 let phases = parse_tasks_md(
664 "## Phase 1: Setup\n\
665 - [x] T001 done\n\
666 ## Phase 2: Build\n\
667 - [x] T002 done\n\
668 - [ ] T003 todo\n\
669 ## Phase 3: Polish\n\
670 - [ ] T004 future\n",
671 );
672 let cp = current_phase(&phases).unwrap();
673 assert_eq!(cp.number, 2);
674 }
675
676 #[test]
677 fn current_phase_returns_none_when_all_complete() {
678 let phases = parse_tasks_md(
679 "## Phase 1: Setup\n- [x] T001 done\n## Phase 2: Build\n- [x] T002 done\n",
680 );
681 assert!(current_phase(&phases).is_none());
682 }
683
684 #[test]
685 fn current_phase_handles_implicit_phase() {
686 let phases = parse_tasks_md("- [ ] T001 only\n");
687 let cp = current_phase(&phases).unwrap();
688 assert_eq!(cp.number, IMPLICIT_PHASE_NUMBER);
689 }
690
691 fn feature_fixture(dir_name: &str, tasks_md: &str) -> Feature {
694 Feature {
695 dir: PathBuf::from(dir_name),
696 phases: parse_tasks_md(tasks_md),
697 spec_md: Some("SPEC".to_string()),
698 plan_md: Some("PLAN".to_string()),
699 checklists: vec![],
700 }
701 }
702
703 #[test]
704 fn decompose_mixed_phase_produces_n_plus_one() {
705 let feat = feature_fixture(
706 "003-user-list",
707 "## Phase 2: Build\n\
708 - [ ] T009 [P] do A\n\
709 - [ ] T010 [P] do B\n\
710 - [ ] T011 do C\n\
711 - [ ] T012 do D\n\
712 - [ ] T013 do E\n",
713 );
714 let entries = decompose_feature(&feat);
715 assert_eq!(entries.len(), 3);
716 assert!(entries.iter().any(|e| e.id == "003-user-list-T009"));
717 assert!(entries.iter().any(|e| e.id == "003-user-list-T010"));
718 assert!(entries.iter().any(|e| e.id == "003-user-list-phase-2"));
719 }
720
721 #[test]
722 fn decompose_only_p_tasks_no_consolidated() {
723 let feat = feature_fixture(
724 "002-foo",
725 "## Phase 1: Setup\n\
726 - [ ] T001 [P] one\n\
727 - [ ] T002 [P] two\n\
728 - [ ] T003 [P] three\n\
729 - [ ] T004 [P] four\n",
730 );
731 let entries = decompose_feature(&feat);
732 assert_eq!(entries.len(), 4);
733 assert!(entries.iter().all(|e| e.branch.starts_with("task/")));
734 }
735
736 #[test]
737 fn decompose_only_non_p_one_consolidated_entry() {
738 let feat = feature_fixture(
739 "002-foo",
740 "## Phase 1: Setup\n\
741 - [ ] T001 one\n\
742 - [ ] T002 two\n\
743 - [ ] T003 three\n",
744 );
745 let entries = decompose_feature(&feat);
746 assert_eq!(entries.len(), 1);
747 assert!(entries[0].branch.starts_with("phase/"));
748 assert!(entries[0].prompt.contains("T001"));
749 assert!(entries[0].prompt.contains("T002"));
750 assert!(entries[0].prompt.contains("T003"));
751 }
752
753 #[test]
754 fn decompose_single_non_p_still_uses_phase_branch() {
755 let feat = feature_fixture("002-foo", "## Phase 1: Setup\n- [ ] T001 only\n");
756 let entries = decompose_feature(&feat);
757 assert_eq!(entries.len(), 1);
758 assert!(entries[0].branch.starts_with("phase/"));
759 }
760
761 #[test]
762 fn decompose_fully_complete_yields_nothing() {
763 let feat = feature_fixture(
764 "001-foo",
765 "## Phase 1: Setup\n- [x] T001 done\n- [x] T002 done\n",
766 );
767 let entries = decompose_feature(&feat);
768 assert!(entries.is_empty());
769 }
770
771 #[test]
772 fn decompose_empty_tasks_md_yields_nothing() {
773 let feat = feature_fixture("001-foo", "");
774 let entries = decompose_feature(&feat);
775 assert!(entries.is_empty());
776 }
777
778 #[test]
779 fn decompose_owned_files_is_none() {
780 let feat = feature_fixture(
781 "001-foo",
782 "## Phase 1: Setup\n- [ ] T001 [P] do thing\n- [ ] T002 do other\n",
783 );
784 for entry in decompose_feature(&feat) {
785 assert!(entry.owned_files.is_none(), "id={}", entry.id);
786 assert!(entry.cli.is_none(), "id={}", entry.id);
787 }
788 }
789
790 #[test]
791 fn decompose_branch_shapes() {
792 let feat = feature_fixture(
793 "003-user-list",
794 "## Phase 2: Foundational\n\
795 - [ ] T009 [P] Add login form component\n\
796 - [ ] T010 Setup database schema\n",
797 );
798 let entries = decompose_feature(&feat);
799 let task_entry = entries
800 .iter()
801 .find(|e| e.id == "003-user-list-T009")
802 .unwrap();
803 assert_eq!(task_entry.branch, "task/t009-add-login-form-component");
804
805 let phase_entry = entries
806 .iter()
807 .find(|e| e.id == "003-user-list-phase-2")
808 .unwrap();
809 assert_eq!(phase_entry.branch, "phase/003-user-list-foundational");
810 }
811
812 #[test]
813 fn decompose_branches_use_safe_char_set() {
814 let feat = feature_fixture(
815 "003-user-list",
816 "## Phase 2: User Story #1!\n\
817 - [ ] T001 [P] Punctuation & symbols (yes, with commas)\n\
818 - [ ] T002 plain task\n",
819 );
820 let entries = decompose_feature(&feat);
821 for entry in &entries {
822 let stripped = entry
823 .branch
824 .strip_prefix("task/")
825 .or_else(|| entry.branch.strip_prefix("phase/"))
826 .unwrap();
827 for c in stripped.chars() {
828 assert!(
829 c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_',
830 "unsafe char {c:?} in branch {}",
831 entry.branch
832 );
833 }
834 }
835 }
836
837 #[test]
840 fn prompt_includes_spec_and_plan() {
841 let feat = feature_fixture("003-user-list", "## Phase 1: Setup\n- [ ] T001 one\n");
842 let phase = current_phase(&feat.phases).unwrap();
843 let task = &phase.tasks[0];
844 let prompt = build_prompt(&feat, &EntryKind::Single { task });
845 assert!(prompt.contains("## Feature Context"));
846 assert!(prompt.contains("SPEC"));
847 assert!(prompt.contains("## Implementation Plan"));
848 assert!(prompt.contains("PLAN"));
849 assert!(prompt.contains("T001"));
850 }
851
852 #[test]
853 fn prompt_omits_plan_when_missing() {
854 let mut feat = feature_fixture("003-user-list", "## Phase 1: Setup\n- [ ] T001 one\n");
855 feat.plan_md = None;
856 let phase = current_phase(&feat.phases).unwrap();
857 let task = &phase.tasks[0];
858 let prompt = build_prompt(&feat, &EntryKind::Single { task });
859 assert!(prompt.contains("## Feature Context"));
860 assert!(!prompt.contains("## Implementation Plan"));
861 }
862
863 #[test]
864 fn prompt_includes_checklists_when_present() {
865 let mut feat = feature_fixture("003-user-list", "## Phase 1: Setup\n- [ ] T001 one\n");
866 feat.checklists = vec![
867 ("auth.md".to_string(), "auth criteria".to_string()),
868 ("data.md".to_string(), "data criteria".to_string()),
869 ];
870 let phase = current_phase(&feat.phases).unwrap();
871 let task = &phase.tasks[0];
872 let prompt = build_prompt(&feat, &EntryKind::Single { task });
873 assert!(prompt.contains("## Validation Criteria (advisory)"));
874 assert!(prompt.contains("### auth.md"));
875 assert!(prompt.contains("auth criteria"));
876 assert!(prompt.contains("### data.md"));
877 assert!(prompt.contains("data criteria"));
878 assert!(prompt.contains("advisory"));
879 }
880
881 #[test]
882 fn prompt_omits_checklists_when_empty() {
883 let feat = feature_fixture("003-user-list", "## Phase 1: Setup\n- [ ] T001 one\n");
884 let phase = current_phase(&feat.phases).unwrap();
885 let task = &phase.tasks[0];
886 let prompt = build_prompt(&feat, &EntryKind::Single { task });
887 assert!(!prompt.contains("Validation Criteria"));
888 }
889
890 #[test]
891 fn consolidated_prompt_lists_tasks_in_order_with_ids() {
892 let feat = feature_fixture(
893 "003-user-list",
894 "## Phase 2: Foundational\n\
895 - [ ] T004 Setup database schema\n\
896 - [ ] T005 Create auth tables\n\
897 - [ ] T006 Seed test data\n",
898 );
899 let phase = current_phase(&feat.phases).unwrap();
900 let tasks: Vec<&Task> = phase.tasks.iter().filter(|t| !t.p_marker).collect();
901 let kind = EntryKind::Consolidated {
902 tasks,
903 phase_number: phase.number,
904 phase_name: &phase.name,
905 };
906 let prompt = build_prompt(&feat, &kind);
907
908 let p4 = prompt.find("T004").unwrap();
909 let p5 = prompt.find("T005").unwrap();
910 let p6 = prompt.find("T006").unwrap();
911 assert!(p4 < p5 && p5 < p6, "tasks must appear in source order");
912 assert!(prompt.contains("Setup database schema"));
913 assert!(prompt.contains("Create auth tables"));
914 assert!(prompt.contains("Seed test data"));
915 assert!(prompt.contains("`- [x]`"));
916 assert!(prompt.contains("agent.done"));
917 }
918
919 #[test]
920 fn single_prompt_omits_sequential_instruction() {
921 let feat = feature_fixture(
922 "003-user-list",
923 "## Phase 1: Setup\n- [ ] T009 [P] only one task\n",
924 );
925 let phase = current_phase(&feat.phases).unwrap();
926 let task = &phase.tasks[0];
927 let prompt = build_prompt(&feat, &EntryKind::Single { task });
928 assert!(prompt.contains("T009"));
929 assert!(prompt.contains("only one task"));
930 assert!(!prompt.contains("sequentially"));
931 assert!(!prompt.contains("agent.done"));
932 }
933
934 #[test]
935 fn prompt_sections_separated_by_delimiter() {
936 let feat = feature_fixture("003-user-list", "## Phase 1: Setup\n- [ ] T001 one\n");
937 let phase = current_phase(&feat.phases).unwrap();
938 let task = &phase.tasks[0];
939 let prompt = build_prompt(&feat, &EntryKind::Single { task });
940 assert!(prompt.contains("\n\n---\n\n"));
941 }
942
943 #[test]
948 fn boot_prompt_omits_plan_section_when_plan_missing() {
949 let tmp = tempfile::tempdir().unwrap();
950 let feat_dir = tmp.path().join("009-no-plan");
951 fs::create_dir(&feat_dir).unwrap();
952 fs::write(feat_dir.join("spec.md"), "feature spec body").unwrap();
953 fs::write(
954 feat_dir.join("tasks.md"),
955 "## Phase 1: Setup\n- [ ] T001 do thing\n",
956 )
957 .unwrap();
958 let feature = read_feature(&feat_dir).unwrap().expect("feature loads");
961 let phase = current_phase(&feature.phases).unwrap();
962 let task = &phase.tasks[0];
963 let prompt = build_prompt(&feature, &EntryKind::Single { task });
964 assert!(
965 !prompt.contains("Implementation Plan"),
966 "boot prompt must omit the Implementation Plan section when plan.md is missing; got:\n{prompt}"
967 );
968 }
969
970 #[test]
973 fn boot_prompt_includes_checklists_section_when_present() {
974 let tmp = tempfile::tempdir().unwrap();
975 let feat_dir = tmp.path().join("010-checklisted");
976 fs::create_dir(&feat_dir).unwrap();
977 fs::write(feat_dir.join("spec.md"), "spec body").unwrap();
978 fs::write(
979 feat_dir.join("tasks.md"),
980 "## Phase 1: Setup\n- [ ] T001 do thing\n",
981 )
982 .unwrap();
983 fs::create_dir(feat_dir.join("checklists")).unwrap();
984 fs::write(
985 feat_dir.join("checklists/auth-checklist.md"),
986 "auth criteria text",
987 )
988 .unwrap();
989 fs::write(
990 feat_dir.join("checklists/data-checklist.md"),
991 "data criteria text",
992 )
993 .unwrap();
994
995 let feature = read_feature(&feat_dir).unwrap().expect("feature loads");
996 let phase = current_phase(&feature.phases).unwrap();
997 let task = &phase.tasks[0];
998 let prompt = build_prompt(&feature, &EntryKind::Single { task });
999 assert!(
1000 prompt.contains("Validation Criteria"),
1001 "boot prompt should include the Validation Criteria section; got:\n{prompt}"
1002 );
1003 assert!(
1004 prompt.contains("auth criteria text"),
1005 "boot prompt should include the auth checklist content; got:\n{prompt}"
1006 );
1007 assert!(
1008 prompt.contains("data criteria text"),
1009 "boot prompt should include the data checklist content; got:\n{prompt}"
1010 );
1011 }
1012
1013 #[test]
1016 fn single_p_boot_prompt_contains_one_task_description() {
1017 let feat = feature_fixture(
1018 "011-login",
1019 "## Phase 1: Build\n- [ ] T009 [P] Add login form\n",
1020 );
1021 let phase = current_phase(&feat.phases).unwrap();
1022 let task = &phase.tasks[0];
1023 let prompt = build_prompt(&feat, &EntryKind::Single { task });
1024 assert!(
1025 prompt.contains("T009"),
1026 "prompt should include task id; got:\n{prompt}"
1027 );
1028 assert!(
1029 prompt.contains("Add login form"),
1030 "prompt should include the description; got:\n{prompt}"
1031 );
1032 assert!(
1033 !prompt.contains("agent.done only when"),
1034 "single-[P] prompt must not carry the consolidated-set sequential instruction; got:\n{prompt}"
1035 );
1036 assert!(
1037 !prompt.contains("sequentially"),
1038 "single-[P] prompt must not carry sequential ordering text; got:\n{prompt}"
1039 );
1040 }
1041
1042 #[test]
1045 fn detect_constitution_present() {
1046 let tmp = tempfile::tempdir().unwrap();
1047 let specify = tmp.path().join(".specify");
1048 let specs = specify.join("specs");
1049 let memory = specify.join("memory");
1050 fs::create_dir_all(&specs).unwrap();
1051 fs::create_dir_all(&memory).unwrap();
1052 let cons = memory.join("constitution.md");
1053 fs::write(&cons, "Be excellent.").unwrap();
1054
1055 let detected = detect_constitution(&specs).unwrap();
1056 assert_eq!(detected, cons);
1057 }
1058
1059 #[test]
1060 fn detect_constitution_absent() {
1061 let tmp = tempfile::tempdir().unwrap();
1062 let specs = tmp.path().join(".specify").join("specs");
1063 fs::create_dir_all(&specs).unwrap();
1064 assert!(detect_constitution(&specs).is_none());
1065 }
1066
1067 #[test]
1068 fn detect_constitution_no_parent() {
1069 let root = Path::new("/");
1071 assert!(detect_constitution(root).is_none());
1076 }
1077
1078 #[test]
1081 fn scan_multi_feature_round_trip() {
1082 let tmp = tempfile::tempdir().unwrap();
1083 let specs_dir = tmp.path().join("specs");
1084 fs::create_dir_all(&specs_dir).unwrap();
1085
1086 let f1 = specs_dir.join("001-alpha");
1088 fs::create_dir(&f1).unwrap();
1089 fs::write(f1.join("spec.md"), "alpha spec").unwrap();
1090 fs::write(f1.join("plan.md"), "alpha plan").unwrap();
1091 fs::write(
1092 f1.join("tasks.md"),
1093 "## Phase 1: Setup\n\
1094 - [x] T001 done\n\
1095 ## Phase 2: Foundational\n\
1096 - [ ] T002 [P] parallel one\n\
1097 - [ ] T003 sequential task\n\
1098 - [ ] T004 sequential other\n",
1099 )
1100 .unwrap();
1101
1102 let f2 = specs_dir.join("002-beta");
1104 fs::create_dir(&f2).unwrap();
1105 fs::write(f2.join("spec.md"), "beta spec").unwrap();
1106 fs::write(
1107 f2.join("tasks.md"),
1108 "## Phase 1: Setup\n\
1109 - [ ] T010 [P] alpha\n\
1110 - [ ] T011 [P] beta\n\
1111 ## Phase 2: Polish\n\
1112 - [ ] T020 deferred\n",
1113 )
1114 .unwrap();
1115
1116 let f3 = specs_dir.join("003-gamma");
1118 fs::create_dir(&f3).unwrap();
1119 fs::write(f3.join("tasks.md"), "## Phase 1: Setup\n- [x] T030 done\n").unwrap();
1120
1121 let backend = SpecKitBackend;
1122 let entries = backend.scan(&specs_dir).unwrap();
1123 assert_eq!(entries.len(), 4, "got entries: {entries:?}");
1125 let ids: std::collections::HashSet<String> = entries.iter().map(|e| e.id.clone()).collect();
1126 assert!(ids.contains("001-alpha-T002"));
1127 assert!(ids.contains("001-alpha-phase-2"));
1128 assert!(ids.contains("002-beta-T010"));
1129 assert!(ids.contains("002-beta-T011"));
1130 assert!(!ids.iter().any(|id| id.starts_with("003-gamma")));
1132
1133 let alpha = entries
1135 .iter()
1136 .find(|e| e.id == "001-alpha-phase-2")
1137 .unwrap();
1138 assert!(alpha.prompt.contains("alpha spec"));
1139 assert!(alpha.prompt.contains("alpha plan"));
1140
1141 let beta = entries.iter().find(|e| e.id == "002-beta-T010").unwrap();
1142 assert!(beta.prompt.contains("beta spec"));
1143 assert!(!beta.prompt.contains("## Implementation Plan"));
1145 }
1146
1147 #[test]
1148 fn scan_advances_phase_when_phase_one_clears() {
1149 let tmp = tempfile::tempdir().unwrap();
1150 let specs_dir = tmp.path().join("specs");
1151 let feat = specs_dir.join("001-feature");
1152 fs::create_dir_all(&feat).unwrap();
1153 let tasks_path = feat.join("tasks.md");
1154
1155 fs::write(
1157 &tasks_path,
1158 "## Phase 1: Setup\n- [ ] T001 a\n## Phase 2: Build\n- [ ] T002 b\n",
1159 )
1160 .unwrap();
1161 let backend = SpecKitBackend;
1162 let entries = backend.scan(&specs_dir).unwrap();
1163 assert_eq!(entries.len(), 1);
1164 assert_eq!(entries[0].id, "001-feature-phase-1");
1165
1166 fs::write(
1168 &tasks_path,
1169 "## Phase 1: Setup\n- [x] T001 a\n## Phase 2: Build\n- [ ] T002 b\n",
1170 )
1171 .unwrap();
1172 let entries = backend.scan(&specs_dir).unwrap();
1173 assert_eq!(entries.len(), 1);
1174 assert_eq!(entries[0].id, "001-feature-phase-2");
1175 }
1176}