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    /// Run the interactive setup wizard
153    Setup {
154        /// Reset all settings to defaults
155        #[arg(long)]
156        reset: bool,
157    },
158    /// Reset all settings and data to factory defaults
159    ///
160    /// Use when configuration has become tangled and you want a clean start.
161    /// An interactive confirmation prompt will be shown.
162    Reset {
163        /// Skip the confirmation prompt
164        #[arg(long, short)]
165        yes: bool,
166        /// Also delete the project-local .oxi/ directory
167        #[arg(long)]
168        include_project: bool,
169    },
170    /// Export a session to HTML
171    Export {
172        /// Session ID or prefix (default: most recent for this project)
173        session_id: Option<String>,
174        /// Output file path (default: oxi-export-{id}.html in CWD)
175        #[arg(short, long)]
176        output: Option<PathBuf>,
177    },
178    /// Import a session from a JSONL file
179    Import {
180        /// Path to the JSONL session file
181        path: PathBuf,
182    },
183    /// Share a session as a GitHub Gist (requires gh CLI)
184    Share {
185        /// Session ID or prefix (default: most recent for this project)
186        session_id: Option<String>,
187    },
188}
189
190// ── Package subcommands ────────────────────────────────────────────
191
192/// Package management subcommands
193#[derive(Debug, Clone, Subcommand)]
194pub enum PkgCommands {
195    /// Install a package from a local path or npm:@scope/name
196    Install {
197        /// Package source: a local directory path or npm:@scope/name
198        source: String,
199    },
200    /// List installed packages
201    List,
202    /// Uninstall a package by name
203    Uninstall {
204        /// Package name to uninstall
205        name: String,
206    },
207    /// Update a package to the latest version
208    Update {
209        /// Package name to update (updates all if omitted)
210        name: Option<String>,
211    },
212}
213
214// ── Issue subcommands ───────────────────────────────────────────────
215
216/// Local issue management subcommands.
217#[derive(Debug, Clone, Subcommand)]
218pub enum IssueCommands {
219    /// List local issues (default: open only)
220    List {
221        /// Show closed issues too
222        #[arg(long)]
223        all: bool,
224        /// Filter by label
225        #[arg(long)]
226        label: Option<String>,
227        /// Filter by substring of title
228        #[arg(long)]
229        text: Option<String>,
230    },
231    /// Show a single issue (prints content + content_hash for `update`)
232    Show {
233        /// Issue id
234        id: u32,
235    },
236    /// Create a new issue
237    New {
238        /// Issue title
239        title: String,
240        /// Issue body (markdown); pass via stdin or $EDITOR
241        #[arg(long, short)]
242        body: Option<String>,
243        /// Priority: low|medium|high|critical (default: medium)
244        #[arg(long)]
245        priority: Option<String>,
246        /// Comma-separated labels
247        #[arg(long)]
248        labels: Option<String>,
249    },
250    /// Close an issue (releases any assignment; must be owner)
251    Close {
252        /// Issue id
253        id: u32,
254        /// Content hash from `show` (skip to bypass CAS check)
255        #[arg(long)]
256        hash: Option<String>,
257    },
258}
259
260// ── Extension subcommands ──────────────────────────────────────────────
261
262/// Extension management subcommands
263#[derive(Debug, Clone, Subcommand)]
264pub enum ExtCommands {
265    /// Install a WASM extension from a GitHub repo (owner/repo or owner/repo@version)
266    Install {
267        /// Extension source: owner/repo or owner/repo@version
268        source: String,
269        /// Include pre-release versions
270        #[arg(long)]
271        prerelease: bool,
272    },
273    /// List installed extensions
274    List,
275    /// Remove an installed extension
276    Remove {
277        /// Extension source: owner/repo
278        source: String,
279    },
280    /// Update extension(s) to latest version
281    Update {
282        /// Extension source: owner/repo (updates all if omitted)
283        source: Option<String>,
284    },
285    /// Show info about a remote extension (without installing)
286    Info {
287        /// Extension source: owner/repo
288        source: String,
289    },
290}
291
292// ── Config subcommands ─────────────────────────────────────────────
293
294/// Configuration management subcommands
295#[derive(Debug, Clone, Subcommand)]
296pub enum ConfigCommands {
297    /// Show current configuration
298    Show,
299    /// List all enabled resources
300    List {
301        /// Resource type filter (extensions, skills, prompts, themes)
302        resource_type: Option<String>,
303    },
304    /// Enable a resource (extension, skill, prompt, or theme)
305    Enable {
306        /// Resource type: extension, skill, prompt, or theme
307        resource_type: String,
308        /// Resource path or name
309        name: String,
310    },
311    /// Disable a resource
312    Disable {
313        /// Resource type: extension, skill, prompt, or theme
314        resource_type: String,
315        /// Resource path or name
316        name: String,
317    },
318    /// Set a configuration value
319    Set {
320        /// Setting key (e.g. theme, model, thinking_level)
321        key: String,
322        /// Setting value
323        value: String,
324    },
325    /// Get a configuration value
326    Get {
327        /// Setting key
328        key: String,
329    },
330    /// Add a custom OpenAI-compatible provider
331    AddProvider {
332        /// Provider name (e.g. minimax)
333        name: String,
334        /// Base URL (e.g. <https://api.minimax.chat/v1>)
335        base_url: String,
336        /// Environment variable name for API key (e.g. MINIMAX_API_KEY)
337        api_key_env: String,
338        /// API type: openai-completions or openai-responses (default: openai-completions)
339        #[arg(default_value = "openai-completions")]
340        api: String,
341    },
342    /// Remove a custom provider
343    RemoveProvider {
344        /// Provider name to remove
345        name: String,
346    },
347    /// Reset credentials (auth.json) and optionally settings
348    Reset {
349        /// Also reset settings (settings.toml / settings.json)
350        #[arg(long, short)]
351        all: bool,
352    },
353}
354
355// ── Parsing helpers ────────────────────────────────────────────────
356
357/// Parse CLI arguments from the command line
358///
359/// # Examples
360///
361/// ```ignore
362/// use oxi_cli::CliArgs;
363///
364/// fn main() {
365///     let args = CliArgs::parse();
366///     match args.command {
367///         Some(Commands::Sessions) => { /* list sessions */ }
368///         Some(Commands::Tree { session_id }) => { /* show tree */ }
369///         _ => { /* interactive mode */ }
370///     }
371/// }
372/// ```
373pub fn parse_args() -> CliArgs {
374    CliArgs::parse()
375}
376
377/// Parse CLI arguments from a specific iterator
378pub fn parse_args_from<I, T>(iter: I) -> Result<CliArgs, clap::Error>
379where
380    I: IntoIterator<Item = T>,
381    T: Into<std::ffi::OsString> + Clone,
382{
383    CliArgs::try_parse_from(iter)
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389
390    #[test]
391    fn test_parse_basic_prompt() {
392        let args = parse_args_from(["oxi", "Hello", "world"]).unwrap();
393        assert_eq!(args.prompt, vec!["Hello", "world"]);
394    }
395
396    #[test]
397    fn test_parse_with_provider_and_model() {
398        let args = parse_args_from([
399            "oxi",
400            "--provider",
401            "anthropic",
402            "--model",
403            "claude-sonnet-4-20250514",
404            "Hello",
405        ])
406        .unwrap();
407        assert_eq!(args.provider, Some("anthropic".to_string()));
408        assert_eq!(args.model, Some("claude-sonnet-4-20250514".to_string()));
409    }
410
411    #[test]
412    fn test_parse_interactive_flag() {
413        let args = parse_args_from(["oxi", "-i"]).unwrap();
414        assert!(args.interactive);
415    }
416
417    #[test]
418    fn test_parse_extension_paths() {
419        let args =
420            parse_args_from(["oxi", "-e", "/path/to/ext.so", "-e", "/other/ext.so"]).unwrap();
421        assert_eq!(args.extensions.len(), 2);
422    }
423
424    #[test]
425    fn test_parse_sessions_command() {
426        let args = parse_args_from(["oxi", "sessions"]).unwrap();
427        assert!(matches!(args.command, Some(Commands::Sessions)));
428    }
429
430    #[test]
431    fn test_parse_tree_command() {
432        let args = parse_args_from(["oxi", "tree", "abc-123"]).unwrap();
433        match args.command {
434            Some(Commands::Tree { session_id }) => {
435                assert_eq!(session_id, "abc-123");
436            }
437            _ => panic!("Expected Tree command"),
438        }
439    }
440
441    #[test]
442    fn test_parse_tree_command_default() {
443        let args = parse_args_from(["oxi", "tree"]).unwrap();
444        match args.command {
445            Some(Commands::Tree { session_id }) => {
446                assert_eq!(session_id, "");
447            }
448            _ => panic!("Expected Tree command"),
449        }
450    }
451
452    #[test]
453    fn test_parse_fork_command() {
454        let args = parse_args_from(["oxi", "fork", "parent-id", "entry-id"]).unwrap();
455        match args.command {
456            Some(Commands::Fork {
457                parent_id,
458                entry_id,
459            }) => {
460                assert_eq!(parent_id, "parent-id");
461                assert_eq!(entry_id, "entry-id");
462            }
463            _ => panic!("Expected Fork command"),
464        }
465    }
466
467    #[test]
468    fn test_parse_delete_command() {
469        let args = parse_args_from(["oxi", "delete", "session-123"]).unwrap();
470        match args.command {
471            Some(Commands::Delete { session_id }) => {
472                assert_eq!(session_id, "session-123");
473            }
474            _ => panic!("Expected Delete command"),
475        }
476    }
477
478    #[test]
479    fn test_parse_pkg_install() {
480        let args = parse_args_from(["oxi", "pkg", "install", "npm:@scope/name"]).unwrap();
481        match args.command {
482            Some(Commands::Pkg { action }) => match action {
483                PkgCommands::Install { source } => {
484                    assert_eq!(source, "npm:@scope/name");
485                }
486                _ => panic!("Expected Install subcommand"),
487            },
488            _ => panic!("Expected Pkg command"),
489        }
490    }
491
492    #[test]
493    fn test_parse_pkg_list() {
494        let args = parse_args_from(["oxi", "pkg", "list"]).unwrap();
495        match args.command {
496            Some(Commands::Pkg { action }) => {
497                assert!(matches!(action, PkgCommands::List));
498            }
499            _ => panic!("Expected Pkg command"),
500        }
501    }
502
503    #[test]
504    fn test_parse_pkg_update_all() {
505        let args = parse_args_from(["oxi", "pkg", "update"]).unwrap();
506        match args.command {
507            Some(Commands::Pkg { action }) => match action {
508                PkgCommands::Update { name } => assert!(name.is_none()),
509                _ => panic!("Expected Update subcommand"),
510            },
511            _ => panic!("Expected Pkg command"),
512        }
513    }
514
515    #[test]
516    fn test_parse_pkg_update_named() {
517        let args = parse_args_from(["oxi", "pkg", "update", "my-pkg"]).unwrap();
518        match args.command {
519            Some(Commands::Pkg { action }) => match action {
520                PkgCommands::Update { name } => assert_eq!(name, Some("my-pkg".to_string())),
521                _ => panic!("Expected Update subcommand"),
522            },
523            _ => panic!("Expected Pkg command"),
524        }
525    }
526
527    #[test]
528    fn test_parse_config_show() {
529        let args = parse_args_from(["oxi", "config", "show"]).unwrap();
530        assert!(matches!(
531            args.command,
532            Some(Commands::Config {
533                action: ConfigCommands::Show
534            })
535        ));
536    }
537
538    #[test]
539    fn test_parse_config_set() {
540        let args = parse_args_from(["oxi", "config", "set", "theme", "dracula"]).unwrap();
541        match args.command {
542            Some(Commands::Config { action }) => match action {
543                ConfigCommands::Set { key, value } => {
544                    assert_eq!(key, "theme");
545                    assert_eq!(value, "dracula");
546                }
547                _ => panic!("Expected Set subcommand"),
548            },
549            _ => panic!("Expected Config command"),
550        }
551    }
552
553    #[test]
554    fn test_parse_config_get() {
555        let args = parse_args_from(["oxi", "config", "get", "theme"]).unwrap();
556        match args.command {
557            Some(Commands::Config { action }) => match action {
558                ConfigCommands::Get { key } => {
559                    assert_eq!(key, "theme");
560                }
561                _ => panic!("Expected Get subcommand"),
562            },
563            _ => panic!("Expected Config command"),
564        }
565    }
566
567    #[test]
568    fn test_parse_config_enable() {
569        let args = parse_args_from(["oxi", "config", "enable", "extension", "my-ext"]).unwrap();
570        match args.command {
571            Some(Commands::Config { action }) => match action {
572                ConfigCommands::Enable {
573                    resource_type,
574                    name,
575                } => {
576                    assert_eq!(resource_type, "extension");
577                    assert_eq!(name, "my-ext");
578                }
579                _ => panic!("Expected Enable subcommand"),
580            },
581            _ => panic!("Expected Config command"),
582        }
583    }
584
585    #[test]
586    fn test_parse_config_disable() {
587        let args = parse_args_from(["oxi", "config", "disable", "skill", "my-skill"]).unwrap();
588        match args.command {
589            Some(Commands::Config { action }) => match action {
590                ConfigCommands::Disable {
591                    resource_type,
592                    name,
593                } => {
594                    assert_eq!(resource_type, "skill");
595                    assert_eq!(name, "my-skill");
596                }
597                _ => panic!("Expected Disable subcommand"),
598            },
599            _ => panic!("Expected Config command"),
600        }
601    }
602
603    #[test]
604    fn test_parse_config_list() {
605        let args = parse_args_from(["oxi", "config", "list"]).unwrap();
606        match args.command {
607            Some(Commands::Config { action }) => match action {
608                ConfigCommands::List { resource_type } => {
609                    assert!(resource_type.is_none());
610                }
611                _ => panic!("Expected List subcommand"),
612            },
613            _ => panic!("Expected Config command"),
614        }
615    }
616
617    #[test]
618    fn test_parse_config_list_filtered() {
619        let args = parse_args_from(["oxi", "config", "list", "extensions"]).unwrap();
620        match args.command {
621            Some(Commands::Config { action }) => match action {
622                ConfigCommands::List { resource_type } => {
623                    assert_eq!(resource_type, Some("extensions".to_string()));
624                }
625                _ => panic!("Expected List subcommand"),
626            },
627            _ => panic!("Expected Config command"),
628        }
629    }
630
631    #[test]
632    fn test_thinking_level_reexport() {
633        // Verify the re-export from settings works
634        assert_eq!(format!("{:?}", ThinkingLevel::Medium), "Medium");
635    }
636
637    #[test]
638    fn test_parse_config_add_provider() {
639        let args = parse_args_from([
640            "oxi",
641            "config",
642            "add-provider",
643            "minimax",
644            "https://api.minimax.chat/v1",
645            "MINIMAX_API_KEY",
646            "openai-completions",
647        ])
648        .unwrap();
649        match args.command {
650            Some(Commands::Config { action }) => match action {
651                ConfigCommands::AddProvider {
652                    name,
653                    base_url,
654                    api_key_env,
655                    api,
656                } => {
657                    assert_eq!(name, "minimax");
658                    assert_eq!(base_url, "https://api.minimax.chat/v1");
659                    assert_eq!(api_key_env, "MINIMAX_API_KEY");
660                    assert_eq!(api, "openai-completions");
661                }
662                _ => panic!("Expected AddProvider subcommand"),
663            },
664            _ => panic!("Expected Config command"),
665        }
666    }
667
668    #[test]
669    fn test_parse_config_add_provider_default_api() {
670        let args = parse_args_from([
671            "oxi",
672            "config",
673            "add-provider",
674            "zai",
675            "https://api.z.ai/v1",
676            "ZAI_API_KEY",
677        ])
678        .unwrap();
679        match args.command {
680            Some(Commands::Config { action }) => match action {
681                ConfigCommands::AddProvider {
682                    name,
683                    base_url,
684                    api_key_env,
685                    api,
686                } => {
687                    assert_eq!(name, "zai");
688                    assert_eq!(base_url, "https://api.z.ai/v1");
689                    assert_eq!(api_key_env, "ZAI_API_KEY");
690                    assert_eq!(api, "openai-completions"); // default
691                }
692                _ => panic!("Expected AddProvider subcommand"),
693            },
694            _ => panic!("Expected Config command"),
695        }
696    }
697
698    #[test]
699    fn test_parse_config_remove_provider() {
700        let args = parse_args_from(["oxi", "config", "remove-provider", "minimax"]).unwrap();
701        match args.command {
702            Some(Commands::Config { action }) => match action {
703                ConfigCommands::RemoveProvider { name } => {
704                    assert_eq!(name, "minimax");
705                }
706                _ => panic!("Expected RemoveProvider subcommand"),
707            },
708            _ => panic!("Expected Config command"),
709        }
710    }
711
712    #[test]
713    fn test_parse_models_command() {
714        let args = parse_args_from(["oxi", "models"]).unwrap();
715        match args.command {
716            Some(Commands::Models { provider }) => {
717                assert!(provider.is_none());
718            }
719            _ => panic!("Expected Models command"),
720        }
721    }
722
723    #[test]
724    fn test_parse_models_with_provider() {
725        let args = parse_args_from(["oxi", "models", "--provider", "minimax"]).unwrap();
726        match args.command {
727            Some(Commands::Models { provider }) => {
728                assert_eq!(provider, Some("minimax".to_string()));
729            }
730            _ => panic!("Expected Models command"),
731        }
732    }
733
734    #[test]
735    fn test_parse_setup_command() {
736        let args = parse_args_from(["oxi", "setup"]).unwrap();
737        match args.command {
738            Some(Commands::Setup { reset }) => {
739                assert!(!reset);
740            }
741            _ => panic!("Expected Setup command"),
742        }
743    }
744
745    #[test]
746    fn test_parse_setup_reset() {
747        let args = parse_args_from(["oxi", "setup", "--reset"]).unwrap();
748        match args.command {
749            Some(Commands::Setup { reset }) => {
750                assert!(reset);
751            }
752            _ => panic!("Expected Setup command with reset"),
753        }
754    }
755
756    // ── Routing flags ──────────────────────────────────────────────
757
758    #[test]
759    fn test_parse_enable_routing_flag() {
760        let args = parse_args_from(["oxi", "--enable-routing", "Hello"]).unwrap();
761        assert!(args.enable_routing);
762        assert!(!args.prefer_cost_efficient);
763        assert!(args.fallback_chain.is_empty());
764        assert!(!args.disable_fallback);
765    }
766
767    #[test]
768    fn test_parse_prefer_cost_efficient_flag() {
769        let args = parse_args_from(["oxi", "--prefer-cost-efficient", "Hello"]).unwrap();
770        // prefer_cost_efficient alone should NOT set enable_routing
771        assert!(!args.enable_routing); // enable_routing is a separate flag
772        assert!(args.prefer_cost_efficient);
773        assert!(args.fallback_chain.is_empty());
774        assert!(!args.disable_fallback);
775    }
776
777    #[test]
778    fn test_parse_fallback_chain_single() {
779        let args = parse_args_from(["oxi", "--fallback-chain", "openai/gpt-4o", "Hello"]).unwrap();
780        assert_eq!(args.fallback_chain, vec!["openai/gpt-4o"]);
781    }
782
783    #[test]
784    fn test_parse_fallback_chain_comma_separated() {
785        let args = parse_args_from([
786            "oxi",
787            "--fallback-chain",
788            "openai/gpt-4o,anthropic/claude-3",
789            "Hello",
790        ])
791        .unwrap();
792        assert_eq!(
793            args.fallback_chain,
794            vec!["openai/gpt-4o", "anthropic/claude-3"]
795        );
796    }
797
798    #[test]
799    fn test_parse_fallback_chain_multiple_args() {
800        let args = parse_args_from([
801            "oxi",
802            "--fallback-chain",
803            "openai/gpt-4o",
804            "--fallback-chain",
805            "anthropic/claude-3",
806            "Hello",
807        ])
808        .unwrap();
809        assert_eq!(
810            args.fallback_chain,
811            vec!["openai/gpt-4o", "anthropic/claude-3"]
812        );
813    }
814
815    #[test]
816    fn test_parse_fallback_chain_empty() {
817        let args = parse_args_from(["oxi", "Hello"]).unwrap();
818        assert!(args.fallback_chain.is_empty());
819    }
820
821    #[test]
822    fn test_parse_disable_fallback_flag() {
823        let args = parse_args_from(["oxi", "--disable-fallback", "Hello"]).unwrap();
824        assert!(args.disable_fallback);
825    }
826
827    #[test]
828    fn test_parse_routing_all_flags() {
829        let args = parse_args_from([
830            "oxi",
831            "--enable-routing",
832            "--prefer-cost-efficient",
833            "--fallback-chain",
834            "openai/gpt-4o,anthropic/claude-3",
835            "--disable-fallback",
836            "Hello",
837        ])
838        .unwrap();
839        assert!(args.enable_routing);
840        assert!(args.prefer_cost_efficient);
841        assert_eq!(
842            args.fallback_chain,
843            vec!["openai/gpt-4o", "anthropic/claude-3"]
844        );
845        assert!(args.disable_fallback);
846    }
847
848    // ── Reset command ────────────────────────────────────────────
849
850    #[test]
851    fn test_parse_reset_command() {
852        let args = parse_args_from(["oxi", "reset"]).unwrap();
853        match args.command {
854            Some(Commands::Reset {
855                yes,
856                include_project,
857            }) => {
858                assert!(!yes);
859                assert!(!include_project);
860            }
861            _ => panic!("Expected Reset command"),
862        }
863    }
864
865    #[test]
866    fn test_parse_reset_yes_flag() {
867        let args = parse_args_from(["oxi", "reset", "--yes"]).unwrap();
868        match args.command {
869            Some(Commands::Reset {
870                yes,
871                include_project,
872            }) => {
873                assert!(yes);
874                assert!(!include_project);
875            }
876            _ => panic!("Expected Reset command with --yes"),
877        }
878    }
879
880    #[test]
881    fn test_parse_reset_include_project() {
882        let args = parse_args_from(["oxi", "reset", "--yes", "--include-project"]).unwrap();
883        match args.command {
884            Some(Commands::Reset {
885                yes,
886                include_project,
887            }) => {
888                assert!(yes);
889                assert!(include_project);
890            }
891            _ => panic!("Expected Reset command with all flags"),
892        }
893    }
894}