use std::fs;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use speccoder_core::snapshot::{Diagnostic, Severity};
pub const DIR: &str = "openspec";
pub const SCHEMA: &str = "spec-driven";
#[must_use]
pub fn find_root(start: &Path) -> Option<PathBuf> {
let start = fs::canonicalize(start).ok()?;
start
.ancestors()
.find(|d| {
let o = d.join(DIR);
o.is_dir()
&& (o.join("config.yaml").is_file()
|| o.join("specs").is_dir()
|| o.join("changes").is_dir()
|| o.join("project.md").is_file())
})
.map(Path::to_path_buf)
}
#[derive(Debug, Default, Deserialize)]
struct RawConfig {
schema: Option<String>,
context: Option<String>,
store: Option<serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Config {
pub schema: String,
pub context: Option<String>,
pub store: bool,
pub present: bool,
}
pub fn config(root: &Path, diags: &mut Vec<Diagnostic>) -> Config {
let path = root.join(DIR).join("config.yaml");
let Ok(text) = fs::read_to_string(&path) else {
return Config {
schema: SCHEMA.to_owned(),
context: None,
store: false,
present: false,
};
};
let raw: RawConfig = if text.trim().is_empty() || is_only_comments(&text) {
RawConfig::default()
} else {
match serde_saphyr::from_str(&text) {
Ok(r) => r,
Err(e) => {
diags.push(
Diagnostic::new(
Severity::Error,
"config_unreadable",
format!("openspec/config.yaml: {e}"),
)
.at("openspec/config.yaml"),
);
RawConfig::default()
}
}
};
Config {
schema: raw.schema.unwrap_or_else(|| SCHEMA.to_owned()),
context: raw.context.filter(|c| !c.trim().is_empty()),
store: raw.store.is_some_and(|s| !s.is_null()),
present: true,
}
}
fn is_only_comments(text: &str) -> bool {
text.lines()
.all(|l| l.trim().is_empty() || l.trim_start().starts_with('#'))
}
#[must_use]
pub fn change_schema(change_dir: &Path) -> Option<String> {
#[derive(Deserialize)]
struct Meta {
schema: Option<String>,
}
let text = fs::read_to_string(change_dir.join(".openspec.yaml")).ok()?;
serde_saphyr::from_str::<Meta>(&text).ok()?.schema
}
#[must_use]
pub fn changes(root: &Path) -> Vec<String> {
let Ok(entries) = fs::read_dir(root.join(DIR).join("changes")) else {
return Vec::new();
};
let mut ids: Vec<String> = entries
.flatten()
.filter(|e| e.path().is_dir())
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| n != "archive" && !n.starts_with('.'))
.collect();
ids.sort();
ids
}
#[must_use]
pub fn spec_files(dir: &Path) -> Vec<(String, PathBuf)> {
fn walk(base: &Path, dir: &Path, out: &mut Vec<(String, PathBuf)>) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
let mut entries: Vec<_> = entries.flatten().map(|e| e.path()).collect();
entries.sort();
for p in entries {
if p.is_dir() {
walk(base, &p, out);
} else if p.file_name().is_some_and(|n| n == "spec.md")
&& let Some(cap) = p
.parent()
.and_then(|d| d.strip_prefix(base).ok())
.map(|c| c.to_string_lossy().replace('\\', "/"))
.filter(|c| !c.is_empty())
{
out.push((cap, p.clone()));
}
}
}
let mut out = Vec::new();
walk(dir, dir, &mut out);
out
}