Skip to main content

speccoder_openspec/
discover.rs

1//! Where the OpenSpec project is, how it is configured, and which change is
2//! selected — without OpenSpec's CLI: the nearest `openspec/` with
3//! `config.yaml`, `specs/` or `changes/`, as OpenSpec's "nearest" root.
4
5use std::fs;
6use std::path::{Path, PathBuf};
7
8use serde::Deserialize;
9use speccoder_core::snapshot::{Diagnostic, Severity};
10
11/// The directory of a project's specs and changes.
12pub const DIR: &str = "openspec";
13
14/// The workflow schema speccoder runs.
15pub const SCHEMA: &str = "spec-driven";
16
17/// The nearest ancestor of `start` (inclusive) containing an OpenSpec
18/// directory, canonical.
19#[must_use]
20pub fn find_root(start: &Path) -> Option<PathBuf> {
21    let start = fs::canonicalize(start).ok()?;
22    start
23        .ancestors()
24        .find(|d| {
25            let o = d.join(DIR);
26            o.is_dir()
27                && (o.join("config.yaml").is_file()
28                    || o.join("specs").is_dir()
29                    || o.join("changes").is_dir()
30                    || o.join("project.md").is_file())
31        })
32        .map(Path::to_path_buf)
33}
34
35#[derive(Debug, Default, Deserialize)]
36struct RawConfig {
37    schema: Option<String>,
38    context: Option<String>,
39    store: Option<serde_json::Value>,
40}
41
42/// `openspec/config.yaml`, as far as speccoder reads it.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct Config {
45    /// `schema` (default `spec-driven`).
46    pub schema: String,
47    /// `context`: constraints for every artifact and operation.
48    pub context: Option<String>,
49    /// `store:` is set (OpenSpec's beta shared stores).
50    pub store: bool,
51    /// There is a `config.yaml` at all.
52    pub present: bool,
53}
54
55/// Reads `openspec/config.yaml` under `root`.
56pub fn config(root: &Path, diags: &mut Vec<Diagnostic>) -> Config {
57    let path = root.join(DIR).join("config.yaml");
58    let Ok(text) = fs::read_to_string(&path) else {
59        return Config {
60            schema: SCHEMA.to_owned(),
61            context: None,
62            store: false,
63            present: false,
64        };
65    };
66    let raw: RawConfig = if text.trim().is_empty() || is_only_comments(&text) {
67        RawConfig::default()
68    } else {
69        match serde_saphyr::from_str(&text) {
70            Ok(r) => r,
71            Err(e) => {
72                diags.push(
73                    Diagnostic::new(
74                        Severity::Error,
75                        "config_unreadable",
76                        format!("openspec/config.yaml: {e}"),
77                    )
78                    .at("openspec/config.yaml"),
79                );
80                RawConfig::default()
81            }
82        }
83    };
84    Config {
85        schema: raw.schema.unwrap_or_else(|| SCHEMA.to_owned()),
86        context: raw.context.filter(|c| !c.trim().is_empty()),
87        store: raw.store.is_some_and(|s| !s.is_null()),
88        present: true,
89    }
90}
91
92fn is_only_comments(text: &str) -> bool {
93    text.lines()
94        .all(|l| l.trim().is_empty() || l.trim_start().starts_with('#'))
95}
96
97/// `schema` of a change's `.openspec.yaml`, if any.
98#[must_use]
99pub fn change_schema(change_dir: &Path) -> Option<String> {
100    #[derive(Deserialize)]
101    struct Meta {
102        schema: Option<String>,
103    }
104    let text = fs::read_to_string(change_dir.join(".openspec.yaml")).ok()?;
105    serde_saphyr::from_str::<Meta>(&text).ok()?.schema
106}
107
108/// Active changes under `root`: the directories directly under
109/// `openspec/changes/` except `archive`, sorted.
110#[must_use]
111pub fn changes(root: &Path) -> Vec<String> {
112    let Ok(entries) = fs::read_dir(root.join(DIR).join("changes")) else {
113        return Vec::new();
114    };
115    let mut ids: Vec<String> = entries
116        .flatten()
117        .filter(|e| e.path().is_dir())
118        .map(|e| e.file_name().to_string_lossy().into_owned())
119        .filter(|n| n != "archive" && !n.starts_with('.'))
120        .collect();
121    ids.sort();
122    ids
123}
124
125/// Capability files under `dir` (`…/specs`): (capability, path) pairs, the
126/// capability being the path between `specs/` and `/spec.md`.
127#[must_use]
128pub fn spec_files(dir: &Path) -> Vec<(String, PathBuf)> {
129    fn walk(base: &Path, dir: &Path, out: &mut Vec<(String, PathBuf)>) {
130        let Ok(entries) = fs::read_dir(dir) else {
131            return;
132        };
133        let mut entries: Vec<_> = entries.flatten().map(|e| e.path()).collect();
134        entries.sort();
135        for p in entries {
136            if p.is_dir() {
137                walk(base, &p, out);
138            } else if p.file_name().is_some_and(|n| n == "spec.md")
139                && let Some(cap) = p
140                    .parent()
141                    .and_then(|d| d.strip_prefix(base).ok())
142                    .map(|c| c.to_string_lossy().replace('\\', "/"))
143                    .filter(|c| !c.is_empty())
144            {
145                out.push((cap, p.clone()));
146            }
147        }
148    }
149    let mut out = Vec::new();
150    walk(dir, dir, &mut out);
151    out
152}