1use clap::{Parser, Subcommand, ValueEnum};
2
3#[derive(Parser, Debug)]
5#[command(author, version, about, long_about = None, disable_help_subcommand = true)]
6pub struct Cli {
7 #[arg(long, global = true, default_value = "text")]
9 pub output: String,
10
11 #[arg(long, global = true)]
13 pub non_interactive: bool,
14
15 #[arg(long, global = true)]
17 pub trace_id: Option<String>,
18
19 #[arg(long, global = true, default_value = "text")]
21 pub log_format: String,
22
23 #[arg(long, global = true)]
25 pub max_cost_credits: Option<u32>,
26
27 #[arg(long, global = true)]
29 pub budget_daily_credits: Option<u32>,
30
31 #[arg(long, global = true)]
33 pub dry_run: bool,
34
35 #[command(subcommand)]
36 pub command: Option<Commands>,
37}
38
39#[derive(Subcommand, Debug)]
40pub enum Commands {
41 Commands,
43
44 Schema {
46 #[arg(long)]
48 command: String,
49 },
50
51 Help {
53 command: String,
55 },
56
57 DemoInteractive,
59
60 Tweets {
62 #[command(subcommand)]
63 command: TweetsCommands,
64 },
65
66 Bookmarks {
68 #[command(subcommand)]
69 command: BookmarksCommands,
70 },
71
72 Auth {
74 #[command(subcommand)]
75 command: AuthCommands,
76 },
77
78 Billing {
80 #[command(subcommand)]
81 command: BillingCommands,
82 },
83
84 Doctor {
86 #[arg(long)]
88 probe: bool,
89 },
90
91 InstallSkills {
93 #[arg(long)]
95 skill: Option<String>,
96
97 #[arg(long)]
99 agent: Option<String>,
100
101 #[arg(long)]
103 global: bool,
104
105 #[arg(long)]
107 yes: bool,
108 },
109
110 Search {
112 #[command(subcommand)]
113 command: SearchCommands,
114 },
115
116 Timeline {
118 #[command(subcommand)]
119 command: TimelineCommands,
120 },
121
122 Media {
124 #[command(subcommand)]
125 command: MediaCommands,
126 },
127
128 Completion {
130 #[arg(long, value_enum)]
132 shell: ShellChoice,
133 },
134}
135
136#[derive(Clone, Debug, ValueEnum)]
138pub enum ShellChoice {
139 Bash,
141 Zsh,
143 Fish,
145}
146
147#[derive(Subcommand, Debug)]
148pub enum TimelineCommands {
149 Home {
151 #[arg(long, default_value = "10")]
153 limit: usize,
154
155 #[arg(long)]
157 cursor: Option<String>,
158 },
159
160 Mentions {
162 #[arg(long, default_value = "10")]
164 limit: usize,
165
166 #[arg(long)]
168 cursor: Option<String>,
169 },
170
171 User {
173 handle: String,
175
176 #[arg(long, default_value = "10")]
178 limit: usize,
179
180 #[arg(long)]
182 cursor: Option<String>,
183 },
184}
185
186#[derive(Subcommand, Debug)]
187pub enum TweetsCommands {
188 Create {
190 text: String,
192
193 #[arg(long)]
195 client_request_id: Option<String>,
196
197 #[arg(long, default_value = "return")]
199 if_exists: String,
200 },
201
202 List {
204 #[arg(long)]
206 fields: Option<String>,
207
208 #[arg(long)]
210 limit: Option<usize>,
211
212 #[arg(long)]
214 cursor: Option<String>,
215 },
216
217 Like {
219 tweet_id: String,
221 },
222
223 Unlike {
225 tweet_id: String,
227 },
228
229 Retweet {
231 tweet_id: String,
233 },
234
235 Unretweet {
237 tweet_id: String,
239 },
240
241 Reply {
243 tweet_id: String,
245
246 text: String,
248
249 #[arg(long)]
251 client_request_id: Option<String>,
252
253 #[arg(long, default_value = "return")]
255 if_exists: String,
256 },
257
258 Thread {
260 texts: Vec<String>,
262
263 #[arg(long)]
265 client_request_id_prefix: Option<String>,
266
267 #[arg(long, default_value = "return")]
269 if_exists: String,
270 },
271
272 Show {
274 tweet_id: String,
276 },
277
278 Conversation {
280 tweet_id: String,
282 },
283}
284
285#[derive(Subcommand, Debug)]
286pub enum BookmarksCommands {
287 Add {
289 tweet_id: String,
291 },
292
293 Remove {
295 tweet_id: String,
297 },
298
299 List {
301 #[arg(long)]
303 limit: Option<usize>,
304
305 #[arg(long)]
307 cursor: Option<String>,
308 },
309}
310
311#[derive(Subcommand, Debug)]
312pub enum AuthCommands {
313 Status,
315
316 Export,
318
319 Import {
321 data: String,
323
324 #[arg(long)]
326 dry_run: bool,
327 },
328}
329
330#[derive(Subcommand, Debug)]
331pub enum BillingCommands {
332 Estimate {
334 operation: String,
336
337 #[arg(long)]
339 text: Option<String>,
340 },
341
342 Report,
344}
345
346#[derive(Subcommand, Debug)]
347pub enum MediaCommands {
348 Upload {
350 path: String,
352 },
353}
354
355#[derive(Subcommand, Debug)]
356pub enum SearchCommands {
357 Recent {
359 query: String,
361
362 #[arg(long)]
364 limit: Option<usize>,
365
366 #[arg(long)]
368 cursor: Option<String>,
369 },
370
371 Users {
373 query: String,
375
376 #[arg(long)]
378 limit: Option<usize>,
379
380 #[arg(long)]
382 cursor: Option<String>,
383 },
384}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389
390 fn parse<I, S>(args: I) -> Cli
395 where
396 I: IntoIterator<Item = S>,
397 S: Into<std::ffi::OsString> + Clone,
398 {
399 Cli::parse_from(args)
400 }
401
402 #[test]
407 fn test_global_flags() {
408 struct Case {
409 args: Vec<&'static str>,
410 output: &'static str,
411 non_interactive: bool,
412 trace_id: Option<&'static str>,
413 log_format: &'static str,
414 max_cost_credits: Option<u32>,
415 budget_daily_credits: Option<u32>,
416 dry_run: bool,
417 }
418
419 let cases = vec![
420 Case {
421 args: vec!["xcom-rs", "commands"],
422 output: "text",
423 non_interactive: false,
424 trace_id: None,
425 log_format: "text",
426 max_cost_credits: None,
427 budget_daily_credits: None,
428 dry_run: false,
429 },
430 Case {
431 args: vec!["xcom-rs", "--output", "json", "commands"],
432 output: "json",
433 non_interactive: false,
434 trace_id: None,
435 log_format: "text",
436 max_cost_credits: None,
437 budget_daily_credits: None,
438 dry_run: false,
439 },
440 Case {
441 args: vec!["xcom-rs", "--trace-id", "test-123", "commands"],
442 output: "text",
443 non_interactive: false,
444 trace_id: Some("test-123"),
445 log_format: "text",
446 max_cost_credits: None,
447 budget_daily_credits: None,
448 dry_run: false,
449 },
450 Case {
451 args: vec!["xcom-rs", "--non-interactive", "commands"],
452 output: "text",
453 non_interactive: true,
454 trace_id: None,
455 log_format: "text",
456 max_cost_credits: None,
457 budget_daily_credits: None,
458 dry_run: false,
459 },
460 Case {
461 args: vec!["xcom-rs", "--log-format", "json", "commands"],
462 output: "text",
463 non_interactive: false,
464 trace_id: None,
465 log_format: "json",
466 max_cost_credits: None,
467 budget_daily_credits: None,
468 dry_run: false,
469 },
470 Case {
471 args: vec!["xcom-rs", "--max-cost-credits", "100", "commands"],
472 output: "text",
473 non_interactive: false,
474 trace_id: None,
475 log_format: "text",
476 max_cost_credits: Some(100),
477 budget_daily_credits: None,
478 dry_run: false,
479 },
480 Case {
481 args: vec!["xcom-rs", "--budget-daily-credits", "500", "commands"],
482 output: "text",
483 non_interactive: false,
484 trace_id: None,
485 log_format: "text",
486 max_cost_credits: None,
487 budget_daily_credits: Some(500),
488 dry_run: false,
489 },
490 Case {
491 args: vec!["xcom-rs", "--dry-run", "commands"],
492 output: "text",
493 non_interactive: false,
494 trace_id: None,
495 log_format: "text",
496 max_cost_credits: None,
497 budget_daily_credits: None,
498 dry_run: true,
499 },
500 ];
501
502 for case in &cases {
503 let cli = parse(case.args.iter().copied());
504 assert_eq!(cli.output, case.output, "args={:?}", case.args);
505 assert_eq!(
506 cli.non_interactive, case.non_interactive,
507 "args={:?}",
508 case.args
509 );
510 assert_eq!(
511 cli.trace_id,
512 case.trace_id.map(str::to_owned),
513 "args={:?}",
514 case.args
515 );
516 assert_eq!(cli.log_format, case.log_format, "args={:?}", case.args);
517 assert_eq!(
518 cli.max_cost_credits, case.max_cost_credits,
519 "args={:?}",
520 case.args
521 );
522 assert_eq!(
523 cli.budget_daily_credits, case.budget_daily_credits,
524 "args={:?}",
525 case.args
526 );
527 assert_eq!(cli.dry_run, case.dry_run, "args={:?}", case.args);
528 }
529 }
530
531 #[test]
536 fn test_top_level_commands() {
537 type Matcher = fn(Option<Commands>) -> bool;
539 let cases: Vec<(Vec<&str>, Matcher)> = vec![
540 (vec!["xcom-rs"], |cmd| cmd.is_none()),
541 (vec!["xcom-rs", "commands"], |cmd| {
542 matches!(cmd, Some(Commands::Commands))
543 }),
544 (vec!["xcom-rs", "doctor"], |cmd| {
545 matches!(cmd, Some(Commands::Doctor { probe: false }))
546 }),
547 (vec!["xcom-rs", "doctor", "--probe"], |cmd| {
548 matches!(cmd, Some(Commands::Doctor { probe: true }))
549 }),
550 (vec!["xcom-rs", "demo-interactive"], |cmd| {
551 matches!(cmd, Some(Commands::DemoInteractive))
552 }),
553 ];
554
555 for (args, matcher) in cases {
556 let cli = parse(args.iter().copied());
557 assert!(matcher(cli.command), "args={args:?}");
558 }
559 }
560
561 #[test]
566 fn test_completion_subcommand() {
567 let cases = vec![
568 (
569 vec!["xcom-rs", "completion", "--shell", "bash"],
570 ShellChoice::Bash,
571 ),
572 (
573 vec!["xcom-rs", "completion", "--shell", "zsh"],
574 ShellChoice::Zsh,
575 ),
576 (
577 vec!["xcom-rs", "completion", "--shell", "fish"],
578 ShellChoice::Fish,
579 ),
580 ];
581
582 for (args, expected_shell) in cases {
583 let cli = parse(args.iter().copied());
584 let Some(Commands::Completion { shell }) = cli.command else {
585 panic!("Expected Completion command for args={args:?}");
586 };
587 assert!(
588 matches!(
589 (shell, expected_shell),
590 (ShellChoice::Bash, ShellChoice::Bash)
591 | (ShellChoice::Zsh, ShellChoice::Zsh)
592 | (ShellChoice::Fish, ShellChoice::Fish)
593 ),
594 "args={args:?}"
595 );
596 }
597 }
598
599 #[test]
604 fn test_schema_and_help_commands() {
605 let cli = parse(["xcom-rs", "schema", "--command", "commands"]);
607 assert!(matches!(
608 cli.command,
609 Some(Commands::Schema { command }) if command == "commands"
610 ));
611
612 let cli = parse(["xcom-rs", "help", "commands"]);
614 assert!(matches!(
615 cli.command,
616 Some(Commands::Help { command }) if command == "commands"
617 ));
618 }
619
620 #[test]
625 fn test_tweets_subcommands() {
626 type TweetsAssert = fn(TweetsCommands);
628
629 let cases: Vec<(Vec<&str>, TweetsAssert)> = vec![
630 (vec!["xcom-rs", "tweets", "create", "Hello world"], |cmd| {
632 let TweetsCommands::Create {
633 text,
634 client_request_id,
635 if_exists,
636 } = cmd
637 else {
638 panic!("Expected Create");
639 };
640 assert_eq!(text, "Hello world");
641 assert!(client_request_id.is_none());
642 assert_eq!(if_exists, "return");
643 }),
644 (vec!["xcom-rs", "tweets", "like", "tweet123"], |cmd| {
646 let TweetsCommands::Like { tweet_id } = cmd else {
647 panic!("Expected Like");
648 };
649 assert_eq!(tweet_id, "tweet123");
650 }),
651 (vec!["xcom-rs", "tweets", "unlike", "tweet123"], |cmd| {
653 let TweetsCommands::Unlike { tweet_id } = cmd else {
654 panic!("Expected Unlike");
655 };
656 assert_eq!(tweet_id, "tweet123");
657 }),
658 (vec!["xcom-rs", "tweets", "retweet", "tweet123"], |cmd| {
660 let TweetsCommands::Retweet { tweet_id } = cmd else {
661 panic!("Expected Retweet");
662 };
663 assert_eq!(tweet_id, "tweet123");
664 }),
665 (vec!["xcom-rs", "tweets", "unretweet", "tweet123"], |cmd| {
667 let TweetsCommands::Unretweet { tweet_id } = cmd else {
668 panic!("Expected Unretweet");
669 };
670 assert_eq!(tweet_id, "tweet123");
671 }),
672 (vec!["xcom-rs", "tweets", "show", "tweet_999"], |cmd| {
674 let TweetsCommands::Show { tweet_id } = cmd else {
675 panic!("Expected Show");
676 };
677 assert_eq!(tweet_id, "tweet_999");
678 }),
679 (
681 vec!["xcom-rs", "tweets", "conversation", "tweet_root"],
682 |cmd| {
683 let TweetsCommands::Conversation { tweet_id } = cmd else {
684 panic!("Expected Conversation");
685 };
686 assert_eq!(tweet_id, "tweet_root");
687 },
688 ),
689 (
691 vec!["xcom-rs", "tweets", "reply", "tweet_123", "Hello!"],
692 |cmd| {
693 let TweetsCommands::Reply {
694 tweet_id,
695 text,
696 client_request_id,
697 if_exists,
698 } = cmd
699 else {
700 panic!("Expected Reply");
701 };
702 assert_eq!(tweet_id, "tweet_123");
703 assert_eq!(text, "Hello!");
704 assert!(client_request_id.is_none());
705 assert_eq!(if_exists, "return");
706 },
707 ),
708 (
710 vec![
711 "xcom-rs",
712 "tweets",
713 "reply",
714 "tweet_123",
715 "Hello!",
716 "--client-request-id",
717 "my-reply-001",
718 ],
719 |cmd| {
720 let TweetsCommands::Reply {
721 client_request_id, ..
722 } = cmd
723 else {
724 panic!("Expected Reply");
725 };
726 assert_eq!(client_request_id, Some("my-reply-001".to_string()));
727 },
728 ),
729 (
731 vec!["xcom-rs", "tweets", "thread", "First tweet", "Second tweet"],
732 |cmd| {
733 let TweetsCommands::Thread {
734 texts,
735 client_request_id_prefix,
736 if_exists,
737 } = cmd
738 else {
739 panic!("Expected Thread");
740 };
741 assert_eq!(texts, vec!["First tweet", "Second tweet"]);
742 assert!(client_request_id_prefix.is_none());
743 assert_eq!(if_exists, "return");
744 },
745 ),
746 (
748 vec![
749 "xcom-rs",
750 "tweets",
751 "thread",
752 "A",
753 "B",
754 "--client-request-id-prefix",
755 "thread-001",
756 ],
757 |cmd| {
758 let TweetsCommands::Thread {
759 texts,
760 client_request_id_prefix,
761 ..
762 } = cmd
763 else {
764 panic!("Expected Thread");
765 };
766 assert_eq!(texts, vec!["A", "B"]);
767 assert_eq!(client_request_id_prefix, Some("thread-001".to_string()));
768 },
769 ),
770 ];
771
772 for (args, assert_fn) in cases {
773 let cli = parse(args.iter().copied());
774 let Some(Commands::Tweets { command }) = cli.command else {
775 panic!("Expected Tweets command for args={args:?}");
776 };
777 assert_fn(command);
778 }
779 }
780
781 #[test]
786 fn test_bookmarks_subcommands() {
787 type BookmarksAssert = fn(BookmarksCommands);
788
789 let cases: Vec<(Vec<&str>, BookmarksAssert)> = vec![
790 (vec!["xcom-rs", "bookmarks", "add", "tweet123"], |cmd| {
792 let BookmarksCommands::Add { tweet_id } = cmd else {
793 panic!("Expected Add");
794 };
795 assert_eq!(tweet_id, "tweet123");
796 }),
797 (vec!["xcom-rs", "bookmarks", "remove", "tweet123"], |cmd| {
799 let BookmarksCommands::Remove { tweet_id } = cmd else {
800 panic!("Expected Remove");
801 };
802 assert_eq!(tweet_id, "tweet123");
803 }),
804 (
806 vec!["xcom-rs", "bookmarks", "list", "--limit", "10"],
807 |cmd| {
808 let BookmarksCommands::List { limit, cursor } = cmd else {
809 panic!("Expected List");
810 };
811 assert_eq!(limit, Some(10));
812 assert!(cursor.is_none());
813 },
814 ),
815 (
817 vec![
818 "xcom-rs",
819 "bookmarks",
820 "list",
821 "--limit",
822 "5",
823 "--cursor",
824 "next_page_token",
825 ],
826 |cmd| {
827 let BookmarksCommands::List { limit, cursor } = cmd else {
828 panic!("Expected List");
829 };
830 assert_eq!(limit, Some(5));
831 assert_eq!(cursor, Some("next_page_token".to_string()));
832 },
833 ),
834 ];
835
836 for (args, assert_fn) in cases {
837 let cli = parse(args.iter().copied());
838 let Some(Commands::Bookmarks { command }) = cli.command else {
839 panic!("Expected Bookmarks command for args={args:?}");
840 };
841 assert_fn(command);
842 }
843 }
844
845 #[test]
850 fn test_timeline_subcommands() {
851 type TimelineAssert = fn(TimelineCommands);
852
853 let cases: Vec<(Vec<&str>, TimelineAssert)> = vec![
854 (vec!["xcom-rs", "timeline", "home"], |cmd| {
856 let TimelineCommands::Home { limit, cursor } = cmd else {
857 panic!("Expected Home");
858 };
859 assert_eq!(limit, 10);
860 assert!(cursor.is_none());
861 }),
862 (
864 vec!["xcom-rs", "timeline", "home", "--limit", "20"],
865 |cmd| {
866 let TimelineCommands::Home { limit, cursor } = cmd else {
867 panic!("Expected Home");
868 };
869 assert_eq!(limit, 20);
870 assert!(cursor.is_none());
871 },
872 ),
873 (
875 vec!["xcom-rs", "timeline", "home", "--cursor", "next_token_123"],
876 |cmd| {
877 let TimelineCommands::Home { cursor, .. } = cmd else {
878 panic!("Expected Home");
879 };
880 assert_eq!(cursor, Some("next_token_123".to_string()));
881 },
882 ),
883 (vec!["xcom-rs", "timeline", "mentions"], |cmd| {
885 let TimelineCommands::Mentions { limit, cursor } = cmd else {
886 panic!("Expected Mentions");
887 };
888 assert_eq!(limit, 10);
889 assert!(cursor.is_none());
890 }),
891 (vec!["xcom-rs", "timeline", "user", "johndoe"], |cmd| {
893 let TimelineCommands::User {
894 handle,
895 limit,
896 cursor,
897 } = cmd
898 else {
899 panic!("Expected User");
900 };
901 assert_eq!(handle, "johndoe");
902 assert_eq!(limit, 10);
903 assert!(cursor.is_none());
904 }),
905 (
907 vec![
908 "xcom-rs",
909 "timeline",
910 "user",
911 "johndoe",
912 "--limit",
913 "5",
914 "--cursor",
915 "cursor_abc",
916 ],
917 |cmd| {
918 let TimelineCommands::User {
919 handle,
920 limit,
921 cursor,
922 } = cmd
923 else {
924 panic!("Expected User");
925 };
926 assert_eq!(handle, "johndoe");
927 assert_eq!(limit, 5);
928 assert_eq!(cursor, Some("cursor_abc".to_string()));
929 },
930 ),
931 ];
932
933 for (args, assert_fn) in cases {
934 let cli = parse(args.iter().copied());
935 let Some(Commands::Timeline { command }) = cli.command else {
936 panic!("Expected Timeline command for args={args:?}");
937 };
938 assert_fn(command);
939 }
940 }
941
942 #[test]
947 fn test_search_subcommands() {
948 type SearchAssert = fn(SearchCommands);
949
950 let cases: Vec<(Vec<&str>, SearchAssert)> = vec![
951 (vec!["xcom-rs", "search", "recent", "hello world"], |cmd| {
953 let SearchCommands::Recent {
954 query,
955 limit,
956 cursor,
957 } = cmd
958 else {
959 panic!("Expected Recent");
960 };
961 assert_eq!(query, "hello world");
962 assert!(limit.is_none());
963 assert!(cursor.is_none());
964 }),
965 (
967 vec!["xcom-rs", "search", "recent", "rust", "--limit", "20"],
968 |cmd| {
969 let SearchCommands::Recent { query, limit, .. } = cmd else {
970 panic!("Expected Recent");
971 };
972 assert_eq!(query, "rust");
973 assert_eq!(limit, Some(20));
974 },
975 ),
976 (
978 vec![
979 "xcom-rs",
980 "search",
981 "recent",
982 "rust",
983 "--cursor",
984 "cursor_10",
985 ],
986 |cmd| {
987 let SearchCommands::Recent { cursor, .. } = cmd else {
988 panic!("Expected Recent");
989 };
990 assert_eq!(cursor, Some("cursor_10".to_string()));
991 },
992 ),
993 (vec!["xcom-rs", "search", "users", "alice"], |cmd| {
995 let SearchCommands::Users {
996 query,
997 limit,
998 cursor,
999 } = cmd
1000 else {
1001 panic!("Expected Users");
1002 };
1003 assert_eq!(query, "alice");
1004 assert!(limit.is_none());
1005 assert!(cursor.is_none());
1006 }),
1007 (
1009 vec![
1010 "xcom-rs", "search", "users", "bob", "--limit", "5", "--cursor", "cursor_5",
1011 ],
1012 |cmd| {
1013 let SearchCommands::Users {
1014 query,
1015 limit,
1016 cursor,
1017 } = cmd
1018 else {
1019 panic!("Expected Users");
1020 };
1021 assert_eq!(query, "bob");
1022 assert_eq!(limit, Some(5));
1023 assert_eq!(cursor, Some("cursor_5".to_string()));
1024 },
1025 ),
1026 ];
1027
1028 for (args, assert_fn) in cases {
1029 let cli = parse(args.iter().copied());
1030 let Some(Commands::Search { command }) = cli.command else {
1031 panic!("Expected Search command for args={args:?}");
1032 };
1033 assert_fn(command);
1034 }
1035 }
1036
1037 #[test]
1042 fn test_media_subcommands() {
1043 type MediaAssert = fn(MediaCommands);
1044
1045 let cases: Vec<(Vec<&str>, MediaAssert)> = vec![
1046 (
1048 vec!["xcom-rs", "media", "upload", "/tmp/image.jpg"],
1049 |cmd| {
1050 let MediaCommands::Upload { path } = cmd;
1051 assert_eq!(path, "/tmp/image.jpg");
1052 },
1053 ),
1054 (
1056 vec![
1057 "xcom-rs",
1058 "--output",
1059 "json",
1060 "media",
1061 "upload",
1062 "/tmp/photo.png",
1063 ],
1064 |cmd| {
1065 let MediaCommands::Upload { path } = cmd;
1066 assert_eq!(path, "/tmp/photo.png");
1067 },
1068 ),
1069 ];
1070
1071 for (args, assert_fn) in cases {
1072 let cli = parse(args.iter().copied());
1073 let Some(Commands::Media { command }) = cli.command else {
1074 panic!("Expected Media command for args={args:?}");
1075 };
1076 assert_fn(command);
1077 }
1078 }
1079
1080 #[test]
1085 fn test_media_upload_global_output_flag() {
1086 let cli = parse([
1087 "xcom-rs",
1088 "--output",
1089 "json",
1090 "media",
1091 "upload",
1092 "/tmp/photo.png",
1093 ]);
1094 assert_eq!(cli.output, "json");
1095 }
1096}