Skip to main content

speccoder_runtime/format/speckit/
preset.rs

1//! The `speccoder` Spec Kit preset: a tasks template that plans
2//! for evidence, bundled in the binary and installed through Spec Kit's own
3//! `specify preset add --dev` — on a person's confirmed command, never from
4//! the network. Spec 101's New Project passes [`extract`]'s directory to
5//! `specify init --preset`.
6
7use std::path::{Path, PathBuf};
8use std::process::Command;
9
10use speccoder_core::{CommandData, CommandResult, CommandStatus, PresetReport, PresetState};
11
12/// The preset's id.
13pub const ID: &str = "speccoder";
14
15const MANIFEST: &str = include_str!("../../../assets/preset/speccoder/preset.yml");
16const TEMPLATE: &str = include_str!("../../../assets/preset/speccoder/templates/tasks-template.md");
17
18/// The Spec Kit version the preset needs (`requires.speckit_version`).
19pub const NEEDS: (u64, u64, u64) = (1, 2, 0);
20
21/// How long a `specify` command may take.
22const SPECIFY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
23
24/// This build's preset version: speccoder's.
25#[must_use]
26pub fn version() -> &'static str {
27    env!("CARGO_PKG_VERSION")
28}
29
30/// Where [`extract`] puts the bundled preset: `<state>/presets/speccoder-<version>/`
31/// (read-only: a plan names it before anything is written).
32#[must_use]
33pub fn dir(state: &Path) -> PathBuf {
34    state.join("presets").join(format!("{ID}-{}", version()))
35}
36
37/// Writes the bundled preset to `<state>/presets/speccoder-<version>/`
38/// (idempotent: files already as bundled are kept) and returns that
39/// directory — what `specify preset add --dev` and `specify init --preset`
40/// are given.
41///
42/// # Errors
43///
44/// The directory cannot be written.
45pub fn extract(state: &Path) -> Result<PathBuf, String> {
46    let dir = dir(state);
47    let files = [
48        ("preset.yml", MANIFEST.replace("{version}", version())),
49        ("templates/tasks-template.md", TEMPLATE.to_owned()),
50    ];
51    for (rel, content) in &files {
52        let f = dir.join(rel);
53        if std::fs::read_to_string(&f).ok().as_deref() == Some(content.as_str()) {
54            continue;
55        }
56        if let Some(p) = f.parent() {
57            std::fs::create_dir_all(p)
58                .map_err(|e| format!("cannot create {}: {e}", p.display()))?;
59        }
60        std::fs::write(&f, content).map_err(|e| format!("cannot write {}: {e}", f.display()))?;
61    }
62    Ok(dir)
63}
64
65/// Runs `specify` in `root` with a deadline; `None` when it cannot run or
66/// does not finish.
67fn specify(root: &Path, args: &[&str]) -> Option<std::process::Output> {
68    let mut child = Command::new("specify")
69        .args(args)
70        .envs(super::python::command_env(&|k| std::env::var(k).ok()))
71        .current_dir(root)
72        .stdin(std::process::Stdio::null())
73        .stdout(std::process::Stdio::piped())
74        .stderr(std::process::Stdio::piped())
75        .spawn()
76        .ok()?;
77    let began = std::time::Instant::now();
78    loop {
79        match child.try_wait() {
80            Ok(Some(_)) => return child.wait_with_output().ok(),
81            Ok(None) if began.elapsed() > SPECIFY_TIMEOUT => {
82                let _ = child.kill();
83                let _ = child.wait();
84                return None;
85            }
86            Ok(None) => std::thread::sleep(std::time::Duration::from_millis(50)),
87            Err(_) => return None,
88        }
89    }
90}
91
92fn parse_version(text: &str) -> Option<(u64, u64, u64)> {
93    let v = text
94        .split_whitespace()
95        .find(|w| w.chars().next().is_some_and(|c| c.is_ascii_digit()))?;
96    let mut n = v.split('.').map(|p| {
97        p.trim_matches(|c: char| !c.is_ascii_digit())
98            .parse::<u64>()
99            .ok()
100    });
101    Some((
102        n.next()??,
103        n.next().flatten().unwrap_or(0),
104        n.next().flatten().unwrap_or(0),
105    ))
106}
107
108/// The installed `specify`'s version, when it runs.
109#[must_use]
110pub fn speckit_version() -> Option<(u64, u64, u64)> {
111    let out = specify(Path::new("."), &["--version"])?;
112    let text = String::from_utf8_lossy(&out.stdout);
113    text.lines().rev().find_map(parse_version)
114}
115
116/// The preset as Spec Kit reports it in the project at `root`.
117#[must_use]
118pub fn state(root: &Path) -> PresetState {
119    // Not in the project: nothing to ask Spec Kit (doctor stays fast).
120    let dir = root.join(".specify/presets").join(ID);
121    if !dir.is_dir() {
122        return PresetState {
123            installed: None,
124            active_for_tasks_template: false,
125            bundled: version().to_owned(),
126        };
127    }
128    let listed = specify(root, &["preset", "list"])
129        .map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
130        .unwrap_or_default();
131    let from_manifest = std::fs::read_to_string(dir.join("preset.yml"))
132        .ok()
133        .and_then(|t| {
134            t.lines()
135                .find_map(|l| l.trim().strip_prefix("version:"))
136                .map(|v| v.trim().trim_matches('"').to_owned())
137        });
138    let installed = from_manifest.or_else(|| {
139        listed
140            .lines()
141            .find(|l| l.contains(ID) && !l.contains("No presets"))
142            .and_then(|l| {
143                l.split_whitespace().find_map(|w| {
144                    parse_version(w).map(|_| {
145                        w.trim_matches(|c: char| !c.is_ascii_digit() && c != '.')
146                            .to_owned()
147                    })
148                })
149            })
150            .or_else(|| Some("unknown".to_owned()))
151    });
152    let resolved = specify(root, &["preset", "resolve", "tasks-template"])
153        .map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
154        .unwrap_or_default();
155    PresetState {
156        installed,
157        active_for_tasks_template: resolved.contains(&format!("presets/{ID}"))
158            || resolved.contains(&format!("from: {ID} ")),
159        bundled: version().to_owned(),
160    }
161}
162
163fn refuse(code: &str, status: CommandStatus, message: String) -> CommandResult {
164    CommandResult::new("preset", status, &message).with_code(code)
165}
166
167/// `speccoder preset add`: installs (or, when `update`, replaces) the
168/// bundled preset in the Spec Kit project at `root` with `specify preset
169/// add --dev <dir> --priority 10` — run only when `confirmed`; otherwise
170/// the command and what it writes come back for a person to confirm.
171#[must_use]
172pub fn add(root: &Path, update: bool, confirmed: bool, state_dir: &Path) -> CommandResult {
173    let opened = crate::format::open(root, None);
174    let speckit = opened
175        .as_ref()
176        .is_ok_and(|o| o.adapter.kind() == speccoder_core::spec::FormatKind::Speckit);
177    if !speckit {
178        return refuse(
179            "PRESET-NOT-SPECKIT",
180            CommandStatus::UsageError,
181            "presets are a Spec Kit mechanism: this is not a Spec Kit project".to_owned(),
182        );
183    }
184    let project = opened
185        .as_ref()
186        .map(|o| PathBuf::from(&o.snapshot.root))
187        .unwrap_or_else(|_| root.to_path_buf());
188    match speckit_version() {
189        Some(v) if v >= NEEDS => {}
190        found => {
191            return refuse(
192                "PRESET-SPECKIT-MISSING",
193                CommandStatus::ConfigError,
194                format!(
195                    "the preset needs Spec Kit's `specify` {}.{}.{} or newer{}",
196                    NEEDS.0,
197                    NEEDS.1,
198                    NEEDS.2,
199                    found.map_or_else(
200                        || " (not installed)".to_owned(),
201                        |(a, b, c)| format!(" (found {a}.{b}.{c})")
202                    )
203                ),
204            );
205        }
206    }
207    let dir = match extract(state_dir) {
208        Ok(d) => d,
209        Err(e) => return refuse("PRESET-FAILED", CommandStatus::Error, e),
210    };
211    let dir_text = dir.display().to_string();
212    let before = state(&project);
213    // Already this version and active: nothing to do, nothing run.
214    if before.installed.as_deref() == Some(version()) && before.active_for_tasks_template {
215        return CommandResult::new(
216            "preset",
217            CommandStatus::Ok,
218            &format!(
219                "the speccoder preset {} is already active for tasks-template",
220                version()
221            ),
222        )
223        .with_data(CommandData::Preset(Box::new(PresetReport {
224            preset: ID.to_owned(),
225            version: version().to_owned(),
226            command: Vec::new(),
227            output: String::new(),
228            state: before,
229        })));
230    }
231    let update = update || before.installed.is_some();
232    let command: Vec<String> = if update {
233        vec![
234            "specify",
235            "preset",
236            "update",
237            ID,
238            "--dev",
239            &dir_text,
240            "--priority",
241            "10",
242        ]
243    } else {
244        vec![
245            "specify",
246            "preset",
247            "add",
248            "--dev",
249            &dir_text,
250            "--priority",
251            "10",
252        ]
253    }
254    .into_iter()
255    .map(str::to_owned)
256    .collect();
257    let report = |output: String, state: PresetState| PresetReport {
258        preset: ID.to_owned(),
259        version: version().to_owned(),
260        command: command.clone(),
261        output,
262        state,
263    };
264    // Spec Kit's scripts resolve a preset's templates with PyYAML.
265    let python = super::python::warning_before_add(&|k| std::env::var(k).ok());
266    let warn = |message: String| match &python {
267        Some(w) => format!("{message}. Warning: {w}"),
268        None => message,
269    };
270    let fix: Vec<String> = python
271        .iter()
272        .flat_map(|_| super::python::FIX.iter().map(|f| (*f).to_owned()))
273        .collect();
274    if !confirmed {
275        let asked = CommandResult::new(
276            "preset",
277            CommandStatus::NeedsHuman,
278            &warn(format!(
279                "Run `{}`? It writes .specify/presets/{ID}/ and Spec Kit's preset registry; nothing in specs/ changes",
280                command.join(" ")
281            )),
282        )
283        .with_code("NEEDS-HUMAN")
284        .with_next(vec![speccoder_core::next::NextStep {
285            command: "speccoder preset add --yes".to_owned(),
286            why: "confirm: Spec Kit installs the preset".to_owned(),
287        }])
288        .with_data(CommandData::Preset(Box::new(report(String::new(), before))));
289        return if fix.is_empty() {
290            asked
291        } else {
292            asked.with_fix(fix)
293        };
294    }
295    let args: Vec<&str> = command.iter().skip(1).map(String::as_str).collect();
296    let Some(out) = specify(&project, &args) else {
297        return refuse(
298            "PRESET-SPECKIT-MISSING",
299            CommandStatus::ConfigError,
300            "`specify` could not be run".to_owned(),
301        );
302    };
303    let output = format!(
304        "{}{}",
305        String::from_utf8_lossy(&out.stdout),
306        String::from_utf8_lossy(&out.stderr)
307    );
308    let excerpt: String = output
309        .lines()
310        .rev()
311        .take(12)
312        .collect::<Vec<_>>()
313        .into_iter()
314        .rev()
315        .collect::<Vec<_>>()
316        .join("\n");
317    if !out.status.success() {
318        return refuse(
319            "PRESET-FAILED",
320            CommandStatus::Error,
321            format!("`{}` failed: {excerpt}", command.join(" ")),
322        )
323        .with_data(CommandData::Preset(Box::new(report(excerpt, before))));
324    }
325    let after = state(&project);
326    let added = CommandResult::new(
327        "preset",
328        CommandStatus::Ok,
329        &warn(format!(
330            "the speccoder preset {} is {} for tasks-template",
331            version(),
332            if after.active_for_tasks_template {
333                "active"
334            } else {
335                "installed (not active)"
336            }
337        )),
338    )
339    .with_data(CommandData::Preset(Box::new(report(excerpt, after))));
340    if fix.is_empty() {
341        added
342    } else {
343        added.with_fix(fix)
344    }
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350
351    #[test]
352    fn extraction_is_idempotent_and_names_this_version() {
353        let d = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
354        let a = extract(d.path()).unwrap_or_else(|e| panic!("{e}"));
355        let b = extract(d.path()).unwrap_or_else(|e| panic!("{e}"));
356        assert_eq!(a, b);
357        let manifest = std::fs::read_to_string(a.join("preset.yml")).unwrap_or_default();
358        assert!(
359            manifest.contains(&format!("version: \"{}\"", version())),
360            "{manifest}"
361        );
362        assert!(a.join("templates/tasks-template.md").is_file());
363        assert_eq!(parse_version("specify 1.2.3"), Some((1, 2, 3)));
364    }
365}