1use std::collections::HashMap;
19
20use super::AlefConfig;
21use super::output::{BuildCommandConfig, CleanConfig, LintConfig, SetupConfig, StringOrVec, TestConfig, UpdateConfig};
22use super::tools::LangContext;
23use super::{build_defaults, clean_defaults, lint_defaults, setup_defaults, test_defaults, update_defaults};
24use crate::error::AlefError;
25
26pub fn validate(config: &AlefConfig) -> Result<(), AlefError> {
33 validate_tools(&config.tools)?;
34 if let Some(map) = &config.lint {
35 validate_section("lint", map, lint_main_fields, |c| c.precondition.as_deref())?;
36 }
37 if let Some(map) = &config.test {
38 validate_section("test", map, test_main_fields, |c| c.precondition.as_deref())?;
39 }
40 if let Some(map) = &config.build_commands {
41 validate_section("build_commands", map, build_main_fields, |c| c.precondition.as_deref())?;
42 }
43 if let Some(map) = &config.setup {
44 validate_section("setup", map, setup_main_fields, |c| c.precondition.as_deref())?;
45 }
46 if let Some(map) = &config.update {
47 validate_section("update", map, update_main_fields, |c| c.precondition.as_deref())?;
48 }
49 if let Some(map) = &config.clean {
50 validate_section("clean", map, clean_main_fields, |c| c.precondition.as_deref())?;
51 }
52 warn_redundant_defaults(config);
53 Ok(())
54}
55
56fn validate_section<C, F, P>(
57 section: &str,
58 table: &HashMap<String, C>,
59 main_fields: F,
60 precondition: P,
61) -> Result<(), AlefError>
62where
63 F: Fn(&C) -> Vec<&'static str>,
64 P: Fn(&C) -> Option<&str>,
65{
66 for (lang, cfg) in table {
67 let main = main_fields(cfg);
68 if !main.is_empty() && precondition(cfg).is_none() {
69 let fields = main.iter().map(|f| format!("`{f}`")).collect::<Vec<_>>().join("/");
70 return Err(AlefError::Config(format!(
71 "[{section}.{lang}] sets a main command ({fields}) without `precondition`. \
72 Custom commands must declare a `precondition` so the step degrades gracefully \
73 when the tool is missing on the user's system. Use a POSIX check such as \
74 `precondition = \"command -v <tool> >/dev/null 2>&1\"`."
75 )));
76 }
77 }
78 Ok(())
79}
80
81fn lint_main_fields(c: &LintConfig) -> Vec<&'static str> {
91 let mut v = Vec::new();
92 if c.format.is_some() {
93 v.push("format");
94 }
95 if c.check.is_some() {
96 v.push("check");
97 }
98 if c.typecheck.is_some() {
99 v.push("typecheck");
100 }
101 v
102}
103
104fn test_main_fields(c: &TestConfig) -> Vec<&'static str> {
105 let mut v = Vec::new();
106 if c.command.is_some() {
107 v.push("command");
108 }
109 if c.e2e.is_some() {
110 v.push("e2e");
111 }
112 if c.coverage.is_some() {
113 v.push("coverage");
114 }
115 v
116}
117
118fn build_main_fields(c: &BuildCommandConfig) -> Vec<&'static str> {
119 let mut v = Vec::new();
120 if c.build.is_some() {
121 v.push("build");
122 }
123 if c.build_release.is_some() {
124 v.push("build_release");
125 }
126 v
127}
128
129fn setup_main_fields(c: &SetupConfig) -> Vec<&'static str> {
130 if c.install.is_some() {
131 vec!["install"]
132 } else {
133 Vec::new()
134 }
135}
136
137fn update_main_fields(c: &UpdateConfig) -> Vec<&'static str> {
138 let mut v = Vec::new();
139 if c.update.is_some() {
140 v.push("update");
141 }
142 if c.upgrade.is_some() {
143 v.push("upgrade");
144 }
145 v
146}
147
148fn clean_main_fields(c: &CleanConfig) -> Vec<&'static str> {
149 if c.clean.is_some() { vec!["clean"] } else { Vec::new() }
150}
151
152fn validate_tools(tools: &super::tools::ToolsConfig) -> Result<(), AlefError> {
170 if let Some(pm) = tools.python_package_manager.as_deref() {
171 ensure_well_formed_tool_name("tools.python_package_manager", pm)?;
172 }
173 if let Some(pm) = tools.node_package_manager.as_deref() {
174 ensure_well_formed_tool_name("tools.node_package_manager", pm)?;
175 }
176 if let Some(list) = tools.rust_dev_tools.as_deref() {
177 for tool in list {
178 ensure_well_formed_tool_name("tools.rust_dev_tools[]", tool)?;
179 }
180 }
181 Ok(())
182}
183
184fn is_well_formed_tool_char(c: char) -> bool {
185 c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')
186}
187
188fn ensure_well_formed_tool_name(field: &str, value: &str) -> Result<(), AlefError> {
189 if value.is_empty() || !value.chars().all(is_well_formed_tool_char) {
190 return Err(AlefError::Config(format!(
191 "{field} = {value:?} is not a well-formed tool name. \
192 Tool names must match `[A-Za-z0-9._-]+` (single executable, no spaces or shell metacharacters)."
193 )));
194 }
195 Ok(())
196}
197
198fn parse_language(lang_str: &str) -> Option<super::extras::Language> {
208 match lang_str {
209 "python" => Some(super::extras::Language::Python),
210 "node" => Some(super::extras::Language::Node),
211 "ruby" => Some(super::extras::Language::Ruby),
212 "php" => Some(super::extras::Language::Php),
213 "elixir" => Some(super::extras::Language::Elixir),
214 "wasm" => Some(super::extras::Language::Wasm),
215 "ffi" => Some(super::extras::Language::Ffi),
216 "go" => Some(super::extras::Language::Go),
217 "java" => Some(super::extras::Language::Java),
218 "csharp" => Some(super::extras::Language::Csharp),
219 "r" => Some(super::extras::Language::R),
220 "rust" => Some(super::extras::Language::Rust),
221 _ => None,
222 }
223}
224
225fn commands_eq(a: &Option<StringOrVec>, b: &Option<StringOrVec>) -> bool {
227 match (a, b) {
228 (None, None) => true,
229 (Some(x), Some(y)) => x.commands() == y.commands(),
230 _ => false,
231 }
232}
233
234fn warn_lint_defaults(lang_str: &str, user_cfg: &LintConfig, default_cfg: &LintConfig) {
236 let has_custom_main = user_cfg.format.is_some() || user_cfg.check.is_some() || user_cfg.typecheck.is_some();
240 if !has_custom_main {
241 if let (Some(u), Some(d)) = (&user_cfg.precondition, &default_cfg.precondition) {
242 if u == d {
243 tracing::warn!(
244 "[lint.{lang}] field `precondition` matches the built-in default — remove it from alef.toml to avoid drift",
245 lang = lang_str
246 );
247 }
248 }
249 }
250 if commands_eq(&user_cfg.before, &default_cfg.before) && user_cfg.before.is_some() {
251 tracing::warn!(
252 "[lint.{lang}] field `before` matches the built-in default — remove it from alef.toml to avoid drift",
253 lang = lang_str
254 );
255 }
256 if commands_eq(&user_cfg.format, &default_cfg.format) && user_cfg.format.is_some() {
257 tracing::warn!(
258 "[lint.{lang}] field `format` matches the built-in default — remove it from alef.toml to avoid drift",
259 lang = lang_str
260 );
261 }
262 if commands_eq(&user_cfg.check, &default_cfg.check) && user_cfg.check.is_some() {
263 tracing::warn!(
264 "[lint.{lang}] field `check` matches the built-in default — remove it from alef.toml to avoid drift",
265 lang = lang_str
266 );
267 }
268 if commands_eq(&user_cfg.typecheck, &default_cfg.typecheck) && user_cfg.typecheck.is_some() {
269 tracing::warn!(
270 "[lint.{lang}] field `typecheck` matches the built-in default — remove it from alef.toml to avoid drift",
271 lang = lang_str
272 );
273 }
274}
275
276fn warn_test_defaults(lang_str: &str, user_cfg: &TestConfig, default_cfg: &TestConfig) {
278 let has_custom_main = user_cfg.command.is_some() || user_cfg.e2e.is_some() || user_cfg.coverage.is_some();
279 if !has_custom_main {
280 if let (Some(u), Some(d)) = (&user_cfg.precondition, &default_cfg.precondition) {
281 if u == d {
282 tracing::warn!(
283 "[test.{lang}] field `precondition` matches the built-in default — remove it from alef.toml to avoid drift",
284 lang = lang_str
285 );
286 }
287 }
288 }
289 if commands_eq(&user_cfg.before, &default_cfg.before) && user_cfg.before.is_some() {
290 tracing::warn!(
291 "[test.{lang}] field `before` matches the built-in default — remove it from alef.toml to avoid drift",
292 lang = lang_str
293 );
294 }
295 if commands_eq(&user_cfg.command, &default_cfg.command) && user_cfg.command.is_some() {
296 tracing::warn!(
297 "[test.{lang}] field `command` matches the built-in default — remove it from alef.toml to avoid drift",
298 lang = lang_str
299 );
300 }
301 if commands_eq(&user_cfg.e2e, &default_cfg.e2e) && user_cfg.e2e.is_some() {
302 tracing::warn!(
303 "[test.{lang}] field `e2e` matches the built-in default — remove it from alef.toml to avoid drift",
304 lang = lang_str
305 );
306 }
307 if commands_eq(&user_cfg.coverage, &default_cfg.coverage) && user_cfg.coverage.is_some() {
308 tracing::warn!(
309 "[test.{lang}] field `coverage` matches the built-in default — remove it from alef.toml to avoid drift",
310 lang = lang_str
311 );
312 }
313}
314
315fn warn_build_defaults(lang_str: &str, user_cfg: &BuildCommandConfig, default_cfg: &BuildCommandConfig) {
317 let has_custom_main = user_cfg.build.is_some() || user_cfg.build_release.is_some();
318 if !has_custom_main {
319 if let (Some(u), Some(d)) = (&user_cfg.precondition, &default_cfg.precondition) {
320 if u == d {
321 tracing::warn!(
322 "[build_commands.{lang}] field `precondition` matches the built-in default — remove it from alef.toml to avoid drift",
323 lang = lang_str
324 );
325 }
326 }
327 }
328 if commands_eq(&user_cfg.before, &default_cfg.before) && user_cfg.before.is_some() {
329 tracing::warn!(
330 "[build_commands.{lang}] field `before` matches the built-in default — remove it from alef.toml to avoid drift",
331 lang = lang_str
332 );
333 }
334 if commands_eq(&user_cfg.build, &default_cfg.build) && user_cfg.build.is_some() {
335 tracing::warn!(
336 "[build_commands.{lang}] field `build` matches the built-in default — remove it from alef.toml to avoid drift",
337 lang = lang_str
338 );
339 }
340 if commands_eq(&user_cfg.build_release, &default_cfg.build_release) && user_cfg.build_release.is_some() {
341 tracing::warn!(
342 "[build_commands.{lang}] field `build_release` matches the built-in default — remove it from alef.toml to avoid drift",
343 lang = lang_str
344 );
345 }
346}
347
348fn warn_setup_defaults(lang_str: &str, user_cfg: &SetupConfig, default_cfg: &SetupConfig) {
350 let has_custom_main = user_cfg.install.is_some();
351 if !has_custom_main {
352 if let (Some(u), Some(d)) = (&user_cfg.precondition, &default_cfg.precondition) {
353 if u == d {
354 tracing::warn!(
355 "[setup.{lang}] field `precondition` matches the built-in default — remove it from alef.toml to avoid drift",
356 lang = lang_str
357 );
358 }
359 }
360 }
361 if commands_eq(&user_cfg.before, &default_cfg.before) && user_cfg.before.is_some() {
362 tracing::warn!(
363 "[setup.{lang}] field `before` matches the built-in default — remove it from alef.toml to avoid drift",
364 lang = lang_str
365 );
366 }
367 if commands_eq(&user_cfg.install, &default_cfg.install) && user_cfg.install.is_some() {
368 tracing::warn!(
369 "[setup.{lang}] field `install` matches the built-in default — remove it from alef.toml to avoid drift",
370 lang = lang_str
371 );
372 }
373}
374
375fn warn_update_defaults(lang_str: &str, user_cfg: &UpdateConfig, default_cfg: &UpdateConfig) {
377 let has_custom_main = user_cfg.update.is_some() || user_cfg.upgrade.is_some();
378 if !has_custom_main {
379 if let (Some(u), Some(d)) = (&user_cfg.precondition, &default_cfg.precondition) {
380 if u == d {
381 tracing::warn!(
382 "[update.{lang}] field `precondition` matches the built-in default — remove it from alef.toml to avoid drift",
383 lang = lang_str
384 );
385 }
386 }
387 }
388 if commands_eq(&user_cfg.before, &default_cfg.before) && user_cfg.before.is_some() {
389 tracing::warn!(
390 "[update.{lang}] field `before` matches the built-in default — remove it from alef.toml to avoid drift",
391 lang = lang_str
392 );
393 }
394 if commands_eq(&user_cfg.update, &default_cfg.update) && user_cfg.update.is_some() {
395 tracing::warn!(
396 "[update.{lang}] field `update` matches the built-in default — remove it from alef.toml to avoid drift",
397 lang = lang_str
398 );
399 }
400 if commands_eq(&user_cfg.upgrade, &default_cfg.upgrade) && user_cfg.upgrade.is_some() {
401 tracing::warn!(
402 "[update.{lang}] field `upgrade` matches the built-in default — remove it from alef.toml to avoid drift",
403 lang = lang_str
404 );
405 }
406}
407
408fn warn_clean_defaults(lang_str: &str, user_cfg: &CleanConfig, default_cfg: &CleanConfig) {
410 let has_custom_main = user_cfg.clean.is_some();
411 if !has_custom_main {
412 if let (Some(u), Some(d)) = (&user_cfg.precondition, &default_cfg.precondition) {
413 if u == d {
414 tracing::warn!(
415 "[clean.{lang}] field `precondition` matches the built-in default — remove it from alef.toml to avoid drift",
416 lang = lang_str
417 );
418 }
419 }
420 }
421 if commands_eq(&user_cfg.before, &default_cfg.before) && user_cfg.before.is_some() {
422 tracing::warn!(
423 "[clean.{lang}] field `before` matches the built-in default — remove it from alef.toml to avoid drift",
424 lang = lang_str
425 );
426 }
427 if commands_eq(&user_cfg.clean, &default_cfg.clean) && user_cfg.clean.is_some() {
428 tracing::warn!(
429 "[clean.{lang}] field `clean` matches the built-in default — remove it from alef.toml to avoid drift",
430 lang = lang_str
431 );
432 }
433}
434
435fn warn_redundant_defaults(config: &AlefConfig) {
437 let output_dir = |lang| config.package_dir(lang);
438 let tools = &config.tools;
439
440 if let Some(map) = &config.lint {
441 for (lang_str, user_cfg) in map {
442 if let Some(lang) = parse_language(lang_str) {
443 let ctx = LangContext::default(tools);
444 let default_cfg = lint_defaults::default_lint_config(lang, &output_dir(lang), &ctx);
445 warn_lint_defaults(lang_str, user_cfg, &default_cfg);
446 }
447 }
448 }
449
450 if let Some(map) = &config.test {
451 for (lang_str, user_cfg) in map {
452 if let Some(lang) = parse_language(lang_str) {
453 let ctx = LangContext::default(tools);
454 let default_cfg = test_defaults::default_test_config(lang, &output_dir(lang), &ctx);
455 warn_test_defaults(lang_str, user_cfg, &default_cfg);
456 }
457 }
458 }
459
460 if let Some(map) = &config.build_commands {
461 for (lang_str, user_cfg) in map {
462 if let Some(lang) = parse_language(lang_str) {
463 let ctx = LangContext::default(tools);
464 let default_cfg =
465 build_defaults::default_build_config(lang, &output_dir(lang), &config.crate_config.name, &ctx);
466 warn_build_defaults(lang_str, user_cfg, &default_cfg);
467 }
468 }
469 }
470
471 if let Some(map) = &config.setup {
472 for (lang_str, user_cfg) in map {
473 if let Some(lang) = parse_language(lang_str) {
474 let ctx = LangContext::default(tools);
475 let default_cfg = setup_defaults::default_setup_config(lang, &output_dir(lang), &ctx);
476 warn_setup_defaults(lang_str, user_cfg, &default_cfg);
477 }
478 }
479 }
480
481 if let Some(map) = &config.update {
482 for (lang_str, user_cfg) in map {
483 if let Some(lang) = parse_language(lang_str) {
484 let ctx = LangContext::default(tools);
485 let default_cfg = update_defaults::default_update_config(lang, &output_dir(lang), &ctx);
486 warn_update_defaults(lang_str, user_cfg, &default_cfg);
487 }
488 }
489 }
490
491 if let Some(map) = &config.clean {
492 for (lang_str, user_cfg) in map {
493 if let Some(lang) = parse_language(lang_str) {
494 let ctx = LangContext::default(tools);
495 let default_cfg = clean_defaults::default_clean_config(lang, &output_dir(lang), &ctx);
496 warn_clean_defaults(lang_str, user_cfg, &default_cfg);
497 }
498 }
499 }
500}
501
502#[cfg(test)]
503mod tests {
504 use super::*;
505 use tracing_test::traced_test;
506
507 fn parse(toml_str: &str) -> AlefConfig {
508 toml::from_str(toml_str).expect("config should parse")
509 }
510
511 fn base_config() -> &'static str {
512 r#"
513languages = ["python"]
514[crate]
515name = "test-lib"
516sources = ["src/lib.rs"]
517"#
518 }
519
520 #[test]
521 fn no_user_overrides_is_valid() {
522 let config = parse(base_config());
523 validate(&config).expect("default config should validate");
524 }
525
526 #[test]
527 fn lint_override_with_main_cmd_no_precondition_errors() {
528 let config = parse(&format!(
529 "{base}\n\n[lint.python]\nformat = \"black .\"\n",
530 base = base_config()
531 ));
532 let err = validate(&config).expect_err("missing precondition should error");
533 let msg = format!("{err}");
534 assert!(msg.contains("[lint.python]"), "error should name the section: {msg}");
535 assert!(msg.contains("precondition"), "error should mention precondition: {msg}");
536 }
537
538 #[test]
539 fn lint_override_with_main_cmd_and_precondition_is_ok() {
540 let config = parse(&format!(
541 "{base}\n\n[lint.python]\nprecondition = \"command -v black\"\nformat = \"black .\"\n",
542 base = base_config()
543 ));
544 validate(&config).expect("config with precondition should validate");
545 }
546
547 #[test]
548 fn lint_override_with_only_before_no_precondition_is_ok() {
549 let config = parse(&format!(
551 "{base}\n\n[lint.python]\nbefore = \"echo hi\"\n",
552 base = base_config()
553 ));
554 validate(&config).expect("table with only `before` should validate");
555 }
556
557 #[test]
558 fn test_override_with_main_cmd_no_precondition_errors() {
559 let config = parse(&format!(
560 "{base}\n\n[test.python]\ncommand = \"pytest\"\n",
561 base = base_config()
562 ));
563 let err = validate(&config).expect_err("missing precondition should error");
564 assert!(format!("{err}").contains("[test.python]"));
565 }
566
567 #[test]
568 fn test_override_with_only_e2e_requires_precondition() {
569 let config = parse(&format!(
570 "{base}\n\n[test.python]\ne2e = \"pytest tests/e2e\"\n",
571 base = base_config()
572 ));
573 validate(&config).expect_err("e2e without precondition should error");
574 }
575
576 #[test]
577 fn build_override_with_main_cmd_no_precondition_errors() {
578 let config = parse(&format!(
579 "{base}\n\n[build_commands.python]\nbuild = \"maturin develop\"\n",
580 base = base_config()
581 ));
582 let err = validate(&config).expect_err("missing precondition should error");
583 assert!(format!("{err}").contains("[build_commands.python]"));
584 }
585
586 #[test]
587 fn setup_override_with_install_no_precondition_errors() {
588 let config = parse(&format!(
589 "{base}\n\n[setup.python]\ninstall = \"uv sync\"\n",
590 base = base_config()
591 ));
592 validate(&config).expect_err("setup install without precondition should error");
593 }
594
595 #[test]
596 fn update_override_with_main_cmd_no_precondition_errors() {
597 let config = parse(&format!(
598 "{base}\n\n[update.python]\nupdate = \"uv sync --upgrade\"\n",
599 base = base_config()
600 ));
601 validate(&config).expect_err("update without precondition should error");
602 }
603
604 #[test]
605 fn clean_override_with_main_cmd_no_precondition_errors() {
606 let config = parse(&format!(
607 "{base}\n\n[clean.python]\nclean = \"rm -rf dist\"\n",
608 base = base_config()
609 ));
610 validate(&config).expect_err("clean without precondition should error");
611 }
612
613 #[test]
614 fn error_message_lists_only_actually_set_main_fields() {
615 let config = parse(&format!(
617 "{base}\n\n[lint.python]\nformat = \"black .\"\n",
618 base = base_config()
619 ));
620 let msg = format!("{}", validate(&config).unwrap_err());
621 assert!(msg.contains("`format`"), "expected `format`, got: {msg}");
622 assert!(!msg.contains("`check`"), "should not mention unset `check`: {msg}");
623 assert!(
624 !msg.contains("`typecheck`"),
625 "should not mention unset `typecheck`: {msg}"
626 );
627 }
628
629 #[test]
630 fn before_plus_main_cmd_without_precondition_still_errors() {
631 let config = parse(&format!(
633 "{base}\n\n[lint.python]\nbefore = \"echo hi\"\nformat = \"black .\"\n",
634 base = base_config()
635 ));
636 validate(&config).expect_err("before + main without precondition must error");
637 }
638
639 #[test]
640 fn malformed_python_package_manager_value_is_rejected() {
641 let config = parse(&format!(
642 "{base}\n\n[tools]\npython_package_manager = \"uv; rm -rf /\"\n",
643 base = base_config()
644 ));
645 let err = validate(&config).expect_err("non-identifier tool name must be rejected");
646 assert!(format!("{err}").contains("well-formed"));
647 }
648
649 #[test]
650 fn malformed_node_package_manager_value_is_rejected() {
651 let config = parse(&format!(
652 "{base}\n\n[tools]\nnode_package_manager = \"pnpm$(echo bad)\"\n",
653 base = base_config()
654 ));
655 validate(&config).expect_err("non-identifier tool name must be rejected");
656 }
657
658 #[test]
659 fn malformed_rust_dev_tool_entry_is_rejected() {
660 let config = parse(&format!(
661 "{base}\n\n[tools]\nrust_dev_tools = [\"cargo-edit\", \"cargo`evil`\"]\n",
662 base = base_config()
663 ));
664 validate(&config).expect_err("non-identifier tool name must be rejected");
665 }
666
667 #[test]
668 fn whitespace_in_tool_name_is_rejected() {
669 let config = parse(&format!(
671 "{base}\n\n[tools]\npython_package_manager = \"uv \"\n",
672 base = base_config()
673 ));
674 validate(&config).expect_err("trailing whitespace must be rejected");
675 }
676
677 #[test]
678 fn empty_tool_name_is_rejected() {
679 let config = parse(&format!(
680 "{base}\n\n[tools]\npython_package_manager = \"\"\n",
681 base = base_config()
682 ));
683 validate(&config).expect_err("empty tool name must be rejected");
684 }
685
686 #[test]
687 fn safe_tool_names_are_accepted() {
688 let config = parse(&format!(
690 "{base}\n\n[tools]\npython_package_manager = \"uv\"\n\
691 node_package_manager = \"pnpm\"\n\
692 rust_dev_tools = [\"cargo-edit\", \"cargo_sort\", \"tool.v2\"]\n",
693 base = base_config()
694 ));
695 validate(&config).expect("normal tool names should validate");
696 }
697
698 #[test]
699 fn override_with_main_cmd_and_precondition_validates_for_each_section() {
700 let cases = [
701 ("lint.python", "format", "command -v black"),
702 ("test.python", "command", "command -v pytest"),
703 ("build_commands.python", "build", "command -v maturin"),
704 ("setup.python", "install", "command -v uv"),
705 ("update.python", "update", "command -v uv"),
706 ("clean.python", "clean", "command -v rm"),
707 ];
708 for (header, field, pre) in cases {
709 let toml_str = format!(
710 "{base}\n\n[{header}]\nprecondition = \"{pre}\"\n{field} = \"echo run\"\n",
711 base = base_config()
712 );
713 let config = parse(&toml_str);
714 validate(&config).unwrap_or_else(|_| panic!("[{header}] with precondition should validate"));
715 }
716 }
717
718 #[test]
719 #[traced_test]
720 fn lint_verbatim_default_emits_warning() {
721 let config = parse(&format!(
723 "{base}\n\n[lint.python]\nformat = \"ruff format packages/python\"\nprecondition = \"command -v ruff\"\n",
724 base = base_config()
725 ));
726 validate(&config).expect("config should validate");
727 assert!(logs_contain(
729 "[lint.python] field `format` matches the built-in default"
730 ));
731 }
732
733 #[test]
734 #[traced_test]
735 fn lint_partial_default_warns_only_for_default_field() {
736 let config = parse(&format!(
739 "{base}\n\n[lint.python]\nformat = \"ruff format packages/python\"\ncheck = \"custom check\"\nprecondition = \"command -v ruff\"\n",
740 base = base_config()
741 ));
742 validate(&config).expect("config should validate");
743 assert!(logs_contain(
744 "[lint.python] field `format` matches the built-in default"
745 ));
746 assert!(!logs_contain(
747 "[lint.python] field `check` matches the built-in default"
748 ));
749 }
750
751 #[test]
752 #[traced_test]
753 fn lint_all_custom_emits_no_warning() {
754 let config = parse(&format!(
756 "{base}\n\n[lint.python]\nformat = \"black .\"\ncheck = \"pylint .\"\nprecondition = \"command -v black\"\n",
757 base = base_config()
758 ));
759 validate(&config).expect("config should validate");
760 assert!(!logs_contain(
761 "[lint.python] field `format` matches the built-in default"
762 ));
763 assert!(!logs_contain(
764 "[lint.python] field `check` matches the built-in default"
765 ));
766 }
767
768 #[test]
769 #[traced_test]
770 fn test_verbatim_default_emits_warning() {
771 let config = parse(&format!(
773 "{base}\n\n[test.python]\ncommand = \"cd packages/python && uv run pytest\"\nprecondition = \"command -v uv\"\n",
774 base = base_config()
775 ));
776 validate(&config).expect("config should validate");
777 assert!(logs_contain(
778 "[test.python] field `command` matches the built-in default"
779 ));
780 }
781
782 #[test]
783 #[traced_test]
784 fn build_verbatim_default_emits_warning() {
785 let config = parse(&format!(
789 "{base}\n\n[build_commands.python]\nbuild = \"maturin develop --manifest-path crates/test-lib-py/Cargo.toml\"\nprecondition = \"command -v maturin\"\n",
790 base = base_config()
791 ));
792 validate(&config).expect("config should validate");
793 assert!(logs_contain(
794 "[build_commands.python] field `build` matches the built-in default"
795 ));
796 }
797
798 #[test]
799 #[traced_test]
800 fn setup_verbatim_default_emits_warning() {
801 let config = parse(&format!(
802 "{base}\n\n[setup.python]\ninstall = \"cd packages/python && uv sync\"\nprecondition = \"command -v uv\"\n",
803 base = base_config()
804 ));
805 validate(&config).expect("config should validate");
806 assert!(logs_contain(
807 "[setup.python] field `install` matches the built-in default"
808 ));
809 }
810
811 #[test]
812 #[traced_test]
813 fn update_verbatim_default_emits_warning() {
814 let config = parse(&format!(
815 "{base}\n\n[update.python]\nupdate = \"cd packages/python && uv sync --upgrade\"\nprecondition = \"command -v uv\"\n",
816 base = base_config()
817 ));
818 validate(&config).expect("config should validate");
819 assert!(logs_contain(
820 "[update.python] field `update` matches the built-in default"
821 ));
822 }
823
824 #[test]
825 #[traced_test]
826 fn clean_verbatim_default_emits_warning() {
827 let config = parse(&format!(
830 "{base}\n\n[clean.python]\nclean = \"rm -rf packages/python/build\"\nprecondition = \"command -v rm\"\n",
831 base = base_config()
832 ));
833 validate(&config).expect("config should validate");
834 }
837
838 #[test]
839 #[traced_test]
840 fn precondition_redundant_default_emits_warning() {
841 let config = parse(&format!(
844 "{base}\n\n[lint.python]\nprecondition = \"command -v ruff >/dev/null 2>&1\"\n",
845 base = base_config()
846 ));
847 validate(&config).expect("config should validate");
848 assert!(logs_contain(
849 "[lint.python] field `precondition` matches the built-in default"
850 ));
851 }
852
853 #[test]
854 #[traced_test]
855 fn precondition_matching_default_not_warned_when_main_commands_are_custom() {
856 let config = parse(&format!(
860 "{base}\n\n[lint.python]\nformat = \"black .\"\nprecondition = \"command -v ruff >/dev/null 2>&1\"\n",
861 base = base_config()
862 ));
863 validate(&config).expect("config should validate");
864 assert!(
865 !logs_contain("[lint.python] field `precondition` matches the built-in default"),
866 "precondition warning must be suppressed when main command fields are set"
867 );
868 }
869
870 #[test]
871 #[traced_test]
872 fn node_custom_value_no_warning() {
873 let config = parse(&format!(
874 "{base}\nlanguages = [\"node\"]\n\n[lint.node]\nformat = \"prettier --write .\"\nprecondition = \"command -v npm\"\n",
875 base = base_config().lines().skip(1).collect::<Vec<_>>().join("\n")
876 ));
877 validate(&config).expect("config should validate");
878 assert!(!logs_contain("[lint.node] field `format` matches the built-in default"));
880 }
881}