clap_sort/
lib.rs

1//! # clap-sort
2//!
3//! A library to validate that clap subcommands are sorted alphabetically.
4//!
5//! This crate provides functionality to parse Rust source files and check that
6//! clap subcommands defined in enums are sorted alphabetically by their CLI names.
7
8use std::path::Path;
9use syn::{Attribute, File, Meta, Variant};
10use syn::visit::Visit;
11
12/// Error type for validation failures
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct ValidationError {
15    pub message: String,
16    pub enum_name: String,
17    pub unsorted_variants: Vec<(String, String)>, // (actual_order, expected_order)
18}
19
20impl std::fmt::Display for ValidationError {
21    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22        write!(f, "{}", self.message)
23    }
24}
25
26impl std::error::Error for ValidationError {}
27
28/// Extract the CLI name from a clap attribute, if present
29fn get_cli_name(variant: &Variant) -> Option<String> {
30    for attr in &variant.attrs {
31        if let Meta::List(meta_list) = &attr.meta {
32            if meta_list.path.is_ident("command") || meta_list.path.is_ident("clap") {
33                // Parse the attribute content
34                let tokens = meta_list.tokens.to_string();
35
36                // Look for name = "..." pattern
37                if let Some(start) = tokens.find("name = \"") {
38                    let after_equals = &tokens[start + 8..];
39                    if let Some(end) = after_equals.find('"') {
40                        return Some(after_equals[..end].to_string());
41                    }
42                }
43            }
44        }
45    }
46    None
47}
48
49/// Check if an enum has the Subcommand derive
50fn has_subcommand_derive(attrs: &[Attribute]) -> bool {
51    for attr in attrs {
52        if let Meta::List(meta_list) = &attr.meta {
53            if meta_list.path.is_ident("derive") {
54                let tokens = meta_list.tokens.to_string();
55                if tokens.contains("Subcommand") {
56                    return true;
57                }
58            }
59        }
60    }
61    false
62}
63
64/// Validate that an enum's variants are sorted alphabetically by their CLI names
65pub fn validate_enum_sorted(enum_item: &syn::ItemEnum) -> Result<(), ValidationError> {
66    // Only check enums with #[derive(Subcommand)]
67    if !has_subcommand_derive(&enum_item.attrs) {
68        return Ok(());
69    }
70
71    let mut cli_names = Vec::new();
72
73    for variant in &enum_item.variants {
74        let name = get_cli_name(variant)
75            .unwrap_or_else(|| variant.ident.to_string().to_lowercase().replace('_', "-"));
76        cli_names.push(name);
77    }
78
79    // Check if sorted
80    let mut sorted_names = cli_names.clone();
81    sorted_names.sort();
82
83    if cli_names != sorted_names {
84        return Err(ValidationError {
85            message: format!(
86                "Enum '{}' has unsorted subcommands.\nActual order: {:?}\nExpected order: {:?}",
87                enum_item.ident, cli_names, sorted_names
88            ),
89            enum_name: enum_item.ident.to_string(),
90            unsorted_variants: cli_names.into_iter().zip(sorted_names).collect(),
91        });
92    }
93
94    Ok(())
95}
96
97/// Visitor to find and validate all enums in a syntax tree
98struct EnumValidator {
99    errors: Vec<ValidationError>,
100}
101
102impl<'ast> Visit<'ast> for EnumValidator {
103    fn visit_item_enum(&mut self, node: &'ast syn::ItemEnum) {
104        if let Err(e) = validate_enum_sorted(node) {
105            self.errors.push(e);
106        }
107    }
108}
109
110/// Validate that all clap Subcommand enums in a source file are sorted
111pub fn validate_file(source: &str) -> Result<(), Vec<ValidationError>> {
112    let syntax_tree: File = syn::parse_str(source)
113        .map_err(|e| vec![ValidationError {
114            message: format!("Failed to parse source: {}", e),
115            enum_name: String::new(),
116            unsorted_variants: Vec::new(),
117        }])?;
118
119    let mut validator = EnumValidator { errors: Vec::new() };
120    validator.visit_file(&syntax_tree);
121
122    if validator.errors.is_empty() {
123        Ok(())
124    } else {
125        Err(validator.errors)
126    }
127}
128
129/// Validate a Rust source file at the given path
130pub fn validate_file_path(path: &Path) -> Result<(), Vec<ValidationError>> {
131    let source = std::fs::read_to_string(path)
132        .map_err(|e| vec![ValidationError {
133            message: format!("Failed to read file: {}", e),
134            enum_name: String::new(),
135            unsorted_variants: Vec::new(),
136        }])?;
137
138    validate_file(&source)
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn test_sorted_enum() {
147        let source = r#"
148            use clap::Subcommand;
149
150            #[derive(Subcommand)]
151            enum Commands {
152                Add,
153                Delete,
154                List,
155            }
156        "#;
157
158        assert!(validate_file(source).is_ok());
159    }
160
161    #[test]
162    fn test_unsorted_enum() {
163        let source = r#"
164            use clap::Subcommand;
165
166            #[derive(Subcommand)]
167            enum Commands {
168                List,
169                Add,
170                Delete,
171            }
172        "#;
173
174        let result = validate_file(source);
175        assert!(result.is_err());
176        let errors = result.unwrap_err();
177        assert_eq!(errors.len(), 1);
178        assert_eq!(errors[0].enum_name, "Commands");
179    }
180
181    #[test]
182    fn test_custom_names_sorted() {
183        let source = r#"
184            use clap::Subcommand;
185
186            #[derive(Subcommand)]
187            enum Commands {
188                #[command(name = "add")]
189                AddCmd,
190                #[command(name = "delete")]
191                DeleteCmd,
192                #[command(name = "list")]
193                ListCmd,
194            }
195        "#;
196
197        assert!(validate_file(source).is_ok());
198    }
199
200    #[test]
201    fn test_custom_names_unsorted() {
202        let source = r#"
203            use clap::Subcommand;
204
205            #[derive(Subcommand)]
206            enum Commands {
207                #[command(name = "list")]
208                ListCmd,
209                #[command(name = "add")]
210                AddCmd,
211                #[command(name = "delete")]
212                DeleteCmd,
213            }
214        "#;
215
216        let result = validate_file(source);
217        assert!(result.is_err());
218    }
219
220    #[test]
221    fn test_non_subcommand_enum_ignored() {
222        let source = r#"
223            #[derive(Debug)]
224            enum NotACommand {
225                Zebra,
226                Apple,
227                Banana,
228            }
229        "#;
230
231        // Should not error since it's not a Subcommand enum
232        assert!(validate_file(source).is_ok());
233    }
234
235    #[test]
236    fn test_multiple_enums_mixed() {
237        let source = r#"
238            use clap::Subcommand;
239
240            #[derive(Subcommand)]
241            enum Commands {
242                Add,
243                List,
244            }
245
246            #[derive(Debug)]
247            enum Other {
248                Zebra,
249                Apple,
250            }
251
252            #[derive(Subcommand)]
253            enum MoreCommands {
254                Delete,
255                Update,
256            }
257        "#;
258
259        assert!(validate_file(source).is_ok());
260    }
261}