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        // Build full command path for clearer error messages
234        let full_path = get_command_path(cmd);
235        return Err(format!(
236            "Arguments in '{}' are not in correct group order!\nExpected: [positional, short flags, long-only flags]\nActual: {:?}\nExpected: {:?}",
237            full_path,
238            arg_ids,
239            expected_order
240        ));
241    }
242
243    Ok(())
244}
245
246/// Get the full command path (e.g., "cache prune" instead of just "prune")
247fn get_command_path(cmd: &clap::Command) -> String {
248    let mut parts = vec![cmd.get_name()];
249    let mut current = cmd;
250
251    // Try to walk up to find parent commands
252    // Note: clap doesn't expose parent relationship, so we can only show the immediate name
253    // This is a limitation of clap's API
254    parts.into_iter().rev().collect::<Vec<_>>().join(" ")
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260    use clap::{Command, CommandFactory, Parser, Subcommand};
261
262    #[test]
263    fn test_sorted_subcommands() {
264        let cmd = Command::new("test")
265            .subcommand(Command::new("add"))
266            .subcommand(Command::new("delete"))
267            .subcommand(Command::new("list"));
268
269        assert_sorted(&cmd);
270    }
271
272    #[test]
273    #[should_panic(expected = "are not sorted alphabetically")]
274    fn test_unsorted_subcommands() {
275        let cmd = Command::new("test")
276            .subcommand(Command::new("list"))
277            .subcommand(Command::new("add"))
278            .subcommand(Command::new("delete"));
279
280        assert_sorted(&cmd);
281    }
282
283    #[test]
284    fn test_is_sorted_ok() {
285        let cmd = Command::new("test")
286            .subcommand(Command::new("add"))
287            .subcommand(Command::new("delete"))
288            .subcommand(Command::new("list"));
289
290        assert!(is_sorted(&cmd).is_ok());
291    }
292
293    #[test]
294    fn test_is_sorted_err() {
295        let cmd = Command::new("test")
296            .subcommand(Command::new("list"))
297            .subcommand(Command::new("add"));
298
299        assert!(is_sorted(&cmd).is_err());
300    }
301
302    #[test]
303    fn test_no_subcommands() {
304        let cmd = Command::new("test");
305        assert_sorted(&cmd);
306        assert!(is_sorted(&cmd).is_ok());
307    }
308
309    #[test]
310    fn test_with_derive_sorted() {
311        #[derive(Parser)]
312        struct Cli {
313            #[command(subcommand)]
314            command: Commands,
315        }
316
317        #[derive(Subcommand)]
318        enum Commands {
319            Add,
320            Delete,
321            List,
322        }
323
324        let cmd = Cli::command();
325        assert_sorted(&cmd);
326    }
327
328    #[test]
329    #[should_panic(expected = "are not sorted alphabetically")]
330    fn test_with_derive_unsorted() {
331        #[derive(Parser)]
332        struct Cli {
333            #[command(subcommand)]
334            command: Commands,
335        }
336
337        #[derive(Subcommand)]
338        enum Commands {
339            List,
340            Add,
341            Delete,
342        }
343
344        let cmd = Cli::command();
345        assert_sorted(&cmd);
346    }
347
348    // Tests for argument sorting
349
350    #[test]
351    fn test_arguments_correctly_sorted() {
352        use clap::{Arg, ArgAction};
353
354        let cmd = Command::new("test")
355            .arg(Arg::new("file")) // Positional
356            .arg(Arg::new("debug").short('d').long("debug").action(ArgAction::SetTrue))
357            .arg(Arg::new("output").short('o').long("output"))
358            .arg(Arg::new("verbose").short('v').long("verbose").action(ArgAction::SetTrue))
359            .arg(Arg::new("config").long("config"))
360            .arg(Arg::new("no-color").long("no-color").action(ArgAction::SetTrue));
361
362        assert_sorted(&cmd);
363    }
364
365    #[test]
366    #[should_panic(expected = "Flags with short options")]
367    fn test_short_flags_unsorted() {
368        use clap::Arg;
369
370        let cmd = Command::new("test")
371            .arg(Arg::new("verbose").short('v').long("verbose"))
372            .arg(Arg::new("debug").short('d').long("debug"));
373
374        assert_sorted(&cmd);
375    }
376
377    #[test]
378    #[should_panic(expected = "Long-only flags")]
379    fn test_long_only_unsorted() {
380        use clap::{Arg, ArgAction};
381
382        let cmd = Command::new("test")
383            .arg(Arg::new("zebra").long("zebra").action(ArgAction::SetTrue))
384            .arg(Arg::new("alpha").long("alpha").action(ArgAction::SetTrue));
385
386        assert_sorted(&cmd);
387    }
388
389    #[test]
390    #[should_panic(expected = "not in correct group order")]
391    fn test_wrong_group_order() {
392        use clap::Arg;
393
394        // Long-only flag before short flag
395        let cmd = Command::new("test")
396            .arg(Arg::new("config").long("config"))
397            .arg(Arg::new("verbose").short('v').long("verbose"));
398
399        assert_sorted(&cmd);
400    }
401
402    #[test]
403    fn test_positional_order_not_enforced() {
404        // Positional arguments can be in any order since their order matters for parsing
405        let cmd = Command::new("test")
406            .arg(clap::Arg::new("second"))
407            .arg(clap::Arg::new("first"));
408
409        assert_sorted(&cmd);
410    }
411
412    #[test]
413    fn test_is_sorted_ok_with_args() {
414        use clap::{Arg, ArgAction};
415
416        let cmd = Command::new("test")
417            .arg(Arg::new("file"))
418            .arg(Arg::new("output").short('o').long("output"))
419            .arg(Arg::new("config").long("config"))
420            .subcommand(Command::new("add"))
421            .subcommand(Command::new("delete"));
422
423        assert!(is_sorted(&cmd).is_ok());
424    }
425
426    #[test]
427    fn test_is_sorted_err_args() {
428        use clap::Arg;
429
430        let cmd = Command::new("test")
431            .arg(Arg::new("zebra").short('z').long("zebra"))
432            .arg(Arg::new("alpha").short('a').long("alpha"));
433
434        assert!(is_sorted(&cmd).is_err());
435    }
436
437    #[test]
438    fn test_recursive_subcommand_args() {
439        use clap::{Arg, ArgAction};
440
441        let cmd = Command::new("test")
442            .arg(Arg::new("verbose").short('v').long("verbose").action(ArgAction::SetTrue))
443            .subcommand(
444                Command::new("sub")
445                    .arg(Arg::new("debug").short('d').long("debug").action(ArgAction::SetTrue))
446                    .arg(Arg::new("output").short('o').long("output")),
447            );
448
449        assert_sorted(&cmd);
450    }
451
452    #[test]
453    #[should_panic(expected = "Flags with short options")]
454    fn test_recursive_subcommand_args_fails() {
455        use clap::Arg;
456
457        let cmd = Command::new("test")
458            .subcommand(
459                Command::new("sub")
460                    .arg(Arg::new("output").short('o').long("output"))
461                    .arg(Arg::new("debug").short('d').long("debug")),
462            );
463
464        assert_sorted(&cmd);
465    }
466
467    #[test]
468    fn test_global_flags_not_checked_in_subcommands() {
469        use clap::{Arg, ArgAction};
470
471        // Global flag 'v' should not interfere with subcommand's 'd' flag ordering
472        let cmd = Command::new("test")
473            .arg(Arg::new("verbose").short('v').long("verbose").global(true).action(ArgAction::SetTrue))
474            .subcommand(
475                Command::new("sub")
476                    .arg(Arg::new("debug").short('d').long("debug").action(ArgAction::SetTrue))
477                    .arg(Arg::new("output").short('o').long("output")),
478            );
479
480        // Should not panic - we only check non-global args in subcommands
481        assert_sorted(&cmd);
482    }
483
484    #[test]
485    fn test_global_flags_dont_appear_in_subcommand_args() {
486        use clap::{Arg, ArgAction};
487
488        // Verify that global flags don't appear in subcommand's get_arguments()
489        // This confirms there's no bug with global flags interfering with sorting
490        let cmd = Command::new("test")
491            .arg(Arg::new("verbose").short('v').long("verbose").global(true).action(ArgAction::SetTrue))
492            .subcommand(
493                Command::new("sub")
494                    .arg(Arg::new("debug").short('d').long("debug").action(ArgAction::SetTrue))
495                    .arg(Arg::new("output").short('o').long("output")),
496            );
497
498        let subcmd = cmd.find_subcommand("sub").unwrap();
499        let args: Vec<_> = subcmd.get_arguments().collect();
500
501        // Subcommand should only see its own 2 args, not the global flag
502        assert_eq!(args.len(), 2);
503
504        // Verify neither arg is global
505        for arg in &args {
506            assert!(!arg.is_global_set(), "Subcommand arg {} should not be global", arg.get_id());
507        }
508
509        // Should pass - subcommand args are sorted and global flag doesn't interfere
510        assert_sorted(&cmd);
511    }
512}