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::Json | 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    /// Server management commands
136    Server {
137        #[command(subcommand)]
138        command: ServerCommand,
139    },
140    /// Show CLI and file format version information
141    Version,
142    /// Generate shell completion scripts
143    Completions {
144        /// Shell type (bash, zsh, fish, pwsh)
145        #[arg(value_parser = parse_shell, value_name = "SHELL")]
146        shell: Shell,
147    },
148}
149
150/// Profile subcommands
151#[derive(Subcommand, Debug, Clone)]
152pub enum ProfileCommand {
153    /// Create a profile
154    Create {
155        /// Profile name
156        name: String,
157        /// Path to the database directory (local path or S3 URI)
158        #[arg(long)]
159        data_dir: String,
160    },
161    /// List profiles
162    List,
163    /// Show profile details
164    Show {
165        /// Profile name
166        name: String,
167    },
168    /// Delete a profile
169    Delete {
170        /// Profile name
171        name: String,
172    },
173    /// Set the default profile
174    SetDefault {
175        /// Profile name
176        name: String,
177    },
178}
179
180/// KV subcommands
181#[derive(Subcommand, Debug)]
182pub enum KvCommand {
183    /// Get a value by key
184    Get {
185        /// The key to retrieve
186        key: String,
187    },
188    /// Put a key-value pair
189    Put {
190        /// The key to set
191        key: String,
192        /// The value to store
193        value: String,
194    },
195    /// Delete a key
196    Delete {
197        /// The key to delete
198        key: String,
199    },
200    /// List keys with optional prefix
201    List {
202        /// Filter keys by prefix
203        #[arg(long)]
204        prefix: Option<String>,
205    },
206    /// Transaction operations
207    #[command(subcommand)]
208    Txn(KvTxnCommand),
209}
210
211/// KV transaction subcommands
212#[derive(Subcommand, Debug)]
213pub enum KvTxnCommand {
214    /// Begin a transaction
215    Begin {
216        /// Transaction timeout in seconds (default: 60)
217        #[arg(long)]
218        timeout_secs: Option<u64>,
219    },
220    /// Get a value within a transaction
221    Get {
222        /// The key to retrieve
223        key: String,
224        /// Transaction ID
225        #[arg(long)]
226        txn_id: String,
227    },
228    /// Put a key-value pair within a transaction
229    Put {
230        /// The key to set
231        key: String,
232        /// The value to store
233        value: String,
234        /// Transaction ID
235        #[arg(long)]
236        txn_id: String,
237    },
238    /// Delete a key within a transaction
239    Delete {
240        /// The key to delete
241        key: String,
242        /// Transaction ID
243        #[arg(long)]
244        txn_id: String,
245    },
246    /// Commit a transaction
247    Commit {
248        /// Transaction ID
249        #[arg(long)]
250        txn_id: String,
251    },
252    /// Roll back a transaction
253    Rollback {
254        /// Transaction ID
255        #[arg(long)]
256        txn_id: String,
257    },
258}
259
260/// SQL subcommand
261#[derive(Parser, Debug)]
262pub struct SqlCommand {
263    /// SQL query to execute
264    #[arg(conflicts_with = "file")]
265    pub query: Option<String>,
266
267    /// File containing SQL query
268    #[arg(long, short = 'f')]
269    pub file: Option<String>,
270
271    /// Fetch size for server streaming
272    #[arg(long)]
273    pub fetch_size: Option<usize>,
274
275    /// Max rows to return before stopping
276    #[arg(long)]
277    pub max_rows: Option<usize>,
278
279    /// Deadline for query execution (e.g. 60s, 5m)
280    #[arg(long)]
281    pub deadline: Option<String>,
282
283    /// Launch interactive TUI preview
284    #[arg(long)]
285    pub tui: bool,
286}
287
288/// Vector subcommands
289#[derive(Subcommand, Debug)]
290pub enum VectorCommand {
291    /// Search for similar vectors
292    Search {
293        /// Index name
294        #[arg(long)]
295        index: String,
296        /// Query vector as JSON array
297        #[arg(long)]
298        query: String,
299        /// Number of results to return
300        #[arg(long, short = 'k', default_value = "10")]
301        k: usize,
302        /// Show progress indicator
303        #[arg(long)]
304        progress: bool,
305    },
306    /// Upsert a single vector
307    Upsert {
308        /// Index name
309        #[arg(long)]
310        index: String,
311        /// Vector key/ID
312        #[arg(long)]
313        key: String,
314        /// Vector as JSON array
315        #[arg(long)]
316        vector: String,
317    },
318    /// Delete a single vector by key
319    Delete {
320        /// Index name
321        #[arg(long)]
322        index: String,
323        /// Vector key/ID to delete
324        #[arg(long)]
325        key: String,
326    },
327}
328
329/// Distance metric for HNSW index
330#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Default)]
331pub enum DistanceMetric {
332    /// Cosine similarity (default)
333    #[default]
334    Cosine,
335    /// Euclidean distance (L2)
336    L2,
337    /// Inner product
338    Ip,
339}
340
341/// HNSW subcommands
342#[derive(Subcommand, Debug)]
343pub enum HnswCommand {
344    /// Create a new HNSW index
345    Create {
346        /// Index name
347        name: String,
348        /// Vector dimensions
349        #[arg(long)]
350        dim: usize,
351        /// Distance metric
352        #[arg(long, value_enum, default_value = "cosine")]
353        metric: DistanceMetric,
354    },
355    /// Show index statistics
356    Stats {
357        /// Index name
358        name: String,
359    },
360    /// Drop an index
361    Drop {
362        /// Index name
363        name: String,
364    },
365}
366
367/// Columnar subcommands
368#[derive(Subcommand, Debug)]
369pub enum ColumnarCommand {
370    /// Scan a columnar segment
371    Scan {
372        /// Segment ID
373        #[arg(long)]
374        segment: String,
375        /// Show progress indicator
376        #[arg(long)]
377        progress: bool,
378    },
379    /// Show segment statistics
380    Stats {
381        /// Segment ID
382        #[arg(long)]
383        segment: String,
384    },
385    /// List all columnar segments
386    List,
387    /// Ingest a file into columnar storage
388    Ingest {
389        /// Input file path (CSV or Parquet)
390        #[arg(long)]
391        file: PathBuf,
392        /// Target table name
393        #[arg(long)]
394        table: String,
395        /// CSV delimiter character
396        #[arg(long, default_value = ",", value_parser = clap::value_parser!(char))]
397        delimiter: char,
398        /// Whether the CSV has a header row
399        #[arg(
400            long,
401            default_value = "true",
402            value_parser = clap::value_parser!(bool),
403            action = clap::ArgAction::Set
404        )]
405        header: bool,
406        /// Compression type (lz4, zstd, none)
407        #[arg(long, default_value = "lz4")]
408        compression: String,
409        /// Row group size (rows per group)
410        #[arg(long)]
411        row_group_size: Option<usize>,
412    },
413    /// Index management
414    #[command(subcommand)]
415    Index(IndexCommand),
416}
417
418/// Columnar index subcommands
419#[derive(Subcommand, Debug)]
420pub enum IndexCommand {
421    /// Create an index
422    Create {
423        /// Segment ID
424        #[arg(long)]
425        segment: String,
426        /// Column name
427        #[arg(long)]
428        column: String,
429        /// Index type (minmax, bloom)
430        #[arg(long = "type")]
431        index_type: String,
432    },
433    /// List indexes
434    List {
435        /// Segment ID
436        #[arg(long)]
437        segment: String,
438    },
439    /// Drop an index
440    Drop {
441        /// Segment ID
442        #[arg(long)]
443        segment: String,
444        /// Column name
445        #[arg(long)]
446        column: String,
447    },
448}
449
450/// Server management subcommands
451#[derive(Subcommand, Debug)]
452pub enum ServerCommand {
453    /// Show server status
454    Status,
455    /// Show server metrics
456    Metrics,
457    /// Show server health check results
458    Health,
459    /// Server compaction management
460    Compaction {
461        #[command(subcommand)]
462        command: CompactionCommand,
463    },
464}
465
466/// Server compaction subcommands
467#[derive(Subcommand, Debug)]
468pub enum CompactionCommand {
469    /// Trigger server compaction
470    Trigger,
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476
477    #[test]
478    fn test_parse_in_memory_kv_get() {
479        let args = vec!["alopex", "--in-memory", "kv", "get", "mykey"];
480        let cli = Cli::try_parse_from(args).unwrap();
481
482        assert!(cli.in_memory);
483        assert!(cli.data_dir.is_none());
484        assert_eq!(cli.output, OutputFormat::Table);
485        assert!(matches!(
486            cli.command,
487            Command::Kv {
488                command: KvCommand::Get { key }
489            } if key == "mykey"
490        ));
491    }
492
493    #[test]
494    fn test_parse_data_dir_sql() {
495        let args = vec![
496            "alopex",
497            "--data-dir",
498            "/path/to/db",
499            "sql",
500            "SELECT * FROM users",
501        ];
502        let cli = Cli::try_parse_from(args).unwrap();
503
504        assert!(!cli.in_memory);
505        assert_eq!(cli.data_dir, Some("/path/to/db".to_string()));
506        assert!(matches!(
507            cli.command,
508            Command::Sql(SqlCommand { query: Some(q), file: None, .. }) if q == "SELECT * FROM users"
509        ));
510    }
511
512    #[test]
513    fn test_parse_output_format() {
514        let args = vec!["alopex", "--in-memory", "--output", "jsonl", "kv", "list"];
515        let cli = Cli::try_parse_from(args).unwrap();
516
517        assert_eq!(cli.output, OutputFormat::Jsonl);
518    }
519
520    #[test]
521    fn test_parse_limit() {
522        let args = vec!["alopex", "--in-memory", "--limit", "100", "kv", "list"];
523        let cli = Cli::try_parse_from(args).unwrap();
524
525        assert_eq!(cli.limit, Some(100));
526    }
527
528    #[test]
529    fn test_parse_sql_streaming_options() {
530        let args = vec![
531            "alopex",
532            "sql",
533            "--fetch-size",
534            "500",
535            "--max-rows",
536            "250",
537            "--deadline",
538            "30s",
539            "SELECT 1",
540        ];
541        let cli = Cli::try_parse_from(args).unwrap();
542
543        match cli.command {
544            Command::Sql(cmd) => {
545                assert_eq!(cmd.fetch_size, Some(500));
546                assert_eq!(cmd.max_rows, Some(250));
547                assert_eq!(cmd.deadline.as_deref(), Some("30s"));
548                assert!(!cmd.tui);
549            }
550            _ => panic!("expected sql command"),
551        }
552    }
553
554    #[test]
555    fn test_parse_sql_tui_flag() {
556        let args = vec!["alopex", "sql", "--tui", "SELECT 1"];
557        let cli = Cli::try_parse_from(args).unwrap();
558
559        match cli.command {
560            Command::Sql(cmd) => {
561                assert!(cmd.tui);
562                assert_eq!(cmd.query.as_deref(), Some("SELECT 1"));
563            }
564            _ => panic!("expected sql command"),
565        }
566    }
567
568    #[test]
569    fn test_parse_server_status() {
570        let args = vec!["alopex", "server", "status"];
571        let cli = Cli::try_parse_from(args).unwrap();
572
573        assert!(matches!(
574            cli.command,
575            Command::Server {
576                command: ServerCommand::Status
577            }
578        ));
579    }
580
581    #[test]
582    fn test_parse_server_compaction_trigger() {
583        let args = vec!["alopex", "server", "compaction", "trigger"];
584        let cli = Cli::try_parse_from(args).unwrap();
585
586        assert!(matches!(
587            cli.command,
588            Command::Server {
589                command: ServerCommand::Compaction {
590                    command: CompactionCommand::Trigger
591                }
592            }
593        ));
594    }
595
596    #[test]
597    fn test_parse_verbose_quiet() {
598        let args = vec!["alopex", "--in-memory", "--verbose", "kv", "list"];
599        let cli = Cli::try_parse_from(args).unwrap();
600
601        assert!(cli.verbose);
602        assert!(!cli.quiet);
603    }
604
605    #[test]
606    fn test_parse_thread_mode() {
607        let args = vec![
608            "alopex",
609            "--in-memory",
610            "--thread-mode",
611            "single",
612            "kv",
613            "list",
614        ];
615        let cli = Cli::try_parse_from(args).unwrap();
616
617        assert_eq!(cli.thread_mode, ThreadMode::Single);
618    }
619
620    #[test]
621    fn test_parse_profile_option_batch_yes() {
622        let args = vec![
623            "alopex",
624            "--profile",
625            "dev",
626            "--batch",
627            "--yes",
628            "--in-memory",
629            "kv",
630            "list",
631        ];
632        let cli = Cli::try_parse_from(args).unwrap();
633
634        assert_eq!(cli.profile.as_deref(), Some("dev"));
635        assert!(cli.batch);
636        assert!(cli.yes);
637    }
638
639    #[test]
640    fn test_parse_batch_short_flag() {
641        let args = vec!["alopex", "-b", "--in-memory", "kv", "list"];
642        let cli = Cli::try_parse_from(args).unwrap();
643
644        assert!(cli.batch);
645    }
646
647    #[test]
648    fn test_parse_profile_create_subcommand() {
649        let args = vec![
650            "alopex",
651            "profile",
652            "create",
653            "dev",
654            "--data-dir",
655            "/path/to/db",
656        ];
657        let cli = Cli::try_parse_from(args).unwrap();
658
659        assert!(matches!(
660            cli.command,
661            Command::Profile {
662                command: ProfileCommand::Create { name, data_dir }
663            }
664                if name == "dev" && data_dir == "/path/to/db"
665        ));
666    }
667
668    #[test]
669    fn test_parse_completions_bash() {
670        let args = vec!["alopex", "completions", "bash"];
671        let cli = Cli::try_parse_from(args).unwrap();
672
673        assert!(matches!(
674            cli.command,
675            Command::Completions { shell } if shell == Shell::Bash
676        ));
677    }
678
679    #[test]
680    fn test_parse_completions_pwsh() {
681        let args = vec!["alopex", "completions", "pwsh"];
682        let cli = Cli::try_parse_from(args).unwrap();
683
684        assert!(matches!(
685            cli.command,
686            Command::Completions { shell } if shell == Shell::PowerShell
687        ));
688    }
689
690    #[test]
691    fn test_parse_kv_put() {
692        let args = vec!["alopex", "--in-memory", "kv", "put", "mykey", "myvalue"];
693        let cli = Cli::try_parse_from(args).unwrap();
694
695        assert!(matches!(
696            cli.command,
697            Command::Kv {
698                command: KvCommand::Put { key, value }
699            } if key == "mykey" && value == "myvalue"
700        ));
701    }
702
703    #[test]
704    fn test_parse_kv_delete() {
705        let args = vec!["alopex", "--in-memory", "kv", "delete", "mykey"];
706        let cli = Cli::try_parse_from(args).unwrap();
707
708        assert!(matches!(
709            cli.command,
710            Command::Kv {
711                command: KvCommand::Delete { key }
712            } if key == "mykey"
713        ));
714    }
715
716    #[test]
717    fn test_parse_kv_txn_begin() {
718        let args = vec!["alopex", "kv", "txn", "begin", "--timeout-secs", "30"];
719        let cli = Cli::try_parse_from(args).unwrap();
720
721        assert!(matches!(
722            cli.command,
723            Command::Kv {
724                command: KvCommand::Txn(KvTxnCommand::Begin {
725                    timeout_secs: Some(30)
726                })
727            }
728        ));
729    }
730
731    #[test]
732    fn test_parse_kv_txn_get_requires_txn_id() {
733        let args = vec!["alopex", "kv", "txn", "get", "mykey"];
734
735        assert!(Cli::try_parse_from(args).is_err());
736    }
737
738    #[test]
739    fn test_parse_kv_txn_get() {
740        let args = vec!["alopex", "kv", "txn", "get", "mykey", "--txn-id", "txn123"];
741        let cli = Cli::try_parse_from(args).unwrap();
742
743        assert!(matches!(
744            cli.command,
745            Command::Kv {
746                command: KvCommand::Txn(KvTxnCommand::Get { key, txn_id })
747            } if key == "mykey" && txn_id == "txn123"
748        ));
749    }
750
751    #[test]
752    fn test_parse_kv_list_with_prefix() {
753        let args = vec!["alopex", "--in-memory", "kv", "list", "--prefix", "user:"];
754        let cli = Cli::try_parse_from(args).unwrap();
755
756        assert!(matches!(
757            cli.command,
758            Command::Kv {
759                command: KvCommand::List { prefix: Some(p) }
760            } if p == "user:"
761        ));
762    }
763
764    #[test]
765    fn test_parse_sql_from_file() {
766        let args = vec!["alopex", "--in-memory", "sql", "-f", "query.sql"];
767        let cli = Cli::try_parse_from(args).unwrap();
768
769        assert!(matches!(
770            cli.command,
771            Command::Sql(SqlCommand { query: None, file: Some(f), .. }) if f == "query.sql"
772        ));
773    }
774
775    #[test]
776    fn test_parse_vector_search() {
777        let args = vec![
778            "alopex",
779            "--in-memory",
780            "vector",
781            "search",
782            "--index",
783            "my_index",
784            "--query",
785            "[1.0,2.0,3.0]",
786            "-k",
787            "5",
788        ];
789        let cli = Cli::try_parse_from(args).unwrap();
790
791        assert!(matches!(
792            cli.command,
793            Command::Vector {
794                command: VectorCommand::Search { index, query, k, progress }
795            } if index == "my_index" && query == "[1.0,2.0,3.0]" && k == 5 && !progress
796        ));
797    }
798
799    #[test]
800    fn test_parse_vector_upsert() {
801        let args = vec![
802            "alopex",
803            "--in-memory",
804            "vector",
805            "upsert",
806            "--index",
807            "my_index",
808            "--key",
809            "vec1",
810            "--vector",
811            "[1.0,2.0,3.0]",
812        ];
813        let cli = Cli::try_parse_from(args).unwrap();
814
815        assert!(matches!(
816            cli.command,
817            Command::Vector {
818                command: VectorCommand::Upsert { index, key, vector }
819            } if index == "my_index" && key == "vec1" && vector == "[1.0,2.0,3.0]"
820        ));
821    }
822
823    #[test]
824    fn test_parse_vector_delete() {
825        let args = vec![
826            "alopex",
827            "--in-memory",
828            "vector",
829            "delete",
830            "--index",
831            "my_index",
832            "--key",
833            "vec1",
834        ];
835        let cli = Cli::try_parse_from(args).unwrap();
836
837        assert!(matches!(
838            cli.command,
839            Command::Vector {
840                command: VectorCommand::Delete { index, key }
841            } if index == "my_index" && key == "vec1"
842        ));
843    }
844
845    #[test]
846    fn test_parse_hnsw_create() {
847        let args = vec![
848            "alopex",
849            "--in-memory",
850            "hnsw",
851            "create",
852            "my_index",
853            "--dim",
854            "128",
855            "--metric",
856            "l2",
857        ];
858        let cli = Cli::try_parse_from(args).unwrap();
859
860        assert!(matches!(
861            cli.command,
862            Command::Hnsw {
863                command: HnswCommand::Create { name, dim, metric }
864            } if name == "my_index" && dim == 128 && metric == DistanceMetric::L2
865        ));
866    }
867
868    #[test]
869    fn test_parse_hnsw_create_default_metric() {
870        let args = vec![
871            "alopex",
872            "--in-memory",
873            "hnsw",
874            "create",
875            "my_index",
876            "--dim",
877            "128",
878        ];
879        let cli = Cli::try_parse_from(args).unwrap();
880
881        assert!(matches!(
882            cli.command,
883            Command::Hnsw {
884                command: HnswCommand::Create { name, dim, metric }
885            } if name == "my_index" && dim == 128 && metric == DistanceMetric::Cosine
886        ));
887    }
888
889    #[test]
890    fn test_parse_columnar_scan() {
891        let args = vec![
892            "alopex",
893            "--in-memory",
894            "columnar",
895            "scan",
896            "--segment",
897            "seg_001",
898        ];
899        let cli = Cli::try_parse_from(args).unwrap();
900
901        assert!(matches!(
902            cli.command,
903            Command::Columnar {
904                command: ColumnarCommand::Scan { segment, progress }
905            } if segment == "seg_001" && !progress
906        ));
907    }
908
909    #[test]
910    fn test_parse_columnar_stats() {
911        let args = vec![
912            "alopex",
913            "--in-memory",
914            "columnar",
915            "stats",
916            "--segment",
917            "seg_001",
918        ];
919        let cli = Cli::try_parse_from(args).unwrap();
920
921        assert!(matches!(
922            cli.command,
923            Command::Columnar {
924                command: ColumnarCommand::Stats { segment }
925            } if segment == "seg_001"
926        ));
927    }
928
929    #[test]
930    fn test_parse_columnar_list() {
931        let args = vec!["alopex", "--in-memory", "columnar", "list"];
932        let cli = Cli::try_parse_from(args).unwrap();
933
934        assert!(matches!(
935            cli.command,
936            Command::Columnar {
937                command: ColumnarCommand::List
938            }
939        ));
940    }
941
942    #[test]
943    fn test_parse_columnar_ingest_defaults() {
944        let args = vec![
945            "alopex",
946            "--in-memory",
947            "columnar",
948            "ingest",
949            "--file",
950            "data.csv",
951            "--table",
952            "events",
953        ];
954        let cli = Cli::try_parse_from(args).unwrap();
955
956        assert!(matches!(
957            cli.command,
958            Command::Columnar {
959                command: ColumnarCommand::Ingest {
960                    file,
961                    table,
962                    delimiter,
963                    header,
964                    compression,
965                    row_group_size,
966                }
967            } if file == std::path::Path::new("data.csv")
968                && table == "events"
969                && delimiter == ','
970                && header
971                && compression == "lz4"
972                && row_group_size.is_none()
973        ));
974    }
975
976    #[test]
977    fn test_parse_columnar_ingest_custom_options() {
978        let args = vec![
979            "alopex",
980            "--in-memory",
981            "columnar",
982            "ingest",
983            "--file",
984            "data.csv",
985            "--table",
986            "events",
987            "--delimiter",
988            ";",
989            "--header",
990            "false",
991            "--compression",
992            "zstd",
993            "--row-group-size",
994            "500",
995        ];
996        let cli = Cli::try_parse_from(args).unwrap();
997
998        assert!(matches!(
999            cli.command,
1000            Command::Columnar {
1001                command: ColumnarCommand::Ingest {
1002                    file,
1003                    table,
1004                    delimiter,
1005                    header,
1006                    compression,
1007                    row_group_size,
1008                }
1009            } if file == std::path::Path::new("data.csv")
1010                && table == "events"
1011                && delimiter == ';'
1012                && !header
1013                && compression == "zstd"
1014                && row_group_size == Some(500)
1015        ));
1016    }
1017
1018    #[test]
1019    fn test_parse_columnar_index_create() {
1020        let args = vec![
1021            "alopex",
1022            "--in-memory",
1023            "columnar",
1024            "index",
1025            "create",
1026            "--segment",
1027            "123:1",
1028            "--column",
1029            "col1",
1030            "--type",
1031            "bloom",
1032        ];
1033        let cli = Cli::try_parse_from(args).unwrap();
1034
1035        assert!(matches!(
1036            cli.command,
1037            Command::Columnar {
1038                command: ColumnarCommand::Index(IndexCommand::Create {
1039                    segment,
1040                    column,
1041                    index_type,
1042                })
1043            } if segment == "123:1"
1044                && column == "col1"
1045                && index_type == "bloom"
1046        ));
1047    }
1048
1049    #[test]
1050    fn test_output_format_supports_streaming() {
1051        assert!(!OutputFormat::Table.supports_streaming());
1052        assert!(OutputFormat::Json.supports_streaming());
1053        assert!(OutputFormat::Jsonl.supports_streaming());
1054        assert!(OutputFormat::Csv.supports_streaming());
1055        assert!(OutputFormat::Tsv.supports_streaming());
1056    }
1057
1058    #[test]
1059    fn test_default_values() {
1060        let args = vec!["alopex", "--in-memory", "kv", "list"];
1061        let cli = Cli::try_parse_from(args).unwrap();
1062
1063        assert_eq!(cli.output, OutputFormat::Table);
1064        assert_eq!(cli.thread_mode, ThreadMode::Multi);
1065        assert!(cli.limit.is_none());
1066        assert!(!cli.quiet);
1067        assert!(!cli.verbose);
1068    }
1069
1070    #[test]
1071    fn test_s3_data_dir() {
1072        let args = vec![
1073            "alopex",
1074            "--data-dir",
1075            "s3://my-bucket/prefix",
1076            "kv",
1077            "list",
1078        ];
1079        let cli = Cli::try_parse_from(args).unwrap();
1080
1081        assert_eq!(cli.data_dir, Some("s3://my-bucket/prefix".to_string()));
1082    }
1083}