alef_core/config/validation/
mod.rs1mod preconditions;
19
20use super::resolved::ResolvedCrateConfig;
21use crate::error::AlefError;
22use preconditions::{
23 build_main_fields, clean_main_fields, lint_main_fields, setup_main_fields, test_main_fields, update_main_fields,
24 validate_section, validate_tools,
25};
26
27pub fn validate_resolved(config: &ResolvedCrateConfig) -> Result<(), AlefError> {
33 validate_tools(&config.tools)?;
34 validate_section("lint", &config.lint, lint_main_fields, |c| c.precondition.as_deref())?;
35 validate_section("test", &config.test, test_main_fields, |c| c.precondition.as_deref())?;
36 validate_section("build_commands", &config.build_commands, build_main_fields, |c| {
37 c.precondition.as_deref()
38 })?;
39 validate_section("setup", &config.setup, setup_main_fields, |c| c.precondition.as_deref())?;
40 validate_section("update", &config.update, update_main_fields, |c| {
41 c.precondition.as_deref()
42 })?;
43 validate_section("clean", &config.clean, clean_main_fields, |c| c.precondition.as_deref())?;
44 Ok(())
45}
46
47#[cfg(test)]
48mod tests {
49 use super::*;
50 use crate::config::new_config::NewAlefConfig;
51 use tracing_test::traced_test;
52
53 fn resolve_first(toml_str: &str) -> ResolvedCrateConfig {
55 let cfg: NewAlefConfig = toml::from_str(toml_str).expect("config should parse");
56 cfg.resolve().expect("config should resolve").remove(0)
57 }
58
59 fn base_config() -> &'static str {
60 r#"
61[workspace]
62languages = ["python"]
63
64[[crates]]
65name = "test-lib"
66sources = ["src/lib.rs"]
67"#
68 }
69
70 #[test]
71 fn no_user_overrides_is_valid() {
72 let config = resolve_first(base_config());
73 validate_resolved(&config).expect("default config should validate");
74 }
75
76 #[test]
77 fn lint_override_with_main_cmd_no_precondition_errors() {
78 let toml = format!(
79 "{base}\n[crates.lint.python]\nformat = \"black .\"\n",
80 base = base_config()
81 );
82 let config = resolve_first(&toml);
83 let err = validate_resolved(&config).expect_err("missing precondition should error");
84 let msg = format!("{err}");
85 assert!(msg.contains("[lint.python]"), "error should name the section: {msg}");
86 assert!(msg.contains("precondition"), "error should mention precondition: {msg}");
87 }
88
89 #[test]
90 fn lint_override_with_main_cmd_and_precondition_is_ok() {
91 let toml = format!(
92 "{base}\n[crates.lint.python]\nprecondition = \"command -v black\"\nformat = \"black .\"\n",
93 base = base_config()
94 );
95 let config = resolve_first(&toml);
96 validate_resolved(&config).expect("config with precondition should validate");
97 }
98
99 #[test]
100 fn lint_override_with_only_before_no_precondition_is_ok() {
101 let toml = format!(
102 "{base}\n[crates.lint.python]\nbefore = \"echo hi\"\n",
103 base = base_config()
104 );
105 let config = resolve_first(&toml);
106 validate_resolved(&config).expect("table with only `before` should validate");
107 }
108
109 #[test]
110 fn test_override_with_main_cmd_no_precondition_errors() {
111 let toml = format!(
112 "{base}\n[crates.test.python]\ncommand = \"pytest\"\n",
113 base = base_config()
114 );
115 let config = resolve_first(&toml);
116 let err = validate_resolved(&config).expect_err("missing precondition should error");
117 assert!(format!("{err}").contains("[test.python]"));
118 }
119
120 #[test]
121 fn test_override_with_only_e2e_requires_precondition() {
122 let toml = format!(
123 "{base}\n[crates.test.python]\ne2e = \"pytest tests/e2e\"\n",
124 base = base_config()
125 );
126 let config = resolve_first(&toml);
127 validate_resolved(&config).expect_err("e2e without precondition should error");
128 }
129
130 #[test]
131 fn build_override_with_main_cmd_no_precondition_errors() {
132 let toml = format!(
133 "{base}\n[crates.build_commands.python]\nbuild = \"maturin develop\"\n",
134 base = base_config()
135 );
136 let config = resolve_first(&toml);
137 let err = validate_resolved(&config).expect_err("missing precondition should error");
138 assert!(format!("{err}").contains("[build_commands.python]"));
139 }
140
141 #[test]
142 fn setup_override_with_install_no_precondition_errors() {
143 let toml = format!(
144 "{base}\n[crates.setup.python]\ninstall = \"uv sync\"\n",
145 base = base_config()
146 );
147 let config = resolve_first(&toml);
148 validate_resolved(&config).expect_err("setup install without precondition should error");
149 }
150
151 #[test]
152 fn update_override_with_main_cmd_no_precondition_errors() {
153 let toml = format!(
154 "{base}\n[crates.update.python]\nupdate = \"uv sync --upgrade\"\n",
155 base = base_config()
156 );
157 let config = resolve_first(&toml);
158 validate_resolved(&config).expect_err("update without precondition should error");
159 }
160
161 #[test]
162 fn clean_override_with_main_cmd_no_precondition_errors() {
163 let toml = format!(
164 "{base}\n[crates.clean.python]\nclean = \"rm -rf dist\"\n",
165 base = base_config()
166 );
167 let config = resolve_first(&toml);
168 validate_resolved(&config).expect_err("clean without precondition should error");
169 }
170
171 #[test]
172 fn error_message_lists_only_actually_set_main_fields() {
173 let toml = format!(
174 "{base}\n[crates.lint.python]\nformat = \"black .\"\n",
175 base = base_config()
176 );
177 let config = resolve_first(&toml);
178 let msg = format!("{}", validate_resolved(&config).unwrap_err());
179 assert!(msg.contains("`format`"), "expected `format`, got: {msg}");
180 assert!(!msg.contains("`check`"), "should not mention unset `check`: {msg}");
181 assert!(
182 !msg.contains("`typecheck`"),
183 "should not mention unset `typecheck`: {msg}"
184 );
185 }
186
187 #[test]
188 fn before_plus_main_cmd_without_precondition_still_errors() {
189 let toml = format!(
190 "{base}\n[crates.lint.python]\nbefore = \"echo hi\"\nformat = \"black .\"\n",
191 base = base_config()
192 );
193 let config = resolve_first(&toml);
194 validate_resolved(&config).expect_err("before + main without precondition must error");
195 }
196
197 #[test]
198 fn malformed_python_package_manager_value_is_rejected() {
199 let toml = format!(
200 "{base}\n[workspace.tools]\npython_package_manager = \"uv; rm -rf /\"\n",
201 base = base_config()
202 );
203 let config = resolve_first(&toml);
204 let err = validate_resolved(&config).expect_err("non-identifier tool name must be rejected");
205 assert!(format!("{err}").contains("well-formed"));
206 }
207
208 #[test]
209 fn malformed_node_package_manager_value_is_rejected() {
210 let toml = format!(
211 "{base}\n[workspace.tools]\nnode_package_manager = \"pnpm$(echo bad)\"\n",
212 base = base_config()
213 );
214 let config = resolve_first(&toml);
215 validate_resolved(&config).expect_err("non-identifier tool name must be rejected");
216 }
217
218 #[test]
219 fn malformed_rust_dev_tool_entry_is_rejected() {
220 let toml = format!(
221 "{base}\n[workspace.tools]\nrust_dev_tools = [\"cargo-edit\", \"cargo`evil`\"]\n",
222 base = base_config()
223 );
224 let config = resolve_first(&toml);
225 validate_resolved(&config).expect_err("non-identifier tool name must be rejected");
226 }
227
228 #[test]
229 fn whitespace_in_tool_name_is_rejected() {
230 let toml = format!(
231 "{base}\n[workspace.tools]\npython_package_manager = \"uv \"\n",
232 base = base_config()
233 );
234 let config = resolve_first(&toml);
235 validate_resolved(&config).expect_err("trailing whitespace must be rejected");
236 }
237
238 #[test]
239 fn empty_tool_name_is_rejected() {
240 let toml = format!(
241 "{base}\n[workspace.tools]\npython_package_manager = \"\"\n",
242 base = base_config()
243 );
244 let config = resolve_first(&toml);
245 validate_resolved(&config).expect_err("empty tool name must be rejected");
246 }
247
248 #[test]
249 fn safe_tool_names_are_accepted() {
250 let toml = format!(
251 "{base}\n[workspace.tools]\npython_package_manager = \"uv\"\n\
252 node_package_manager = \"pnpm\"\n\
253 rust_dev_tools = [\"cargo-edit\", \"cargo_sort\", \"tool.v2\"]\n",
254 base = base_config()
255 );
256 let config = resolve_first(&toml);
257 validate_resolved(&config).expect("normal tool names should validate");
258 }
259
260 #[test]
261 fn override_with_main_cmd_and_precondition_validates_for_each_section() {
262 for (section, field, lang) in [
263 ("lint", "format", "python"),
264 ("test", "command", "python"),
265 ("build_commands", "build", "python"),
266 ("setup", "install", "python"),
267 ("update", "update", "python"),
268 ("clean", "clean", "python"),
269 ] {
270 let toml = format!(
271 "{base}\n[crates.{section}.{lang}]\nprecondition = \"command -v tool\"\n{field} = \"tool run\"\n",
272 base = base_config()
273 );
274 let config = resolve_first(&toml);
275 validate_resolved(&config).unwrap_or_else(|e| panic!("[{section}] with precondition should validate: {e}"));
276 }
277 }
278
279 #[traced_test]
284 #[test]
285 fn lint_verbatim_default_emits_warning() {
286 use crate::config::extras::Language;
287 use crate::config::lint_defaults;
288 use crate::config::tools::LangContext;
289 let config = resolve_first(base_config());
290 let ctx = LangContext::default(&config.tools);
291 let default = lint_defaults::default_lint_config(Language::Python, "packages/python", &ctx);
292 let Some(fmt_cmd) = default.format.as_ref().map(|c| c.commands().join(" ")) else {
293 return;
294 };
295 let toml = format!(
297 "{base}\n[crates.lint.python]\nformat = {fmt_cmd:?}\n",
298 base = base_config()
299 );
300 let _resolved = resolve_first(&toml);
306 }
307
308 #[traced_test]
309 #[test]
310 fn lint_all_custom_emits_no_warning() {
311 let toml = format!(
313 "{base}\n[crates.lint.python]\nprecondition = \"command -v custom\"\nformat = \"custom-fmt\"\n",
314 base = base_config()
315 );
316 let config = resolve_first(&toml);
317 validate_resolved(&config).expect("custom lint with precondition must validate");
318 assert!(!logs_contain("matches the built-in default"));
319 }
320
321 #[traced_test]
322 #[test]
323 fn node_custom_value_no_warning() {
324 let toml_str = r#"
325[workspace]
326languages = ["node"]
327
328[[crates]]
329name = "test-lib"
330sources = ["src/lib.rs"]
331
332[crates.lint.node]
333precondition = "command -v custom-linter"
334check = "custom-linter src/"
335"#;
336 let config = resolve_first(toml_str);
337 validate_resolved(&config).expect("custom node lint must validate");
338 assert!(!logs_contain("matches the built-in default"));
339 }
340}