speccoder_runtime/format/speckit/
workflow.rs1use std::path::{Path, PathBuf};
10
11use speccoder_core::{CommandData, CommandResult, CommandStatus};
12
13pub const FILES: [(&str, &str); 3] = [
15 (
16 ".specify/workflows/steps/speccoder/step.yml",
17 include_str!("../../../assets/workflow/step.yml"),
18 ),
19 (
20 ".specify/workflows/steps/speccoder/__init__.py",
21 include_str!("../../../assets/workflow/__init__.py"),
22 ),
23 (
24 ".specify/workflows/speccoder/workflow.yml",
25 include_str!("../../../assets/workflow/workflow.yml"),
26 ),
27];
28
29#[must_use]
31pub fn install(path: &Path, force: bool) -> CommandResult {
32 let root = match crate::inspect(path, None).data() {
33 Some(CommandData::Inspect(s)) => PathBuf::from(&s.root),
34 _ => {
35 return CommandResult::new(
36 "workflow",
37 CommandStatus::NotAProject,
38 "not a Spec Kit project (no .specify/ found); run `specify init --here --integration claude` first",
39 );
40 }
41 };
42 let existing: Vec<&str> = FILES
43 .iter()
44 .filter(|(p, _)| root.join(p).exists())
45 .map(|(p, _)| *p)
46 .collect();
47 if !existing.is_empty() && !force {
48 return CommandResult::new(
49 "workflow",
50 CommandStatus::ConfigError,
51 &format!("{} exist(s); pass --force to replace", existing.join(", ")),
52 );
53 }
54 for (rel, text) in FILES {
55 let file = root.join(rel);
56 if let Some(dir) = file.parent()
57 && let Err(e) = std::fs::create_dir_all(dir)
58 {
59 return CommandResult::new(
60 "workflow",
61 CommandStatus::Error,
62 &format!("{}: {e}", dir.display()),
63 );
64 }
65 if let Err(e) = std::fs::write(&file, text) {
66 return CommandResult::new(
67 "workflow",
68 CommandStatus::Error,
69 &format!("{}: {e}", file.display()),
70 );
71 }
72 }
73 CommandResult::new(
74 "workflow",
75 CommandStatus::Ok,
76 "installed the `speccoder` step type and workflow; run it with `specify workflow run .specify/workflows/speccoder/workflow.yml --input spec=\"…\"` (resume after a pause with `specify workflow resume <run>`)",
77 )
78}
79
80#[derive(Debug, Clone, Copy)]
84pub struct Overlay {
85 pub name: &'static str,
87 pub extends: &'static str,
89 pub replace: &'static str,
91 pub needs: Option<&'static str>,
93 pub step: &'static str,
95}
96
97pub const OVERLAYS: &[Overlay] = &[Overlay {
99 name: "bugfix",
100 extends: "bugfix",
101 replace: "test",
102 needs: Some("bug"),
103 step: " id: test\n type: speccoder\n bug: \"{{ inputs.slug }}\"\n",
104}];
105
106#[must_use]
108pub fn overlay_file(o: &Overlay) -> String {
109 format!(".specify/workflows/overlays/{}/speccoder.yml", o.extends)
110}
111
112#[must_use]
114pub fn overlay_text(o: &Overlay) -> String {
115 format!(
116 "# Written by `speccoder workflow install --overlay {name}`; removed by `speccoder workflow uninstall --overlay {name}`.\nschema_version: \"1.0\"\nid: speccoder-{name}\nextends: {extends}\nedits:\n - replace: {replace}\n step:\n{step}",
117 name = o.name,
118 extends = o.extends,
119 replace = o.replace,
120 step = o.step,
121 )
122}
123
124fn project_root(path: &Path) -> Result<PathBuf, Box<CommandResult>> {
125 match crate::inspect(path, None).data() {
126 Some(CommandData::Inspect(s)) => Ok(PathBuf::from(&s.root)),
127 _ => Err(Box::new(CommandResult::new(
128 "workflow",
129 CommandStatus::NotAProject,
130 "not a Spec Kit project (no .specify/ found); run `specify init --here --integration claude` first",
131 ))),
132 }
133}
134
135fn named(name: &str) -> Result<&'static Overlay, Box<CommandResult>> {
136 OVERLAYS.iter().find(|o| o.name == name).ok_or_else(|| {
137 Box::new(
138 CommandResult::new(
139 "workflow",
140 CommandStatus::UsageError,
141 &format!(
142 "no overlay `{name}` (known: {})",
143 OVERLAYS
144 .iter()
145 .map(|o| o.name)
146 .collect::<Vec<_>>()
147 .join(", ")
148 ),
149 )
150 .with_code("USAGE"),
151 )
152 })
153}
154
155#[must_use]
159pub fn install_overlay(path: &Path, name: &str, force: bool) -> CommandResult {
160 let root = match project_root(path) {
161 Ok(r) => r,
162 Err(r) => return *r,
163 };
164 let o = match named(name) {
165 Ok(o) => o,
166 Err(r) => return *r,
167 };
168 let refuse = |m: String| CommandResult::new("workflow", CommandStatus::ConfigError, &m);
169 if let Some(ext) = o.needs
170 && super::extensions::inventory(&root).is_none_or(|i| i.enabled(ext).is_none())
171 {
172 return refuse(format!(
173 "the `{}` overlay needs Spec Kit's `{ext}` extension installed and enabled: `specify extension add {ext}`",
174 o.name
175 ));
176 }
177 let workflow = root
178 .join(".specify/workflows")
179 .join(o.extends)
180 .join("workflow.yml");
181 let Ok(text) = std::fs::read_to_string(&workflow) else {
182 return refuse(format!(
183 "there is no `{}` workflow to overlay: `specify workflow add {}`",
184 o.extends, o.extends
185 ));
186 };
187 if !text.lines().any(|l| {
188 l.trim() == format!("- id: {}", o.replace) || l.trim() == format!("- id: \"{}\"", o.replace)
189 }) {
190 return refuse(format!(
191 "the `{}` workflow has no step `{}` to replace",
192 o.extends, o.replace
193 ));
194 }
195 let file = root.join(overlay_file(o));
196 if file.exists() && !force {
197 return refuse(format!(
198 "{} exists; pass --force to replace",
199 overlay_file(o)
200 ));
201 }
202 for (rel, body) in &FILES[..2] {
204 let f = root.join(rel);
205 if f.exists() && !force {
206 continue;
207 }
208 if let Some(dir) = f.parent()
209 && let Err(e) = std::fs::create_dir_all(dir)
210 {
211 return CommandResult::new(
212 "workflow",
213 CommandStatus::Error,
214 &format!("{}: {e}", dir.display()),
215 );
216 }
217 if let Err(e) = std::fs::write(&f, body) {
218 return CommandResult::new(
219 "workflow",
220 CommandStatus::Error,
221 &format!("{}: {e}", f.display()),
222 );
223 }
224 }
225 if let Some(dir) = file.parent()
226 && let Err(e) = std::fs::create_dir_all(dir)
227 {
228 return CommandResult::new(
229 "workflow",
230 CommandStatus::Error,
231 &format!("{}: {e}", dir.display()),
232 );
233 }
234 if let Err(e) = std::fs::write(&file, overlay_text(o)) {
235 return CommandResult::new(
236 "workflow",
237 CommandStatus::Error,
238 &format!("{}: {e}", file.display()),
239 );
240 }
241 CommandResult::new(
242 "workflow",
243 CommandStatus::Ok,
244 &format!(
245 "installed the `{}` overlay: `specify workflow run {}` now runs speccoder for its `{}` step (it pauses for your confirmation); `speccoder workflow uninstall --overlay {}` restores the original",
246 o.name, o.extends, o.replace, o.name
247 ),
248 )
249}
250
251#[must_use]
253pub fn uninstall_overlay(path: &Path, name: &str) -> CommandResult {
254 let root = match project_root(path) {
255 Ok(r) => r,
256 Err(r) => return *r,
257 };
258 let o = match named(name) {
259 Ok(o) => o,
260 Err(r) => return *r,
261 };
262 let file = root.join(overlay_file(o));
263 if !file.exists() {
264 return CommandResult::new(
265 "workflow",
266 CommandStatus::NotFound,
267 &format!("the `{}` overlay is not installed", o.name),
268 );
269 }
270 if let Err(e) = std::fs::remove_file(&file) {
271 return CommandResult::new(
272 "workflow",
273 CommandStatus::Error,
274 &format!("{}: {e}", file.display()),
275 );
276 }
277 if let Some(dir) = file.parent() {
278 let _ = std::fs::remove_dir(dir);
279 }
280 CommandResult::new(
281 "workflow",
282 CommandStatus::Ok,
283 &format!(
284 "removed the `{}` overlay: the `{}` workflow runs its own `{}` step again",
285 o.name, o.extends, o.replace
286 ),
287 )
288}
289
290#[cfg(test)]
291mod tests {
292 use super::*;
293
294 #[test]
295 fn the_assets_declare_the_step_type_and_use_it() {
296 assert!(FILES[0].1.contains("type_key: \"speccoder\""));
297 assert!(
298 FILES[1].1.contains("type_key = \"speccoder\"")
299 && FILES[1].1.contains("NEEDS_HUMAN = 23")
300 );
301 assert!(FILES[2].1.contains("type: speccoder") && FILES[2].1.contains("converge: true"));
302 }
303}