alopex_cli/
cli.rs

1//! CLI Parser - Command-line argument parsing with clap
2//!
3//! This module defines the CLI structure using clap derive macros.
4
5use std::path::PathBuf;
6
7use clap::{Parser, Subcommand, ValueEnum};
8use clap_complete::Shell;
9
10fn parse_shell(value: &str) -> Result<Shell, String> {
11    match value {
12        "bash" => Ok(Shell::Bash),
13        "zsh" => Ok(Shell::Zsh),
14        "fish" => Ok(Shell::Fish),
15        "pwsh" | "powershell" => Ok(Shell::PowerShell),
16        _ => Err(format!(
17            "Unsupported shell: {}. Use bash, zsh, fish, or pwsh.",
18            value
19        )),
20    }
21}
22
23/// Alopex CLI - Command-line interface for Alopex DB
24#[derive(Parser, Debug)]
25#[command(name = "alopex")]
26#[command(version, about, long_about = None)]
27pub struct Cli {
28    /// Path to the database directory (local path or S3 URI)
29    #[arg(long)]
30    pub data_dir: Option<String>,
31
32    /// Profile name to use for database configuration
33    #[arg(long)]
34    pub profile: Option<String>,
35
36    /// Run in in-memory mode (no persistence)
37    #[arg(long, conflicts_with = "data_dir")]
38    pub in_memory: bool,
39
40    /// Output format
41    #[arg(long, value_enum, default_value = "table")]
42    pub output: OutputFormat,
43
44    /// Limit the number of output rows
45    #[arg(long)]
46    pub limit: Option<usize>,
47
48    /// Suppress informational messages
49    #[arg(long)]
50    pub quiet: bool,
51
52    /// Enable verbose output (includes stack traces for errors)
53    #[arg(long)]
54    pub verbose: bool,
55
56    /// Thread mode (multi or single)
57    #[arg(long, value_enum, default_value = "multi")]
58    pub thread_mode: ThreadMode,
59
60    /// Enable batch mode (non-interactive)
61    #[arg(long, short = 'b')]
62    pub batch: bool,
63
64    /// Automatically answer yes to prompts
65    #[arg(long)]
66    pub yes: bool,
67
68    /// Subcommand to execute
69    #[command(subcommand)]
70    pub command: Command,
71}
72
73/// Output format for query results
74#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
75pub enum OutputFormat {
76    /// Human-readable table format
77    Table,
78    /// JSON array format
79    Json,
80    /// JSON Lines format (one JSON object per line)
81    Jsonl,
82    /// CSV format (RFC 4180)
83    Csv,
84    /// TSV format (tab-separated values)
85    Tsv,
86}
87
88impl OutputFormat {
89    /// Returns true if this format supports streaming output.
90    #[allow(dead_code)]
91    pub fn supports_streaming(&self) -> bool {
92        matches!(self, Self::Jsonl | Self::Csv | Self::Tsv)
93    }
94}
95
96/// Thread mode for database operations
97#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
98pub enum ThreadMode {
99    /// Multi-threaded mode (default)
100    Multi,
101    /// Single-threaded mode (not supported in v0.3.2)
102    Single,
103}
104
105/// Top-level subcommands
106#[derive(Subcommand, Debug)]
107pub enum Command {
108    /// Profile management
109    Profile {
110        #[command(subcommand)]
111        command: ProfileCommand,
112    },
113    /// Key-Value operations
114    Kv {
115        #[command(subcommand)]
116        command: KvCommand,
117    },
118    /// SQL query execution
119    Sql(SqlCommand),
120    /// Vector operations
121    Vector {
122        #[command(subcommand)]
123        command: VectorCommand,
124    },
125    /// HNSW index management
126    Hnsw {
127        #[command(subcommand)]
128        command: HnswCommand,
129    },
130    /// Columnar segment operations
131    Columnar {
132        #[command(subcommand)]
133        command: ColumnarCommand,
134    },
135    /// Show CLI and file format version information
136    Version,
137    /// Generate shell completion scripts
138    Completions {
139        /// Shell type (bash, zsh, fish, pwsh)
140        #[arg(value_parser = parse_shell, value_name = "SHELL")]
141        shell: Shell,
142    },
143}
144
145/// Profile subcommands
146#[derive(Subcommand, Debug, Clone)]
147pub enum ProfileCommand {
148    /// Create a profile
149    Create {
150        /// Profile name
151        name: String,
152        /// Path to the database directory (local path or S3 URI)
153        #[arg(long)]
154        data_dir: String,
155    },
156    /// List profiles
157    List,
158    /// Show profile details
159    Show {
160        /// Profile name
161        name: String,
162    },
163    /// Delete a profile
164    Delete {
165        /// Profile name
166        name: String,
167    },
168    /// Set the default profile
169    SetDefault {
170        /// Profile name
171        name: String,
172    },
173}
174
175/// KV subcommands
176#[derive(Subcommand, Debug)]
177pub enum KvCommand {
178    /// Get a value by key
179    Get {
180        /// The key to retrieve
181        key: String,
182    },
183    /// Put a key-value pair
184    Put {
185        /// The key to set
186        key: String,
187        /// The value to store
188        value: String,
189    },
190    /// Delete a key
191    Delete {
192        /// The key to delete
193        key: String,
194    },
195    /// List keys with optional prefix
196    List {
197        /// Filter keys by prefix
198        #[arg(long)]
199        prefix: Option<String>,
200    },
201    /// Transaction operations
202    #[command(subcommand)]
203    Txn(KvTxnCommand),
204}
205
206/// KV transaction subcommands
207#[derive(Subcommand, Debug)]
208pub enum KvTxnCommand {
209    /// Begin a transaction
210    Begin {
211        /// Transaction timeout in seconds (default: 60)
212        #[arg(long)]
213        timeout_secs: Option<u64>,
214    },
215    /// Get a value within a transaction
216    Get {
217        /// The key to retrieve
218        key: String,
219        /// Transaction ID
220        #[arg(long)]
221        txn_id: String,
222    },
223    /// Put a key-value pair within a transaction
224    Put {
225        /// The key to set
226        key: String,
227        /// The value to store
228        value: String,
229        /// Transaction ID
230        #[arg(long)]
231        txn_id: String,
232    },
233    /// Delete a key within a transaction
234    Delete {
235        /// The key to delete
236        key: String,
237        /// Transaction ID
238        #[arg(long)]
239        txn_id: String,
240    },
241    /// Commit a transaction
242    Commit {
243        /// Transaction ID
244        #[arg(long)]
245        txn_id: String,
246    },
247    /// Roll back a transaction
248    Rollback {
249        /// Transaction ID
250        #[arg(long)]
251        txn_id: String,
252    },
253}
254
255/// SQL subcommand
256#[derive(Parser, Debug)]
257pub struct SqlCommand {
258    /// SQL query to execute
259    #[arg(conflicts_with = "file")]
260    pub query: Option<String>,
261
262    /// File containing SQL query
263    #[arg(long, short = 'f')]
264    pub file: Option<String>,
265}
266
267/// Vector subcommands
268#[derive(Subcommand, Debug)]
269pub enum VectorCommand {
270    /// Search for similar vectors
271    Search {
272        /// Index name
273        #[arg(long)]
274        index: String,
275        /// Query vector as JSON array
276        #[arg(long)]
277        query: String,
278        /// Number of results to return
279        #[arg(long, short = 'k', default_value = "10")]
280        k: usize,
281        /// Show progress indicator
282        #[arg(long)]
283        progress: bool,
284    },
285    /// Upsert a single vector
286    Upsert {
287        /// Index name
288        #[arg(long)]
289        index: String,
290        /// Vector key/ID
291        #[arg(long)]
292        key: String,
293        /// Vector as JSON array
294        #[arg(long)]
295        vector: String,
296    },
297    /// Delete a single vector by key
298    Delete {
299        /// Index name
300        #[arg(long)]
301        index: String,
302        /// Vector key/ID to delete
303        #[arg(long)]
304        key: String,
305    },
306}
307
308/// Distance metric for HNSW index
309#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Default)]
310pub enum DistanceMetric {
311    /// Cosine similarity (default)
312    #[default]
313    Cosine,
314    /// Euclidean distance (L2)
315    L2,
316    /// Inner product
317    Ip,
318}
319
320/// HNSW subcommands
321#[derive(Subcommand, Debug)]
322pub enum HnswCommand {
323    /// Create a new HNSW index
324    Create {
325        /// Index name
326        name: String,
327        /// Vector dimensions
328        #[arg(long)]
329        dim: usize,
330        /// Distance metric
331        #[arg(long, value_enum, default_value = "cosine")]
332        metric: DistanceMetric,
333    },
334    /// Show index statistics
335    Stats {
336        /// Index name
337        name: String,
338    },
339    /// Drop an index
340    Drop {
341        /// Index name
342        name: String,
343    },
344}
345
346/// Columnar subcommands
347#[derive(Subcommand, Debug)]
348pub enum ColumnarCommand {
349    /// Scan a columnar segment
350    Scan {
351        /// Segment ID
352        #[arg(long)]
353        segment: String,
354        /// Show progress indicator
355        #[arg(long)]
356        progress: bool,
357    },
358    /// Show segment statistics
359    Stats {
360        /// Segment ID
361        #[arg(long)]
362        segment: String,
363    },
364    /// List all columnar segments
365    List,
366    /// Ingest a file into columnar storage
367    Ingest {
368        /// Input file path (CSV or Parquet)
369        #[arg(long)]
370        file: PathBuf,
371        /// Target table name
372        #[arg(long)]
373        table: String,
374        /// CSV delimiter character
375        #[arg(long, default_value = ",", value_parser = clap::value_parser!(char))]
376        delimiter: char,
377        /// Whether the CSV has a header row
378        #[arg(
379            long,
380            default_value = "true",
381            value_parser = clap::value_parser!(bool),
382            action = clap::ArgAction::Set
383        )]
384        header: bool,
385        /// Compression type (lz4, zstd, none)
386        #[arg(long, default_value = "lz4")]
387        compression: String,
388        /// Row group size (rows per group)
389        #[arg(long)]
390        row_group_size: Option<usize>,
391    },
392    /// Index management
393    #[command(subcommand)]
394    Index(IndexCommand),
395}
396
397/// Columnar index subcommands
398#[derive(Subcommand, Debug)]
399pub enum IndexCommand {
400    /// Create an index
401    Create {
402        /// Segment ID
403        #[arg(long)]
404        segment: String,
405        /// Column name
406        #[arg(long)]
407        column: String,
408        /// Index type (minmax, bloom)
409        #[arg(long = "type")]
410        index_type: String,
411    },
412    /// List indexes
413    List {
414        /// Segment ID
415        #[arg(long)]
416        segment: String,
417    },
418    /// Drop an index
419    Drop {
420        /// Segment ID
421        #[arg(long)]
422        segment: String,
423        /// Column name
424        #[arg(long)]
425        column: String,
426    },
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432
433    #[test]
434    fn test_parse_in_memory_kv_get() {
435        let args = vec!["alopex", "--in-memory", "kv", "get", "mykey"];
436        let cli = Cli::try_parse_from(args).unwrap();
437
438        assert!(cli.in_memory);
439        assert!(cli.data_dir.is_none());
440        assert_eq!(cli.output, OutputFormat::Table);
441        assert!(matches!(
442            cli.command,
443            Command::Kv {
444                command: KvCommand::Get { key }
445            } if key == "mykey"
446        ));
447    }
448
449    #[test]
450    fn test_parse_data_dir_sql() {
451        let args = vec![
452            "alopex",
453            "--data-dir",
454            "/path/to/db",
455            "sql",
456            "SELECT * FROM users",
457        ];
458        let cli = Cli::try_parse_from(args).unwrap();
459
460        assert!(!cli.in_memory);
461        assert_eq!(cli.data_dir, Some("/path/to/db".to_string()));
462        assert!(matches!(
463            cli.command,
464            Command::Sql(SqlCommand { query: Some(q), file: None }) if q == "SELECT * FROM users"
465        ));
466    }
467
468    #[test]
469    fn test_parse_output_format() {
470        let args = vec!["alopex", "--in-memory", "--output", "jsonl", "kv", "list"];
471        let cli = Cli::try_parse_from(args).unwrap();
472
473        assert_eq!(cli.output, OutputFormat::Jsonl);
474    }
475
476    #[test]
477    fn test_parse_limit() {
478        let args = vec!["alopex", "--in-memory", "--limit", "100", "kv", "list"];
479        let cli = Cli::try_parse_from(args).unwrap();
480
481        assert_eq!(cli.limit, Some(100));
482    }
483
484    #[test]
485    fn test_parse_verbose_quiet() {
486        let args = vec!["alopex", "--in-memory", "--verbose", "kv", "list"];
487        let cli = Cli::try_parse_from(args).unwrap();
488
489        assert!(cli.verbose);
490        assert!(!cli.quiet);
491    }
492
493    #[test]
494    fn test_parse_thread_mode() {
495        let args = vec![
496            "alopex",
497            "--in-memory",
498            "--thread-mode",
499            "single",
500            "kv",
501            "list",
502        ];
503        let cli = Cli::try_parse_from(args).unwrap();
504
505        assert_eq!(cli.thread_mode, ThreadMode::Single);
506    }
507
508    #[test]
509    fn test_parse_profile_option_batch_yes() {
510        let args = vec![
511            "alopex",
512            "--profile",
513            "dev",
514            "--batch",
515            "--yes",
516            "--in-memory",
517            "kv",
518            "list",
519        ];
520        let cli = Cli::try_parse_from(args).unwrap();
521
522        assert_eq!(cli.profile.as_deref(), Some("dev"));
523        assert!(cli.batch);
524        assert!(cli.yes);
525    }
526
527    #[test]
528    fn test_parse_batch_short_flag() {
529        let args = vec!["alopex", "-b", "--in-memory", "kv", "list"];
530        let cli = Cli::try_parse_from(args).unwrap();
531
532        assert!(cli.batch);
533    }
534
535    #[test]
536    fn test_parse_profile_create_subcommand() {
537        let args = vec![
538            "alopex",
539            "profile",
540            "create",
541            "dev",
542            "--data-dir",
543            "/path/to/db",
544        ];
545        let cli = Cli::try_parse_from(args).unwrap();
546
547        assert!(matches!(
548            cli.command,
549            Command::Profile {
550                command: ProfileCommand::Create { name, data_dir }
551            }
552                if name == "dev" && data_dir == "/path/to/db"
553        ));
554    }
555
556    #[test]
557    fn test_parse_completions_bash() {
558        let args = vec!["alopex", "completions", "bash"];
559        let cli = Cli::try_parse_from(args).unwrap();
560
561        assert!(matches!(
562            cli.command,
563            Command::Completions { shell } if shell == Shell::Bash
564        ));
565    }
566
567    #[test]
568    fn test_parse_completions_pwsh() {
569        let args = vec!["alopex", "completions", "pwsh"];
570        let cli = Cli::try_parse_from(args).unwrap();
571
572        assert!(matches!(
573            cli.command,
574            Command::Completions { shell } if shell == Shell::PowerShell
575        ));
576    }
577
578    #[test]
579    fn test_parse_kv_put() {
580        let args = vec!["alopex", "--in-memory", "kv", "put", "mykey", "myvalue"];
581        let cli = Cli::try_parse_from(args).unwrap();
582
583        assert!(matches!(
584            cli.command,
585            Command::Kv {
586                command: KvCommand::Put { key, value }
587            } if key == "mykey" && value == "myvalue"
588        ));
589    }
590
591    #[test]
592    fn test_parse_kv_delete() {
593        let args = vec!["alopex", "--in-memory", "kv", "delete", "mykey"];
594        let cli = Cli::try_parse_from(args).unwrap();
595
596        assert!(matches!(
597            cli.command,
598            Command::Kv {
599                command: KvCommand::Delete { key }
600            } if key == "mykey"
601        ));
602    }
603
604    #[test]
605    fn test_parse_kv_txn_begin() {
606        let args = vec!["alopex", "kv", "txn", "begin", "--timeout-secs", "30"];
607        let cli = Cli::try_parse_from(args).unwrap();
608
609        assert!(matches!(
610            cli.command,
611            Command::Kv {
612                command: KvCommand::Txn(KvTxnCommand::Begin {
613                    timeout_secs: Some(30)
614                })
615            }
616        ));
617    }
618
619    #[test]
620    fn test_parse_kv_txn_get_requires_txn_id() {
621        let args = vec!["alopex", "kv", "txn", "get", "mykey"];
622
623        assert!(Cli::try_parse_from(args).is_err());
624    }
625
626    #[test]
627    fn test_parse_kv_txn_get() {
628        let args = vec!["alopex", "kv", "txn", "get", "mykey", "--txn-id", "txn123"];
629        let cli = Cli::try_parse_from(args).unwrap();
630
631        assert!(matches!(
632            cli.command,
633            Command::Kv {
634                command: KvCommand::Txn(KvTxnCommand::Get { key, txn_id })
635            } if key == "mykey" && txn_id == "txn123"
636        ));
637    }
638
639    #[test]
640    fn test_parse_kv_list_with_prefix() {
641        let args = vec!["alopex", "--in-memory", "kv", "list", "--prefix", "user:"];
642        let cli = Cli::try_parse_from(args).unwrap();
643
644        assert!(matches!(
645            cli.command,
646            Command::Kv {
647                command: KvCommand::List { prefix: Some(p) }
648            } if p == "user:"
649        ));
650    }
651
652    #[test]
653    fn test_parse_sql_from_file() {
654        let args = vec!["alopex", "--in-memory", "sql", "-f", "query.sql"];
655        let cli = Cli::try_parse_from(args).unwrap();
656
657        assert!(matches!(
658            cli.command,
659            Command::Sql(SqlCommand { query: None, file: Some(f) }) if f == "query.sql"
660        ));
661    }
662
663    #[test]
664    fn test_parse_vector_search() {
665        let args = vec![
666            "alopex",
667            "--in-memory",
668            "vector",
669            "search",
670            "--index",
671            "my_index",
672            "--query",
673            "[1.0,2.0,3.0]",
674            "-k",
675            "5",
676        ];
677        let cli = Cli::try_parse_from(args).unwrap();
678
679        assert!(matches!(
680            cli.command,
681            Command::Vector {
682                command: VectorCommand::Search { index, query, k, progress }
683            } if index == "my_index" && query == "[1.0,2.0,3.0]" && k == 5 && !progress
684        ));
685    }
686
687    #[test]
688    fn test_parse_vector_upsert() {
689        let args = vec![
690            "alopex",
691            "--in-memory",
692            "vector",
693            "upsert",
694            "--index",
695            "my_index",
696            "--key",
697            "vec1",
698            "--vector",
699            "[1.0,2.0,3.0]",
700        ];
701        let cli = Cli::try_parse_from(args).unwrap();
702
703        assert!(matches!(
704            cli.command,
705            Command::Vector {
706                command: VectorCommand::Upsert { index, key, vector }
707            } if index == "my_index" && key == "vec1" && vector == "[1.0,2.0,3.0]"
708        ));
709    }
710
711    #[test]
712    fn test_parse_vector_delete() {
713        let args = vec![
714            "alopex",
715            "--in-memory",
716            "vector",
717            "delete",
718            "--index",
719            "my_index",
720            "--key",
721            "vec1",
722        ];
723        let cli = Cli::try_parse_from(args).unwrap();
724
725        assert!(matches!(
726            cli.command,
727            Command::Vector {
728                command: VectorCommand::Delete { index, key }
729            } if index == "my_index" && key == "vec1"
730        ));
731    }
732
733    #[test]
734    fn test_parse_hnsw_create() {
735        let args = vec![
736            "alopex",
737            "--in-memory",
738            "hnsw",
739            "create",
740            "my_index",
741            "--dim",
742            "128",
743            "--metric",
744            "l2",
745        ];
746        let cli = Cli::try_parse_from(args).unwrap();
747
748        assert!(matches!(
749            cli.command,
750            Command::Hnsw {
751                command: HnswCommand::Create { name, dim, metric }
752            } if name == "my_index" && dim == 128 && metric == DistanceMetric::L2
753        ));
754    }
755
756    #[test]
757    fn test_parse_hnsw_create_default_metric() {
758        let args = vec![
759            "alopex",
760            "--in-memory",
761            "hnsw",
762            "create",
763            "my_index",
764            "--dim",
765            "128",
766        ];
767        let cli = Cli::try_parse_from(args).unwrap();
768
769        assert!(matches!(
770            cli.command,
771            Command::Hnsw {
772                command: HnswCommand::Create { name, dim, metric }
773            } if name == "my_index" && dim == 128 && metric == DistanceMetric::Cosine
774        ));
775    }
776
777    #[test]
778    fn test_parse_columnar_scan() {
779        let args = vec![
780            "alopex",
781            "--in-memory",
782            "columnar",
783            "scan",
784            "--segment",
785            "seg_001",
786        ];
787        let cli = Cli::try_parse_from(args).unwrap();
788
789        assert!(matches!(
790            cli.command,
791            Command::Columnar {
792                command: ColumnarCommand::Scan { segment, progress }
793            } if segment == "seg_001" && !progress
794        ));
795    }
796
797    #[test]
798    fn test_parse_columnar_stats() {
799        let args = vec![
800            "alopex",
801            "--in-memory",
802            "columnar",
803            "stats",
804            "--segment",
805            "seg_001",
806        ];
807        let cli = Cli::try_parse_from(args).unwrap();
808
809        assert!(matches!(
810            cli.command,
811            Command::Columnar {
812                command: ColumnarCommand::Stats { segment }
813            } if segment == "seg_001"
814        ));
815    }
816
817    #[test]
818    fn test_parse_columnar_list() {
819        let args = vec!["alopex", "--in-memory", "columnar", "list"];
820        let cli = Cli::try_parse_from(args).unwrap();
821
822        assert!(matches!(
823            cli.command,
824            Command::Columnar {
825                command: ColumnarCommand::List
826            }
827        ));
828    }
829
830    #[test]
831    fn test_parse_columnar_ingest_defaults() {
832        let args = vec![
833            "alopex",
834            "--in-memory",
835            "columnar",
836            "ingest",
837            "--file",
838            "data.csv",
839            "--table",
840            "events",
841        ];
842        let cli = Cli::try_parse_from(args).unwrap();
843
844        assert!(matches!(
845            cli.command,
846            Command::Columnar {
847                command: ColumnarCommand::Ingest {
848                    file,
849                    table,
850                    delimiter,
851                    header,
852                    compression,
853                    row_group_size,
854                }
855            } if file == std::path::Path::new("data.csv")
856                && table == "events"
857                && delimiter == ','
858                && header
859                && compression == "lz4"
860                && row_group_size.is_none()
861        ));
862    }
863
864    #[test]
865    fn test_parse_columnar_ingest_custom_options() {
866        let args = vec![
867            "alopex",
868            "--in-memory",
869            "columnar",
870            "ingest",
871            "--file",
872            "data.csv",
873            "--table",
874            "events",
875            "--delimiter",
876            ";",
877            "--header",
878            "false",
879            "--compression",
880            "zstd",
881            "--row-group-size",
882            "500",
883        ];
884        let cli = Cli::try_parse_from(args).unwrap();
885
886        assert!(matches!(
887            cli.command,
888            Command::Columnar {
889                command: ColumnarCommand::Ingest {
890                    file,
891                    table,
892                    delimiter,
893                    header,
894                    compression,
895                    row_group_size,
896                }
897            } if file == std::path::Path::new("data.csv")
898                && table == "events"
899                && delimiter == ';'
900                && !header
901                && compression == "zstd"
902                && row_group_size == Some(500)
903        ));
904    }
905
906    #[test]
907    fn test_parse_columnar_index_create() {
908        let args = vec![
909            "alopex",
910            "--in-memory",
911            "columnar",
912            "index",
913            "create",
914            "--segment",
915            "123:1",
916            "--column",
917            "col1",
918            "--type",
919            "bloom",
920        ];
921        let cli = Cli::try_parse_from(args).unwrap();
922
923        assert!(matches!(
924            cli.command,
925            Command::Columnar {
926                command: ColumnarCommand::Index(IndexCommand::Create {
927                    segment,
928                    column,
929                    index_type,
930                })
931            } if segment == "123:1"
932                && column == "col1"
933                && index_type == "bloom"
934        ));
935    }
936
937    #[test]
938    fn test_output_format_supports_streaming() {
939        assert!(!OutputFormat::Table.supports_streaming());
940        assert!(!OutputFormat::Json.supports_streaming());
941        assert!(OutputFormat::Jsonl.supports_streaming());
942        assert!(OutputFormat::Csv.supports_streaming());
943        assert!(OutputFormat::Tsv.supports_streaming());
944    }
945
946    #[test]
947    fn test_default_values() {
948        let args = vec!["alopex", "--in-memory", "kv", "list"];
949        let cli = Cli::try_parse_from(args).unwrap();
950
951        assert_eq!(cli.output, OutputFormat::Table);
952        assert_eq!(cli.thread_mode, ThreadMode::Multi);
953        assert!(cli.limit.is_none());
954        assert!(!cli.quiet);
955        assert!(!cli.verbose);
956    }
957
958    #[test]
959    fn test_s3_data_dir() {
960        let args = vec![
961            "alopex",
962            "--data-dir",
963            "s3://my-bucket/prefix",
964            "kv",
965            "list",
966        ];
967        let cli = Cli::try_parse_from(args).unwrap();
968
969        assert_eq!(cli.data_dir, Some("s3://my-bucket/prefix".to_string()));
970    }
971}