Skip to main content

speccoder_runtime/format/speckit/
workflow.rs

1//! `speccoder workflow install`: speccoder as a Spec Kit workflow
2//! step type, and a workflow that uses it.
3//!
4//! Spec Kit owns the phase level (`specify workflow run`, gates, resume);
5//! speccoder owns the task level. The step type `speccoder` maps speccoder's
6//! exit code onto Spec Kit's step status — 23 (a person is needed) becomes
7//! `paused`, so `specify workflow resume` continues after the decision.
8
9use std::path::{Path, PathBuf};
10
11use speccoder_core::{CommandData, CommandResult, CommandStatus};
12
13/// The files, by project-relative path.
14pub 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/// Writes the step type and the workflow into the Spec Kit project at `path`.
30#[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/// An overlay of one of Spec Kit's workflows: one `replace` edit of one
81/// step with a `speccoder` step. The table is the general mechanism; each
82/// entry is one overlay.
83#[derive(Debug, Clone, Copy)]
84pub struct Overlay {
85    /// The name `--overlay` takes.
86    pub name: &'static str,
87    /// The workflow it extends.
88    pub extends: &'static str,
89    /// The step it replaces.
90    pub replace: &'static str,
91    /// An extension it needs installed and enabled.
92    pub needs: Option<&'static str>,
93    /// The replacing step's configuration (YAML lines under `step:`).
94    pub step: &'static str,
95}
96
97/// Every overlay speccoder writes.
98pub 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/// Where an overlay's file goes.
107#[must_use]
108pub fn overlay_file(o: &Overlay) -> String {
109    format!(".specify/workflows/overlays/{}/speccoder.yml", o.extends)
110}
111
112/// The overlay's YAML.
113#[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/// `speccoder workflow install --overlay NAME`: the overlay, and the
156/// `speccoder` step type it uses. Refused when the workflow it extends, the
157/// step it replaces or the extension it needs is absent.
158#[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    // The step type it uses, unless installed already.
203    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/// `speccoder workflow uninstall --overlay NAME`.
252#[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}