speccoder_runtime/format/speckit/
mod.rs1pub 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#[derive(Debug)]
27pub struct SpecKit;
28
29pub 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 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 story: match task.story.as_deref() {
159 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 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 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 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#[must_use]
286pub fn precondition(specify_dir: &str, feature_dir: &str) -> CommitPrecondition {
287 CommitPrecondition {
288 strict: vec![feature_dir.to_owned()],
290 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
304fn 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}