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 (alphabetically)
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 (alphabetically)
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    // Check positional args are sorted
139    let positional_ids: Vec<&str> = positional.iter().map(|a| a.get_id().as_str()).collect();
140    let mut sorted_positional = positional_ids.clone();
141    sorted_positional.sort_unstable();
142
143    if positional_ids != sorted_positional {
144        return Err(format!(
145            "Positional arguments in '{}' are not sorted!\nActual: {:?}\nExpected: {:?}",
146            cmd.get_name(),
147            positional_ids,
148            sorted_positional
149        ));
150    }
151
152    // Check short flags are sorted by short option
153    let with_short_shorts: Vec<char> = with_short
154        .iter()
155        .filter_map(|a| a.get_short())
156        .collect();
157    let mut sorted_shorts = with_short_shorts.clone();
158    sorted_shorts.sort_by(|a, b| {
159        let a_lower = a.to_ascii_lowercase();
160        let b_lower = b.to_ascii_lowercase();
161        match a_lower.cmp(&b_lower) {
162            std::cmp::Ordering::Equal => {
163                // Lowercase before uppercase for same letter
164                if a.is_lowercase() && b.is_uppercase() {
165                    std::cmp::Ordering::Less
166                } else if a.is_uppercase() && b.is_lowercase() {
167                    std::cmp::Ordering::Greater
168                } else {
169                    std::cmp::Ordering::Equal
170                }
171            }
172            other => other,
173        }
174    });
175
176    if with_short_shorts != sorted_shorts {
177        let current: Vec<String> = with_short
178            .iter()
179            .map(|a| format!("-{}", a.get_short().unwrap()))
180            .collect();
181        let mut sorted_args = with_short.clone();
182        sorted_args.sort_by(|a, b| {
183            let a_char = a.get_short().unwrap();
184            let b_char = b.get_short().unwrap();
185            let a_lower = a_char.to_ascii_lowercase();
186            let b_lower = b_char.to_ascii_lowercase();
187            match a_lower.cmp(&b_lower) {
188                std::cmp::Ordering::Equal => {
189                    if a_char.is_lowercase() && b_char.is_uppercase() {
190                        std::cmp::Ordering::Less
191                    } else if a_char.is_uppercase() && b_char.is_lowercase() {
192                        std::cmp::Ordering::Greater
193                    } else {
194                        std::cmp::Ordering::Equal
195                    }
196                }
197                other => other,
198            }
199        });
200        let expected: Vec<String> = sorted_args
201            .iter()
202            .map(|a| format!("-{}", a.get_short().unwrap()))
203            .collect();
204
205        return Err(format!(
206            "Flags with short options in '{}' are not sorted!\nActual: {:?}\nExpected: {:?}",
207            cmd.get_name(),
208            current,
209            expected
210        ));
211    }
212
213    // Check long-only flags are sorted
214    let long_only_longs: Vec<&str> = long_only
215        .iter()
216        .filter_map(|a| a.get_long())
217        .collect();
218    let mut sorted_longs = long_only_longs.clone();
219    sorted_longs.sort_unstable();
220
221    if long_only_longs != sorted_longs {
222        let current: Vec<String> = long_only_longs
223            .iter()
224            .map(|l| format!("--{}", l))
225            .collect();
226        let expected: Vec<String> = sorted_longs.iter().map(|l| format!("--{}", l)).collect();
227
228        return Err(format!(
229            "Long-only flags in '{}' are not sorted!\nActual: {:?}\nExpected: {:?}",
230            cmd.get_name(),
231            current,
232            expected
233        ));
234    }
235
236    // Check that groups appear in correct order
237    let arg_ids: Vec<&str> = args.iter().map(|a| a.get_id().as_str()).collect();
238
239    let mut expected_order = Vec::new();
240    expected_order.extend(positional.iter().map(|a| a.get_id().as_str()));
241    expected_order.extend(with_short.iter().map(|a| a.get_id().as_str()));
242    expected_order.extend(long_only.iter().map(|a| a.get_id().as_str()));
243
244    if arg_ids != expected_order {
245        return Err(format!(
246            "Arguments in '{}' are not in correct group order!\nExpected: [positional, short flags, long-only flags]\nActual: {:?}\nExpected: {:?}",
247            cmd.get_name(),
248            arg_ids,
249            expected_order
250        ));
251    }
252
253    Ok(())
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259    use clap::{Command, CommandFactory, Parser, Subcommand};
260
261    #[test]
262    fn test_sorted_subcommands() {
263        let cmd = Command::new("test")
264            .subcommand(Command::new("add"))
265            .subcommand(Command::new("delete"))
266            .subcommand(Command::new("list"));
267
268        assert_sorted(&cmd);
269    }
270
271    #[test]
272    #[should_panic(expected = "are not sorted alphabetically")]
273    fn test_unsorted_subcommands() {
274        let cmd = Command::new("test")
275            .subcommand(Command::new("list"))
276            .subcommand(Command::new("add"))
277            .subcommand(Command::new("delete"));
278
279        assert_sorted(&cmd);
280    }
281
282    #[test]
283    fn test_is_sorted_ok() {
284        let cmd = Command::new("test")
285            .subcommand(Command::new("add"))
286            .subcommand(Command::new("delete"))
287            .subcommand(Command::new("list"));
288
289        assert!(is_sorted(&cmd).is_ok());
290    }
291
292    #[test]
293    fn test_is_sorted_err() {
294        let cmd = Command::new("test")
295            .subcommand(Command::new("list"))
296            .subcommand(Command::new("add"));
297
298        assert!(is_sorted(&cmd).is_err());
299    }
300
301    #[test]
302    fn test_no_subcommands() {
303        let cmd = Command::new("test");
304        assert_sorted(&cmd);
305        assert!(is_sorted(&cmd).is_ok());
306    }
307
308    #[test]
309    fn test_with_derive_sorted() {
310        #[derive(Parser)]
311        struct Cli {
312            #[command(subcommand)]
313            command: Commands,
314        }
315
316        #[derive(Subcommand)]
317        enum Commands {
318            Add,
319            Delete,
320            List,
321        }
322
323        let cmd = Cli::command();
324        assert_sorted(&cmd);
325    }
326
327    #[test]
328    #[should_panic(expected = "are not sorted alphabetically")]
329    fn test_with_derive_unsorted() {
330        #[derive(Parser)]
331        struct Cli {
332            #[command(subcommand)]
333            command: Commands,
334        }
335
336        #[derive(Subcommand)]
337        enum Commands {
338            List,
339            Add,
340            Delete,
341        }
342
343        let cmd = Cli::command();
344        assert_sorted(&cmd);
345    }
346
347    // Tests for argument sorting
348
349    #[test]
350    fn test_arguments_correctly_sorted() {
351        use clap::{Arg, ArgAction};
352
353        let cmd = Command::new("test")
354            .arg(Arg::new("file")) // Positional
355            .arg(Arg::new("debug").short('d').long("debug").action(ArgAction::SetTrue))
356            .arg(Arg::new("output").short('o').long("output"))
357            .arg(Arg::new("verbose").short('v').long("verbose").action(ArgAction::SetTrue))
358            .arg(Arg::new("config").long("config"))
359            .arg(Arg::new("no-color").long("no-color").action(ArgAction::SetTrue));
360
361        assert_sorted(&cmd);
362    }
363
364    #[test]
365    #[should_panic(expected = "Flags with short options")]
366    fn test_short_flags_unsorted() {
367        use clap::Arg;
368
369        let cmd = Command::new("test")
370            .arg(Arg::new("verbose").short('v').long("verbose"))
371            .arg(Arg::new("debug").short('d').long("debug"));
372
373        assert_sorted(&cmd);
374    }
375
376    #[test]
377    #[should_panic(expected = "Long-only flags")]
378    fn test_long_only_unsorted() {
379        use clap::{Arg, ArgAction};
380
381        let cmd = Command::new("test")
382            .arg(Arg::new("zebra").long("zebra").action(ArgAction::SetTrue))
383            .arg(Arg::new("alpha").long("alpha").action(ArgAction::SetTrue));
384
385        assert_sorted(&cmd);
386    }
387
388    #[test]
389    #[should_panic(expected = "not in correct group order")]
390    fn test_wrong_group_order() {
391        use clap::Arg;
392
393        // Long-only flag before short flag
394        let cmd = Command::new("test")
395            .arg(Arg::new("config").long("config"))
396            .arg(Arg::new("verbose").short('v').long("verbose"));
397
398        assert_sorted(&cmd);
399    }
400
401    #[test]
402    #[should_panic(expected = "Positional arguments")]
403    fn test_positional_unsorted() {
404        let cmd = Command::new("test")
405            .arg(clap::Arg::new("second"))
406            .arg(clap::Arg::new("first"));
407
408        assert_sorted(&cmd);
409    }
410
411    #[test]
412    fn test_is_sorted_ok_with_args() {
413        use clap::{Arg, ArgAction};
414
415        let cmd = Command::new("test")
416            .arg(Arg::new("file"))
417            .arg(Arg::new("output").short('o').long("output"))
418            .arg(Arg::new("config").long("config"))
419            .subcommand(Command::new("add"))
420            .subcommand(Command::new("delete"));
421
422        assert!(is_sorted(&cmd).is_ok());
423    }
424
425    #[test]
426    fn test_is_sorted_err_args() {
427        use clap::Arg;
428
429        let cmd = Command::new("test")
430            .arg(Arg::new("zebra").short('z').long("zebra"))
431            .arg(Arg::new("alpha").short('a').long("alpha"));
432
433        assert!(is_sorted(&cmd).is_err());
434    }
435
436    #[test]
437    fn test_recursive_subcommand_args() {
438        use clap::{Arg, ArgAction};
439
440        let cmd = Command::new("test")
441            .arg(Arg::new("verbose").short('v').long("verbose").action(ArgAction::SetTrue))
442            .subcommand(
443                Command::new("sub")
444                    .arg(Arg::new("debug").short('d').long("debug").action(ArgAction::SetTrue))
445                    .arg(Arg::new("output").short('o').long("output")),
446            );
447
448        assert_sorted(&cmd);
449    }
450
451    #[test]
452    #[should_panic(expected = "Flags with short options")]
453    fn test_recursive_subcommand_args_fails() {
454        use clap::Arg;
455
456        let cmd = Command::new("test")
457            .subcommand(
458                Command::new("sub")
459                    .arg(Arg::new("output").short('o').long("output"))
460                    .arg(Arg::new("debug").short('d').long("debug")),
461            );
462
463        assert_sorted(&cmd);
464    }
465}