Skip to main content

oxi/
cli.rs

1//! CLI argument parsing with clap
2//!
3//! Provides the unified command-line argument types for the oxi CLI.
4//! This is the single source of truth for all CLI parsing — main.rs
5//! imports from here rather than defining its own types.
6
7use clap::{Parser, Subcommand};
8use std::path::PathBuf;
9
10// ── Re-exports ─────────────────────────────────────────────────────
11// Use the canonical ThinkingLevel from settings (None/Minimal/Standard/Thorough).
12pub use crate::store::settings::ThinkingLevel;
13
14// ── Main CLI arguments ─────────────────────────────────────────────
15
16/// CLI arguments
17#[derive(Debug, Clone, Parser)]
18#[command(name = "oxi")]
19#[command(about = "CLI coding harness for oxi")]
20#[command(version)]
21pub struct CliArgs {
22    /// pub.
23    #[command(subcommand)]
24    pub command: Option<Commands>,
25
26    /// Provider to use (e.g., anthropic, openai, google, deepseek)
27    #[arg(short, long)]
28    pub provider: Option<String>,
29
30    /// Model to use (e.g., claude-sonnet-4-20250514, gpt-4o)
31    #[arg(short, long)]
32    pub model: Option<String>,
33
34    /// Initial prompt (non-interactive mode)
35    #[arg(default_value = "")]
36    pub prompt: Vec<String>,
37
38    /// Interactive mode (default when no prompt is given)
39    #[arg(short, long)]
40    pub interactive: bool,
41
42    /// Thinking level (none, minimal, standard, thorough)
43    #[arg(long)]
44    pub thinking: Option<String>,
45
46    /// Load an extension from a shared library (.so / .dll / .dylib).
47    /// Can be specified multiple times.
48    #[arg(short = 'e', long = "extension", value_name = "PATH")]
49    pub extensions: Vec<PathBuf>,
50
51    /// Output mode: text or json (newline-delimited JSON events)
52    #[arg(long)]
53    pub mode: Option<String>,
54
55    /// Comma-separated list of tools to enable. Default: all builtins.
56    #[arg(long)]
57    pub tools: Option<String>,
58
59    /// Append system prompt from a file
60    #[arg(long)]
61    pub append_system_prompt: Option<PathBuf>,
62
63    /// Single-shot print mode (non-interactive)
64    #[arg(long)]
65    pub print: bool,
66
67    /// Disable session persistence
68    #[arg(long)]
69    pub no_session: bool,
70
71    /// Timeout in seconds for print mode
72    #[arg(long)]
73    pub timeout: Option<u64>,
74
75    /// Resume the most recent session for this project
76    #[arg(short, long)]
77    pub continue_session: bool,
78
79    // ── Routing configuration ─────────────────────────────────────────
80    /// Enable automatic model routing (falls back to cost-efficient models on errors)
81    #[arg(long = "enable-routing")]
82    pub enable_routing: bool,
83
84    /// Prefer cost-efficient models when routing is enabled
85    #[arg(long = "prefer-cost-efficient")]
86    pub prefer_cost_efficient: bool,
87
88    /// Fallback chain: comma-separated list of provider/model IDs (can be specified multiple times)
89    #[arg(long = "fallback-chain", value_delimiter = ',')]
90    pub fallback_chain: Vec<String>,
91
92    /// Disable automatic fallback (fail fast on errors instead of trying alternatives)
93    #[arg(long = "disable-fallback")]
94    pub disable_fallback: bool,
95}
96
97// ── Subcommands ────────────────────────────────────────────────────
98
99/// CLI subcommands
100#[derive(Debug, Clone, Subcommand)]
101pub enum Commands {
102    /// List all sessions for this project
103    Sessions,
104    /// Show session entry tree structure
105    Tree {
106        /// Session ID or prefix (default: current/last session for this project)
107        #[arg(default_value = "")]
108        session_id: String,
109    },
110    /// Fork a new session from a specific entry
111    Fork {
112        /// Parent session ID or prefix
113        parent_id: String,
114        /// Entry ID to branch from
115        entry_id: String,
116    },
117    /// Delete a session by ID (prefix match supported)
118    Delete {
119        /// Session ID or prefix (from `oxi sessions`)
120        session_id: String,
121    },
122    /// Local issue management
123    Issue {
124        /// Action
125        #[command(subcommand)]
126        action: IssueCommands,
127    },
128    /// Package management
129    Pkg {
130        /// action.
131        #[command(subcommand)]
132        action: PkgCommands,
133    },
134    /// Configuration management
135    Config {
136        /// action.
137        #[command(subcommand)]
138        action: ConfigCommands,
139    },
140    /// Extension management — install, update, remove WASM extensions
141    Ext {
142        /// action.
143        #[command(subcommand)]
144        action: ExtCommands,
145    },
146    /// List available models
147    Models {
148        /// Filter by provider name (e.g., openai, anthropic, minimax)
149        #[arg(long)]
150        provider: Option<String>,
151    },
152    /// Refresh the model catalog from models.dev
153    ///
154    /// Performs a conditional GET (ETag). Updates take effect on next start.
155    Refresh {},
156    /// Run the interactive setup wizard
157    Setup {
158        /// Reset all settings to defaults
159        #[arg(long)]
160        reset: bool,
161    },
162    /// Reset all settings and data to factory defaults
163    ///
164    /// Use when configuration has become tangled and you want a clean start.
165    /// An interactive confirmation prompt will be shown.
166    Reset {
167        /// Skip the confirmation prompt
168        #[arg(long, short)]
169        yes: bool,
170        /// Also delete the project-local .oxi/ directory
171        #[arg(long)]
172        include_project: bool,
173    },
174    /// Export a session to HTML
175    Export {
176        /// Session ID or prefix (default: most recent for this project)
177        session_id: Option<String>,
178        /// Output file path (default: oxi-export-{id}.html in CWD)
179        #[arg(short, long)]
180        output: Option<PathBuf>,
181    },
182    /// Import a session from a JSONL file
183    Import {
184        /// Path to the JSONL session file
185        path: PathBuf,
186    },
187    /// Share a session as a GitHub Gist (requires gh CLI)
188    Share {
189        /// Session ID or prefix (default: most recent for this project)
190        session_id: Option<String>,
191    },
192}
193
194// ── Package subcommands ────────────────────────────────────────────
195
196/// Package management subcommands
197#[derive(Debug, Clone, Subcommand)]
198pub enum PkgCommands {
199    /// Install a package from a local path or npm:@scope/name
200    Install {
201        /// Package source: a local directory path or npm:@scope/name
202        source: String,
203    },
204    /// List installed packages
205    List,
206    /// Uninstall a package by name
207    Uninstall {
208        /// Package name to uninstall
209        name: String,
210    },
211    /// Update a package to the latest version
212    Update {
213        /// Package name to update (updates all if omitted)
214        name: Option<String>,
215    },
216}
217
218// ── Issue subcommands ───────────────────────────────────────────────
219
220/// Local issue management subcommands.
221#[derive(Debug, Clone, Subcommand)]
222pub enum IssueCommands {
223    /// List local issues (default: open only)
224    List {
225        /// Show closed issues too
226        #[arg(long)]
227        all: bool,
228        /// Filter by label
229        #[arg(long)]
230        label: Option<String>,
231        /// Filter by substring of title
232        #[arg(long)]
233        text: Option<String>,
234    },
235    /// Show a single issue (prints content + content_hash for `update`)
236    Show {
237        /// Issue id
238        id: u32,
239    },
240    /// Create a new issue
241    New {
242        /// Issue title
243        title: String,
244        /// Issue body (markdown); pass via stdin or $EDITOR
245        #[arg(long, short)]
246        body: Option<String>,
247        /// Priority: low|medium|high|critical (default: medium)
248        #[arg(long)]
249        priority: Option<String>,
250        /// Comma-separated labels
251        #[arg(long)]
252        labels: Option<String>,
253    },
254    /// Close an issue (releases any assignment; must be owner)
255    Close {
256        /// Issue id
257        id: u32,
258        /// Content hash from `show` (skip to bypass CAS check)
259        #[arg(long)]
260        hash: Option<String>,
261    },
262    /// Reopen a closed issue (anyone may reopen after close)
263    Reopen {
264        /// Issue id
265        id: u32,
266        /// Content hash from `show` (skip to bypass CAS check)
267        #[arg(long)]
268        hash: Option<String>,
269    },
270    /// Reap dead alive-lock files under `.oxi/issues/.alive/` (best-effort cleanup
271    /// of zombie locks left by crashed/killed processes). Age-gated: only files
272    /// older than 1 hour and not currently held are removed. Prints the count.
273    Reap,
274}
275
276// ── Extension subcommands ──────────────────────────────────────────────
277
278/// Extension management subcommands
279#[derive(Debug, Clone, Subcommand)]
280pub enum ExtCommands {
281    /// Install a WASM extension from a GitHub repo (owner/repo or owner/repo@version)
282    Install {
283        /// Extension source: owner/repo or owner/repo@version
284        source: String,
285        /// Include pre-release versions
286        #[arg(long)]
287        prerelease: bool,
288    },
289    /// List installed extensions
290    List,
291    /// Remove an installed extension
292    Remove {
293        /// Extension source: owner/repo
294        source: String,
295    },
296    /// Update extension(s) to latest version
297    Update {
298        /// Extension source: owner/repo (updates all if omitted)
299        source: Option<String>,
300    },
301    /// Show info about a remote extension (without installing)
302    Info {
303        /// Extension source: owner/repo
304        source: String,
305    },
306}
307
308// ── Config subcommands ─────────────────────────────────────────────
309
310/// Configuration management subcommands
311#[derive(Debug, Clone, Subcommand)]
312pub enum ConfigCommands {
313    /// Show current configuration
314    Show,
315    /// List all enabled resources
316    List {
317        /// Resource type filter (extensions, skills, prompts, themes)
318        resource_type: Option<String>,
319    },
320    /// Enable a resource (extension, skill, prompt, or theme)
321    Enable {
322        /// Resource type: extension, skill, prompt, or theme
323        resource_type: String,
324        /// Resource path or name
325        name: String,
326    },
327    /// Disable a resource
328    Disable {
329        /// Resource type: extension, skill, prompt, or theme
330        resource_type: String,
331        /// Resource path or name
332        name: String,
333    },
334    /// Set a configuration value
335    Set {
336        /// Setting key (e.g. theme, model, thinking_level)
337        key: String,
338        /// Setting value
339        value: String,
340    },
341    /// Get a configuration value
342    Get {
343        /// Setting key
344        key: String,
345    },
346    /// Add a custom OpenAI-compatible provider
347    AddProvider {
348        /// Provider name (e.g. minimax)
349        name: String,
350        /// Base URL (e.g. <https://api.minimax.chat/v1>)
351        base_url: String,
352        /// Environment variable name for API key (e.g. MINIMAX_API_KEY)
353        api_key_env: String,
354        /// API type: openai-completions or openai-responses (default: openai-completions)
355        #[arg(default_value = "openai-completions")]
356        api: String,
357    },
358    /// Remove a custom provider
359    RemoveProvider {
360        /// Provider name to remove
361        name: String,
362    },
363    /// Reset credentials (auth.json) and optionally settings
364    Reset {
365        /// Also reset settings (settings.toml / settings.json)
366        #[arg(long, short)]
367        all: bool,
368    },
369}
370
371// ── Parsing helpers ────────────────────────────────────────────────
372
373/// Parse CLI arguments from the command line
374///
375/// # Examples
376///
377/// ```ignore
378/// use oxi_cli::CliArgs;
379///
380/// fn main() {
381///     let args = CliArgs::parse();
382///     match args.command {
383///         Some(Commands::Sessions) => { /* list sessions */ }
384///         Some(Commands::Tree { session_id }) => { /* show tree */ }
385///         _ => { /* interactive mode */ }
386///     }
387/// }
388/// ```
389pub fn parse_args() -> CliArgs {
390    CliArgs::parse()
391}
392
393/// Parse CLI arguments from a specific iterator
394pub fn parse_args_from<I, T>(iter: I) -> Result<CliArgs, clap::Error>
395where
396    I: IntoIterator<Item = T>,
397    T: Into<std::ffi::OsString> + Clone,
398{
399    CliArgs::try_parse_from(iter)
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405
406    #[test]
407    fn test_parse_basic_prompt() {
408        let args = parse_args_from(["oxi", "Hello", "world"]).unwrap();
409        assert_eq!(args.prompt, vec!["Hello", "world"]);
410    }
411
412    #[test]
413    fn test_parse_with_provider_and_model() {
414        let args = parse_args_from([
415            "oxi",
416            "--provider",
417            "anthropic",
418            "--model",
419            "claude-sonnet-4-20250514",
420            "Hello",
421        ])
422        .unwrap();
423        assert_eq!(args.provider, Some("anthropic".to_string()));
424        assert_eq!(args.model, Some("claude-sonnet-4-20250514".to_string()));
425    }
426
427    #[test]
428    fn test_parse_interactive_flag() {
429        let args = parse_args_from(["oxi", "-i"]).unwrap();
430        assert!(args.interactive);
431    }
432
433    #[test]
434    fn test_parse_extension_paths() {
435        let args =
436            parse_args_from(["oxi", "-e", "/path/to/ext.so", "-e", "/other/ext.so"]).unwrap();
437        assert_eq!(args.extensions.len(), 2);
438    }
439
440    #[test]
441    fn test_parse_sessions_command() {
442        let args = parse_args_from(["oxi", "sessions"]).unwrap();
443        assert!(matches!(args.command, Some(Commands::Sessions)));
444    }
445
446    #[test]
447    fn test_parse_tree_command() {
448        let args = parse_args_from(["oxi", "tree", "abc-123"]).unwrap();
449        match args.command {
450            Some(Commands::Tree { session_id }) => {
451                assert_eq!(session_id, "abc-123");
452            }
453            _ => panic!("Expected Tree command"),
454        }
455    }
456
457    #[test]
458    fn test_parse_tree_command_default() {
459        let args = parse_args_from(["oxi", "tree"]).unwrap();
460        match args.command {
461            Some(Commands::Tree { session_id }) => {
462                assert_eq!(session_id, "");
463            }
464            _ => panic!("Expected Tree command"),
465        }
466    }
467
468    #[test]
469    fn test_parse_fork_command() {
470        let args = parse_args_from(["oxi", "fork", "parent-id", "entry-id"]).unwrap();
471        match args.command {
472            Some(Commands::Fork {
473                parent_id,
474                entry_id,
475            }) => {
476                assert_eq!(parent_id, "parent-id");
477                assert_eq!(entry_id, "entry-id");
478            }
479            _ => panic!("Expected Fork command"),
480        }
481    }
482
483    #[test]
484    fn test_parse_delete_command() {
485        let args = parse_args_from(["oxi", "delete", "session-123"]).unwrap();
486        match args.command {
487            Some(Commands::Delete { session_id }) => {
488                assert_eq!(session_id, "session-123");
489            }
490            _ => panic!("Expected Delete command"),
491        }
492    }
493
494    #[test]
495    fn test_parse_pkg_install() {
496        let args = parse_args_from(["oxi", "pkg", "install", "npm:@scope/name"]).unwrap();
497        match args.command {
498            Some(Commands::Pkg { action }) => match action {
499                PkgCommands::Install { source } => {
500                    assert_eq!(source, "npm:@scope/name");
501                }
502                _ => panic!("Expected Install subcommand"),
503            },
504            _ => panic!("Expected Pkg command"),
505        }
506    }
507
508    #[test]
509    fn test_parse_pkg_list() {
510        let args = parse_args_from(["oxi", "pkg", "list"]).unwrap();
511        match args.command {
512            Some(Commands::Pkg { action }) => {
513                assert!(matches!(action, PkgCommands::List));
514            }
515            _ => panic!("Expected Pkg command"),
516        }
517    }
518
519    #[test]
520    fn test_parse_pkg_update_all() {
521        let args = parse_args_from(["oxi", "pkg", "update"]).unwrap();
522        match args.command {
523            Some(Commands::Pkg { action }) => match action {
524                PkgCommands::Update { name } => assert!(name.is_none()),
525                _ => panic!("Expected Update subcommand"),
526            },
527            _ => panic!("Expected Pkg command"),
528        }
529    }
530
531    #[test]
532    fn test_parse_pkg_update_named() {
533        let args = parse_args_from(["oxi", "pkg", "update", "my-pkg"]).unwrap();
534        match args.command {
535            Some(Commands::Pkg { action }) => match action {
536                PkgCommands::Update { name } => assert_eq!(name, Some("my-pkg".to_string())),
537                _ => panic!("Expected Update subcommand"),
538            },
539            _ => panic!("Expected Pkg command"),
540        }
541    }
542
543    #[test]
544    fn test_parse_config_show() {
545        let args = parse_args_from(["oxi", "config", "show"]).unwrap();
546        assert!(matches!(
547            args.command,
548            Some(Commands::Config {
549                action: ConfigCommands::Show
550            })
551        ));
552    }
553
554    #[test]
555    fn test_parse_config_set() {
556        let args = parse_args_from(["oxi", "config", "set", "theme", "dracula"]).unwrap();
557        match args.command {
558            Some(Commands::Config { action }) => match action {
559                ConfigCommands::Set { key, value } => {
560                    assert_eq!(key, "theme");
561                    assert_eq!(value, "dracula");
562                }
563                _ => panic!("Expected Set subcommand"),
564            },
565            _ => panic!("Expected Config command"),
566        }
567    }
568
569    #[test]
570    fn test_parse_config_get() {
571        let args = parse_args_from(["oxi", "config", "get", "theme"]).unwrap();
572        match args.command {
573            Some(Commands::Config { action }) => match action {
574                ConfigCommands::Get { key } => {
575                    assert_eq!(key, "theme");
576                }
577                _ => panic!("Expected Get subcommand"),
578            },
579            _ => panic!("Expected Config command"),
580        }
581    }
582
583    #[test]
584    fn test_parse_config_enable() {
585        let args = parse_args_from(["oxi", "config", "enable", "extension", "my-ext"]).unwrap();
586        match args.command {
587            Some(Commands::Config { action }) => match action {
588                ConfigCommands::Enable {
589                    resource_type,
590                    name,
591                } => {
592                    assert_eq!(resource_type, "extension");
593                    assert_eq!(name, "my-ext");
594                }
595                _ => panic!("Expected Enable subcommand"),
596            },
597            _ => panic!("Expected Config command"),
598        }
599    }
600
601    #[test]
602    fn test_parse_config_disable() {
603        let args = parse_args_from(["oxi", "config", "disable", "skill", "my-skill"]).unwrap();
604        match args.command {
605            Some(Commands::Config { action }) => match action {
606                ConfigCommands::Disable {
607                    resource_type,
608                    name,
609                } => {
610                    assert_eq!(resource_type, "skill");
611                    assert_eq!(name, "my-skill");
612                }
613                _ => panic!("Expected Disable subcommand"),
614            },
615            _ => panic!("Expected Config command"),
616        }
617    }
618
619    #[test]
620    fn test_parse_config_list() {
621        let args = parse_args_from(["oxi", "config", "list"]).unwrap();
622        match args.command {
623            Some(Commands::Config { action }) => match action {
624                ConfigCommands::List { resource_type } => {
625                    assert!(resource_type.is_none());
626                }
627                _ => panic!("Expected List subcommand"),
628            },
629            _ => panic!("Expected Config command"),
630        }
631    }
632
633    #[test]
634    fn test_parse_config_list_filtered() {
635        let args = parse_args_from(["oxi", "config", "list", "extensions"]).unwrap();
636        match args.command {
637            Some(Commands::Config { action }) => match action {
638                ConfigCommands::List { resource_type } => {
639                    assert_eq!(resource_type, Some("extensions".to_string()));
640                }
641                _ => panic!("Expected List subcommand"),
642            },
643            _ => panic!("Expected Config command"),
644        }
645    }
646
647    #[test]
648    fn test_thinking_level_reexport() {
649        // Verify the re-export from settings works
650        assert_eq!(format!("{:?}", ThinkingLevel::Medium), "Medium");
651    }
652
653    #[test]
654    fn test_parse_config_add_provider() {
655        let args = parse_args_from([
656            "oxi",
657            "config",
658            "add-provider",
659            "minimax",
660            "https://api.minimax.chat/v1",
661            "MINIMAX_API_KEY",
662            "openai-completions",
663        ])
664        .unwrap();
665        match args.command {
666            Some(Commands::Config { action }) => match action {
667                ConfigCommands::AddProvider {
668                    name,
669                    base_url,
670                    api_key_env,
671                    api,
672                } => {
673                    assert_eq!(name, "minimax");
674                    assert_eq!(base_url, "https://api.minimax.chat/v1");
675                    assert_eq!(api_key_env, "MINIMAX_API_KEY");
676                    assert_eq!(api, "openai-completions");
677                }
678                _ => panic!("Expected AddProvider subcommand"),
679            },
680            _ => panic!("Expected Config command"),
681        }
682    }
683
684    #[test]
685    fn test_parse_config_add_provider_default_api() {
686        let args = parse_args_from([
687            "oxi",
688            "config",
689            "add-provider",
690            "zai",
691            "https://api.z.ai/v1",
692            "ZAI_API_KEY",
693        ])
694        .unwrap();
695        match args.command {
696            Some(Commands::Config { action }) => match action {
697                ConfigCommands::AddProvider {
698                    name,
699                    base_url,
700                    api_key_env,
701                    api,
702                } => {
703                    assert_eq!(name, "zai");
704                    assert_eq!(base_url, "https://api.z.ai/v1");
705                    assert_eq!(api_key_env, "ZAI_API_KEY");
706                    assert_eq!(api, "openai-completions"); // default
707                }
708                _ => panic!("Expected AddProvider subcommand"),
709            },
710            _ => panic!("Expected Config command"),
711        }
712    }
713
714    #[test]
715    fn test_parse_config_remove_provider() {
716        let args = parse_args_from(["oxi", "config", "remove-provider", "minimax"]).unwrap();
717        match args.command {
718            Some(Commands::Config { action }) => match action {
719                ConfigCommands::RemoveProvider { name } => {
720                    assert_eq!(name, "minimax");
721                }
722                _ => panic!("Expected RemoveProvider subcommand"),
723            },
724            _ => panic!("Expected Config command"),
725        }
726    }
727
728    #[test]
729    fn test_parse_models_command() {
730        let args = parse_args_from(["oxi", "models"]).unwrap();
731        match args.command {
732            Some(Commands::Models { provider }) => {
733                assert!(provider.is_none());
734            }
735            _ => panic!("Expected Models command"),
736        }
737    }
738
739    #[test]
740    fn test_parse_models_with_provider() {
741        let args = parse_args_from(["oxi", "models", "--provider", "minimax"]).unwrap();
742        match args.command {
743            Some(Commands::Models { provider }) => {
744                assert_eq!(provider, Some("minimax".to_string()));
745            }
746            _ => panic!("Expected Models command"),
747        }
748    }
749
750    #[test]
751    fn test_parse_setup_command() {
752        let args = parse_args_from(["oxi", "setup"]).unwrap();
753        match args.command {
754            Some(Commands::Setup { reset }) => {
755                assert!(!reset);
756            }
757            _ => panic!("Expected Setup command"),
758        }
759    }
760
761    #[test]
762    fn test_parse_setup_reset() {
763        let args = parse_args_from(["oxi", "setup", "--reset"]).unwrap();
764        match args.command {
765            Some(Commands::Setup { reset }) => {
766                assert!(reset);
767            }
768            _ => panic!("Expected Setup command with reset"),
769        }
770    }
771
772    // ── Routing flags ──────────────────────────────────────────────
773
774    #[test]
775    fn test_parse_enable_routing_flag() {
776        let args = parse_args_from(["oxi", "--enable-routing", "Hello"]).unwrap();
777        assert!(args.enable_routing);
778        assert!(!args.prefer_cost_efficient);
779        assert!(args.fallback_chain.is_empty());
780        assert!(!args.disable_fallback);
781    }
782
783    #[test]
784    fn test_parse_prefer_cost_efficient_flag() {
785        let args = parse_args_from(["oxi", "--prefer-cost-efficient", "Hello"]).unwrap();
786        // prefer_cost_efficient alone should NOT set enable_routing
787        assert!(!args.enable_routing); // enable_routing is a separate flag
788        assert!(args.prefer_cost_efficient);
789        assert!(args.fallback_chain.is_empty());
790        assert!(!args.disable_fallback);
791    }
792
793    #[test]
794    fn test_parse_fallback_chain_single() {
795        let args = parse_args_from(["oxi", "--fallback-chain", "openai/gpt-4o", "Hello"]).unwrap();
796        assert_eq!(args.fallback_chain, vec!["openai/gpt-4o"]);
797    }
798
799    #[test]
800    fn test_parse_fallback_chain_comma_separated() {
801        let args = parse_args_from([
802            "oxi",
803            "--fallback-chain",
804            "openai/gpt-4o,anthropic/claude-3",
805            "Hello",
806        ])
807        .unwrap();
808        assert_eq!(
809            args.fallback_chain,
810            vec!["openai/gpt-4o", "anthropic/claude-3"]
811        );
812    }
813
814    #[test]
815    fn test_parse_fallback_chain_multiple_args() {
816        let args = parse_args_from([
817            "oxi",
818            "--fallback-chain",
819            "openai/gpt-4o",
820            "--fallback-chain",
821            "anthropic/claude-3",
822            "Hello",
823        ])
824        .unwrap();
825        assert_eq!(
826            args.fallback_chain,
827            vec!["openai/gpt-4o", "anthropic/claude-3"]
828        );
829    }
830
831    #[test]
832    fn test_parse_fallback_chain_empty() {
833        let args = parse_args_from(["oxi", "Hello"]).unwrap();
834        assert!(args.fallback_chain.is_empty());
835    }
836
837    #[test]
838    fn test_parse_disable_fallback_flag() {
839        let args = parse_args_from(["oxi", "--disable-fallback", "Hello"]).unwrap();
840        assert!(args.disable_fallback);
841    }
842
843    #[test]
844    fn test_parse_routing_all_flags() {
845        let args = parse_args_from([
846            "oxi",
847            "--enable-routing",
848            "--prefer-cost-efficient",
849            "--fallback-chain",
850            "openai/gpt-4o,anthropic/claude-3",
851            "--disable-fallback",
852            "Hello",
853        ])
854        .unwrap();
855        assert!(args.enable_routing);
856        assert!(args.prefer_cost_efficient);
857        assert_eq!(
858            args.fallback_chain,
859            vec!["openai/gpt-4o", "anthropic/claude-3"]
860        );
861        assert!(args.disable_fallback);
862    }
863
864    // ── Reset command ────────────────────────────────────────────
865
866    #[test]
867    fn test_parse_reset_command() {
868        let args = parse_args_from(["oxi", "reset"]).unwrap();
869        match args.command {
870            Some(Commands::Reset {
871                yes,
872                include_project,
873            }) => {
874                assert!(!yes);
875                assert!(!include_project);
876            }
877            _ => panic!("Expected Reset command"),
878        }
879    }
880
881    #[test]
882    fn test_parse_reset_yes_flag() {
883        let args = parse_args_from(["oxi", "reset", "--yes"]).unwrap();
884        match args.command {
885            Some(Commands::Reset {
886                yes,
887                include_project,
888            }) => {
889                assert!(yes);
890                assert!(!include_project);
891            }
892            _ => panic!("Expected Reset command with --yes"),
893        }
894    }
895
896    #[test]
897    fn test_parse_reset_include_project() {
898        let args = parse_args_from(["oxi", "reset", "--yes", "--include-project"]).unwrap();
899        match args.command {
900            Some(Commands::Reset {
901                yes,
902                include_project,
903            }) => {
904                assert!(yes);
905                assert!(include_project);
906            }
907            _ => panic!("Expected Reset command with all flags"),
908        }
909    }
910}