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 if let Some(private) = super::private_root::mapped(&root) {
37 let toml = private.join(CONFIG_FILE);
38 if toml.is_file() {
39 return Self::load(&toml);
40 }
41 bail!(
42 "{} is mapped to {} in {}, which has no {CONFIG_FILE}; next: spec -C {} init --write",
43 root.display(),
44 private.display(),
45 super::private_root::file().unwrap_or_default().display(),
46 private.display()
47 );
48 }
49 Ok(Self::from_raw(root, RawConfig::default(), true))
50 }
51
52 pub fn load(path: &Path) -> Result<Self> {
53 let text = std::fs::read_to_string(path).with_context(|| format!("cannot read {}", path.display()))?;
54 let raw: RawConfig = match toml::from_str(&text) {
55 Ok(r) => r,
56 Err(e) => {
57 let (_, changes) = super::upgrade::plan(&text, super::upgrade::RENAMED);
58 let hint =
59 if changes.is_empty() { String::new() } else { "; renamed keys: spec init --upgrade".into() };
60 return Err(anyhow::Error::new(e).context(format!("invalid {}{hint}", path.display())));
61 }
62 };
63 if raw.spec.schema > defaults::schema() {
64 return Err(newer_schema(path, raw.spec.schema));
65 }
66 if raw.spec.schema != defaults::schema() {
67 bail!(
68 "{}: schema {} is older than this version reads ({}); preview the upgrade: spec init --upgrade",
69 path.display(),
70 raw.spec.schema,
71 defaults::schema()
72 );
73 }
74 for g in &raw.guards {
76 if !matches!(g.range.as_str(), "staged" | "push" | "all") && !g.range.contains("..") {
77 bail!(
78 "{}: guard '{}' has range = \"{}\"; use \"staged\", \"push\", \"all\" or a git range such as \"origin/main..HEAD\" (spec explain config)",
79 path.display(),
80 g.name,
81 g.range
82 );
83 }
84 }
85 for k in &raw.owners.kinds {
86 if let Some(why) = crate::workspace::owners::patterns::kind_pattern_problem(&k.pattern) {
87 bail!("{}: {why}; next: spec explain config", path.display());
88 }
89 }
90 if let Some((why, value)) = super::validate::pattern_problem(&raw) {
91 let at = text.lines().position(|l| l.contains(&value)).map(|n| format!(" at line {}", n + 1));
92 bail!("{}{}: {why}; next: spec explain config", path.display(), at.unwrap_or_default());
93 }
94 let mut seen: Vec<(&str, String)> = Vec::new();
96 for g in &raw.guards {
97 let Some(b) = g.baseline.as_deref() else { continue };
98 let b = same_file_key(path.parent().unwrap_or(Path::new(".")), b);
99 if let Some((other, _)) = seen.iter().find(|(_, x)| *x == b) {
100 bail!(
101 "{}: guards '{other}' and '{}' both write the baseline file \"{b}\"; give each guard its own baseline file; next: spec explain config",
102 path.display(),
103 g.name
104 );
105 }
106 seen.push((&g.name, b));
107 }
108 let root = path.parent().unwrap_or(Path::new(".")).to_path_buf();
109 Ok(Self::from_raw(root, raw, false))
110 }
111
112 pub fn from_raw(root: PathBuf, mut raw: RawConfig, implicit: bool) -> Self {
113 if raw.spec.submodule_remotes {
114 for (name, path) in submodule_specs(&root) {
115 raw.remotes.entry(name).or_insert(crate::config::types::RemoteRoot { path, paths: vec![], auto: true });
116 }
117 }
118 for (name, dir) in super::private_root::prefixes() {
119 let path = dir.to_string_lossy().into_owned();
120 raw.remotes.entry(name).or_insert(crate::config::types::RemoteRoot { path, paths: vec![], auto: false });
121 }
122 let registry = Registry::new(&raw.types);
123 Config { root, raw, registry, implicit }
124 }
125
126 pub fn spec_dir(&self) -> &str {
127 &self.raw.spec.dir
128 }
129
130 pub fn manifests(&self) -> Vec<String> {
131 match &self.raw.owners.manifests {
132 Some(m) => m.clone(),
133 None => defaults::MANIFESTS.iter().map(|s| s.to_string()).collect(),
134 }
135 }
136
137 pub fn root_owner(&self) -> &str {
138 &self.raw.owners.root
139 }
140
141 pub fn local_dir(&self) -> &str {
142 &self.raw.local.path
143 }
144
145 pub fn expand_path(&self, p: &str) -> PathBuf {
146 paths::expand(&self.root, p)
147 }
148}
149
150fn submodule_specs(root: &Path) -> Vec<(String, String)> {
152 let Ok(text) = std::fs::read_to_string(root.join(".gitmodules")) else { return vec![] };
153 let mut out = vec![];
154 for line in text.lines() {
155 let Some((k, v)) = line.split_once('=') else { continue };
156 if k.trim() != "path" {
157 continue;
158 }
159 let path = v.trim().trim_matches('"').trim_end_matches('/').to_string();
160 let name = path.rsplit('/').next().unwrap_or(&path).to_string();
161 if !name.is_empty() && root.join(&path).join("spec.toml").is_file() {
162 out.push((name, path));
163 }
164 }
165 out
166}
167
168pub fn newer_schema(path: &Path, schema: u32) -> anyhow::Error {
171 anyhow::anyhow!(
172 "{}: schema {schema} was written by a newer specfold (this version reads {}); install the latest spec",
173 path.display(),
174 defaults::schema()
175 )
176}
177
178fn same_file_key(root: &Path, p: &str) -> String {
181 if let Ok(croot) = root.canonicalize() {
185 let abs = root.join(p);
186 let comps: Vec<_> = abs.components().collect();
187 for k in (1..=comps.len()).rev() {
188 let Ok(mut out) = comps[..k].iter().collect::<std::path::PathBuf>().canonicalize() else {
189 continue;
190 };
191 for c in &comps[k..] {
192 match c {
193 std::path::Component::ParentDir => {
194 out.pop();
195 }
196 std::path::Component::Normal(n) => out.push(n),
197 _ => {}
198 }
199 }
200 return match out.strip_prefix(&croot) {
201 Ok(r) => r.to_string_lossy().replace('\\', "/"),
202 Err(_) => out.to_string_lossy().into_owned(),
203 };
204 }
205 }
206 let p = Path::new(p);
207 let rel = p.strip_prefix(root).unwrap_or(p);
208 let mut parts: Vec<&str> = vec![];
209 for c in rel.to_str().unwrap_or_default().split(['/', '\\']) {
210 match c {
211 "" | "." => {}
212 ".." if parts.last().is_some_and(|l| *l != "..") => {
213 parts.pop();
214 }
215 other => parts.push(other),
216 }
217 }
218 parts.join("/")
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224
225 #[test]
226 fn parses_full_config() {
227 let raw: RawConfig = toml::from_str(
228 r#"
229[spec]
230schema = 1
231[owners]
232root = "ws"
233retired = ["services/old"]
234[[owners.kinds]]
235pattern = "services/*"
236id = "{1}"
237[public]
238paths = ["libs/open"]
239[public.companion]
240"libs/open" = "services/open"
241[external.notes]
242path = "~/notes"
243[files.store]
244kind = "dir"
245path = "/tmp/store"
246[types.adr]
247statuses = ["draft", "active"]
248[types.memo]
249key = "slug"
250[[guards]]
251name = "public"
252paths = ["libs/open/**"]
253deny_terms = ["internal-name"]
254"#,
255 )
256 .unwrap();
257 let cfg = Config::from_raw(PathBuf::from("/r"), raw, false);
258 assert_eq!(cfg.root_owner(), "ws");
259 assert!(cfg.registry.get("memo").is_some());
260 assert_eq!(cfg.registry.get("adr").unwrap().statuses.len(), 2);
261 assert_eq!(cfg.raw.guards[0].range, "staged");
262 }
263
264 #[test]
265 fn unknown_keys_are_refused() {
266 assert!(toml::from_str::<RawConfig>("[spec]\nschema = 1\ntypo = 2\n").is_err());
267 }
268}