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    assert_sorted_with_path(cmd, vec![]);
40}
41
42fn assert_sorted_with_path(cmd: &clap::Command, parent_path: Vec<&str>) {
43    let mut current_path = parent_path.clone();
44    current_path.push(cmd.get_name());
45
46    // Check subcommands
47    let subcommands: Vec<_> = cmd.get_subcommands().map(|s| s.get_name()).collect();
48
49    if !subcommands.is_empty() {
50        let mut sorted = subcommands.clone();
51        sorted.sort();
52
53        if subcommands != sorted {
54            panic!(
55                "Subcommands in '{}' are not sorted alphabetically!\nActual order: {:?}\nExpected order: {:?}",
56                current_path.join(" "),
57                subcommands,
58                sorted
59            );
60        }
61    }
62
63    // Check arguments
64    assert_arguments_sorted_with_path(cmd, &current_path);
65
66    // Recursively check subcommands
67    for subcmd in cmd.get_subcommands() {
68        assert_sorted_with_path(subcmd, current_path.clone());
69    }
70}
71
72/// Checks if subcommands and arguments are sorted, returning a Result instead of panicking.
73///
74/// This checks:
75/// - Subcommands are sorted alphabetically
76/// - Arguments are grouped and sorted by type
77///
78/// Recursively validates all subcommands.
79///
80/// # Example
81///
82/// ```rust
83/// use clap::Command;
84///
85/// let cmd = Command::new("mycli");
86/// match clap_sort::is_sorted(&cmd) {
87///     Ok(()) => println!("Everything is sorted!"),
88///     Err(msg) => eprintln!("Error: {}", msg),
89/// }
90/// ```
91pub fn is_sorted(cmd: &clap::Command) -> Result<(), String> {
92    is_sorted_with_path(cmd, vec![])
93}
94
95fn is_sorted_with_path(cmd: &clap::Command, parent_path: Vec<&str>) -> Result<(), String> {
96    let mut current_path = parent_path.clone();
97    current_path.push(cmd.get_name());
98
99    // Check subcommands
100    let subcommands: Vec<_> = cmd.get_subcommands().map(|s| s.get_name()).collect();
101
102    if !subcommands.is_empty() {
103        let mut sorted = subcommands.clone();
104        sorted.sort();
105
106        if subcommands != sorted {
107            return Err(format!(
108                "Subcommands in '{}' are not sorted alphabetically!\nActual order: {:?}\nExpected order: {:?}",
109                current_path.join(" "),
110                subcommands,
111                sorted
112            ));
113        }
114    }
115
116    // Check arguments
117    is_arguments_sorted_with_path(cmd, &current_path)?;
118
119    // Recursively check subcommands
120    for subcmd in cmd.get_subcommands() {
121        is_sorted_with_path(subcmd, current_path.clone())?;
122    }
123
124    Ok(())
125}
126
127/// Internal function to assert arguments are sorted.
128fn assert_arguments_sorted_with_path(cmd: &clap::Command, path: &[&str]) {
129    if let Err(msg) = is_arguments_sorted_with_path(cmd, path) {
130        panic!("{}", msg);
131    }
132}
133
134/// Checks if arguments are sorted correctly, returning a Result.
135fn is_arguments_sorted_with_path(cmd: &clap::Command, path: &[&str]) -> Result<(), String> {
136    let args: Vec<_> = cmd.get_arguments().collect();
137
138    let mut positional = Vec::new();
139    let mut with_short = Vec::new();
140    let mut long_only = Vec::new();
141
142    for arg in &args {
143        if arg.is_positional() {
144            positional.push(*arg);
145        } else if arg.get_short().is_some() {
146            with_short.push(*arg);
147        } else if arg.get_long().is_some() {
148            long_only.push(*arg);
149        }
150    }
151
152    // Note: We don't check if positional args are sorted - their order matters for parsing
153
154    // Check short flags are sorted by short option
155    let with_short_shorts: Vec<char> = with_short.iter().filter_map(|a| a.get_short()).collect();
156    let mut sorted_shorts = with_short_shorts.clone();
157    sorted_shorts.sort_by(|a, b| {
158        let a_lower = a.to_ascii_lowercase();
159        let b_lower = b.to_ascii_lowercase();
160        match a_lower.cmp(&b_lower) {
161            std::cmp::Ordering::Equal => {
162                // Lowercase before uppercase for same letter
163                if a.is_lowercase() && b.is_uppercase() {
164                    std::cmp::Ordering::Less
165                } else if a.is_uppercase() && b.is_lowercase() {
166                    std::cmp::Ordering::Greater
167                } else {
168                    std::cmp::Ordering::Equal
169                }
170            }
171            other => other,
172        }
173    });
174
175    if with_short_shorts != sorted_shorts {
176        let current: Vec<String> = with_short
177            .iter()
178            .map(|a| format!("-{}", a.get_short().unwrap()))
179            .collect();
180        let mut sorted_args = with_short.clone();
181        sorted_args.sort_by(|a, b| {
182            let a_char = a.get_short().unwrap();
183            let b_char = b.get_short().unwrap();
184            let a_lower = a_char.to_ascii_lowercase();
185            let b_lower = b_char.to_ascii_lowercase();
186            match a_lower.cmp(&b_lower) {
187                std::cmp::Ordering::Equal => {
188                    if a_char.is_lowercase() && b_char.is_uppercase() {
189                        std::cmp::Ordering::Less
190                    } else if a_char.is_uppercase() && b_char.is_lowercase() {
191                        std::cmp::Ordering::Greater
192                    } else {
193                        std::cmp::Ordering::Equal
194                    }
195                }
196                other => other,
197            }
198        });
199        let expected: Vec<String> = sorted_args
200            .iter()
201            .map(|a| format!("-{}", a.get_short().unwrap()))
202            .collect();
203
204        return Err(format!(
205            "Flags with short options in '{}' are not sorted!\nActual: {:?}\nExpected: {:?}",
206            path.join(" "),
207            current,
208            expected
209        ));
210    }
211
212    // Check long-only flags are sorted
213    let long_only_longs: Vec<&str> = long_only.iter().filter_map(|a| a.get_long()).collect();
214    let mut sorted_longs = long_only_longs.clone();
215    sorted_longs.sort_unstable();
216
217    if long_only_longs != sorted_longs {
218        let current: Vec<String> = long_only_longs.iter().map(|l| format!("--{}", l)).collect();
219        let expected: Vec<String> = sorted_longs.iter().map(|l| format!("--{}", l)).collect();
220
221        return Err(format!(
222            "Long-only flags in '{}' are not sorted!\nActual: {:?}\nExpected: {:?}",
223            path.join(" "),
224            current,
225            expected
226        ));
227    }
228
229    // Skip group order checking when flattened structs are involved
230    // Flattened structs can cause positionals and flags to be interspersed,
231    // which is valid for clap but would fail a strict group order check.
232    // We only care that within each group (positionals, short flags, long-only flags),
233    // the items are sorted correctly.
234
235    Ok(())
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241    use clap::{Command, CommandFactory, Parser, Subcommand};
242
243    #[test]
244    fn test_sorted_subcommands() {
245        let cmd = Command::new("test")
246            .subcommand(Command::new("add"))
247            .subcommand(Command::new("delete"))
248            .subcommand(Command::new("list"));
249
250        assert_sorted(&cmd);
251    }
252
253    #[test]
254    #[should_panic(expected = "are not sorted alphabetically")]
255    fn test_unsorted_subcommands() {
256        let cmd = Command::new("test")
257            .subcommand(Command::new("list"))
258            .subcommand(Command::new("add"))
259            .subcommand(Command::new("delete"));
260
261        assert_sorted(&cmd);
262    }
263
264    #[test]
265    fn test_is_sorted_ok() {
266        let cmd = Command::new("test")
267            .subcommand(Command::new("add"))
268            .subcommand(Command::new("delete"))
269            .subcommand(Command::new("list"));
270
271        assert!(is_sorted(&cmd).is_ok());
272    }
273
274    #[test]
275    fn test_is_sorted_err() {
276        let cmd = Command::new("test")
277            .subcommand(Command::new("list"))
278            .subcommand(Command::new("add"));
279
280        assert!(is_sorted(&cmd).is_err());
281    }
282
283    #[test]
284    fn test_no_subcommands() {
285        let cmd = Command::new("test");
286        assert_sorted(&cmd);
287        assert!(is_sorted(&cmd).is_ok());
288    }
289
290    #[test]
291    fn test_with_derive_sorted() {
292        #[derive(Parser)]
293        struct Cli {
294            #[command(subcommand)]
295            command: Commands,
296        }
297
298        #[derive(Subcommand)]
299        enum Commands {
300            Add,
301            Delete,
302            List,
303        }
304
305        let cmd = Cli::command();
306        assert_sorted(&cmd);
307    }
308
309    #[test]
310    #[should_panic(expected = "are not sorted alphabetically")]
311    fn test_with_derive_unsorted() {
312        #[derive(Parser)]
313        struct Cli {
314            #[command(subcommand)]
315            command: Commands,
316        }
317
318        #[derive(Subcommand)]
319        enum Commands {
320            List,
321            Add,
322            Delete,
323        }
324
325        let cmd = Cli::command();
326        assert_sorted(&cmd);
327    }
328
329    // Tests for argument sorting
330
331    #[test]
332    fn test_arguments_correctly_sorted() {
333        use clap::{Arg, ArgAction};
334
335        let cmd = Command::new("test")
336            .arg(Arg::new("file")) // Positional
337            .arg(
338                Arg::new("debug")
339                    .short('d')
340                    .long("debug")
341                    .action(ArgAction::SetTrue),
342            )
343            .arg(Arg::new("output").short('o').long("output"))
344            .arg(
345                Arg::new("verbose")
346                    .short('v')
347                    .long("verbose")
348                    .action(ArgAction::SetTrue),
349            )
350            .arg(Arg::new("config").long("config"))
351            .arg(
352                Arg::new("no-color")
353                    .long("no-color")
354                    .action(ArgAction::SetTrue),
355            );
356
357        assert_sorted(&cmd);
358    }
359
360    #[test]
361    #[should_panic(expected = "Flags with short options")]
362    fn test_short_flags_unsorted() {
363        use clap::Arg;
364
365        let cmd = Command::new("test")
366            .arg(Arg::new("verbose").short('v').long("verbose"))
367            .arg(Arg::new("debug").short('d').long("debug"));
368
369        assert_sorted(&cmd);
370    }
371
372    #[test]
373    #[should_panic(expected = "Long-only flags")]
374    fn test_long_only_unsorted() {
375        use clap::{Arg, ArgAction};
376
377        let cmd = Command::new("test")
378            .arg(Arg::new("zebra").long("zebra").action(ArgAction::SetTrue))
379            .arg(Arg::new("alpha").long("alpha").action(ArgAction::SetTrue));
380
381        assert_sorted(&cmd);
382    }
383
384    #[test]
385    fn test_positional_order_not_enforced() {
386        // Positional arguments can be in any order since their order matters for parsing
387        let cmd = Command::new("test")
388            .arg(clap::Arg::new("second"))
389            .arg(clap::Arg::new("first"));
390
391        assert_sorted(&cmd);
392    }
393
394    #[test]
395    fn test_is_sorted_ok_with_args() {
396        use clap::Arg;
397
398        let cmd = Command::new("test")
399            .arg(Arg::new("file"))
400            .arg(Arg::new("output").short('o').long("output"))
401            .arg(Arg::new("config").long("config"))
402            .subcommand(Command::new("add"))
403            .subcommand(Command::new("delete"));
404
405        assert!(is_sorted(&cmd).is_ok());
406    }
407
408    #[test]
409    fn test_is_sorted_err_args() {
410        use clap::Arg;
411
412        let cmd = Command::new("test")
413            .arg(Arg::new("zebra").short('z').long("zebra"))
414            .arg(Arg::new("alpha").short('a').long("alpha"));
415
416        assert!(is_sorted(&cmd).is_err());
417    }
418
419    #[test]
420    fn test_recursive_subcommand_args() {
421        use clap::{Arg, ArgAction};
422
423        let cmd = Command::new("test")
424            .arg(
425                Arg::new("verbose")
426                    .short('v')
427                    .long("verbose")
428                    .action(ArgAction::SetTrue),
429            )
430            .subcommand(
431                Command::new("sub")
432                    .arg(
433                        Arg::new("debug")
434                            .short('d')
435                            .long("debug")
436                            .action(ArgAction::SetTrue),
437                    )
438                    .arg(Arg::new("output").short('o').long("output")),
439            );
440
441        assert_sorted(&cmd);
442    }
443
444    #[test]
445    #[should_panic(expected = "Flags with short options")]
446    fn test_recursive_subcommand_args_fails() {
447        use clap::Arg;
448
449        let cmd = Command::new("test").subcommand(
450            Command::new("sub")
451                .arg(Arg::new("output").short('o').long("output"))
452                .arg(Arg::new("debug").short('d').long("debug")),
453        );
454
455        assert_sorted(&cmd);
456    }
457
458    #[test]
459    fn test_global_flags_not_checked_in_subcommands() {
460        use clap::{Arg, ArgAction};
461
462        // Global flag 'v' should not interfere with subcommand's 'd' flag ordering
463        let cmd = Command::new("test")
464            .arg(
465                Arg::new("verbose")
466                    .short('v')
467                    .long("verbose")
468                    .global(true)
469                    .action(ArgAction::SetTrue),
470            )
471            .subcommand(
472                Command::new("sub")
473                    .arg(
474                        Arg::new("debug")
475                            .short('d')
476                            .long("debug")
477                            .action(ArgAction::SetTrue),
478                    )
479                    .arg(Arg::new("output").short('o').long("output")),
480            );
481
482        // Should not panic - we only check non-global args in subcommands
483        assert_sorted(&cmd);
484    }
485
486    #[test]
487    fn test_global_flags_dont_appear_in_subcommand_args() {
488        use clap::{Arg, ArgAction};
489
490        // Verify that global flags don't appear in subcommand's get_arguments()
491        // This confirms there's no bug with global flags interfering with sorting
492        let cmd = Command::new("test")
493            .arg(
494                Arg::new("verbose")
495                    .short('v')
496                    .long("verbose")
497                    .global(true)
498                    .action(ArgAction::SetTrue),
499            )
500            .subcommand(
501                Command::new("sub")
502                    .arg(
503                        Arg::new("debug")
504                            .short('d')
505                            .long("debug")
506                            .action(ArgAction::SetTrue),
507                    )
508                    .arg(Arg::new("output").short('o').long("output")),
509            );
510
511        let subcmd = cmd.find_subcommand("sub").unwrap();
512        let args: Vec<_> = subcmd.get_arguments().collect();
513
514        // Subcommand should only see its own 2 args, not the global flag
515        assert_eq!(args.len(), 2);
516
517        // Verify neither arg is global
518        for arg in &args {
519            assert!(
520                !arg.is_global_set(),
521                "Subcommand arg {} should not be global",
522                arg.get_id()
523            );
524        }
525
526        // Should pass - subcommand args are sorted and global flag doesn't interfere
527        assert_sorted(&cmd);
528    }
529
530    #[test]
531    #[should_panic(expected = "Flags with short options")]
532    fn test_uppercase_before_lowercase_same_letter() {
533        use clap::Arg;
534
535        // Uppercase I before lowercase i - should fail
536        let cmd = Command::new("test")
537            .arg(Arg::new("index").short('I').long("index"))
538            .arg(Arg::new("inject").short('i').long("inject"));
539
540        assert_sorted(&cmd);
541    }
542
543    #[test]
544    fn test_lowercase_before_uppercase_same_letter() {
545        use clap::Arg;
546
547        // Lowercase i before uppercase I - should pass
548        let cmd = Command::new("test")
549            .arg(Arg::new("inject").short('i').long("inject"))
550            .arg(Arg::new("index").short('I').long("index"));
551
552        assert_sorted(&cmd);
553    }
554
555    #[test]
556    #[should_panic(expected = "Flags with short options")]
557    fn test_task_docs_flags_unsorted() {
558        use clap::Arg;
559
560        // Reproduces the task_docs issue: -I before -i
561        let cmd = Command::new("generate").subcommand(
562            Command::new("task-docs")
563                .arg(Arg::new("index").short('I').long("index"))
564                .arg(Arg::new("inject").short('i').long("inject"))
565                .arg(Arg::new("multi").short('m').long("multi"))
566                .arg(Arg::new("output").short('o').long("output"))
567                .arg(Arg::new("root").short('r').long("root"))
568                .arg(Arg::new("style").short('s').long("style")),
569        );
570
571        assert_sorted(&cmd);
572    }
573
574    #[test]
575    fn test_error_message_shows_full_command_path() {
576        use clap::Arg;
577
578        // Test that error message shows the full command path
579        let cmd = Command::new("parent-has-no-flags").subcommand(
580            Command::new("child-has-unsorted-flags")
581                .arg(Arg::new("zebra").short('z').long("zebra"))
582                .arg(Arg::new("alpha").short('a').long("alpha")),
583        );
584
585        let result = is_sorted(&cmd);
586        assert!(result.is_err());
587        let err = result.unwrap_err();
588
589        // Should show full path: 'parent-has-no-flags child-has-unsorted-flags'
590        assert!(
591            err.contains("parent-has-no-flags child-has-unsorted-flags"),
592            "Error message should contain full path, got: {}",
593            err
594        );
595    }
596
597    #[test]
598    fn test_error_with_derive_api_nested_subcommands() {
599        use clap::{Args, Parser, Subcommand};
600
601        #[derive(Parser)]
602        struct Cli {
603            #[command(subcommand)]
604            command: Commands,
605        }
606
607        #[derive(Subcommand)]
608        enum Commands {
609            /// Generate command
610            Generate(GenerateArgs),
611        }
612
613        #[derive(Args)]
614        struct GenerateArgs {
615            #[command(subcommand)]
616            command: GenerateCommands,
617        }
618
619        #[derive(Subcommand)]
620        enum GenerateCommands {
621            /// Task docs subcommand
622            TaskDocs(TaskDocsArgs),
623        }
624
625        #[derive(Args)]
626        struct TaskDocsArgs {
627            /// task flag
628            #[arg(short, long)]
629            task: Option<String>,
630
631            /// output flag
632            #[arg(short, long)]
633            output: Option<String>,
634        }
635
636        let cmd = Cli::command();
637        let result = is_sorted(&cmd);
638
639        if let Err(e) = result {
640            // The error should show the full path
641            eprintln!("Error message: {}", e);
642            // Since the root command is the Cli, and we have Generate, then TaskDocs,
643            // the full path would be something like "Cli Generate TaskDocs" but clap
644            // converts names to kebab-case, so it would be "cli generate task-docs"
645            // Actually, let me just check it contains both generate and task-docs
646            assert!(
647                e.contains("task-docs"),
648                "Error should mention 'task-docs'. Got: {}",
649                e
650            );
651            assert!(
652                e.contains("[\"-t\", \"-o\"]"),
653                "Error should show the actual unsorted flags. Got: {}",
654                e
655            );
656        } else {
657            panic!("Expected error for unsorted flags");
658        }
659    }
660}