clap_sort/
lib.rs

1//! # clap-sort
2//!
3//! A library to validate that clap subcommands and arguments are sorted.
4//!
5//! This crate provides functionality to validate that:
6//! - Subcommands are sorted alphabetically
7//! - Arguments are grouped and sorted by type:
8//!   1. Positional arguments (order not enforced - parsing order matters)
9//!   2. Flags with short options (alphabetically by short option)
10//!   3. Long-only flags (alphabetically)
11
12/// Validates that subcommands and arguments are sorted correctly.
13///
14/// This checks:
15/// - Subcommands are sorted alphabetically
16/// - Arguments are grouped and sorted by type:
17///   1. Positional arguments (order not enforced)
18///   2. Flags with short options (alphabetically by short option)
19///   3. Long-only flags (alphabetically)
20///
21/// Recursively validates all subcommands.
22///
23/// # Panics
24/// Panics if subcommands or arguments are not properly sorted.
25///
26/// # Example
27///
28/// ```rust
29/// use clap::{Command, Arg};
30///
31/// let cmd = Command::new("mycli")
32///     .arg(Arg::new("file"))  // Positional
33///     .arg(Arg::new("output").short('o').long("output"))  // Short flag
34///     .arg(Arg::new("config").long("config"));  // Long-only flag
35///
36/// clap_sort::assert_sorted(&cmd);
37/// ```
38pub fn assert_sorted(cmd: &clap::Command) {
39    // Check subcommands
40    let subcommands: Vec<_> = cmd.get_subcommands().map(|s| s.get_name()).collect();
41
42    if !subcommands.is_empty() {
43        let mut sorted = subcommands.clone();
44        sorted.sort();
45
46        if subcommands != sorted {
47            panic!(
48                "Subcommands in '{}' are not sorted alphabetically!\nActual order: {:?}\nExpected order: {:?}",
49                cmd.get_name(),
50                subcommands,
51                sorted
52            );
53        }
54    }
55
56    // Check arguments
57    assert_arguments_sorted(cmd);
58
59    // Recursively check subcommands
60    for subcmd in cmd.get_subcommands() {
61        assert_sorted(subcmd);
62    }
63}
64
65/// Checks if subcommands and arguments are sorted, returning a Result instead of panicking.
66///
67/// This checks:
68/// - Subcommands are sorted alphabetically
69/// - Arguments are grouped and sorted by type
70///
71/// Recursively validates all subcommands.
72///
73/// # Example
74///
75/// ```rust
76/// use clap::Command;
77///
78/// let cmd = Command::new("mycli");
79/// match clap_sort::is_sorted(&cmd) {
80///     Ok(()) => println!("Everything is sorted!"),
81///     Err(msg) => eprintln!("Error: {}", msg),
82/// }
83/// ```
84pub fn is_sorted(cmd: &clap::Command) -> Result<(), String> {
85    // Check subcommands
86    let subcommands: Vec<_> = cmd.get_subcommands().map(|s| s.get_name()).collect();
87
88    if !subcommands.is_empty() {
89        let mut sorted = subcommands.clone();
90        sorted.sort();
91
92        if subcommands != sorted {
93            return Err(format!(
94                "Subcommands in '{}' are not sorted alphabetically!\nActual order: {:?}\nExpected order: {:?}",
95                cmd.get_name(),
96                subcommands,
97                sorted
98            ));
99        }
100    }
101
102    // Check arguments
103    is_arguments_sorted(cmd)?;
104
105    // Recursively check subcommands
106    for subcmd in cmd.get_subcommands() {
107        is_sorted(subcmd)?;
108    }
109
110    Ok(())
111}
112
113/// Internal function to assert arguments are sorted.
114fn assert_arguments_sorted(cmd: &clap::Command) {
115    if let Err(msg) = is_arguments_sorted(cmd) {
116        panic!("{}", msg);
117    }
118}
119
120/// Checks if arguments are sorted correctly, returning a Result.
121fn is_arguments_sorted(cmd: &clap::Command) -> Result<(), String> {
122    let args: Vec<_> = cmd.get_arguments().collect();
123
124    let mut positional = Vec::new();
125    let mut with_short = Vec::new();
126    let mut long_only = Vec::new();
127
128    for arg in &args {
129        if arg.is_positional() {
130            positional.push(*arg);
131        } else if arg.get_short().is_some() {
132            with_short.push(*arg);
133        } else if arg.get_long().is_some() {
134            long_only.push(*arg);
135        }
136    }
137
138    // Note: We don't check if positional args are sorted - their order matters for parsing
139
140    // Check short flags are sorted by short option
141    let with_short_shorts: Vec<char> = with_short
142        .iter()
143        .filter_map(|a| a.get_short())
144        .collect();
145    let mut sorted_shorts = with_short_shorts.clone();
146    sorted_shorts.sort_by(|a, b| {
147        let a_lower = a.to_ascii_lowercase();
148        let b_lower = b.to_ascii_lowercase();
149        match a_lower.cmp(&b_lower) {
150            std::cmp::Ordering::Equal => {
151                // Lowercase before uppercase for same letter
152                if a.is_lowercase() && b.is_uppercase() {
153                    std::cmp::Ordering::Less
154                } else if a.is_uppercase() && b.is_lowercase() {
155                    std::cmp::Ordering::Greater
156                } else {
157                    std::cmp::Ordering::Equal
158                }
159            }
160            other => other,
161        }
162    });
163
164    if with_short_shorts != sorted_shorts {
165        let current: Vec<String> = with_short
166            .iter()
167            .map(|a| format!("-{}", a.get_short().unwrap()))
168            .collect();
169        let mut sorted_args = with_short.clone();
170        sorted_args.sort_by(|a, b| {
171            let a_char = a.get_short().unwrap();
172            let b_char = b.get_short().unwrap();
173            let a_lower = a_char.to_ascii_lowercase();
174            let b_lower = b_char.to_ascii_lowercase();
175            match a_lower.cmp(&b_lower) {
176                std::cmp::Ordering::Equal => {
177                    if a_char.is_lowercase() && b_char.is_uppercase() {
178                        std::cmp::Ordering::Less
179                    } else if a_char.is_uppercase() && b_char.is_lowercase() {
180                        std::cmp::Ordering::Greater
181                    } else {
182                        std::cmp::Ordering::Equal
183                    }
184                }
185                other => other,
186            }
187        });
188        let expected: Vec<String> = sorted_args
189            .iter()
190            .map(|a| format!("-{}", a.get_short().unwrap()))
191            .collect();
192
193        return Err(format!(
194            "Flags with short options in '{}' are not sorted!\nActual: {:?}\nExpected: {:?}",
195            cmd.get_name(),
196            current,
197            expected
198        ));
199    }
200
201    // Check long-only flags are sorted
202    let long_only_longs: Vec<&str> = long_only
203        .iter()
204        .filter_map(|a| a.get_long())
205        .collect();
206    let mut sorted_longs = long_only_longs.clone();
207    sorted_longs.sort_unstable();
208
209    if long_only_longs != sorted_longs {
210        let current: Vec<String> = long_only_longs
211            .iter()
212            .map(|l| format!("--{}", l))
213            .collect();
214        let expected: Vec<String> = sorted_longs.iter().map(|l| format!("--{}", l)).collect();
215
216        return Err(format!(
217            "Long-only flags in '{}' are not sorted!\nActual: {:?}\nExpected: {:?}",
218            cmd.get_name(),
219            current,
220            expected
221        ));
222    }
223
224    // Check that groups appear in correct order
225    let arg_ids: Vec<&str> = args.iter().map(|a| a.get_id().as_str()).collect();
226
227    let mut expected_order = Vec::new();
228    expected_order.extend(positional.iter().map(|a| a.get_id().as_str()));
229    expected_order.extend(with_short.iter().map(|a| a.get_id().as_str()));
230    expected_order.extend(long_only.iter().map(|a| a.get_id().as_str()));
231
232    if arg_ids != expected_order {
233        return Err(format!(
234            "Arguments in '{}' are not in correct group order!\nExpected: [positional, short flags, long-only flags]\nActual: {:?}\nExpected: {:?}",
235            cmd.get_name(),
236            arg_ids,
237            expected_order
238        ));
239    }
240
241    Ok(())
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247    use clap::{Command, CommandFactory, Parser, Subcommand};
248
249    #[test]
250    fn test_sorted_subcommands() {
251        let cmd = Command::new("test")
252            .subcommand(Command::new("add"))
253            .subcommand(Command::new("delete"))
254            .subcommand(Command::new("list"));
255
256        assert_sorted(&cmd);
257    }
258
259    #[test]
260    #[should_panic(expected = "are not sorted alphabetically")]
261    fn test_unsorted_subcommands() {
262        let cmd = Command::new("test")
263            .subcommand(Command::new("list"))
264            .subcommand(Command::new("add"))
265            .subcommand(Command::new("delete"));
266
267        assert_sorted(&cmd);
268    }
269
270    #[test]
271    fn test_is_sorted_ok() {
272        let cmd = Command::new("test")
273            .subcommand(Command::new("add"))
274            .subcommand(Command::new("delete"))
275            .subcommand(Command::new("list"));
276
277        assert!(is_sorted(&cmd).is_ok());
278    }
279
280    #[test]
281    fn test_is_sorted_err() {
282        let cmd = Command::new("test")
283            .subcommand(Command::new("list"))
284            .subcommand(Command::new("add"));
285
286        assert!(is_sorted(&cmd).is_err());
287    }
288
289    #[test]
290    fn test_no_subcommands() {
291        let cmd = Command::new("test");
292        assert_sorted(&cmd);
293        assert!(is_sorted(&cmd).is_ok());
294    }
295
296    #[test]
297    fn test_with_derive_sorted() {
298        #[derive(Parser)]
299        struct Cli {
300            #[command(subcommand)]
301            command: Commands,
302        }
303
304        #[derive(Subcommand)]
305        enum Commands {
306            Add,
307            Delete,
308            List,
309        }
310
311        let cmd = Cli::command();
312        assert_sorted(&cmd);
313    }
314
315    #[test]
316    #[should_panic(expected = "are not sorted alphabetically")]
317    fn test_with_derive_unsorted() {
318        #[derive(Parser)]
319        struct Cli {
320            #[command(subcommand)]
321            command: Commands,
322        }
323
324        #[derive(Subcommand)]
325        enum Commands {
326            List,
327            Add,
328            Delete,
329        }
330
331        let cmd = Cli::command();
332        assert_sorted(&cmd);
333    }
334
335    // Tests for argument sorting
336
337    #[test]
338    fn test_arguments_correctly_sorted() {
339        use clap::{Arg, ArgAction};
340
341        let cmd = Command::new("test")
342            .arg(Arg::new("file")) // Positional
343            .arg(Arg::new("debug").short('d').long("debug").action(ArgAction::SetTrue))
344            .arg(Arg::new("output").short('o').long("output"))
345            .arg(Arg::new("verbose").short('v').long("verbose").action(ArgAction::SetTrue))
346            .arg(Arg::new("config").long("config"))
347            .arg(Arg::new("no-color").long("no-color").action(ArgAction::SetTrue));
348
349        assert_sorted(&cmd);
350    }
351
352    #[test]
353    #[should_panic(expected = "Flags with short options")]
354    fn test_short_flags_unsorted() {
355        use clap::Arg;
356
357        let cmd = Command::new("test")
358            .arg(Arg::new("verbose").short('v').long("verbose"))
359            .arg(Arg::new("debug").short('d').long("debug"));
360
361        assert_sorted(&cmd);
362    }
363
364    #[test]
365    #[should_panic(expected = "Long-only flags")]
366    fn test_long_only_unsorted() {
367        use clap::{Arg, ArgAction};
368
369        let cmd = Command::new("test")
370            .arg(Arg::new("zebra").long("zebra").action(ArgAction::SetTrue))
371            .arg(Arg::new("alpha").long("alpha").action(ArgAction::SetTrue));
372
373        assert_sorted(&cmd);
374    }
375
376    #[test]
377    #[should_panic(expected = "not in correct group order")]
378    fn test_wrong_group_order() {
379        use clap::Arg;
380
381        // Long-only flag before short flag
382        let cmd = Command::new("test")
383            .arg(Arg::new("config").long("config"))
384            .arg(Arg::new("verbose").short('v').long("verbose"));
385
386        assert_sorted(&cmd);
387    }
388
389    #[test]
390    fn test_positional_order_not_enforced() {
391        // Positional arguments can be in any order since their order matters for parsing
392        let cmd = Command::new("test")
393            .arg(clap::Arg::new("second"))
394            .arg(clap::Arg::new("first"));
395
396        assert_sorted(&cmd);
397    }
398
399    #[test]
400    fn test_is_sorted_ok_with_args() {
401        use clap::{Arg, ArgAction};
402
403        let cmd = Command::new("test")
404            .arg(Arg::new("file"))
405            .arg(Arg::new("output").short('o').long("output"))
406            .arg(Arg::new("config").long("config"))
407            .subcommand(Command::new("add"))
408            .subcommand(Command::new("delete"));
409
410        assert!(is_sorted(&cmd).is_ok());
411    }
412
413    #[test]
414    fn test_is_sorted_err_args() {
415        use clap::Arg;
416
417        let cmd = Command::new("test")
418            .arg(Arg::new("zebra").short('z').long("zebra"))
419            .arg(Arg::new("alpha").short('a').long("alpha"));
420
421        assert!(is_sorted(&cmd).is_err());
422    }
423
424    #[test]
425    fn test_recursive_subcommand_args() {
426        use clap::{Arg, ArgAction};
427
428        let cmd = Command::new("test")
429            .arg(Arg::new("verbose").short('v').long("verbose").action(ArgAction::SetTrue))
430            .subcommand(
431                Command::new("sub")
432                    .arg(Arg::new("debug").short('d').long("debug").action(ArgAction::SetTrue))
433                    .arg(Arg::new("output").short('o').long("output")),
434            );
435
436        assert_sorted(&cmd);
437    }
438
439    #[test]
440    #[should_panic(expected = "Flags with short options")]
441    fn test_recursive_subcommand_args_fails() {
442        use clap::Arg;
443
444        let cmd = Command::new("test")
445            .subcommand(
446                Command::new("sub")
447                    .arg(Arg::new("output").short('o').long("output"))
448                    .arg(Arg::new("debug").short('d').long("debug")),
449            );
450
451        assert_sorted(&cmd);
452    }
453}