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
156        .iter()
157        .filter_map(|a| a.get_short())
158        .collect();
159    let mut sorted_shorts = with_short_shorts.clone();
160    sorted_shorts.sort_by(|a, b| {
161        let a_lower = a.to_ascii_lowercase();
162        let b_lower = b.to_ascii_lowercase();
163        match a_lower.cmp(&b_lower) {
164            std::cmp::Ordering::Equal => {
165                // Lowercase before uppercase for same letter
166                if a.is_lowercase() && b.is_uppercase() {
167                    std::cmp::Ordering::Less
168                } else if a.is_uppercase() && b.is_lowercase() {
169                    std::cmp::Ordering::Greater
170                } else {
171                    std::cmp::Ordering::Equal
172                }
173            }
174            other => other,
175        }
176    });
177
178    if with_short_shorts != sorted_shorts {
179        let current: Vec<String> = with_short
180            .iter()
181            .map(|a| format!("-{}", a.get_short().unwrap()))
182            .collect();
183        let mut sorted_args = with_short.clone();
184        sorted_args.sort_by(|a, b| {
185            let a_char = a.get_short().unwrap();
186            let b_char = b.get_short().unwrap();
187            let a_lower = a_char.to_ascii_lowercase();
188            let b_lower = b_char.to_ascii_lowercase();
189            match a_lower.cmp(&b_lower) {
190                std::cmp::Ordering::Equal => {
191                    if a_char.is_lowercase() && b_char.is_uppercase() {
192                        std::cmp::Ordering::Less
193                    } else if a_char.is_uppercase() && b_char.is_lowercase() {
194                        std::cmp::Ordering::Greater
195                    } else {
196                        std::cmp::Ordering::Equal
197                    }
198                }
199                other => other,
200            }
201        });
202        let expected: Vec<String> = sorted_args
203            .iter()
204            .map(|a| format!("-{}", a.get_short().unwrap()))
205            .collect();
206
207        return Err(format!(
208            "Flags with short options in '{}' are not sorted!\nActual: {:?}\nExpected: {:?}",
209            path.join(" "),
210            current,
211            expected
212        ));
213    }
214
215    // Check long-only flags are sorted
216    let long_only_longs: Vec<&str> = long_only
217        .iter()
218        .filter_map(|a| a.get_long())
219        .collect();
220    let mut sorted_longs = long_only_longs.clone();
221    sorted_longs.sort_unstable();
222
223    if long_only_longs != sorted_longs {
224        let current: Vec<String> = long_only_longs
225            .iter()
226            .map(|l| format!("--{}", l))
227            .collect();
228        let expected: Vec<String> = sorted_longs.iter().map(|l| format!("--{}", l)).collect();
229
230        return Err(format!(
231            "Long-only flags in '{}' are not sorted!\nActual: {:?}\nExpected: {:?}",
232            path.join(" "),
233            current,
234            expected
235        ));
236    }
237
238    // Check that groups appear in correct order
239    let arg_ids: Vec<&str> = args.iter().map(|a| a.get_id().as_str()).collect();
240
241    let mut expected_order = Vec::new();
242    expected_order.extend(positional.iter().map(|a| a.get_id().as_str()));
243    expected_order.extend(with_short.iter().map(|a| a.get_id().as_str()));
244    expected_order.extend(long_only.iter().map(|a| a.get_id().as_str()));
245
246    if arg_ids != expected_order {
247        return Err(format!(
248            "Arguments in '{}' are not in correct group order!\nExpected: [positional, short flags, long-only flags]\nActual: {:?}\nExpected: {:?}",
249            path.join(" "),
250            arg_ids,
251            expected_order
252        ));
253    }
254
255    Ok(())
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261    use clap::{Command, CommandFactory, Parser, Subcommand};
262
263    #[test]
264    fn test_sorted_subcommands() {
265        let cmd = Command::new("test")
266            .subcommand(Command::new("add"))
267            .subcommand(Command::new("delete"))
268            .subcommand(Command::new("list"));
269
270        assert_sorted(&cmd);
271    }
272
273    #[test]
274    #[should_panic(expected = "are not sorted alphabetically")]
275    fn test_unsorted_subcommands() {
276        let cmd = Command::new("test")
277            .subcommand(Command::new("list"))
278            .subcommand(Command::new("add"))
279            .subcommand(Command::new("delete"));
280
281        assert_sorted(&cmd);
282    }
283
284    #[test]
285    fn test_is_sorted_ok() {
286        let cmd = Command::new("test")
287            .subcommand(Command::new("add"))
288            .subcommand(Command::new("delete"))
289            .subcommand(Command::new("list"));
290
291        assert!(is_sorted(&cmd).is_ok());
292    }
293
294    #[test]
295    fn test_is_sorted_err() {
296        let cmd = Command::new("test")
297            .subcommand(Command::new("list"))
298            .subcommand(Command::new("add"));
299
300        assert!(is_sorted(&cmd).is_err());
301    }
302
303    #[test]
304    fn test_no_subcommands() {
305        let cmd = Command::new("test");
306        assert_sorted(&cmd);
307        assert!(is_sorted(&cmd).is_ok());
308    }
309
310    #[test]
311    fn test_with_derive_sorted() {
312        #[derive(Parser)]
313        struct Cli {
314            #[command(subcommand)]
315            command: Commands,
316        }
317
318        #[derive(Subcommand)]
319        enum Commands {
320            Add,
321            Delete,
322            List,
323        }
324
325        let cmd = Cli::command();
326        assert_sorted(&cmd);
327    }
328
329    #[test]
330    #[should_panic(expected = "are not sorted alphabetically")]
331    fn test_with_derive_unsorted() {
332        #[derive(Parser)]
333        struct Cli {
334            #[command(subcommand)]
335            command: Commands,
336        }
337
338        #[derive(Subcommand)]
339        enum Commands {
340            List,
341            Add,
342            Delete,
343        }
344
345        let cmd = Cli::command();
346        assert_sorted(&cmd);
347    }
348
349    // Tests for argument sorting
350
351    #[test]
352    fn test_arguments_correctly_sorted() {
353        use clap::{Arg, ArgAction};
354
355        let cmd = Command::new("test")
356            .arg(Arg::new("file")) // Positional
357            .arg(Arg::new("debug").short('d').long("debug").action(ArgAction::SetTrue))
358            .arg(Arg::new("output").short('o').long("output"))
359            .arg(Arg::new("verbose").short('v').long("verbose").action(ArgAction::SetTrue))
360            .arg(Arg::new("config").long("config"))
361            .arg(Arg::new("no-color").long("no-color").action(ArgAction::SetTrue));
362
363        assert_sorted(&cmd);
364    }
365
366    #[test]
367    #[should_panic(expected = "Flags with short options")]
368    fn test_short_flags_unsorted() {
369        use clap::Arg;
370
371        let cmd = Command::new("test")
372            .arg(Arg::new("verbose").short('v').long("verbose"))
373            .arg(Arg::new("debug").short('d').long("debug"));
374
375        assert_sorted(&cmd);
376    }
377
378    #[test]
379    #[should_panic(expected = "Long-only flags")]
380    fn test_long_only_unsorted() {
381        use clap::{Arg, ArgAction};
382
383        let cmd = Command::new("test")
384            .arg(Arg::new("zebra").long("zebra").action(ArgAction::SetTrue))
385            .arg(Arg::new("alpha").long("alpha").action(ArgAction::SetTrue));
386
387        assert_sorted(&cmd);
388    }
389
390    #[test]
391    #[should_panic(expected = "not in correct group order")]
392    fn test_wrong_group_order() {
393        use clap::Arg;
394
395        // Long-only flag before short flag
396        let cmd = Command::new("test")
397            .arg(Arg::new("config").long("config"))
398            .arg(Arg::new("verbose").short('v').long("verbose"));
399
400        assert_sorted(&cmd);
401    }
402
403    #[test]
404    fn test_positional_order_not_enforced() {
405        // Positional arguments can be in any order since their order matters for parsing
406        let cmd = Command::new("test")
407            .arg(clap::Arg::new("second"))
408            .arg(clap::Arg::new("first"));
409
410        assert_sorted(&cmd);
411    }
412
413    #[test]
414    fn test_is_sorted_ok_with_args() {
415        use clap::Arg;
416
417        let cmd = Command::new("test")
418            .arg(Arg::new("file"))
419            .arg(Arg::new("output").short('o').long("output"))
420            .arg(Arg::new("config").long("config"))
421            .subcommand(Command::new("add"))
422            .subcommand(Command::new("delete"));
423
424        assert!(is_sorted(&cmd).is_ok());
425    }
426
427    #[test]
428    fn test_is_sorted_err_args() {
429        use clap::Arg;
430
431        let cmd = Command::new("test")
432            .arg(Arg::new("zebra").short('z').long("zebra"))
433            .arg(Arg::new("alpha").short('a').long("alpha"));
434
435        assert!(is_sorted(&cmd).is_err());
436    }
437
438    #[test]
439    fn test_recursive_subcommand_args() {
440        use clap::{Arg, ArgAction};
441
442        let cmd = Command::new("test")
443            .arg(Arg::new("verbose").short('v').long("verbose").action(ArgAction::SetTrue))
444            .subcommand(
445                Command::new("sub")
446                    .arg(Arg::new("debug").short('d').long("debug").action(ArgAction::SetTrue))
447                    .arg(Arg::new("output").short('o').long("output")),
448            );
449
450        assert_sorted(&cmd);
451    }
452
453    #[test]
454    #[should_panic(expected = "Flags with short options")]
455    fn test_recursive_subcommand_args_fails() {
456        use clap::Arg;
457
458        let cmd = Command::new("test")
459            .subcommand(
460                Command::new("sub")
461                    .arg(Arg::new("output").short('o').long("output"))
462                    .arg(Arg::new("debug").short('d').long("debug")),
463            );
464
465        assert_sorted(&cmd);
466    }
467
468    #[test]
469    fn test_global_flags_not_checked_in_subcommands() {
470        use clap::{Arg, ArgAction};
471
472        // Global flag 'v' should not interfere with subcommand's 'd' flag ordering
473        let cmd = Command::new("test")
474            .arg(Arg::new("verbose").short('v').long("verbose").global(true).action(ArgAction::SetTrue))
475            .subcommand(
476                Command::new("sub")
477                    .arg(Arg::new("debug").short('d').long("debug").action(ArgAction::SetTrue))
478                    .arg(Arg::new("output").short('o').long("output")),
479            );
480
481        // Should not panic - we only check non-global args in subcommands
482        assert_sorted(&cmd);
483    }
484
485    #[test]
486    fn test_global_flags_dont_appear_in_subcommand_args() {
487        use clap::{Arg, ArgAction};
488
489        // Verify that global flags don't appear in subcommand's get_arguments()
490        // This confirms there's no bug with global flags interfering with sorting
491        let cmd = Command::new("test")
492            .arg(Arg::new("verbose").short('v').long("verbose").global(true).action(ArgAction::SetTrue))
493            .subcommand(
494                Command::new("sub")
495                    .arg(Arg::new("debug").short('d').long("debug").action(ArgAction::SetTrue))
496                    .arg(Arg::new("output").short('o').long("output")),
497            );
498
499        let subcmd = cmd.find_subcommand("sub").unwrap();
500        let args: Vec<_> = subcmd.get_arguments().collect();
501
502        // Subcommand should only see its own 2 args, not the global flag
503        assert_eq!(args.len(), 2);
504
505        // Verify neither arg is global
506        for arg in &args {
507            assert!(!arg.is_global_set(), "Subcommand arg {} should not be global", arg.get_id());
508        }
509
510        // Should pass - subcommand args are sorted and global flag doesn't interfere
511        assert_sorted(&cmd);
512    }
513
514    #[test]
515    #[should_panic(expected = "Flags with short options")]
516    fn test_uppercase_before_lowercase_same_letter() {
517        use clap::Arg;
518
519        // Uppercase I before lowercase i - should fail
520        let cmd = Command::new("test")
521            .arg(Arg::new("index").short('I').long("index"))
522            .arg(Arg::new("inject").short('i').long("inject"));
523
524        assert_sorted(&cmd);
525    }
526
527    #[test]
528    fn test_lowercase_before_uppercase_same_letter() {
529        use clap::Arg;
530
531        // Lowercase i before uppercase I - should pass
532        let cmd = Command::new("test")
533            .arg(Arg::new("inject").short('i').long("inject"))
534            .arg(Arg::new("index").short('I').long("index"));
535
536        assert_sorted(&cmd);
537    }
538
539    #[test]
540    #[should_panic(expected = "Flags with short options")]
541    fn test_task_docs_flags_unsorted() {
542        use clap::Arg;
543
544        // Reproduces the task_docs issue: -I before -i
545        let cmd = Command::new("generate")
546            .subcommand(
547                Command::new("task-docs")
548                    .arg(Arg::new("index").short('I').long("index"))
549                    .arg(Arg::new("inject").short('i').long("inject"))
550                    .arg(Arg::new("multi").short('m').long("multi"))
551                    .arg(Arg::new("output").short('o').long("output"))
552                    .arg(Arg::new("root").short('r').long("root"))
553                    .arg(Arg::new("style").short('s').long("style")),
554            );
555
556        assert_sorted(&cmd);
557    }
558
559    #[test]
560    fn test_error_message_shows_full_command_path() {
561        use clap::Arg;
562
563        // Test that error message shows the full command path
564        let cmd = Command::new("parent-has-no-flags")
565            .subcommand(
566                Command::new("child-has-unsorted-flags")
567                    .arg(Arg::new("zebra").short('z').long("zebra"))
568                    .arg(Arg::new("alpha").short('a').long("alpha")),
569            );
570
571        let result = is_sorted(&cmd);
572        assert!(result.is_err());
573        let err = result.unwrap_err();
574
575        // Should show full path: 'parent-has-no-flags child-has-unsorted-flags'
576        assert!(err.contains("parent-has-no-flags child-has-unsorted-flags"),
577            "Error message should contain full path, got: {}", err);
578    }
579
580    #[test]
581    fn test_error_with_derive_api_nested_subcommands() {
582        use clap::{Args, Parser, Subcommand};
583
584        #[derive(Parser)]
585        struct Cli {
586            #[command(subcommand)]
587            command: Commands,
588        }
589
590        #[derive(Subcommand)]
591        enum Commands {
592            /// Generate command
593            Generate(GenerateArgs),
594        }
595
596        #[derive(Args)]
597        struct GenerateArgs {
598            #[command(subcommand)]
599            command: GenerateCommands,
600        }
601
602        #[derive(Subcommand)]
603        enum GenerateCommands {
604            /// Task docs subcommand
605            TaskDocs(TaskDocsArgs),
606        }
607
608        #[derive(Args)]
609        struct TaskDocsArgs {
610            /// task flag
611            #[arg(short, long)]
612            task: Option<String>,
613
614            /// output flag
615            #[arg(short, long)]
616            output: Option<String>,
617        }
618
619        let cmd = Cli::command();
620        let result = is_sorted(&cmd);
621
622        if let Err(e) = result {
623            // The error should show the full path
624            eprintln!("Error message: {}", e);
625            // Since the root command is the Cli, and we have Generate, then TaskDocs,
626            // the full path would be something like "Cli Generate TaskDocs" but clap
627            // converts names to kebab-case, so it would be "cli generate task-docs"
628            // Actually, let me just check it contains both generate and task-docs
629            assert!(e.contains("task-docs"),
630                "Error should mention 'task-docs'. Got: {}", e);
631            assert!(e.contains("[\"-t\", \"-o\"]"),
632                "Error should show the actual unsorted flags. Got: {}", e);
633        } else {
634            panic!("Expected error for unsorted flags");
635        }
636    }
637}