1use std::path::Path;
9use syn::{Attribute, File, Meta, Variant};
10use syn::visit::Visit;
11
12#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct ValidationError {
15 pub message: String,
16 pub enum_name: String,
17 pub unsorted_variants: Vec<(String, String)>, }
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
28fn 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 let tokens = meta_list.tokens.to_string();
35
36 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
49fn 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
64pub fn validate_enum_sorted(enum_item: &syn::ItemEnum) -> Result<(), ValidationError> {
66 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 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
97struct 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
110pub 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
129pub 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 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}