Skip to main content

speccoder_runtime/format/speckit/
mod.rs

1//! GitHub Spec Kit, the reference format: `.specify/` and `specs/NNN-name/`
2//! with `spec.md`, `plan.md` and `tasks.md`. Parsing is the
3//! `speccoder-speckit` crate's; this adapter is the only runtime code that
4//! knows Spec Kit's paths and words.
5
6pub mod bench;
7pub mod hooks;
8mod quick;
9mod texts;
10pub mod workflow;
11
12use std::path::{Path, PathBuf};
13
14use speccoder_core::ChecklistStatus;
15use speccoder_core::snapshot::{Diagnostic, FeatureSnapshot, ParsedTasks, ProjectSnapshot};
16use speccoder_core::spec::{FormatKind, ScenarioRef};
17
18pub use speccoder_speckit::stories::story_section;
19pub use speccoder_speckit::workspace::Workspace;
20pub use texts::{MESSAGES, PLANNING, RULES, WORDING};
21
22use crate::context::{Bundle, BundleError, DependencyContext, TaskContext, cap};
23use crate::format::{Adapter, CommitPrecondition, Detect, Messages, Planning, Rules, Wording};
24
25/// The Spec Kit adapter.
26#[derive(Debug)]
27pub struct SpecKit;
28
29/// The one instance.
30pub static SPECKIT: SpecKit = SpecKit;
31
32impl Detect for SpecKit {
33    fn kind(&self) -> FormatKind {
34        FormatKind::Speckit
35    }
36
37    fn root(&self, start: &Path) -> Option<PathBuf> {
38        speccoder_speckit::workspace::find_root(start).map(|ws| ws.root().to_path_buf())
39    }
40
41    fn adapter(&self) -> Option<&'static dyn Adapter> {
42        Some(&SPECKIT)
43    }
44}
45
46impl Adapter for SpecKit {
47    fn tool(&self) -> Option<(&'static str, &'static str)> {
48        Some((
49            "specify",
50            "uv tool install specify-cli --from git+https://github.com/github/spec-kit.git",
51        ))
52    }
53
54    fn kind(&self) -> FormatKind {
55        FormatKind::Speckit
56    }
57
58    fn detect(&self, start: &Path) -> Option<PathBuf> {
59        Detect::root(self, start)
60    }
61
62    fn inspect(&self, start: &Path, feature: Option<&str>) -> Option<ProjectSnapshot> {
63        speccoder_speckit::inspect(start, feature).ok()
64    }
65
66    fn open(&self, start: &Path, feature: Option<&str>) -> Option<ProjectSnapshot> {
67        let env = std::env::var(texts::FEATURE_ENV)
68            .ok()
69            .filter(|v| !v.is_empty());
70        speccoder_speckit::inspect_with(start, feature, env.as_deref()).ok()
71    }
72
73    fn parse_tasks(&self, path: &str, text: &str) -> (ParsedTasks, Vec<Diagnostic>) {
74        speccoder_speckit::tasks::parse(path, text)
75    }
76
77    fn rules(&self) -> &'static Rules {
78        &RULES
79    }
80
81    fn wording(&self) -> &'static Wording {
82        &WORDING
83    }
84
85    fn messages(&self) -> &'static Messages {
86        &MESSAGES
87    }
88
89    fn planning(&self) -> &'static Planning {
90        &PLANNING
91    }
92
93    fn current_pointer(&self) -> Option<&'static str> {
94        Some(texts::POINTER)
95    }
96
97    fn set_current(&self, root: &Path, unit_dir: Option<&str>) -> Result<String, String> {
98        // Spec Kit's own per-checkout pointer (git-ignored by `.specify/.gitignore`).
99        let file = root.join(texts::POINTER);
100        match unit_dir {
101            Some(dir) => {
102                let text =
103                    serde_json::to_string_pretty(&serde_json::json!({ "feature_directory": dir }))
104                        .map_err(|e| e.to_string())?;
105                std::fs::write(&file, format!("{text}\n"))
106                    .map_err(|e| format!("{}: {e}", file.display()))?;
107            }
108            None => match std::fs::remove_file(&file) {
109                Ok(()) => {}
110                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
111                Err(e) => return Err(format!("{}: {e}", file.display())),
112            },
113        }
114        Ok(texts::POINTER.to_owned())
115    }
116
117    fn bundle(&self, snapshot: &ProjectSnapshot, task_id: &str) -> Result<Bundle, BundleError> {
118        let feature: &FeatureSnapshot = snapshot.feature.as_ref().ok_or(BundleError::NoFeature)?;
119        let tasks = feature.tasks.as_ref().ok_or(BundleError::NoTasks)?;
120        let task = tasks
121            .tasks
122            .iter()
123            .find(|t| t.id == task_id)
124            .ok_or_else(|| BundleError::NotFound(task_id.to_owned()))?;
125        let ws = Workspace::at(Path::new(&snapshot.root));
126        let read = |rel: &str| {
127            ws.as_ref()
128                .and_then(|ws| ws.read_text(rel).ok())
129                .map(|t| cap(&t))
130                .unwrap_or_default()
131        };
132        let dir = &feature.feature.dir;
133        let spec = read(&format!("{dir}/spec.md"));
134
135        let dependencies = feature
136            .schedule
137            .as_ref()
138            .and_then(|s| s.tasks.iter().find(|t| t.id == task.id))
139            .map(|st| {
140                st.depends_on
141                    .iter()
142                    .filter_map(|d| tasks.tasks.iter().find(|t| t.id == d.id))
143                    .map(|t| DependencyContext {
144                        id: t.id.clone(),
145                        title: t.title.clone(),
146                        done: t.done,
147                    })
148                    .collect()
149            })
150            .unwrap_or_default();
151
152        Ok(Bundle {
153            feature: feature.feature.name.clone(),
154            constitution: read(texts::CONSTITUTION),
155            plan: read(&format!("{dir}/plan.md")),
156            // A Convergence task closes a gap found against the whole spec; it
157            // gets the whole spec, so its acceptance tests have a source.
158            story: match task.story.as_deref() {
159                // A labelled task whose `### User Story N` section is missing
160                // (a spec that left the template's headings) still has the spec
161                // as its intent: acceptance tests are derived from all of it.
162                Some(label) => {
163                    let section = story_section(&spec, label);
164                    if section.trim().is_empty() {
165                        spec.clone()
166                    } else {
167                        section
168                    }
169                }
170                None if self.is_convergence(task.phase.as_deref().unwrap_or_default()) => {
171                    spec.clone()
172                }
173                None => String::new(),
174            },
175            task: TaskContext {
176                id: task.id.clone(),
177                title: task.title.clone(),
178                phase: task.phase.clone().unwrap_or_default(),
179                subsection: task.subsection.clone().unwrap_or_default(),
180                story: task.story.clone().unwrap_or_default(),
181                parallel: task.parallel,
182            },
183            dependencies,
184        })
185    }
186
187    fn scenarios(&self, snapshot: &ProjectSnapshot, task: &str) -> Vec<ScenarioRef> {
188        let Some(f) = snapshot.feature.as_ref() else {
189            return Vec::new();
190        };
191        let label = f
192            .tasks
193            .as_ref()
194            .and_then(|t| t.tasks.iter().find(|t| t.id == task))
195            .and_then(|t| t.story.clone());
196        let spec = Workspace::at(Path::new(&snapshot.root))
197            .and_then(|ws| ws.read_text(&format!("{}/spec.md", f.feature.dir)).ok())
198            .unwrap_or_default();
199        // A labelled task whose story is missing has the whole spec as its
200        // intent, as in its bundle.
201        let own = speccoder_speckit::stories::scenario_refs(&spec, label.as_deref());
202        if own.is_empty() && label.is_some() {
203            speccoder_speckit::stories::scenario_refs(&spec, None)
204        } else {
205            own
206        }
207    }
208
209    fn acceptance_testable(&self, bundle: &Bundle) -> bool {
210        bundle.story.to_lowercase().contains("acceptance")
211    }
212
213    /// A Convergence phase, appended by converge.
214    fn is_convergence(&self, phase: &str) -> bool {
215        phase.to_lowercase().contains("convergence")
216    }
217
218    fn convergence_texts(&self, root: &Path, unit_dir: &Path) -> [String; 4] {
219        let read = |p: PathBuf| std::fs::read_to_string(p).unwrap_or_default();
220        [
221            read(root.join(texts::CONSTITUTION)),
222            read(unit_dir.join("spec.md")),
223            read(unit_dir.join("plan.md")),
224            read(unit_dir.join("tasks.md")),
225        ]
226    }
227
228    fn checklists(&self, root: &Path, unit_dir: &str) -> Vec<ChecklistStatus> {
229        speccoder_speckit::checklists::status(root, unit_dir)
230    }
231
232    fn requirements_file(&self) -> Option<&'static str> {
233        Some("spec.md")
234    }
235
236    fn record_change(
237        &self,
238        root: &Path,
239        request: &str,
240        checks: &[String],
241    ) -> Result<super::Recorded, String> {
242        quick::record(root, request, checks)
243    }
244
245    fn requirements_in(&self, text: &str) -> Vec<(String, String)> {
246        // `- **FR-001**: …` and `- **SC-001**: …` define the identifiers.
247        text.lines()
248            .filter_map(|line| {
249                let rest = line.trim_start().strip_prefix("- **")?;
250                let id = rest.split("**").next()?;
251                let (kind, number) = id.split_once('-')?;
252                (matches!(kind, "FR" | "SC")
253                    && !number.is_empty()
254                    && number.chars().all(|c| c.is_ascii_digit()))
255                .then(|| (id.to_owned(), line.trim_end().to_owned()))
256            })
257            .collect()
258    }
259
260    fn tick(&self, tasks_file: &Path, task: &str, line_hash: &str) -> Result<(), String> {
261        speccoder_speckit::mark_task_complete(tasks_file, task, line_hash)
262    }
263
264    fn append(
265        &self,
266        tasks_file: &Path,
267        file_hash: &str,
268        items: &[String],
269    ) -> Result<Vec<String>, String> {
270        speccoder_speckit::append_convergence(tasks_file, file_hash, items)
271    }
272
273    fn commit_precondition(
274        &self,
275        _root: &Path,
276        join: &dyn Fn(&str) -> String,
277        unit_dir: &str,
278    ) -> CommitPrecondition {
279        precondition(&join(texts::MARKER), &join(unit_dir))
280    }
281}
282
283/// What a Spec Kit worktree needs committed, given the repository-relative
284/// `.specify` and feature directories.
285#[must_use]
286pub fn precondition(specify_dir: &str, feature_dir: &str) -> CommitPrecondition {
287    CommitPrecondition {
288        // The feature directory: nothing untracked, ignored or modified.
289        strict: vec![feature_dir.to_owned()],
290        // .specify: Spec Kit ignores its own per-checkout files (feature.json);
291        // those are fine. Untracked or modified files are not.
292        tracked: vec![specify_dir.to_owned()],
293        runtime,
294        essentials: vec![
295            format!("{specify_dir}/init-options.json"),
296            format!("{feature_dir}/spec.md"),
297        ],
298        message: "the feature's Spec Kit artifacts are not committed, so a worktree would not contain them",
299        hint: "commit them (git add .specify specs/<feature> && git commit), or run in place: speccoder run --in-place",
300        no_commits_hint: "commit the Spec Kit artifacts first (git add .specify specs && git commit)",
301    }
302}
303
304/// Runtime state is not an artifact: Spec Kit's own workflow runs
305/// (`.specify/workflows/runs/`, dirty while `specify workflow run` drives
306/// speccoder) and Python bytecode caches of installed step types.
307fn runtime(p: &str) -> bool {
308    p.contains("/workflows/runs/")
309        || p.starts_with(".specify/workflows/runs/")
310        || p.split('/').any(|c| c == "__pycache__")
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    #[test]
318    fn requirement_ids_are_the_bold_fr_and_sc_items() {
319        let dir = tempfile::tempdir().unwrap();
320        std::fs::create_dir_all(dir.path().join("specs/001-x")).unwrap();
321        std::fs::write(
322            dir.path().join("specs/001-x/spec.md"),
323            "## Requirements\n\n- **FR-001**: System MUST parse.\n- **FR-002**: System MUST render.\n  - **FR-X**: not one\n- **Key**: not one\n\n## Success\n\n- **SC-001**: Under a second.\n",
324        )
325        .unwrap();
326        let ids: Vec<String> = SpecKit
327            .requirement_ids(dir.path(), "specs/001-x")
328            .into_iter()
329            .map(|(id, hash)| {
330                assert_eq!(hash.len(), 64);
331                id
332            })
333            .collect();
334        assert_eq!(ids, ["FR-001", "FR-002", "SC-001"]);
335    }
336}