Skip to main content

specfold_core/config/
reader.rs

1//! Finds and loads spec.toml, and answers questions about the loaded configuration.
2
3use super::defaults::{self, CONFIG_FILE};
4use super::paths;
5use super::types::RawConfig;
6use crate::model::registry::Registry;
7use crate::workspace::vcs;
8use anyhow::{Context, Result, bail};
9use std::path::{Path, PathBuf};
10
11#[derive(Debug, Clone)]
12pub struct Config {
13    /// Repository root: the directory holding spec.toml.
14    pub root: PathBuf,
15    pub raw: RawConfig,
16    pub registry: Registry,
17    /// True when no spec.toml was found and defaults are in use.
18    pub implicit: bool,
19}
20
21impl Config {
22    /// Finds spec.toml in `start` or its ancestors. Without one, the nearest git root (or `start`)
23    /// is used with defaults, so the tool works in any directory.
24    pub fn discover(start: &Path) -> Result<Self> {
25        let start = start.canonicalize().with_context(|| format!("cannot open {}", start.display()))?;
26        let mut dir = Some(start.as_path());
27        while let Some(d) = dir {
28            let candidate = d.join(CONFIG_FILE);
29            if candidate.is_file() {
30                return Self::load(&candidate);
31            }
32            dir = d.parent();
33        }
34        let root = vcs::git_root(&start).unwrap_or(start);
35        Ok(Self::from_raw(root, RawConfig::default(), true))
36    }
37
38    pub fn load(path: &Path) -> Result<Self> {
39        let text = std::fs::read_to_string(path).with_context(|| format!("cannot read {}", path.display()))?;
40        let raw: RawConfig = match toml::from_str(&text) {
41            Ok(r) => r,
42            Err(e) => {
43                let (_, changes) = super::upgrade::plan(&text, super::upgrade::RENAMED);
44                let hint =
45                    if changes.is_empty() { String::new() } else { "; renamed keys: spec init --upgrade".into() };
46                return Err(anyhow::Error::new(e).context(format!("invalid {}{hint}", path.display())));
47            }
48        };
49        if raw.spec.schema > defaults::schema() {
50            return Err(newer_schema(path, raw.spec.schema));
51        }
52        if raw.spec.schema != defaults::schema() {
53            bail!(
54                "{}: schema {} is older than this version reads ({}); preview the upgrade: spec init --upgrade",
55                path.display(),
56                raw.spec.schema,
57                defaults::schema()
58            );
59        }
60        // C141: one guard with a mistyped range would stop every `spec guard` run later.
61        for g in &raw.guards {
62            if !matches!(g.range.as_str(), "staged" | "push" | "all") && !g.range.contains("..") {
63                bail!(
64                    "{}: guard '{}' has range = \"{}\"; use \"staged\", \"push\", \"all\" or a git range such as \"origin/main..HEAD\" (spec explain config)",
65                    path.display(),
66                    g.name,
67                    g.range
68                );
69            }
70        }
71        for k in &raw.owners.kinds {
72            if let Some(why) = crate::workspace::owners::patterns::kind_pattern_problem(&k.pattern) {
73                bail!("{}: {why}; next: spec explain config", path.display());
74            }
75        }
76        if let Some((why, value)) = super::validate::pattern_problem(&raw) {
77            let at = text.lines().position(|l| l.contains(&value)).map(|n| format!(" at line {}", n + 1));
78            bail!("{}{}: {why}; next: spec explain config", path.display(), at.unwrap_or_default());
79        }
80        // C184: guards sharing a baseline file overwrite each other's entries on --write-baseline.
81        let mut seen: Vec<(&str, String)> = Vec::new();
82        for g in &raw.guards {
83            let Some(b) = g.baseline.as_deref() else { continue };
84            let b = same_file_key(path.parent().unwrap_or(Path::new(".")), b);
85            if let Some((other, _)) = seen.iter().find(|(_, x)| *x == b) {
86                bail!(
87                    "{}: guards '{other}' and '{}' both write the baseline file \"{b}\"; give each guard its own baseline file; next: spec explain config",
88                    path.display(),
89                    g.name
90                );
91            }
92            seen.push((&g.name, b));
93        }
94        let root = path.parent().unwrap_or(Path::new(".")).to_path_buf();
95        Ok(Self::from_raw(root, raw, false))
96    }
97
98    pub fn from_raw(root: PathBuf, raw: RawConfig, implicit: bool) -> Self {
99        let registry = Registry::new(&raw.types);
100        Config { root, raw, registry, implicit }
101    }
102
103    pub fn spec_dir(&self) -> &str {
104        &self.raw.spec.dir
105    }
106
107    pub fn manifests(&self) -> Vec<String> {
108        match &self.raw.owners.manifests {
109            Some(m) => m.clone(),
110            None => defaults::MANIFESTS.iter().map(|s| s.to_string()).collect(),
111        }
112    }
113
114    pub fn root_owner(&self) -> &str {
115        &self.raw.owners.root
116    }
117
118    pub fn local_dir(&self) -> &str {
119        &self.raw.local.path
120    }
121
122    pub fn expand_path(&self, p: &str) -> PathBuf {
123        paths::expand(&self.root, p)
124    }
125}
126
127/// The refusal for a spec.toml whose schema is newer than this binary reads; `spec check` and
128/// `spec init --upgrade` (C170) both give it.
129pub fn newer_schema(path: &Path, schema: u32) -> anyhow::Error {
130    anyhow::anyhow!(
131        "{}: schema {schema} was written by a newer specfold (this version reads {}); install the latest spec",
132        path.display(),
133        defaults::schema()
134    )
135}
136
137/// C186: a baseline path as one string per file: relative to the repository root, with `.` and
138/// `..` resolved lexically, so `./b.json`, `x/../b.json` and `<root>/b.json` compare equal.
139fn same_file_key(root: &Path, p: &str) -> String {
140    // C194/C195: resolve the deepest part of the path that exists (symbolic links included)
141    // before any lexical clean-up, then apply the rest; `lnk/sub/..` is judged where the
142    // write would land. Kept root-relative so it compares with the lexical keys below.
143    if let Ok(croot) = root.canonicalize() {
144        let abs = root.join(p);
145        let comps: Vec<_> = abs.components().collect();
146        for k in (1..=comps.len()).rev() {
147            let Ok(mut out) = comps[..k].iter().collect::<std::path::PathBuf>().canonicalize() else {
148                continue;
149            };
150            for c in &comps[k..] {
151                match c {
152                    std::path::Component::ParentDir => {
153                        out.pop();
154                    }
155                    std::path::Component::Normal(n) => out.push(n),
156                    _ => {}
157                }
158            }
159            return match out.strip_prefix(&croot) {
160                Ok(r) => r.to_string_lossy().replace('\\', "/"),
161                Err(_) => out.to_string_lossy().into_owned(),
162            };
163        }
164    }
165    let p = Path::new(p);
166    let rel = p.strip_prefix(root).unwrap_or(p);
167    let mut parts: Vec<&str> = vec![];
168    for c in rel.to_str().unwrap_or_default().split(['/', '\\']) {
169        match c {
170            "" | "." => {}
171            ".." if parts.last().is_some_and(|l| *l != "..") => {
172                parts.pop();
173            }
174            other => parts.push(other),
175        }
176    }
177    parts.join("/")
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    #[test]
185    fn parses_full_config() {
186        let raw: RawConfig = toml::from_str(
187            r#"
188[spec]
189schema = 1
190[owners]
191root = "ws"
192retired = ["services/old"]
193[[owners.kinds]]
194pattern = "services/*"
195id = "{1}"
196[public]
197paths = ["libs/open"]
198[public.companion]
199"libs/open" = "services/open"
200[external.notes]
201path = "~/notes"
202[files.store]
203kind = "dir"
204path = "/tmp/store"
205[types.adr]
206statuses = ["draft", "active"]
207[types.memo]
208key = "slug"
209[[guards]]
210name = "public"
211paths = ["libs/open/**"]
212deny_terms = ["internal-name"]
213"#,
214        )
215        .unwrap();
216        let cfg = Config::from_raw(PathBuf::from("/r"), raw, false);
217        assert_eq!(cfg.root_owner(), "ws");
218        assert!(cfg.registry.get("memo").is_some());
219        assert_eq!(cfg.registry.get("adr").unwrap().statuses.len(), 2);
220        assert_eq!(cfg.raw.guards[0].range, "staged");
221    }
222
223    #[test]
224    fn unknown_keys_are_refused() {
225        assert!(toml::from_str::<RawConfig>("[spec]\nschema = 1\ntypo = 2\n").is_err());
226    }
227}