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 = toml::from_str(&text).with_context(|| format!("invalid {}", path.display()))?;
41        if raw.spec.schema != defaults::schema() {
42            bail!("{}: schema {} is not supported by this version (expected 1)", path.display(), raw.spec.schema);
43        }
44        let root = path.parent().unwrap_or(Path::new(".")).to_path_buf();
45        Ok(Self::from_raw(root, raw, false))
46    }
47
48    pub fn from_raw(root: PathBuf, raw: RawConfig, implicit: bool) -> Self {
49        let registry = Registry::new(&raw.types);
50        Config { root, raw, registry, implicit }
51    }
52
53    pub fn spec_dir(&self) -> &str {
54        &self.raw.spec.dir
55    }
56
57    pub fn manifests(&self) -> Vec<String> {
58        match &self.raw.owners.manifests {
59            Some(m) => m.clone(),
60            None => defaults::MANIFESTS.iter().map(|s| s.to_string()).collect(),
61        }
62    }
63
64    pub fn root_owner(&self) -> &str {
65        &self.raw.owners.root
66    }
67
68    pub fn local_dir(&self) -> &str {
69        &self.raw.local.path
70    }
71
72    pub fn expand_path(&self, p: &str) -> PathBuf {
73        paths::expand(&self.root, p)
74    }
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80
81    #[test]
82    fn parses_full_config() {
83        let raw: RawConfig = toml::from_str(
84            r#"
85[spec]
86schema = 1
87[owners]
88root = "ws"
89retired = ["apps/old"]
90[[owners.kinds]]
91pattern = "apps/*"
92id = "{1}"
93[public]
94paths = ["libs/open"]
95[public.companion]
96"libs/open" = "apps/open"
97[external.notes]
98path = "~/notes"
99[files.store]
100kind = "dir"
101path = "/tmp/store"
102[types.adr]
103statuses = ["draft", "active"]
104[types.memo]
105key = "slug"
106[[guards]]
107name = "public"
108paths = ["libs/open/**"]
109deny_terms = ["internal-name"]
110"#,
111        )
112        .unwrap();
113        let cfg = Config::from_raw(PathBuf::from("/r"), raw, false);
114        assert_eq!(cfg.root_owner(), "ws");
115        assert!(cfg.registry.get("memo").is_some());
116        assert_eq!(cfg.registry.get("adr").unwrap().statuses.len(), 2);
117        assert_eq!(cfg.raw.guards[0].range, "staged");
118    }
119
120    #[test]
121    fn unknown_keys_are_refused() {
122        assert!(toml::from_str::<RawConfig>("[spec]\nschema = 1\ntypo = 2\n").is_err());
123    }
124}