use super::defaults::{self, CONFIG_FILE};
use super::paths;
use super::types::RawConfig;
use crate::model::registry::Registry;
use crate::workspace::vcs;
use anyhow::{Context, Result, bail};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct Config {
pub root: PathBuf,
pub raw: RawConfig,
pub registry: Registry,
pub implicit: bool,
}
impl Config {
pub fn discover(start: &Path) -> Result<Self> {
let start = start.canonicalize().with_context(|| format!("cannot open {}", start.display()))?;
let mut dir = Some(start.as_path());
while let Some(d) = dir {
let candidate = d.join(CONFIG_FILE);
if candidate.is_file() {
return Self::load(&candidate);
}
dir = d.parent();
}
let root = vcs::git_root(&start).unwrap_or(start);
Ok(Self::from_raw(root, RawConfig::default(), true))
}
pub fn load(path: &Path) -> Result<Self> {
let text = std::fs::read_to_string(path).with_context(|| format!("cannot read {}", path.display()))?;
let raw: RawConfig = match toml::from_str(&text) {
Ok(r) => r,
Err(e) => {
let (_, changes) = super::upgrade::plan(&text, super::upgrade::RENAMED);
let hint =
if changes.is_empty() { String::new() } else { "; renamed keys: spec init --upgrade".into() };
return Err(anyhow::Error::new(e).context(format!("invalid {}{hint}", path.display())));
}
};
if raw.spec.schema > defaults::schema() {
return Err(newer_schema(path, raw.spec.schema));
}
if raw.spec.schema != defaults::schema() {
bail!(
"{}: schema {} is older than this version reads ({}); preview the upgrade: spec init --upgrade",
path.display(),
raw.spec.schema,
defaults::schema()
);
}
for g in &raw.guards {
if !matches!(g.range.as_str(), "staged" | "push" | "all") && !g.range.contains("..") {
bail!(
"{}: guard '{}' has range = \"{}\"; use \"staged\", \"push\", \"all\" or a git range such as \"origin/main..HEAD\" (spec explain config)",
path.display(),
g.name,
g.range
);
}
}
for k in &raw.owners.kinds {
if let Some(why) = crate::workspace::owners::patterns::kind_pattern_problem(&k.pattern) {
bail!("{}: {why}; next: spec explain config", path.display());
}
}
if let Some((why, value)) = super::validate::pattern_problem(&raw) {
let at = text.lines().position(|l| l.contains(&value)).map(|n| format!(" at line {}", n + 1));
bail!("{}{}: {why}; next: spec explain config", path.display(), at.unwrap_or_default());
}
let mut seen: Vec<(&str, String)> = Vec::new();
for g in &raw.guards {
let Some(b) = g.baseline.as_deref() else { continue };
let b = same_file_key(path.parent().unwrap_or(Path::new(".")), b);
if let Some((other, _)) = seen.iter().find(|(_, x)| *x == b) {
bail!(
"{}: guards '{other}' and '{}' both write the baseline file \"{b}\"; give each guard its own baseline file; next: spec explain config",
path.display(),
g.name
);
}
seen.push((&g.name, b));
}
let root = path.parent().unwrap_or(Path::new(".")).to_path_buf();
Ok(Self::from_raw(root, raw, false))
}
pub fn from_raw(root: PathBuf, raw: RawConfig, implicit: bool) -> Self {
let registry = Registry::new(&raw.types);
Config { root, raw, registry, implicit }
}
pub fn spec_dir(&self) -> &str {
&self.raw.spec.dir
}
pub fn manifests(&self) -> Vec<String> {
match &self.raw.owners.manifests {
Some(m) => m.clone(),
None => defaults::MANIFESTS.iter().map(|s| s.to_string()).collect(),
}
}
pub fn root_owner(&self) -> &str {
&self.raw.owners.root
}
pub fn local_dir(&self) -> &str {
&self.raw.local.path
}
pub fn expand_path(&self, p: &str) -> PathBuf {
paths::expand(&self.root, p)
}
}
pub fn newer_schema(path: &Path, schema: u32) -> anyhow::Error {
anyhow::anyhow!(
"{}: schema {schema} was written by a newer specfold (this version reads {}); install the latest spec",
path.display(),
defaults::schema()
)
}
fn same_file_key(root: &Path, p: &str) -> String {
if let Ok(croot) = root.canonicalize() {
let abs = root.join(p);
let comps: Vec<_> = abs.components().collect();
for k in (1..=comps.len()).rev() {
let Ok(mut out) = comps[..k].iter().collect::<std::path::PathBuf>().canonicalize() else {
continue;
};
for c in &comps[k..] {
match c {
std::path::Component::ParentDir => {
out.pop();
}
std::path::Component::Normal(n) => out.push(n),
_ => {}
}
}
return match out.strip_prefix(&croot) {
Ok(r) => r.to_string_lossy().replace('\\', "/"),
Err(_) => out.to_string_lossy().into_owned(),
};
}
}
let p = Path::new(p);
let rel = p.strip_prefix(root).unwrap_or(p);
let mut parts: Vec<&str> = vec![];
for c in rel.to_str().unwrap_or_default().split(['/', '\\']) {
match c {
"" | "." => {}
".." if parts.last().is_some_and(|l| *l != "..") => {
parts.pop();
}
other => parts.push(other),
}
}
parts.join("/")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_full_config() {
let raw: RawConfig = toml::from_str(
r#"
[spec]
schema = 1
[owners]
root = "ws"
retired = ["services/old"]
[[owners.kinds]]
pattern = "services/*"
id = "{1}"
[public]
paths = ["libs/open"]
[public.companion]
"libs/open" = "services/open"
[external.notes]
path = "~/notes"
[files.store]
kind = "dir"
path = "/tmp/store"
[types.adr]
statuses = ["draft", "active"]
[types.memo]
key = "slug"
[[guards]]
name = "public"
paths = ["libs/open/**"]
deny_terms = ["internal-name"]
"#,
)
.unwrap();
let cfg = Config::from_raw(PathBuf::from("/r"), raw, false);
assert_eq!(cfg.root_owner(), "ws");
assert!(cfg.registry.get("memo").is_some());
assert_eq!(cfg.registry.get("adr").unwrap().statuses.len(), 2);
assert_eq!(cfg.raw.guards[0].range, "staged");
}
#[test]
fn unknown_keys_are_refused() {
assert!(toml::from_str::<RawConfig>("[spec]\nschema = 1\ntypo = 2\n").is_err());
}
}