baobao_codegen/pipeline/phases/validate/lints/
command_naming.rs

1//! Lint for command naming conventions.
2
3use baobao_manifest::Manifest;
4
5use super::super::Lint;
6use crate::pipeline::Diagnostic;
7
8/// Lint that warns about command names that aren't kebab-case.
9///
10/// Kebab-case means: lowercase letters, numbers, and hyphens only.
11/// Examples: `deploy`, `run-migrations`, `db-migrate`
12pub struct CommandNamingLint;
13
14impl Lint for CommandNamingLint {
15    fn name(&self) -> &'static str {
16        "command-naming"
17    }
18
19    fn description(&self) -> &'static str {
20        "Check command names follow kebab-case conventions"
21    }
22
23    fn check(&self, manifest: &Manifest, diagnostics: &mut Vec<Diagnostic>) {
24        for (name, cmd) in &manifest.commands {
25            check_command_name(name, name, diagnostics);
26            check_subcommand_names(name, cmd, diagnostics);
27        }
28    }
29}
30
31fn is_kebab_case(s: &str) -> bool {
32    if s.is_empty() {
33        return false;
34    }
35    // Must start with lowercase letter
36    let mut chars = s.chars().peekable();
37    match chars.next() {
38        Some(c) if c.is_ascii_lowercase() => {}
39        _ => return false,
40    }
41    // Rest must be lowercase letters, digits, or hyphens (no consecutive hyphens)
42    let mut prev_hyphen = false;
43    for c in chars {
44        if c == '-' {
45            if prev_hyphen {
46                return false; // consecutive hyphens
47            }
48            prev_hyphen = true;
49        } else if c.is_ascii_lowercase() || c.is_ascii_digit() {
50            prev_hyphen = false;
51        } else {
52            return false; // invalid character
53        }
54    }
55    // Must not end with hyphen
56    !prev_hyphen
57}
58
59fn check_command_name(name: &str, path: &str, diagnostics: &mut Vec<Diagnostic>) {
60    if !is_kebab_case(name) {
61        diagnostics.push(
62            Diagnostic::warning(
63                "validate",
64                format!(
65                    "command '{}' should use kebab-case (e.g., 'my-command' not 'my_command' or 'myCommand')",
66                    name
67                ),
68            )
69            .at(format!("commands.{}", path)),
70        );
71    }
72}
73
74fn check_subcommand_names(
75    parent_path: &str,
76    cmd: &baobao_manifest::Command,
77    diagnostics: &mut Vec<Diagnostic>,
78) {
79    for (name, subcmd) in &cmd.commands {
80        let path = format!("{}.{}", parent_path, name);
81        check_command_name(name, &path, diagnostics);
82        check_subcommand_names(&path, subcmd, diagnostics);
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    fn parse_manifest(content: &str) -> Manifest {
91        toml::from_str(content).expect("Failed to parse test manifest")
92    }
93
94    #[test]
95    fn test_is_kebab_case_valid() {
96        assert!(is_kebab_case("deploy"));
97        assert!(is_kebab_case("run-migrations"));
98        assert!(is_kebab_case("db-migrate"));
99        assert!(is_kebab_case("a"));
100        assert!(is_kebab_case("a1"));
101        assert!(is_kebab_case("foo-bar-baz"));
102        assert!(is_kebab_case("v2-api"));
103    }
104
105    #[test]
106    fn test_is_kebab_case_invalid() {
107        assert!(!is_kebab_case("")); // empty
108        assert!(!is_kebab_case("Deploy")); // uppercase start
109        assert!(!is_kebab_case("runMigrations")); // camelCase
110        assert!(!is_kebab_case("run_migrations")); // snake_case
111        assert!(!is_kebab_case("run--migrations")); // consecutive hyphens
112        assert!(!is_kebab_case("-deploy")); // starts with hyphen
113        assert!(!is_kebab_case("deploy-")); // ends with hyphen
114        assert!(!is_kebab_case("1deploy")); // starts with digit
115        assert!(!is_kebab_case("de ploy")); // contains space
116    }
117
118    #[test]
119    fn test_valid_names() {
120        let manifest = parse_manifest(
121            r#"
122            [cli]
123            name = "test"
124            language = "rust"
125
126            [commands.deploy]
127            description = "Deploy"
128
129            [commands.run-migrations]
130            description = "Run migrations"
131        "#,
132        );
133
134        let mut diagnostics = Vec::new();
135        CommandNamingLint.check(&manifest, &mut diagnostics);
136
137        assert!(diagnostics.is_empty());
138    }
139
140    #[test]
141    fn test_invalid_names() {
142        let manifest = parse_manifest(
143            r#"
144            [cli]
145            name = "test"
146            language = "rust"
147
148            [commands.runMigrations]
149            description = "Run migrations"
150
151            [commands.deploy_now]
152            description = "Deploy now"
153        "#,
154        );
155
156        let mut diagnostics = Vec::new();
157        CommandNamingLint.check(&manifest, &mut diagnostics);
158
159        assert_eq!(diagnostics.len(), 2);
160        assert!(diagnostics.iter().all(|d| d.severity.is_warning()));
161        assert!(
162            diagnostics
163                .iter()
164                .any(|d| d.message.contains("runMigrations"))
165        );
166        assert!(diagnostics.iter().any(|d| d.message.contains("deploy_now")));
167    }
168
169    #[test]
170    fn test_nested_invalid_name() {
171        let manifest = parse_manifest(
172            r#"
173            [cli]
174            name = "test"
175            language = "rust"
176
177            [commands.db]
178            description = "Database commands"
179
180            [commands.db.commands.runMigration]
181            description = "Run migration"
182        "#,
183        );
184
185        let mut diagnostics = Vec::new();
186        CommandNamingLint.check(&manifest, &mut diagnostics);
187
188        assert_eq!(diagnostics.len(), 1);
189        assert!(diagnostics[0].message.contains("runMigration"));
190    }
191}