speccoder_runtime/format/speckit/
preset.rs1use std::path::{Path, PathBuf};
8use std::process::Command;
9
10use speccoder_core::{CommandData, CommandResult, CommandStatus, PresetReport, PresetState};
11
12pub 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
18pub const NEEDS: (u64, u64, u64) = (1, 2, 0);
20
21const SPECIFY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
23
24#[must_use]
26pub fn version() -> &'static str {
27 env!("CARGO_PKG_VERSION")
28}
29
30#[must_use]
33pub fn dir(state: &Path) -> PathBuf {
34 state.join("presets").join(format!("{ID}-{}", version()))
35}
36
37pub 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
65fn 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#[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#[must_use]
118pub fn state(root: &Path) -> PresetState {
119 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#[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 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 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}