speccoder_runtime/format/openspec/
cli.rs1use std::path::{Path, PathBuf};
7use std::time::Duration;
8
9use serde_json::Value;
10use speccoder_core::snapshot::{Diagnostic, ProjectSnapshot};
11use speccoder_openspec::crosscheck;
12
13use crate::verify::process::{CommandRunner, Invocation, scrubbed_env};
14
15#[must_use]
17pub fn find() -> Option<PathBuf> {
18 let path = std::env::var_os("PATH")?;
19 std::env::split_paths(&path)
20 .map(|d| d.join("openspec"))
21 .find(|p| p.is_file())
22}
23
24pub fn run(
26 runner: &dyn CommandRunner,
27 program: &Path,
28 cwd: &Path,
29 args: &[&str],
30) -> Result<String, String> {
31 let mut env = scrubbed_env(std::env::vars());
32 for (k, v) in super::RULES.env {
33 env.retain(|(name, _)| name != k);
34 env.push(((*k).to_owned(), (*v).to_owned()));
35 }
36 let mut argv = vec![program.display().to_string()];
37 argv.extend(args.iter().map(|a| (*a).to_owned()));
38 let out = runner.run(&Invocation {
39 argv,
40 cwd: cwd.to_path_buf(),
41 env,
42 timeout: Duration::from_secs(60),
43 output_cap: 4 * 1024 * 1024,
44 });
45 if let Some(e) = out.spawn_error {
46 return Err(e);
47 }
48 if out.timed_out {
49 return Err("timed out after 60 s".to_owned());
50 }
51 Ok(String::from_utf8_lossy(&out.stdout.kept).into_owned())
52}
53
54fn json(text: &str) -> Option<Value> {
56 let start = text.find('{')?;
57 serde_json::from_str(&text[start..]).ok()
58}
59
60#[must_use]
62pub fn crosscheck(snapshot: &ProjectSnapshot, runner: &dyn CommandRunner) -> Vec<Diagnostic> {
63 crosscheck_with(snapshot, runner, find())
64}
65
66#[must_use]
68pub fn crosscheck_with(
69 snapshot: &ProjectSnapshot,
70 runner: &dyn CommandRunner,
71 program: Option<PathBuf>,
72) -> Vec<Diagnostic> {
73 let Some(f) = snapshot.feature.as_ref() else {
74 return Vec::new();
75 };
76 let Some(program) = program else {
77 return vec![crosscheck::skipped("`openspec` is not on PATH")];
78 };
79 let root = Path::new(&snapshot.root);
80 match run(runner, &program, root, &["--version"]) {
81 Ok(v)
82 if v.trim()
83 .split('.')
84 .next()
85 .and_then(|m| m.trim().parse::<u32>().ok())
86 .is_some_and(|m| m >= 1) => {}
87 Ok(v) => {
88 return vec![crosscheck::skipped(&format!(
89 "`openspec --version` said `{}`; 1.0 or newer is needed",
90 v.trim()
91 ))];
92 }
93 Err(e) => return vec![crosscheck::skipped(&format!("`openspec --version`: {e}"))],
94 }
95 let id = f.feature.name.as_str();
96 let validate = run(
97 runner,
98 &program,
99 root,
100 &["validate", id, "--strict", "--json"],
101 )
102 .ok()
103 .and_then(|t| json(&t));
104 let apply = run(
105 runner,
106 &program,
107 root,
108 &["instructions", "apply", "--change", id, "--json"],
109 )
110 .ok()
111 .and_then(|t| json(&t));
112 if validate.is_none() && apply.is_none() {
113 return vec![crosscheck::skipped("OpenSpec printed no JSON")];
114 }
115 crosscheck::compare(
116 &f.feature.dir,
117 f.tasks.as_ref(),
118 validate.as_ref(),
119 apply.as_ref(),
120 )
121}