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 bail!(
51 "{}: schema {} was written by a newer specfold (this version reads {}); install the latest spec",
52 path.display(),
53 raw.spec.schema,
54 defaults::schema()
55 );
56 }
57 if raw.spec.schema != defaults::schema() {
58 bail!(
59 "{}: schema {} is older than this version reads ({}); preview the upgrade: spec init --upgrade",
60 path.display(),
61 raw.spec.schema,
62 defaults::schema()
63 );
64 }
65 let root = path.parent().unwrap_or(Path::new(".")).to_path_buf();
66 Ok(Self::from_raw(root, raw, false))
67 }
68
69 pub fn from_raw(root: PathBuf, raw: RawConfig, implicit: bool) -> Self {
70 let registry = Registry::new(&raw.types);
71 Config { root, raw, registry, implicit }
72 }
73
74 pub fn spec_dir(&self) -> &str {
75 &self.raw.spec.dir
76 }
77
78 pub fn manifests(&self) -> Vec<String> {
79 match &self.raw.owners.manifests {
80 Some(m) => m.clone(),
81 None => defaults::MANIFESTS.iter().map(|s| s.to_string()).collect(),
82 }
83 }
84
85 pub fn root_owner(&self) -> &str {
86 &self.raw.owners.root
87 }
88
89 pub fn local_dir(&self) -> &str {
90 &self.raw.local.path
91 }
92
93 pub fn expand_path(&self, p: &str) -> PathBuf {
94 paths::expand(&self.root, p)
95 }
96}
97
98#[cfg(test)]
99mod tests {
100 use super::*;
101
102 #[test]
103 fn parses_full_config() {
104 let raw: RawConfig = toml::from_str(
105 r#"
106[spec]
107schema = 1
108[owners]
109root = "ws"
110retired = ["services/old"]
111[[owners.kinds]]
112pattern = "services/*"
113id = "{1}"
114[public]
115paths = ["libs/open"]
116[public.companion]
117"libs/open" = "services/open"
118[external.notes]
119path = "~/notes"
120[files.store]
121kind = "dir"
122path = "/tmp/store"
123[types.adr]
124statuses = ["draft", "active"]
125[types.memo]
126key = "slug"
127[[guards]]
128name = "public"
129paths = ["libs/open/**"]
130deny_terms = ["internal-name"]
131"#,
132 )
133 .unwrap();
134 let cfg = Config::from_raw(PathBuf::from("/r"), raw, false);
135 assert_eq!(cfg.root_owner(), "ws");
136 assert!(cfg.registry.get("memo").is_some());
137 assert_eq!(cfg.registry.get("adr").unwrap().statuses.len(), 2);
138 assert_eq!(cfg.raw.guards[0].range, "staged");
139 }
140
141 #[test]
142 fn unknown_keys_are_refused() {
143 assert!(toml::from_str::<RawConfig>("[spec]\nschema = 1\ntypo = 2\n").is_err());
144 }
145}