speccoder_openspec/
discover.rs1use std::fs;
6use std::path::{Path, PathBuf};
7
8use serde::Deserialize;
9use speccoder_core::snapshot::{Diagnostic, Severity};
10
11pub const DIR: &str = "openspec";
13
14pub const SCHEMA: &str = "spec-driven";
16
17#[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#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct Config {
45 pub schema: String,
47 pub context: Option<String>,
49 pub store: bool,
51 pub present: bool,
53}
54
55pub 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#[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#[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#[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}