1use std::collections::HashMap;
16use std::sync::OnceLock;
17
18#[derive(Debug, Clone)]
20pub struct LegacyKey {
21 pub key: String,
23 pub line: usize,
27 pub suggestion: String,
29}
30
31#[derive(Debug, thiserror::Error)]
36#[error(
37 "legacy alef.toml schema detected: {} key(s) must be moved. Run `alef migrate` to update automatically.\n{}",
38 keys.len(),
39 format_keys(keys)
40)]
41pub struct LegacyConfigError {
42 keys: Vec<LegacyKey>,
43}
44
45impl LegacyConfigError {
46 pub fn keys(&self) -> &[LegacyKey] {
48 &self.keys
49 }
50}
51
52fn format_keys(keys: &[LegacyKey]) -> String {
53 keys.iter()
54 .map(|k| format!(" line {}: `{}` — {}", k.line, k.key, k.suggestion))
55 .collect::<Vec<_>>()
56 .join("\n")
57}
58
59pub fn detect_legacy_keys(raw_toml: &str) -> Result<(), LegacyConfigError> {
69 let suggestions = banned_key_suggestions();
70
71 let table: toml::Table = match toml::from_str(raw_toml) {
75 Ok(t) => t,
76 Err(_) => return Ok(()),
79 };
80
81 let mut found: Vec<(String, &str)> = table
83 .keys()
84 .filter_map(|k| suggestions.get(k.as_str()).map(|s| (k.clone(), *s)))
85 .collect();
86
87 if found.is_empty() {
88 return Ok(());
89 }
90
91 found.sort_by(|a, b| a.0.cmp(&b.0));
93
94 let line_map = first_occurrence_lines(raw_toml, found.iter().map(|(k, _)| k.as_str()));
96
97 let keys: Vec<LegacyKey> = found
98 .into_iter()
99 .map(|(key, suggestion)| {
100 let line = line_map.get(key.as_str()).copied().unwrap_or(1);
101 LegacyKey {
102 key,
103 line,
104 suggestion: suggestion.to_string(),
105 }
106 })
107 .collect();
108
109 Err(LegacyConfigError { keys })
110}
111
112fn banned_key_suggestions() -> &'static HashMap<&'static str, &'static str> {
117 static MAP: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
118 MAP.get_or_init(build_banned_key_suggestions)
119}
120
121fn build_banned_key_suggestions() -> HashMap<&'static str, &'static str> {
124 let mut m = HashMap::new();
125
126 m.insert("crate", "move under `[[crates]]` (array of tables)");
128
129 m.insert("version", "rename to `[workspace] alef_version`");
131
132 for lang in [
134 "python", "node", "ruby", "php", "elixir", "wasm", "ffi", "gleam", "go", "java", "dart", "kotlin", "swift",
135 "csharp", "r", "zig",
136 ] {
137 m.insert(lang, "move under `[[crates]]` for the relevant crate");
138 }
139
140 for key in [
142 "output",
143 "exclude",
144 "include",
145 "lint",
146 "test",
147 "setup",
148 "update",
149 "clean",
150 "build_commands",
151 "publish",
152 "e2e",
153 "scaffold",
154 "readme",
155 "custom_files",
156 "custom_modules",
157 "custom_registrations",
158 "adapters",
159 "trait_bridges",
160 ] {
161 m.insert(key, "move under `[[crates]]` for the relevant crate");
162 }
163
164 m.insert("languages", "move to `[workspace] languages`");
166
167 for key in [
169 "tools",
170 "dto",
171 "format",
172 "format_overrides",
173 "generate",
174 "generate_overrides",
175 "opaque_types",
176 "sync",
177 ] {
178 m.insert(key, "move under `[workspace.<key>]`");
179 }
180
181 for key in [
183 "path_mappings",
184 "auto_path_mappings",
185 "source_crates",
186 "extra_dependencies",
187 ] {
188 m.insert(key, "move under `[[crates]] <key>`");
189 }
190
191 m
192}
193
194fn first_occurrence_lines<'k>(raw_toml: &str, keys: impl Iterator<Item = &'k str>) -> HashMap<String, usize> {
198 let keys_vec: Vec<&str> = keys.collect();
199 let mut result: HashMap<String, usize> = HashMap::new();
200
201 for (idx, line) in raw_toml.lines().enumerate() {
202 let line_no = idx + 1;
203 let trimmed = line.trim_start();
204
205 for &key in &keys_vec {
206 if result.contains_key(key) {
207 continue;
208 }
209 if is_top_level_key_line(trimmed, key) {
211 result.insert(key.to_string(), line_no);
212 }
213 }
214
215 if result.len() == keys_vec.len() {
216 break;
217 }
218 }
219
220 result
221}
222
223fn is_top_level_key_line(line: &str, key: &str) -> bool {
226 if let Some(inner) = line.strip_prefix("[[").and_then(|s| s.strip_suffix("]]")) {
229 let first_segment = inner.split('.').next().unwrap_or("").trim();
230 if first_segment == key {
231 return true;
232 }
233 }
234 if let Some(inner) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
235 if !inner.starts_with('[') {
238 let first_segment = inner.split('.').next().unwrap_or("").trim();
239 if first_segment == key {
240 return true;
241 }
242 }
243 }
244 if let Some(rest) = line.strip_prefix(key) {
247 let next = rest.chars().next();
248 let is_word_boundary = match next {
249 Some(c) => !(c.is_alphanumeric() || c == '_' || c == '-'),
250 None => true,
251 };
252 if is_word_boundary {
253 let trimmed = rest.trim_start();
254 if trimmed.starts_with('=') {
255 return true;
256 }
257 }
258 }
259 false
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265
266 #[test]
267 fn detect_legacy_keys_returns_ok_for_new_schema() {
268 let toml_str = r#"
269[workspace]
270alef_version = "0.13.0"
271languages = ["python", "node"]
272
273[[crates]]
274name = "spikard"
275sources = ["src/lib.rs"]
276
277[crates.lint.python]
278check = "ruff check ."
279"#;
280 assert!(detect_legacy_keys(toml_str).is_ok());
281 }
282
283 #[test]
284 fn detect_legacy_keys_catches_bare_crate_table() {
285 let toml_str = r#"
289languages = ["python"]
290
291[crate]
292name = "spikard"
293sources = ["src/lib.rs"]
294"#;
295 let err = detect_legacy_keys(toml_str).unwrap_err();
296 let keys: Vec<&str> = err.keys().iter().map(|k| k.key.as_str()).collect();
297 assert!(keys.contains(&"crate"), "expected `crate` in banned keys: {keys:?}");
298 assert!(
299 keys.contains(&"languages"),
300 "expected `languages` in banned keys: {keys:?}"
301 );
302 }
303
304 #[test]
305 fn detect_legacy_keys_catches_bare_version() {
306 let toml_str = r#"
307version = "0.7.7"
308languages = ["go"]
309
310[crate]
311name = "foo"
312sources = []
313"#;
314 let err = detect_legacy_keys(toml_str).unwrap_err();
315 let keys: Vec<&str> = err.keys().iter().map(|k| k.key.as_str()).collect();
316 assert!(keys.contains(&"version"), "`version` should be banned: {keys:?}");
317 }
318
319 #[test]
320 fn detect_legacy_keys_catches_bare_languages() {
321 let toml_str = r#"
322languages = ["python", "go"]
323
324[crate]
325name = "spikard"
326sources = []
327"#;
328 let err = detect_legacy_keys(toml_str).unwrap_err();
329 let keys: Vec<&str> = err.keys().iter().map(|k| k.key.as_str()).collect();
330 assert!(keys.contains(&"languages"), "`languages` should be banned: {keys:?}");
331 }
332
333 #[test]
334 fn detect_legacy_keys_catches_language_sections() {
335 for lang in [
336 "python", "node", "ruby", "go", "java", "csharp", "wasm", "ffi", "elixir", "gleam", "zig",
337 ] {
338 let toml_str = format!(
340 "languages = [\"{lang}\"]\n\n[crate]\nname = \"foo\"\nsources = []\n\n[{lang}]\nmodule_name = \"foo\"\n"
341 );
342 let err = detect_legacy_keys(&toml_str).unwrap_err();
343 let keys: Vec<&str> = err.keys().iter().map(|k| k.key.as_str()).collect();
344 assert!(keys.contains(&lang), "`{lang}` should be detected as legacy: {keys:?}");
345 }
346 }
347
348 #[test]
349 fn detect_legacy_keys_catches_workspace_level_pipeline_keys() {
350 let toml_str = r#"
352languages = ["python"]
353
354[crate]
355name = "foo"
356sources = []
357
358[tools]
359python_package_manager = "uv"
360
361[dto]
362python = "dataclass"
363
364[format]
365enabled = true
366
367[generate]
368bindings = true
369
370[opaque_types]
371Tree = "tree_sitter::Tree"
372"#;
373 let err = detect_legacy_keys(toml_str).unwrap_err();
374 let keys: Vec<&str> = err.keys().iter().map(|k| k.key.as_str()).collect();
375 for expected in ["tools", "dto", "format", "generate", "opaque_types"] {
376 assert!(
377 keys.contains(&expected),
378 "`{expected}` should be detected as legacy: {keys:?}"
379 );
380 }
381 }
382
383 #[test]
384 fn detect_legacy_keys_catches_per_crate_source_keys() {
385 let toml_str = r#"
388languages = ["python"]
389auto_path_mappings = true
390
391[crate]
392name = "foo"
393sources = []
394
395[path_mappings]
396foo = "foo_core"
397
398[extra_dependencies]
399pyo3 = "0.22"
400"#;
401 let err = detect_legacy_keys(toml_str).unwrap_err();
402 let keys: Vec<&str> = err.keys().iter().map(|k| k.key.as_str()).collect();
403 for expected in ["path_mappings", "auto_path_mappings", "extra_dependencies"] {
404 assert!(
405 keys.contains(&expected),
406 "`{expected}` should be detected as legacy: {keys:?}"
407 );
408 }
409 }
410
411 #[test]
412 fn detect_legacy_keys_catches_pipeline_table_keys() {
413 let toml_str = r#"
414languages = ["python"]
415
416[crate]
417name = "foo"
418sources = []
419
420[lint.python]
421check = "ruff check ."
422
423[test.python]
424command = "pytest"
425
426[build_commands.go]
427build = "go build ./..."
428
429[publish]
430vendored = true
431
432[e2e]
433fixtures_dir = "e2e/fixtures"
434
435[scaffold]
436description = "My lib"
437
438[readme]
439template_dir = "docs/templates"
440"#;
441 let err = detect_legacy_keys(toml_str).unwrap_err();
442 let keys: Vec<&str> = err.keys().iter().map(|k| k.key.as_str()).collect();
443 for expected in ["lint", "test", "build_commands", "publish", "e2e", "scaffold", "readme"] {
444 assert!(
445 keys.contains(&expected),
446 "`{expected}` should be detected as legacy: {keys:?}"
447 );
448 }
449 }
450
451 #[test]
452 fn detect_legacy_keys_line_numbers_are_positive() {
453 let toml_str = r#"
454languages = ["python"]
455
456[crate]
457name = "foo"
458sources = []
459"#;
460 let err = detect_legacy_keys(toml_str).unwrap_err();
461 for k in err.keys() {
462 assert!(k.line > 0, "line number must be positive for key `{}`", k.key);
463 }
464 }
465
466 #[test]
467 fn detect_legacy_keys_suggestions_are_non_empty() {
468 let toml_str = r#"
469languages = ["python"]
470
471[crate]
472name = "foo"
473sources = []
474
475[lint.python]
476check = "ruff check ."
477"#;
478 let err = detect_legacy_keys(toml_str).unwrap_err();
479 for k in err.keys() {
480 assert!(
481 !k.suggestion.is_empty(),
482 "suggestion must be non-empty for key `{}`",
483 k.key
484 );
485 }
486 }
487
488 #[test]
489 fn detect_legacy_keys_invalid_toml_returns_ok() {
490 let bad = "[[[ not valid toml";
493 assert!(detect_legacy_keys(bad).is_ok());
494 }
495
496 #[test]
497 fn is_top_level_key_line_respects_word_boundary_on_bare_assignment() {
498 assert!(!is_top_level_key_line("rust = true", "r"));
501 assert!(!is_top_level_key_line("ruby_extras = []", "ruby"));
502 assert!(is_top_level_key_line("r = { something = true }", "r"));
504 assert!(is_top_level_key_line("ruby = {}", "ruby"));
505 assert!(is_top_level_key_line("ruby={}", "ruby"));
506 }
507}