Skip to main content

git_paw/specs/
speckit.rs

1//! Spec Kit format backend.
2//!
3//! Spec Kit projects place each feature in its own directory under
4//! `.specify/specs/`, containing `spec.md`, `plan.md`, `tasks.md`, and an
5//! optional `checklists/` subdirectory. Unlike the `OpenSpec` and `Markdown`
6//! backends (one input unit → one [`SpecEntry`]), Spec Kit decomposes a
7//! feature's current phase into one entry per `[P]`-marked task plus one
8//! consolidated entry for the non-`[P]` remainder.
9//!
10//! See `openspec/changes/spec-kit-format/` for the full specification.
11//!
12//! [`SpecBackend`]: crate::specs::SpecBackend
13//! [`SpecEntry`]: crate::specs::SpecEntry
14
15use 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
26/// Phase number assigned to tasks that appear before any `## Phase N` heading.
27pub(crate) const IMPLICIT_PHASE_NUMBER: u32 = 0;
28
29/// A single task line parsed from `tasks.md`.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct Task {
32    /// Identifier such as `T009`.
33    pub id: String,
34    /// `true` if the task line carried the `[P]` parallel marker.
35    pub p_marker: bool,
36    /// `true` if the task is checked (`- [x]` / `- [X]`).
37    pub complete: bool,
38    /// Description text following the marker.
39    pub description: String,
40    /// Phase number this task belongs to.
41    pub phase: u32,
42}
43
44/// A phase within a feature's `tasks.md`.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct Phase {
47    /// Phase number (1-based; `0` for the implicit phase used when the file
48    /// contains no `## Phase N` headings, or for tasks that appear before any
49    /// heading).
50    pub number: u32,
51    /// Phase name extracted from the heading (empty string for the implicit
52    /// phase).
53    pub name: String,
54    /// Tasks attached to this phase, in source order.
55    pub tasks: Vec<Task>,
56}
57
58/// A Spec Kit feature directory and its parsed contents.
59#[derive(Debug, Clone)]
60pub struct Feature {
61    /// Path to the feature directory.
62    pub dir: PathBuf,
63    /// Phases parsed from `tasks.md`.
64    pub phases: Vec<Phase>,
65    /// Contents of `spec.md`, if present.
66    pub spec_md: Option<String>,
67    /// Contents of `plan.md`, if present.
68    pub plan_md: Option<String>,
69    /// Checklist files as `(filename, content)` pairs, sorted by filename.
70    pub checklists: Vec<(String, String)>,
71}
72
73/// Backend for the Spec Kit (`.specify/`) artefact format.
74#[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        // Stable ordering: by feature directory name.
100        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
110/// Reads a Spec Kit feature directory into a [`Feature`].
111///
112/// Returns `Ok(None)` (with a stderr warning) if the directory has no
113/// `tasks.md`. Errors only on filesystem failures.
114pub(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
141/// Reads a file into a `String`, returning `Ok(None)` when it does not exist.
142fn 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
151/// Reads every regular file in `dir` as a `(filename, content)` pair, sorted
152/// by filename. Returns an empty vector if `dir` is missing or not a directory.
153fn 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
177// --- tasks.md parser ---
178
179fn phase_heading_re() -> &'static Regex {
180    static RE: OnceLock<Regex> = OnceLock::new();
181    RE.get_or_init(|| {
182        // `## Phase <N> <separator> <Name>` where separator is `:`, `—`, or `-`.
183        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
204/// Parses Spec Kit `tasks.md` content into a list of [`Phase`] values.
205///
206/// Tasks attach to the most recently seen `## Phase N` heading. Tasks that
207/// appear before any heading (or in a file with no headings at all) live in
208/// the implicit phase numbered [`IMPLICIT_PHASE_NUMBER`].
209pub(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        // Unrecognised lines are ignored — preserves intra-phase commentary.
273    }
274
275    phases
276}
277
278/// Returns the lowest-numbered phase with at least one incomplete task, or
279/// `None` if every task is complete (or no tasks exist).
280pub(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
287/// Kind of `SpecEntry` produced by Spec Kit decomposition.
288pub(crate) enum EntryKind<'a> {
289    /// A single `[P]` task — its own worktree.
290    Single { task: &'a Task },
291    /// All incomplete non-`[P]` tasks in the current phase — one worktree.
292    Consolidated {
293        tasks: Vec<&'a Task>,
294        phase_number: u32,
295        phase_name: &'a str,
296    },
297}
298
299/// Decomposes a feature's current phase into the canonical worktree layout
300/// (one entry per `[P]` task plus one consolidated entry for the non-`[P]`
301/// remainder).
302///
303/// Returns an empty vector when the feature has no incomplete tasks. Emits a
304/// stderr warning for fully completed features and parse-empty `tasks.md`
305/// files.
306pub(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        // Empty / parse-empty: skip silently per spec.
322        return Vec::new();
323    };
324
325    let mut entries: Vec<SpecEntry> = Vec::new();
326
327    // One entry per incomplete [P] task, in source order.
328    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    // One consolidated entry for the union of incomplete non-[P] tasks.
344    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
372/// Boot-prompt delimiter between sections.
373const SECTION_DELIM: &str = "\n\n---\n\n";
374
375/// Builds the boot-prompt content for a Spec Kit `SpecEntry`.
376///
377/// Sections in order, separated by `\n\n---\n\n`:
378///
379/// 1. `## Feature Context` — full `spec.md` content (omitted if missing).
380/// 2. `## Implementation Plan` — full `plan.md` content (omitted if missing).
381/// 3. `## Validation Criteria (advisory)` — checklists content (omitted if
382///    none present).
383/// 4. `## Your Task` — single-task description, or ordered list + sequential
384///    instructions for consolidated entries.
385pub(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
454/// Returns the path to a Spec Kit project's `constitution.md` if one exists.
455///
456/// The probe examines `<specs_dir>/../memory/constitution.md` — the canonical
457/// location relative to a `.specify/specs/` configuration. Returns `None`
458/// when the file does not exist or `specs_dir` has no parent.
459pub 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    // --- backend / scan ---
475
476    #[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    // --- read_feature ---
510
511    #[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    // --- parse_tasks_md ---
564
565    #[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        // The parser does not deduplicate — that is a Spec-Kit-author concern.
655        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    // --- current_phase ---
660
661    #[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    // --- decompose_feature ---
692
693    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    // --- build_prompt ---
838
839    #[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    // Maps to scenario `Boot prompt omits Implementation Plan when plan.md is
944    // missing` from spec-kit-format. Uses on-disk fixture via `read_feature`
945    // so the path that real scans take is exercised end-to-end.
946    // (test-coverage-v0-5-0 task 11.2)
947    #[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        // Explicitly no plan.md on disk.
959
960        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    // Maps to scenario `Boot prompt includes checklists when present` from
971    // spec-kit-format. (test-coverage-v0-5-0 task 11.3)
972    #[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    // Maps to scenario `Single-[P] boot prompt contains one task description`
1014    // from spec-kit-format. (test-coverage-v0-5-0 task 11.4)
1015    #[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    // --- detect_constitution ---
1043
1044    #[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        // The root directory `/` has no parent — defensive: returns None.
1070        let root = Path::new("/");
1071        // We do not assert presence of /memory/constitution.md; we only
1072        // assert the call returns None when no parent exists. On Unix `/`
1073        // has no parent so `parent()` returns None. On Windows roots also
1074        // return None.
1075        assert!(detect_constitution(root).is_none());
1076    }
1077
1078    // --- Round-trip: scan a fixture .specify/specs/ tree ---
1079
1080    #[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        // Feature 1: phase 1 done, phase 2 mixed → 1 [P] entry + 1 consolidated.
1087        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        // Feature 2: phase 1 only [P] (2 entries), phase 2 only non-[P] (deferred).
1103        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        // Feature 3: fully complete (skipped).
1117        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        // F1: 1 [P] + 1 consolidated = 2.  F2: 2 [P] = 2.  F3: 0.
1124        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        // Gamma is skipped.
1131        assert!(!ids.iter().any(|id| id.starts_with("003-gamma")));
1132
1133        // Spec/plan content is included.
1134        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        // No plan.md for beta — plan section is absent.
1144        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        // Initial state: phase 1 has one incomplete; phase 2 deferred.
1156        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        // Clear phase 1 — phase 2 becomes current.
1167        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}