Skip to main content

ready_set_sdk/
config.rs

1//! `.ready-set.toml` loading.
2//!
3//! See
4//! [`docs/contracts/ready-set-toml.md`](https://github.com/pulsearc-ai/ready-set/blob/main/docs/contracts/ready-set-toml.md)
5//! for the source of truth.
6
7use std::collections::BTreeMap;
8use std::path::{Path, PathBuf};
9
10use serde::Deserialize;
11
12use crate::capability::{CapabilityRelevance, ProviderId};
13use crate::error::{Error, Result};
14
15/// Project metadata under the `[ready-set]` table.
16#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
17pub struct ProjectMeta {
18    /// Schema version for this file. Always `2` for the capability lifecycle.
19    pub schema_version: u32,
20    /// Product profile used to interpret capability relevance.
21    pub profile: String,
22}
23
24/// Per-capability configuration under `[capabilities.<id>]`.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct CapabilityConfig {
27    /// Effective relevance override for this product.
28    pub relevance: Option<CapabilityRelevance>,
29    /// Provider override for this capability.
30    pub provider: Option<ProviderId>,
31    /// Keys in this capability table not understood by this SDK version.
32    pub unknown_keys: Vec<String>,
33}
34
35/// Loaded `.ready-set.toml`.
36#[derive(Debug, Clone)]
37pub struct Config {
38    /// Path the config was loaded from.
39    pub path: PathBuf,
40    /// `[ready-set]` table.
41    pub ready_set: ProjectMeta,
42    /// Per-capability configuration, keyed by capability id.
43    pub capabilities: BTreeMap<String, CapabilityConfig>,
44    /// Per-plugin sections, keyed by plugin name.
45    ///
46    /// The `toml::Value` exposure here leaks `toml = 0.8.x` semver into
47    /// the SDK's public API. Before `0.1.0` stable this will be wrapped
48    /// in an opaque `PluginSection` type so the underlying value
49    /// representation becomes a private implementation detail.
50    pub plugins: BTreeMap<String, toml::Value>,
51    /// Sections under `[ready-set]` not understood by this SDK version.
52    /// Surfaces forward-compat warnings without crashing on extras.
53    pub unknown_keys: Vec<String>,
54}
55
56#[derive(Debug, Deserialize)]
57struct RawConfig {
58    #[serde(rename = "ready-set")]
59    ready_set: toml::Value,
60    #[serde(default)]
61    capabilities: BTreeMap<String, RawCapabilityConfig>,
62    #[serde(flatten)]
63    rest: BTreeMap<String, toml::Value>,
64}
65
66#[derive(Debug, Deserialize)]
67struct RawCapabilityConfig {
68    #[serde(default)]
69    relevance: Option<CapabilityRelevance>,
70    #[serde(default)]
71    provider: Option<ProviderId>,
72    #[serde(flatten)]
73    rest: BTreeMap<String, toml::Value>,
74}
75
76/// Walk upward from `start` looking for `.ready-set.toml`. Returns
77/// `Ok(None)` if no file is found before the filesystem root.
78///
79/// # Errors
80///
81/// Returns [`Error::Io`] if a directory cannot be read or the config file
82/// cannot be opened, [`Error::TomlParse`] if the file is not valid TOML, or
83/// [`Error::ContractViolation`] if the file is structurally valid TOML but
84/// missing required keys.
85pub fn load_config(start: &Path) -> Result<Option<Config>> {
86    let Some(found) = find_upwards(start) else {
87        return Ok(None);
88    };
89    Ok(Some(parse_at(&found)?))
90}
91
92/// Parse the config at `path` directly, without walking.
93///
94/// # Errors
95///
96/// Same conditions as [`load_config`].
97pub fn parse_at(path: &Path) -> Result<Config> {
98    let raw = std::fs::read_to_string(path)?;
99    let parsed: RawConfig = toml::from_str(&raw)?;
100
101    if let toml::Value::Table(t) = &parsed.ready_set
102        && let Some(version) = t.get("schema_version").and_then(toml::Value::as_integer)
103        && version != 2
104    {
105        return Err(Error::contract(format!(
106            ".ready-set.toml schema_version {version} is unsupported; expected 2"
107        )));
108    }
109
110    let ready_set: ProjectMeta =
111        parsed
112            .ready_set
113            .clone()
114            .try_into()
115            .map_err(|e: toml::de::Error| {
116                Error::contract(format!("[ready-set] missing required keys: {e}"))
117            })?;
118    if ready_set.schema_version != 2 {
119        return Err(Error::contract(format!(
120            ".ready-set.toml schema_version {} is unsupported; expected 2",
121            ready_set.schema_version
122        )));
123    }
124
125    let mut unknown_keys = Vec::new();
126    if let toml::Value::Table(t) = &parsed.ready_set {
127        for k in t.keys() {
128            if !matches!(k.as_str(), "schema_version" | "profile") {
129                unknown_keys.push(k.clone());
130            }
131        }
132    }
133
134    let capabilities = parsed
135        .capabilities
136        .into_iter()
137        .map(|(id, raw)| {
138            let cfg = CapabilityConfig {
139                relevance: raw.relevance,
140                provider: raw.provider,
141                unknown_keys: raw.rest.into_keys().collect(),
142            };
143            (id, cfg)
144        })
145        .collect();
146
147    Ok(Config {
148        path: path.to_path_buf(),
149        ready_set,
150        capabilities,
151        plugins: parsed.rest,
152        unknown_keys,
153    })
154}
155
156fn find_upwards(start: &Path) -> Option<PathBuf> {
157    let mut cur: Option<&Path> = Some(start);
158    while let Some(dir) = cur {
159        let candidate = dir.join(".ready-set.toml");
160        if candidate.is_file() {
161            return Some(candidate);
162        }
163        cur = dir.parent();
164    }
165    None
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    #[test]
173    fn parses_v2_minimal_config() {
174        let dir = tempfile::tempdir().unwrap();
175        let path = dir.path().join(".ready-set.toml");
176        std::fs::write(
177            &path,
178            "[ready-set]\nschema_version = 2\nprofile = \"rust-workspace\"\n",
179        )
180        .unwrap();
181
182        let cfg = parse_at(&path).unwrap();
183        assert_eq!(cfg.ready_set.schema_version, 2);
184        assert_eq!(cfg.ready_set.profile, "rust-workspace");
185        assert!(cfg.capabilities.is_empty());
186        assert!(cfg.plugins.is_empty());
187        assert!(cfg.unknown_keys.is_empty());
188    }
189
190    #[test]
191    fn rejects_v1_config() {
192        let dir = tempfile::tempdir().unwrap();
193        let path = dir.path().join(".ready-set.toml");
194        std::fs::write(&path, "[ready-set]\nschema_version = 1\n").unwrap();
195
196        let err = parse_at(&path).unwrap_err();
197        assert!(err.to_string().contains("schema_version 1 is unsupported"));
198    }
199
200    #[test]
201    fn rejects_missing_profile() {
202        let dir = tempfile::tempdir().unwrap();
203        let path = dir.path().join(".ready-set.toml");
204        std::fs::write(&path, "[ready-set]\nschema_version = 2\n").unwrap();
205
206        let err = parse_at(&path).unwrap_err();
207        assert!(err.to_string().contains("missing required keys"));
208    }
209
210    #[test]
211    fn parses_v2_capability_sections() {
212        let dir = tempfile::tempdir().unwrap();
213        let path = dir.path().join(".ready-set.toml");
214        std::fs::write(
215            &path,
216            r#"
217[ready-set]
218schema_version = 2
219profile = "rust-workspace"
220
221[capabilities.workspace]
222relevance = "required"
223provider = "rust"
224
225[capabilities.toolchain]
226relevance = "required"
227provider = "rust"
228
229[capabilities.formatting]
230relevance = "required"
231provider = "rust"
232
233[capabilities.linting]
234relevance = "optional"
235provider = "rust"
236"#,
237        )
238        .unwrap();
239
240        let cfg = parse_at(&path).unwrap();
241        assert_eq!(cfg.capabilities.len(), 4);
242        assert_eq!(
243            cfg.capabilities["linting"].relevance,
244            Some(CapabilityRelevance::Optional)
245        );
246        assert_eq!(
247            cfg.capabilities["workspace"]
248                .provider
249                .as_ref()
250                .map(ProviderId::as_str),
251            Some("rust")
252        );
253    }
254
255    #[test]
256    fn captures_per_plugin_sections() {
257        let dir = tempfile::tempdir().unwrap();
258        let path = dir.path().join(".ready-set.toml");
259        std::fs::write(
260            &path,
261            "[ready-set]\nschema_version = 2\nprofile = \"rust-workspace\"\n[scan]\nexclude = [\"vendor/**\"]\n",
262        )
263        .unwrap();
264
265        let cfg = parse_at(&path).unwrap();
266        assert!(cfg.plugins.contains_key("scan"));
267        assert!(!cfg.plugins.contains_key("capabilities"));
268    }
269
270    #[test]
271    fn collects_unknown_ready_set_keys_as_warnings_not_errors() {
272        let dir = tempfile::tempdir().unwrap();
273        let path = dir.path().join(".ready-set.toml");
274        std::fs::write(
275            &path,
276            "[ready-set]\nschema_version = 2\nprofile = \"rust-workspace\"\nfuture_field = \"hi\"\n",
277        )
278        .unwrap();
279
280        let cfg = parse_at(&path).unwrap();
281        assert!(cfg.unknown_keys.contains(&"future_field".to_string()));
282    }
283
284    #[test]
285    fn collects_unknown_capability_keys_as_warnings_not_errors() {
286        let dir = tempfile::tempdir().unwrap();
287        let path = dir.path().join(".ready-set.toml");
288        std::fs::write(
289            &path,
290            r#"
291[ready-set]
292schema_version = 2
293profile = "rust-workspace"
294
295[capabilities.custom]
296relevance = "not-needed"
297provider = "custom"
298future_field = "hi"
299"#,
300        )
301        .unwrap();
302
303        let cfg = parse_at(&path).unwrap();
304        assert_eq!(
305            cfg.capabilities["custom"].relevance,
306            Some(CapabilityRelevance::NotNeeded)
307        );
308        assert!(
309            cfg.capabilities["custom"]
310                .unknown_keys
311                .contains(&"future_field".to_string())
312        );
313    }
314
315    #[test]
316    fn walks_upward() {
317        let dir = tempfile::tempdir().unwrap();
318        let inner = dir.path().join("a/b/c");
319        std::fs::create_dir_all(&inner).unwrap();
320        std::fs::write(
321            dir.path().join(".ready-set.toml"),
322            "[ready-set]\nschema_version = 2\nprofile = \"rust-workspace\"\n",
323        )
324        .unwrap();
325        let cfg = load_config(&inner).unwrap().unwrap();
326        assert_eq!(cfg.ready_set.schema_version, 2);
327    }
328}