Skip to main content

alef_core/config/
validation.rs

1//! Validation of user-supplied pipeline overrides in `alef.toml`.
2//!
3//! When a user provides an explicit `[lint.<lang>]` / `[test.<lang>]` /
4//! `[build_commands.<lang>]` / `[setup.<lang>]` / `[update.<lang>]` /
5//! `[clean.<lang>]` table that **sets a main command field**, that table
6//! must also declare a `precondition`. The rationale:
7//!
8//! - Built-in defaults all declare a `command -v <tool>` precondition so
9//!   pipelines degrade gracefully when the underlying tool is missing.
10//! - Custom commands are opaque to alef — only the user knows what the
11//!   command requires. Forcing an explicit `precondition` keeps the
12//!   warn-and-skip behavior intact on systems that can't run the command.
13//!
14//! Tables that only customize `before` (without overriding the main command)
15//! are exempt: the default precondition still applies via the surrounding
16//! defaults logic.
17
18use 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
26/// Validate user-supplied pipeline overrides.
27///
28/// Returns the first error encountered (or `Ok(())` when every user-supplied
29/// table either declares a precondition or only sets non-main fields).
30/// After validation, warns users when they declare values that match the
31/// built-in defaults, so they can remove redundant config.
32pub 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
81// ---------------------------------------------------------------------------
82// Per-config "which main fields are set?" helpers.
83//
84// Each helper returns the names of the main fields that are actually `Some`
85// on the user's override. Emptiness means the table only customizes
86// ancillary fields (typically `before`), which doesn't require a
87// precondition.
88// ---------------------------------------------------------------------------
89
90fn 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
152// ---------------------------------------------------------------------------
153// Tool-name well-formedness.
154//
155// `alef.toml` is trusted configuration: every shell-bound field
156// (`precondition`, `before`, the main command fields) is passed verbatim
157// to `sh -c`, by design — users author these commands and need full shell
158// power (pipes, redirects, `&&`, etc.) to express real-world tooling.
159//
160// `[tools]` values are different. They name a single executable that is
161// interpolated into a `command -v <tool>` precondition, so they should be
162// short identifier-shaped strings — never multi-word commands or shell
163// expressions. Rejecting non-identifier characters here catches typos
164// (trailing space, accidental quote) up-front with a useful error, instead
165// of failing later with a cryptic shell message. It is a well-formedness
166// check, not a security boundary.
167// ---------------------------------------------------------------------------
168
169fn 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
198// ---------------------------------------------------------------------------
199// Redundant default warning
200//
201// Emits warnings for user-supplied pipeline config values that exactly match
202// the built-in defaults. This helps users keep alef.toml minimal by deleting
203// redundant entries.
204// ---------------------------------------------------------------------------
205
206/// Parse a language string to Language, or return None if unparsable.
207fn 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
225/// Compare two Option<StringOrVec> for field-by-field matching.
226fn 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
234/// Warn for each field in LintConfig that matches the default.
235fn warn_lint_defaults(lang_str: &str, user_cfg: &LintConfig, default_cfg: &LintConfig) {
236    // Only warn about a redundant precondition when no main command fields are set.
237    // When main commands are set, validation *requires* a precondition, so warning
238    // to remove it would be contradictory.
239    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
276/// Warn for each field in TestConfig that matches the default.
277fn 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
315/// Warn for each field in BuildCommandConfig that matches the default.
316fn 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
348/// Warn for each field in SetupConfig that matches the default.
349fn 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
375/// Warn for each field in UpdateConfig that matches the default.
376fn 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
408/// Warn for each field in CleanConfig that matches the default.
409fn 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
435/// Emit warnings for user-supplied values that match built-in defaults.
436fn 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        // Adding `before` doesn't override the main command, so no precondition required.
550        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        // User sets only `format` — error should name `format`, not the full triple.
616        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        // The "only-before" exemption must not leak into mixed cases.
632        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        // Catches the common typo of a trailing space (`"uv "`).
670        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        // Dot, hyphen, underscore, alphanumerics are all valid.
689        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        // Setting format to the exact default value should trigger a warning.
722        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        // tracing-test captures logs; check that the warn! was called.
728        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        // Set one field (format) to default, another (check) to custom.
737        // Should warn only on format, not on check.
738        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        // All custom values should produce no warnings.
755        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        // Setting test.command to the exact default should trigger a warning.
772        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        // Python build default uses the crate name in the manifest path; base_config
786        // declares `name = "test-lib"`, so the default is `maturin develop --manifest-path
787        // crates/test-lib-py/Cargo.toml`.
788        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        // CleanConfig for python doesn't have a default, but if we set it to something
828        // and it matches what we compute, it should warn.
829        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        // Since python clean defaults to having no default, this won't warn unless
835        // we actually have a computed default. Skip this assertion and just verify validation works.
836    }
837
838    #[test]
839    #[traced_test]
840    fn precondition_redundant_default_emits_warning() {
841        // Even if precondition alone matches the default (with no main command),
842        // it should still warn if it's redundant.
843        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        // When main command fields are custom, validation *requires* a precondition.
857        // Warning about the precondition matching the built-in default would be contradictory
858        // (remove it → validation fails), so no precondition warning should fire.
859        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        // prettier is custom, not the default (oxfmt), so no warning.
879        assert!(!logs_contain("[lint.node] field `format` matches the built-in default"));
880    }
881}