1use 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#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
17pub struct ProjectMeta {
18 pub schema_version: u32,
20 pub profile: String,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct CapabilityConfig {
27 pub relevance: Option<CapabilityRelevance>,
29 pub provider: Option<ProviderId>,
31 pub unknown_keys: Vec<String>,
33}
34
35#[derive(Debug, Clone)]
37pub struct Config {
38 pub path: PathBuf,
40 pub ready_set: ProjectMeta,
42 pub capabilities: BTreeMap<String, CapabilityConfig>,
44 pub plugins: BTreeMap<String, toml::Value>,
51 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
76pub 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
92pub 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}