Skip to main content

speccoder_runtime/format/openspec/
cli.rs

1//! OpenSpec's own CLI as a second opinion: when `openspec` is
2//! on PATH, `validate --strict --json` and `instructions apply --json` run
3//! read-only, sandboxed, telemetry off, and speccoder's parse is compared
4//! with theirs. Absent or unusable, the cross-check is skipped and says why.
5
6use 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/// `openspec` on `PATH`, if any.
16#[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
24/// Runs `openspec <args>` in `cwd`; its stdout, or why not.
25pub 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
54/// The first JSON object in `text` (OpenSpec may print a notice first).
55fn json(text: &str) -> Option<Value> {
56    let start = text.find('{')?;
57    serde_json::from_str(&text[start..]).ok()
58}
59
60/// Cross-check diagnostics for the selected change of `snapshot`.
61#[must_use]
62pub fn crosscheck(snapshot: &ProjectSnapshot, runner: &dyn CommandRunner) -> Vec<Diagnostic> {
63    crosscheck_with(snapshot, runner, find())
64}
65
66/// [`crosscheck()`] with the `openspec` program given (`None`: not installed).
67#[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}