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

1//! Lint for duplicate command detection.
2
3use std::collections::HashMap;
4
5use baobao_manifest::Manifest;
6
7use super::super::Lint;
8use crate::pipeline::Diagnostic;
9
10/// Lint that errors on duplicate command paths.
11pub struct DuplicateCommandLint;
12
13impl Lint for DuplicateCommandLint {
14    fn name(&self) -> &'static str {
15        "duplicate-command"
16    }
17
18    fn description(&self) -> &'static str {
19        "Detect duplicate command names and paths"
20    }
21
22    fn check(&self, manifest: &Manifest, diagnostics: &mut Vec<Diagnostic>) {
23        let mut seen: HashMap<String, String> = HashMap::new();
24
25        for (name, cmd) in &manifest.commands {
26            // Normalize to handle case-insensitive duplicates
27            let normalized = name.to_lowercase();
28            if let Some(first) = seen.get(&normalized) {
29                diagnostics.push(
30                    Diagnostic::error(
31                        "validate",
32                        format!("duplicate command '{}' (conflicts with '{}')", name, first),
33                    )
34                    .at(format!("commands.{}", name)),
35                );
36            } else {
37                seen.insert(normalized, name.clone());
38            }
39
40            collect_subcommand_paths(name, cmd, &mut seen, diagnostics);
41        }
42    }
43}
44
45fn collect_subcommand_paths(
46    parent_path: &str,
47    cmd: &baobao_manifest::Command,
48    seen: &mut HashMap<String, String>,
49    diagnostics: &mut Vec<Diagnostic>,
50) {
51    for (name, subcmd) in &cmd.commands {
52        let path = format!("{}/{}", parent_path, name);
53        let normalized = path.to_lowercase();
54
55        if let Some(first) = seen.get(&normalized) {
56            diagnostics.push(
57                Diagnostic::error(
58                    "validate",
59                    format!(
60                        "duplicate command path '{}' (conflicts with '{}')",
61                        path, first
62                    ),
63                )
64                .at(format!("commands.{}", path.replace('/', "."))),
65            );
66        } else {
67            seen.insert(normalized, path.clone());
68        }
69
70        collect_subcommand_paths(&path, subcmd, seen, diagnostics);
71    }
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77
78    fn parse_manifest(content: &str) -> Manifest {
79        toml::from_str(content).expect("Failed to parse test manifest")
80    }
81
82    #[test]
83    fn test_no_duplicates() {
84        let manifest = parse_manifest(
85            r#"
86            [cli]
87            name = "test"
88            language = "rust"
89
90            [commands.deploy]
91            description = "Deploy"
92
93            [commands.build]
94            description = "Build"
95        "#,
96        );
97
98        let mut diagnostics = Vec::new();
99        DuplicateCommandLint.check(&manifest, &mut diagnostics);
100
101        assert!(diagnostics.is_empty());
102    }
103
104    #[test]
105    fn test_nested_paths_distinct() {
106        let manifest = parse_manifest(
107            r#"
108            [cli]
109            name = "test"
110            language = "rust"
111
112            [commands.db]
113            description = "Database commands"
114
115            [commands.db.commands.migrate]
116            description = "Migrate"
117
118            [commands.api]
119            description = "API commands"
120
121            [commands.api.commands.migrate]
122            description = "Another migrate"
123        "#,
124        );
125
126        let mut diagnostics = Vec::new();
127        DuplicateCommandLint.check(&manifest, &mut diagnostics);
128
129        // db/migrate and api/migrate are different paths, so no duplicates
130        assert!(diagnostics.is_empty());
131    }
132}