Skip to main content

xcom_rs/
cli.rs

1use clap::{Parser, Subcommand, ValueEnum};
2
3/// X.com CLI tool for agent-friendly interactions
4#[derive(Parser, Debug)]
5#[command(author, version, about, long_about = None, disable_help_subcommand = true)]
6pub struct Cli {
7    /// Output format
8    #[arg(long, global = true, default_value = "text")]
9    pub output: String,
10
11    /// Run in non-interactive mode (no prompts)
12    #[arg(long, global = true)]
13    pub non_interactive: bool,
14
15    /// Trace ID for request correlation
16    #[arg(long, global = true)]
17    pub trace_id: Option<String>,
18
19    /// Log format (json or text)
20    #[arg(long, global = true, default_value = "text")]
21    pub log_format: String,
22
23    /// Maximum cost in credits for a single operation (fail if exceeded)
24    #[arg(long, global = true)]
25    pub max_cost_credits: Option<u32>,
26
27    /// Daily budget in credits (fail if daily total would exceed)
28    #[arg(long, global = true)]
29    pub budget_daily_credits: Option<u32>,
30
31    /// Dry run mode - estimate costs without executing
32    #[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    /// List all available commands with metadata
42    Commands,
43
44    /// Get JSON schema for command input/output
45    Schema {
46        /// Command name to get schema for
47        #[arg(long)]
48        command: String,
49    },
50
51    /// Get detailed help for a command
52    Help {
53        /// Command name to get help for
54        command: String,
55    },
56
57    /// Demo command that requires interaction (for testing non-interactive mode)
58    DemoInteractive,
59
60    /// Tweet operations
61    Tweets {
62        #[command(subcommand)]
63        command: TweetsCommands,
64    },
65
66    /// Bookmark operations
67    Bookmarks {
68        #[command(subcommand)]
69        command: BookmarksCommands,
70    },
71
72    /// Authentication commands
73    Auth {
74        #[command(subcommand)]
75        command: AuthCommands,
76    },
77
78    /// Billing commands
79    Billing {
80        #[command(subcommand)]
81        command: BillingCommands,
82    },
83
84    /// Diagnostic information about configuration and runtime state
85    Doctor {
86        /// Perform an API connectivity probe (requires network access)
87        #[arg(long)]
88        probe: bool,
89    },
90
91    /// Install skills from embedded repository
92    InstallSkills {
93        /// Specific skill name to install (installs all if not specified)
94        #[arg(long)]
95        skill: Option<String>,
96
97        /// Target agent (claude or opencode)
98        #[arg(long)]
99        agent: Option<String>,
100
101        /// Install to global location instead of project
102        #[arg(long)]
103        global: bool,
104
105        /// Skip confirmation prompts
106        #[arg(long)]
107        yes: bool,
108    },
109
110    /// Search operations
111    Search {
112        #[command(subcommand)]
113        command: SearchCommands,
114    },
115
116    /// Timeline operations (home, mentions, user)
117    Timeline {
118        #[command(subcommand)]
119        command: TimelineCommands,
120    },
121
122    /// Media operations
123    Media {
124        #[command(subcommand)]
125        command: MediaCommands,
126    },
127
128    /// Generate shell completion scripts
129    Completion {
130        /// Shell to generate completions for
131        #[arg(long, value_enum)]
132        shell: ShellChoice,
133    },
134}
135
136/// Supported shells for completion generation
137#[derive(Clone, Debug, ValueEnum)]
138pub enum ShellChoice {
139    /// Bash shell
140    Bash,
141    /// Zsh shell
142    Zsh,
143    /// Fish shell
144    Fish,
145}
146
147#[derive(Subcommand, Debug)]
148pub enum TimelineCommands {
149    /// Get home timeline (reverse chronological feed)
150    Home {
151        /// Maximum number of tweets to return
152        #[arg(long, default_value = "10")]
153        limit: usize,
154
155        /// Pagination cursor token
156        #[arg(long)]
157        cursor: Option<String>,
158    },
159
160    /// Get mentions timeline
161    Mentions {
162        /// Maximum number of tweets to return
163        #[arg(long, default_value = "10")]
164        limit: usize,
165
166        /// Pagination cursor token
167        #[arg(long)]
168        cursor: Option<String>,
169    },
170
171    /// Get tweets from a specific user
172    User {
173        /// User handle (without @)
174        handle: String,
175
176        /// Maximum number of tweets to return
177        #[arg(long, default_value = "10")]
178        limit: usize,
179
180        /// Pagination cursor token
181        #[arg(long)]
182        cursor: Option<String>,
183    },
184}
185
186#[derive(Subcommand, Debug)]
187pub enum TweetsCommands {
188    /// Create a new tweet
189    Create {
190        /// Tweet text content
191        text: String,
192
193        /// Client request ID for idempotency (auto-generated if not provided)
194        #[arg(long)]
195        client_request_id: Option<String>,
196
197        /// Policy when operation with same client_request_id exists
198        #[arg(long, default_value = "return")]
199        if_exists: String,
200    },
201
202    /// List tweets
203    List {
204        /// Fields to include in response (comma-separated: id,text,author_id,created_at)
205        #[arg(long)]
206        fields: Option<String>,
207
208        /// Maximum number of tweets to return
209        #[arg(long)]
210        limit: Option<usize>,
211
212        /// Pagination cursor
213        #[arg(long)]
214        cursor: Option<String>,
215    },
216
217    /// Like a tweet
218    Like {
219        /// Tweet ID to like
220        tweet_id: String,
221    },
222
223    /// Unlike a tweet
224    Unlike {
225        /// Tweet ID to unlike
226        tweet_id: String,
227    },
228
229    /// Retweet a tweet
230    Retweet {
231        /// Tweet ID to retweet
232        tweet_id: String,
233    },
234
235    /// Undo a retweet
236    Unretweet {
237        /// Tweet ID to unretweet
238        tweet_id: String,
239    },
240
241    /// Reply to a tweet
242    Reply {
243        /// ID of the tweet to reply to
244        tweet_id: String,
245
246        /// Reply text content
247        text: String,
248
249        /// Client request ID for idempotency (auto-generated if not provided)
250        #[arg(long)]
251        client_request_id: Option<String>,
252
253        /// Policy when operation with same client_request_id exists
254        #[arg(long, default_value = "return")]
255        if_exists: String,
256    },
257
258    /// Post a thread of tweets (sequential replies)
259    Thread {
260        /// Tweet texts (at least one required; first is standalone, rest are replies)
261        texts: Vec<String>,
262
263        /// Prefix for generating per-tweet client_request_ids
264        #[arg(long)]
265        client_request_id_prefix: Option<String>,
266
267        /// Policy when operation with same client_request_id exists
268        #[arg(long, default_value = "return")]
269        if_exists: String,
270    },
271
272    /// Show a single tweet by ID
273    Show {
274        /// Tweet ID to fetch
275        tweet_id: String,
276    },
277
278    /// Retrieve a conversation tree starting from a tweet
279    Conversation {
280        /// Tweet ID (root of the conversation)
281        tweet_id: String,
282    },
283}
284
285#[derive(Subcommand, Debug)]
286pub enum BookmarksCommands {
287    /// Add a tweet to bookmarks
288    Add {
289        /// Tweet ID to bookmark
290        tweet_id: String,
291    },
292
293    /// Remove a tweet from bookmarks
294    Remove {
295        /// Tweet ID to remove from bookmarks
296        tweet_id: String,
297    },
298
299    /// List bookmarked tweets
300    List {
301        /// Maximum number of bookmarks to return
302        #[arg(long)]
303        limit: Option<usize>,
304
305        /// Pagination cursor
306        #[arg(long)]
307        cursor: Option<String>,
308    },
309}
310
311#[derive(Subcommand, Debug)]
312pub enum AuthCommands {
313    /// Get current authentication status
314    Status,
315
316    /// Export authentication data
317    Export,
318
319    /// Import authentication data
320    Import {
321        /// Authentication data to import
322        data: String,
323
324        /// Dry run mode - show what would be changed without saving
325        #[arg(long)]
326        dry_run: bool,
327    },
328}
329
330#[derive(Subcommand, Debug)]
331pub enum BillingCommands {
332    /// Estimate cost for an operation
333    Estimate {
334        /// Operation to estimate (e.g., "tweets.create")
335        operation: String,
336
337        /// Optional parameters (key=value format)
338        #[arg(long)]
339        text: Option<String>,
340    },
341
342    /// Get billing report
343    Report,
344}
345
346#[derive(Subcommand, Debug)]
347pub enum MediaCommands {
348    /// Upload a media file and return the media_id
349    Upload {
350        /// Path to the media file to upload
351        path: String,
352    },
353}
354
355#[derive(Subcommand, Debug)]
356pub enum SearchCommands {
357    /// Search recent tweets matching a query
358    Recent {
359        /// Search query string
360        query: String,
361
362        /// Maximum number of results to return
363        #[arg(long)]
364        limit: Option<usize>,
365
366        /// Pagination cursor
367        #[arg(long)]
368        cursor: Option<String>,
369    },
370
371    /// Search users matching a query
372    Users {
373        /// Search query string
374        query: String,
375
376        /// Maximum number of results to return
377        #[arg(long)]
378        limit: Option<usize>,
379
380        /// Pagination cursor
381        #[arg(long)]
382        cursor: Option<String>,
383    },
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389
390    // ---------------------------------------------------------------------------
391    // Helper: parse CLI args and return the parsed Cli struct.
392    // Accepts an iterator of string-like values.
393    // ---------------------------------------------------------------------------
394    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    // ---------------------------------------------------------------------------
403    // Table-driven tests: global flags
404    // ---------------------------------------------------------------------------
405
406    #[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    // ---------------------------------------------------------------------------
532    // Table-driven tests: top-level commands (no subcommand)
533    // ---------------------------------------------------------------------------
534
535    #[test]
536    fn test_top_level_commands() {
537        // (args, expected_matches_fn)
538        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    // ---------------------------------------------------------------------------
562    // Table-driven tests: completion subcommand
563    // ---------------------------------------------------------------------------
564
565    #[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    // ---------------------------------------------------------------------------
600    // Table-driven tests: schema / help commands
601    // ---------------------------------------------------------------------------
602
603    #[test]
604    fn test_schema_and_help_commands() {
605        // schema --command <name>
606        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        // help <name>
613        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    // ---------------------------------------------------------------------------
621    // Table-driven tests: tweets subcommands
622    // ---------------------------------------------------------------------------
623
624    #[test]
625    fn test_tweets_subcommands() {
626        // Each row: (args, assertion closure)
627        type TweetsAssert = fn(TweetsCommands);
628
629        let cases: Vec<(Vec<&str>, TweetsAssert)> = vec![
630            // tweets create
631            (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            // tweets like
645            (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            // tweets unlike
652            (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            // tweets retweet
659            (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            // tweets unretweet
666            (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            // tweets show
673            (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            // tweets conversation
680            (
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            // tweets reply (no client_request_id)
690            (
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            // tweets reply (with client_request_id)
709            (
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            // tweets thread (no prefix)
730            (
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            // tweets thread (with prefix)
747            (
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    // ---------------------------------------------------------------------------
782    // Table-driven tests: bookmarks subcommands
783    // ---------------------------------------------------------------------------
784
785    #[test]
786    fn test_bookmarks_subcommands() {
787        type BookmarksAssert = fn(BookmarksCommands);
788
789        let cases: Vec<(Vec<&str>, BookmarksAssert)> = vec![
790            // bookmarks add
791            (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            // bookmarks remove
798            (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            // bookmarks list (with limit only)
805            (
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            // bookmarks list (with limit and cursor)
816            (
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    // ---------------------------------------------------------------------------
846    // Table-driven tests: timeline subcommands
847    // ---------------------------------------------------------------------------
848
849    #[test]
850    fn test_timeline_subcommands() {
851        type TimelineAssert = fn(TimelineCommands);
852
853        let cases: Vec<(Vec<&str>, TimelineAssert)> = vec![
854            // timeline home (defaults)
855            (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            // timeline home (custom limit)
863            (
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            // timeline home (with cursor)
874            (
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            // timeline mentions (defaults)
884            (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            // timeline user (defaults)
892            (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            // timeline user (with limit and cursor)
906            (
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    // ---------------------------------------------------------------------------
943    // Table-driven tests: search subcommands
944    // ---------------------------------------------------------------------------
945
946    #[test]
947    fn test_search_subcommands() {
948        type SearchAssert = fn(SearchCommands);
949
950        let cases: Vec<(Vec<&str>, SearchAssert)> = vec![
951            // search recent (no options)
952            (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            // search recent (with limit)
966            (
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            // search recent (with cursor)
977            (
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            // search users (no options)
994            (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            // search users (with limit and cursor)
1008            (
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    // ---------------------------------------------------------------------------
1038    // Table-driven tests: media subcommands
1039    // ---------------------------------------------------------------------------
1040
1041    #[test]
1042    fn test_media_subcommands() {
1043        type MediaAssert = fn(MediaCommands);
1044
1045        let cases: Vec<(Vec<&str>, MediaAssert)> = vec![
1046            // media upload (basic)
1047            (
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            // media upload (with --output json global flag)
1055            (
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    // ---------------------------------------------------------------------------
1081    // Additional regression: media upload with output flag sets global output
1082    // ---------------------------------------------------------------------------
1083
1084    #[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}