1#[cfg(feature = "engine")]
7pub mod bench;
8pub mod bug;
9pub mod extensions;
10pub mod hooks;
11mod quick;
12mod texts;
13pub mod workflow;
14
15use std::path::{Path, PathBuf};
16
17use speccoder_core::ChecklistStatus;
18use speccoder_core::snapshot::{Diagnostic, FeatureSnapshot, ParsedTasks, ProjectSnapshot};
19use speccoder_core::spec::{FormatKind, ScenarioRef};
20
21pub use speccoder_speckit::stories::story_section;
22pub use speccoder_speckit::workspace::Workspace;
23pub use texts::{MESSAGES, PLANNING, RULES, WORDING};
24
25use crate::context::{Bundle, BundleError, DependencyContext, TaskContext, cap};
26use crate::format::{Adapter, CommitPrecondition, Detect, Messages, Planning, Rules, Wording};
27
28#[derive(Debug)]
30pub struct SpecKit;
31
32pub static SPECKIT: SpecKit = SpecKit;
34
35impl Detect for SpecKit {
36 fn kind(&self) -> FormatKind {
37 FormatKind::Speckit
38 }
39
40 fn root(&self, start: &Path) -> Option<PathBuf> {
41 speccoder_speckit::workspace::find_root(start).map(|ws| ws.root().to_path_buf())
42 }
43
44 fn adapter(&self) -> Option<&'static dyn Adapter> {
45 Some(&SPECKIT)
46 }
47}
48
49impl Adapter for SpecKit {
50 fn tool(&self) -> Option<(&'static str, &'static str)> {
51 Some((
52 "specify",
53 "uv tool install specify-cli --from git+https://github.com/github/spec-kit.git",
54 ))
55 }
56
57 fn kind(&self) -> FormatKind {
58 FormatKind::Speckit
59 }
60
61 fn detect(&self, start: &Path) -> Option<PathBuf> {
62 Detect::root(self, start)
63 }
64
65 fn inspect(&self, start: &Path, feature: Option<&str>) -> Option<ProjectSnapshot> {
66 speccoder_speckit::inspect(start, feature).ok()
67 }
68
69 fn open(&self, start: &Path, feature: Option<&str>) -> Option<ProjectSnapshot> {
70 let env = std::env::var(texts::FEATURE_ENV)
71 .ok()
72 .filter(|v| !v.is_empty());
73 speccoder_speckit::inspect_with(start, feature, env.as_deref()).ok()
74 }
75
76 fn parse_tasks(&self, path: &str, text: &str) -> (ParsedTasks, Vec<Diagnostic>) {
77 speccoder_speckit::tasks::parse(path, text)
78 }
79
80 fn rules(&self) -> &'static Rules {
81 &RULES
82 }
83
84 fn wording(&self) -> &'static Wording {
85 &WORDING
86 }
87
88 fn messages(&self) -> &'static Messages {
89 &MESSAGES
90 }
91
92 fn planning(&self) -> &'static Planning {
93 &PLANNING
94 }
95
96 fn current_pointer(&self) -> Option<&'static str> {
97 Some(texts::POINTER)
98 }
99
100 fn set_current(&self, root: &Path, unit_dir: Option<&str>) -> Result<String, String> {
101 let file = root.join(texts::POINTER);
103 match unit_dir {
104 Some(dir) => {
105 let text =
106 serde_json::to_string_pretty(&serde_json::json!({ "feature_directory": dir }))
107 .map_err(|e| e.to_string())?;
108 std::fs::write(&file, format!("{text}\n"))
109 .map_err(|e| format!("{}: {e}", file.display()))?;
110 }
111 None => match std::fs::remove_file(&file) {
112 Ok(()) => {}
113 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
114 Err(e) => return Err(format!("{}: {e}", file.display())),
115 },
116 }
117 Ok(texts::POINTER.to_owned())
118 }
119
120 fn bundle(&self, snapshot: &ProjectSnapshot, task_id: &str) -> Result<Bundle, BundleError> {
121 let feature: &FeatureSnapshot = snapshot.feature.as_ref().ok_or(BundleError::NoFeature)?;
122 let tasks = feature.tasks.as_ref().ok_or(BundleError::NoTasks)?;
123 let task = tasks
124 .tasks
125 .iter()
126 .find(|t| t.id == task_id)
127 .ok_or_else(|| BundleError::NotFound(task_id.to_owned()))?;
128 let ws = Workspace::at(Path::new(&snapshot.root));
129 let read = |rel: &str| {
130 ws.as_ref()
131 .and_then(|ws| ws.read_text(rel).ok())
132 .map(|t| cap(&t))
133 .unwrap_or_default()
134 };
135 let dir = &feature.feature.dir;
136 let spec = read(&format!("{dir}/spec.md"));
137
138 let dependencies = feature
139 .schedule
140 .as_ref()
141 .and_then(|s| s.tasks.iter().find(|t| t.id == task.id))
142 .map(|st| {
143 st.depends_on
144 .iter()
145 .filter_map(|d| tasks.tasks.iter().find(|t| t.id == d.id))
146 .map(|t| DependencyContext {
147 id: t.id.clone(),
148 title: t.title.clone(),
149 done: t.done,
150 })
151 .collect()
152 })
153 .unwrap_or_default();
154
155 Ok(Bundle {
156 feature: feature.feature.name.clone(),
157 constitution: read(texts::CONSTITUTION),
158 plan: read(&format!("{dir}/plan.md")),
159 story: match task.story.as_deref() {
162 Some(label) => {
166 let section = story_section(&spec, label);
167 if section.trim().is_empty() {
168 spec.clone()
169 } else {
170 section
171 }
172 }
173 None if self.is_convergence(task.phase.as_deref().unwrap_or_default()) => {
174 spec.clone()
175 }
176 None => String::new(),
177 },
178 task: TaskContext {
179 id: task.id.clone(),
180 title: task.title.clone(),
181 phase: task.phase.clone().unwrap_or_default(),
182 subsection: task.subsection.clone().unwrap_or_default(),
183 story: task.story.clone().unwrap_or_default(),
184 parallel: task.parallel,
185 },
186 dependencies,
187 })
188 }
189
190 fn scenarios(&self, snapshot: &ProjectSnapshot, task: &str) -> Vec<ScenarioRef> {
191 let Some(f) = snapshot.feature.as_ref() else {
192 return Vec::new();
193 };
194 let label = f
195 .tasks
196 .as_ref()
197 .and_then(|t| t.tasks.iter().find(|t| t.id == task))
198 .and_then(|t| t.story.clone());
199 let spec = Workspace::at(Path::new(&snapshot.root))
200 .and_then(|ws| ws.read_text(&format!("{}/spec.md", f.feature.dir)).ok())
201 .unwrap_or_default();
202 let own = speccoder_speckit::stories::scenario_refs(&spec, label.as_deref());
205 if own.is_empty() && label.is_some() {
206 speccoder_speckit::stories::scenario_refs(&spec, None)
207 } else {
208 own
209 }
210 }
211
212 fn acceptance_testable(&self, bundle: &Bundle) -> bool {
213 bundle.story.to_lowercase().contains("acceptance")
214 }
215
216 fn oracle_requirements(
218 &self,
219 snapshot: &ProjectSnapshot,
220 ) -> Vec<crate::format::OracleRequirement> {
221 let Some(f) = snapshot.feature.as_ref() else {
222 return Vec::new();
223 };
224 let spec = Workspace::at(Path::new(&snapshot.root))
225 .and_then(|ws| ws.read_text(&format!("{}/spec.md", f.feature.dir)).ok())
226 .unwrap_or_default();
227 let (stories, _) = speccoder_speckit::stories::requirements("spec.md", &spec);
228 stories
229 .into_iter()
230 .map(|r| crate::format::OracleRequirement {
231 scenarios: speccoder_speckit::stories::scenario_refs(&spec, Some(&r.key)),
232 key: r.key,
233 title: r.title,
234 text: r.text,
235 })
236 .collect()
237 }
238
239 fn standing_units(&self, root: &Path) -> Vec<(String, speccoder_core::ladder::Rung)> {
241 use speccoder_core::ladder::Rung;
242 let mut out: Vec<(String, Rung)> =
243 std::fs::read_dir(root.join(speccoder_speckit::features::SPECS))
244 .into_iter()
245 .flatten()
246 .flatten()
247 .map(|e| e.file_name().to_string_lossy().into_owned())
248 .filter(|n| speccoder_speckit::features::prefix(n).is_some())
249 .filter_map(|n| {
250 let rung = if n.ends_with("-quick-changes") {
251 Rung::Quick
252 } else if n.ends_with("-bugfixes") {
253 Rung::Bug
254 } else {
255 return None;
256 };
257 Some((n, rung))
258 })
259 .collect();
260 out.push((".specify/bugs".to_owned(), Rung::Bug));
261 out
262 }
263
264 fn is_convergence(&self, phase: &str) -> bool {
266 phase.to_lowercase().contains("convergence")
267 }
268
269 fn convergence_texts(&self, root: &Path, unit_dir: &Path) -> [String; 4] {
270 let read = |p: PathBuf| std::fs::read_to_string(p).unwrap_or_default();
271 [
272 read(root.join(texts::CONSTITUTION)),
273 read(unit_dir.join("spec.md")),
274 read(unit_dir.join("plan.md")),
275 read(unit_dir.join("tasks.md")),
276 ]
277 }
278
279 fn checklists(&self, root: &Path, unit_dir: &str) -> Vec<ChecklistStatus> {
280 speccoder_speckit::checklists::status(root, unit_dir)
281 }
282
283 fn requirements_file(&self) -> Option<&'static str> {
284 Some("spec.md")
285 }
286
287 fn record_change(
288 &self,
289 root: &Path,
290 request: &str,
291 checks: &[String],
292 ) -> Result<super::Recorded, String> {
293 quick::record(root, request, checks)
294 }
295
296 fn extensions(&self, root: &Path) -> Option<crate::format::Inventory> {
297 extensions::inventory(root)
298 }
299
300 fn bug_unit(
301 &self,
302 root: &Path,
303 ) -> Result<speccoder_core::bug::BugHome, crate::format::BugRefusal> {
304 bug::unit(root)
305 }
306
307 fn record_bug(
308 &self,
309 root: &Path,
310 home: &speccoder_core::bug::BugHome,
311 triad: &speccoder_core::bug::Triad,
312 ) -> Result<crate::format::RecordedBug, crate::format::BugRefusal> {
313 bug::record(root, home, triad)
314 }
315
316 fn bug_step(&self) -> Option<crate::format::BugStep> {
317 Some(bug::STEP)
318 }
319
320 fn bug_dir(&self, home: &speccoder_core::bug::BugHome, slug: &str) -> Option<(String, String)> {
321 bug::dir(home, slug)
322 }
323
324 fn bug_slug(&self, root: &Path, _home: &speccoder_core::bug::BugHome, title: &str) -> String {
325 bug::slug(root, title)
326 }
327
328 fn read_bug(
329 &self,
330 root: &Path,
331 home: &speccoder_core::bug::BugHome,
332 key: &str,
333 ) -> Result<crate::format::ReadBug, crate::format::BugRefusal> {
334 bug::read(root, home, key)
335 }
336
337 fn requirements_in(&self, text: &str) -> Vec<(String, String)> {
338 text.lines()
340 .filter_map(|line| {
341 let rest = line.trim_start().strip_prefix("- **")?;
342 let id = rest.split("**").next()?;
343 let (kind, number) = id.split_once('-')?;
344 (matches!(kind, "FR" | "SC")
345 && !number.is_empty()
346 && number.chars().all(|c| c.is_ascii_digit()))
347 .then(|| (id.to_owned(), line.trim_end().to_owned()))
348 })
349 .collect()
350 }
351
352 fn tick(&self, tasks_file: &Path, task: &str, line_hash: &str) -> Result<(), String> {
353 speccoder_speckit::mark_task_complete(tasks_file, task, line_hash)
354 }
355
356 fn append(
357 &self,
358 tasks_file: &Path,
359 file_hash: &str,
360 items: &[String],
361 ) -> Result<Vec<String>, String> {
362 speccoder_speckit::append_convergence(tasks_file, file_hash, items)
363 }
364
365 fn commit_precondition(
366 &self,
367 _root: &Path,
368 join: &dyn Fn(&str) -> String,
369 unit_dir: &str,
370 ) -> CommitPrecondition {
371 precondition(&join(texts::MARKER), &join(unit_dir))
372 }
373}
374
375#[must_use]
378pub fn precondition(specify_dir: &str, feature_dir: &str) -> CommitPrecondition {
379 CommitPrecondition {
380 strict: vec![feature_dir.to_owned()],
382 tracked: vec![specify_dir.to_owned()],
385 runtime,
386 essentials: vec![
387 format!("{specify_dir}/init-options.json"),
388 format!("{feature_dir}/spec.md"),
389 ],
390 message: "the feature's Spec Kit artifacts are not committed, so a worktree would not contain them",
391 hint: "commit them (git add .specify specs/<feature> && git commit), or run in place: speccoder run --in-place",
392 no_commits_hint: "commit the Spec Kit artifacts first (git add .specify specs && git commit)",
393 }
394}
395
396fn runtime(p: &str) -> bool {
400 p.contains("/workflows/runs/")
401 || p.starts_with(".specify/workflows/runs/")
402 || p.split('/').any(|c| c == "__pycache__")
403}
404
405#[cfg(test)]
406mod tests {
407 use super::*;
408
409 #[test]
410 fn requirement_ids_are_the_bold_fr_and_sc_items() {
411 let dir = tempfile::tempdir().unwrap();
412 std::fs::create_dir_all(dir.path().join("specs/001-x")).unwrap();
413 std::fs::write(
414 dir.path().join("specs/001-x/spec.md"),
415 "## 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",
416 )
417 .unwrap();
418 let ids: Vec<String> = SpecKit
419 .requirement_ids(dir.path(), "specs/001-x")
420 .into_iter()
421 .map(|(id, hash)| {
422 assert_eq!(hash.len(), 64);
423 id
424 })
425 .collect();
426 assert_eq!(ids, ["FR-001", "FR-002", "SC-001"]);
427 }
428}