Skip to main content

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