specfold_core/config/
reader.rs1use 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 pub root: PathBuf,
15 pub raw: RawConfig,
16 pub registry: Registry,
17 pub implicit: bool,
19}
20
21impl Config {
22 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 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 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
127pub 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
137fn same_file_key(root: &Path, p: &str) -> String {
140 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}