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    /// Backup data (placeholder)
493    Backup,
494    /// Export data (placeholder)
495    Export,
496}
497
498/// Server compaction subcommands
499#[derive(Subcommand, Debug)]
500pub enum CompactionCommand {
501    /// Trigger server compaction
502    Trigger,
503}
504
505#[cfg(test)]
506mod tests {
507    use super::*;
508
509    #[test]
510    fn test_parse_in_memory_kv_get() {
511        let args = vec!["alopex", "--in-memory", "kv", "get", "mykey"];
512        let cli = Cli::try_parse_from(args).unwrap();
513
514        assert!(cli.in_memory);
515        assert!(cli.data_dir.is_none());
516        assert_eq!(cli.output_format(), OutputFormat::Table);
517        assert!(matches!(
518            cli.command,
519            Some(Command::Kv {
520                command: Some(KvCommand::Get { key })
521            }) if key == "mykey"
522        ));
523    }
524
525    #[test]
526    fn test_parse_data_dir_sql() {
527        let args = vec![
528            "alopex",
529            "--data-dir",
530            "/path/to/db",
531            "sql",
532            "SELECT * FROM users",
533        ];
534        let cli = Cli::try_parse_from(args).unwrap();
535
536        assert!(!cli.in_memory);
537        assert_eq!(cli.data_dir, Some("/path/to/db".to_string()));
538        assert!(matches!(
539            cli.command,
540            Some(Command::Sql(SqlCommand { query: Some(q), file: None, .. })) if q == "SELECT * FROM users"
541        ));
542    }
543
544    #[test]
545    fn test_parse_output_format() {
546        let args = vec!["alopex", "--in-memory", "--output", "jsonl", "kv", "list"];
547        let cli = Cli::try_parse_from(args).unwrap();
548
549        assert_eq!(cli.output_format(), OutputFormat::Jsonl);
550        assert!(cli.output_is_explicit());
551    }
552
553    #[test]
554    fn test_parse_limit() {
555        let args = vec!["alopex", "--in-memory", "--limit", "100", "kv", "list"];
556        let cli = Cli::try_parse_from(args).unwrap();
557
558        assert_eq!(cli.limit, Some(100));
559    }
560
561    #[test]
562    fn test_parse_sql_streaming_options() {
563        let args = vec![
564            "alopex",
565            "sql",
566            "--fetch-size",
567            "500",
568            "--max-rows",
569            "250",
570            "--deadline",
571            "30s",
572            "SELECT 1",
573        ];
574        let cli = Cli::try_parse_from(args).unwrap();
575
576        match cli.command {
577            Some(Command::Sql(cmd)) => {
578                assert_eq!(cmd.fetch_size, Some(500));
579                assert_eq!(cmd.max_rows, Some(250));
580                assert_eq!(cmd.deadline.as_deref(), Some("30s"));
581                assert!(!cmd.tui);
582            }
583            _ => panic!("expected sql command"),
584        }
585    }
586
587    #[test]
588    fn test_parse_sql_tui_flag() {
589        let args = vec!["alopex", "sql", "--tui", "SELECT 1"];
590        let cli = Cli::try_parse_from(args).unwrap();
591
592        match cli.command {
593            Some(Command::Sql(cmd)) => {
594                assert!(cmd.tui);
595                assert_eq!(cmd.query.as_deref(), Some("SELECT 1"));
596            }
597            _ => panic!("expected sql command"),
598        }
599    }
600
601    #[test]
602    fn test_parse_server_status() {
603        let args = vec!["alopex", "server", "status"];
604        let cli = Cli::try_parse_from(args).unwrap();
605
606        assert!(matches!(
607            cli.command,
608            Some(Command::Server {
609                command: Some(ServerCommand::Status)
610            })
611        ));
612    }
613
614    #[test]
615    fn test_parse_server_compaction_trigger() {
616        let args = vec!["alopex", "server", "compaction", "trigger"];
617        let cli = Cli::try_parse_from(args).unwrap();
618
619        assert!(matches!(
620            cli.command,
621            Some(Command::Server {
622                command: Some(ServerCommand::Compaction {
623                    command: CompactionCommand::Trigger
624                })
625            })
626        ));
627    }
628
629    #[test]
630    fn test_parse_verbose_quiet() {
631        let args = vec!["alopex", "--in-memory", "--verbose", "kv", "list"];
632        let cli = Cli::try_parse_from(args).unwrap();
633
634        assert!(cli.verbose);
635        assert!(!cli.quiet);
636    }
637
638    #[test]
639    fn test_parse_thread_mode() {
640        let args = vec![
641            "alopex",
642            "--in-memory",
643            "--thread-mode",
644            "single",
645            "kv",
646            "list",
647        ];
648        let cli = Cli::try_parse_from(args).unwrap();
649
650        assert_eq!(cli.thread_mode, ThreadMode::Single);
651    }
652
653    #[test]
654    fn test_parse_profile_option_batch_yes() {
655        let args = vec![
656            "alopex",
657            "--profile",
658            "dev",
659            "--batch",
660            "--yes",
661            "--in-memory",
662            "kv",
663            "list",
664        ];
665        let cli = Cli::try_parse_from(args).unwrap();
666
667        assert_eq!(cli.profile.as_deref(), Some("dev"));
668        assert!(cli.batch);
669        assert!(cli.yes);
670    }
671
672    #[test]
673    fn test_parse_batch_short_flag() {
674        let args = vec!["alopex", "-b", "--in-memory", "kv", "list"];
675        let cli = Cli::try_parse_from(args).unwrap();
676
677        assert!(cli.batch);
678    }
679
680    #[test]
681    fn test_parse_profile_create_subcommand() {
682        let args = vec![
683            "alopex",
684            "profile",
685            "create",
686            "dev",
687            "--data-dir",
688            "/path/to/db",
689        ];
690        let cli = Cli::try_parse_from(args).unwrap();
691
692        assert!(matches!(
693            cli.command,
694            Some(Command::Profile {
695                command: Some(ProfileCommand::Create { name, data_dir })
696            })
697                if name == "dev" && data_dir == "/path/to/db"
698        ));
699    }
700
701    #[test]
702    fn test_parse_completions_bash() {
703        let args = vec!["alopex", "completions", "bash"];
704        let cli = Cli::try_parse_from(args).unwrap();
705
706        assert!(matches!(
707            cli.command,
708            Some(Command::Completions { shell }) if shell == Shell::Bash
709        ));
710    }
711
712    #[test]
713    fn test_parse_completions_pwsh() {
714        let args = vec!["alopex", "completions", "pwsh"];
715        let cli = Cli::try_parse_from(args).unwrap();
716
717        assert!(matches!(
718            cli.command,
719            Some(Command::Completions { shell }) if shell == Shell::PowerShell
720        ));
721    }
722
723    #[test]
724    fn test_parse_kv_put() {
725        let args = vec!["alopex", "--in-memory", "kv", "put", "mykey", "myvalue"];
726        let cli = Cli::try_parse_from(args).unwrap();
727
728        assert!(matches!(
729            cli.command,
730            Some(Command::Kv {
731                command: Some(KvCommand::Put { key, value })
732            }) if key == "mykey" && value == "myvalue"
733        ));
734    }
735
736    #[test]
737    fn test_parse_kv_delete() {
738        let args = vec!["alopex", "--in-memory", "kv", "delete", "mykey"];
739        let cli = Cli::try_parse_from(args).unwrap();
740
741        assert!(matches!(
742            cli.command,
743            Some(Command::Kv {
744                command: Some(KvCommand::Delete { key })
745            }) if key == "mykey"
746        ));
747    }
748
749    #[test]
750    fn test_parse_kv_txn_begin() {
751        let args = vec!["alopex", "kv", "txn", "begin", "--timeout-secs", "30"];
752        let cli = Cli::try_parse_from(args).unwrap();
753
754        assert!(matches!(
755            cli.command,
756            Some(Command::Kv {
757                command: Some(KvCommand::Txn(KvTxnCommand::Begin {
758                    timeout_secs: Some(30)
759                }))
760            })
761        ));
762    }
763
764    #[test]
765    fn test_parse_kv_txn_get_requires_txn_id() {
766        let args = vec!["alopex", "kv", "txn", "get", "mykey"];
767
768        assert!(Cli::try_parse_from(args).is_err());
769    }
770
771    #[test]
772    fn test_parse_kv_txn_get() {
773        let args = vec!["alopex", "kv", "txn", "get", "mykey", "--txn-id", "txn123"];
774        let cli = Cli::try_parse_from(args).unwrap();
775
776        assert!(matches!(
777            cli.command,
778            Some(Command::Kv {
779                command: Some(KvCommand::Txn(KvTxnCommand::Get { key, txn_id }))
780            }) if key == "mykey" && txn_id == "txn123"
781        ));
782    }
783
784    #[test]
785    fn test_parse_kv_list_with_prefix() {
786        let args = vec!["alopex", "--in-memory", "kv", "list", "--prefix", "user:"];
787        let cli = Cli::try_parse_from(args).unwrap();
788
789        assert!(matches!(
790            cli.command,
791            Some(Command::Kv {
792                command: Some(KvCommand::List { prefix: Some(p) })
793            }) if p == "user:"
794        ));
795    }
796
797    #[test]
798    fn test_parse_sql_from_file() {
799        let args = vec!["alopex", "--in-memory", "sql", "-f", "query.sql"];
800        let cli = Cli::try_parse_from(args).unwrap();
801
802        assert!(matches!(
803            cli.command,
804            Some(Command::Sql(SqlCommand { query: None, file: Some(f), .. })) if f == "query.sql"
805        ));
806    }
807
808    #[test]
809    fn test_parse_vector_search() {
810        let args = vec![
811            "alopex",
812            "--in-memory",
813            "vector",
814            "search",
815            "--index",
816            "my_index",
817            "--query",
818            "[1.0,2.0,3.0]",
819            "-k",
820            "5",
821        ];
822        let cli = Cli::try_parse_from(args).unwrap();
823
824        assert!(matches!(
825            cli.command,
826            Some(Command::Vector {
827                command: Some(VectorCommand::Search { index, query, k, progress })
828            }) if index == "my_index" && query == "[1.0,2.0,3.0]" && k == 5 && !progress
829        ));
830    }
831
832    #[test]
833    fn test_parse_vector_upsert() {
834        let args = vec![
835            "alopex",
836            "--in-memory",
837            "vector",
838            "upsert",
839            "--index",
840            "my_index",
841            "--key",
842            "vec1",
843            "--vector",
844            "[1.0,2.0,3.0]",
845        ];
846        let cli = Cli::try_parse_from(args).unwrap();
847
848        assert!(matches!(
849            cli.command,
850            Some(Command::Vector {
851                command: Some(VectorCommand::Upsert { index, key, vector })
852            }) if index == "my_index" && key == "vec1" && vector == "[1.0,2.0,3.0]"
853        ));
854    }
855
856    #[test]
857    fn test_parse_vector_delete() {
858        let args = vec![
859            "alopex",
860            "--in-memory",
861            "vector",
862            "delete",
863            "--index",
864            "my_index",
865            "--key",
866            "vec1",
867        ];
868        let cli = Cli::try_parse_from(args).unwrap();
869
870        assert!(matches!(
871            cli.command,
872            Some(Command::Vector {
873                command: Some(VectorCommand::Delete { index, key })
874            }) if index == "my_index" && key == "vec1"
875        ));
876    }
877
878    #[test]
879    fn test_parse_hnsw_create() {
880        let args = vec![
881            "alopex",
882            "--in-memory",
883            "hnsw",
884            "create",
885            "my_index",
886            "--dim",
887            "128",
888            "--metric",
889            "l2",
890        ];
891        let cli = Cli::try_parse_from(args).unwrap();
892
893        assert!(matches!(
894            cli.command,
895            Some(Command::Hnsw {
896                command: Some(HnswCommand::Create { name, dim, metric })
897            }) if name == "my_index" && dim == 128 && metric == DistanceMetric::L2
898        ));
899    }
900
901    #[test]
902    fn test_parse_hnsw_create_default_metric() {
903        let args = vec![
904            "alopex",
905            "--in-memory",
906            "hnsw",
907            "create",
908            "my_index",
909            "--dim",
910            "128",
911        ];
912        let cli = Cli::try_parse_from(args).unwrap();
913
914        assert!(matches!(
915            cli.command,
916            Some(Command::Hnsw {
917                command: Some(HnswCommand::Create { name, dim, metric })
918            }) if name == "my_index" && dim == 128 && metric == DistanceMetric::Cosine
919        ));
920    }
921
922    #[test]
923    fn test_parse_columnar_scan() {
924        let args = vec![
925            "alopex",
926            "--in-memory",
927            "columnar",
928            "scan",
929            "--segment",
930            "seg_001",
931        ];
932        let cli = Cli::try_parse_from(args).unwrap();
933
934        assert!(matches!(
935            cli.command,
936            Some(Command::Columnar {
937                command: Some(ColumnarCommand::Scan { segment, progress })
938            }) if segment == "seg_001" && !progress
939        ));
940    }
941
942    #[test]
943    fn test_parse_columnar_stats() {
944        let args = vec![
945            "alopex",
946            "--in-memory",
947            "columnar",
948            "stats",
949            "--segment",
950            "seg_001",
951        ];
952        let cli = Cli::try_parse_from(args).unwrap();
953
954        assert!(matches!(
955            cli.command,
956            Some(Command::Columnar {
957                command: Some(ColumnarCommand::Stats { segment })
958            }) if segment == "seg_001"
959        ));
960    }
961
962    #[test]
963    fn test_parse_columnar_list() {
964        let args = vec!["alopex", "--in-memory", "columnar", "list"];
965        let cli = Cli::try_parse_from(args).unwrap();
966
967        assert!(matches!(
968            cli.command,
969            Some(Command::Columnar {
970                command: Some(ColumnarCommand::List)
971            })
972        ));
973    }
974
975    #[test]
976    fn test_parse_columnar_ingest_defaults() {
977        let args = vec![
978            "alopex",
979            "--in-memory",
980            "columnar",
981            "ingest",
982            "--file",
983            "data.csv",
984            "--table",
985            "events",
986        ];
987        let cli = Cli::try_parse_from(args).unwrap();
988
989        assert!(matches!(
990            cli.command,
991            Some(Command::Columnar {
992                command: Some(ColumnarCommand::Ingest {
993                    file,
994                    table,
995                    delimiter,
996                    header,
997                    compression,
998                    row_group_size,
999                })
1000            }) if file == std::path::Path::new("data.csv")
1001                && table == "events"
1002                && delimiter == ','
1003                && header
1004                && compression == "zstd"
1005                && row_group_size.is_none()
1006        ));
1007    }
1008
1009    #[test]
1010    fn test_parse_columnar_ingest_custom_options() {
1011        let args = vec![
1012            "alopex",
1013            "--in-memory",
1014            "columnar",
1015            "ingest",
1016            "--file",
1017            "data.csv",
1018            "--table",
1019            "events",
1020            "--delimiter",
1021            ";",
1022            "--header",
1023            "false",
1024            "--compression",
1025            "zstd",
1026            "--row-group-size",
1027            "500",
1028        ];
1029        let cli = Cli::try_parse_from(args).unwrap();
1030
1031        assert!(matches!(
1032            cli.command,
1033            Some(Command::Columnar {
1034                command: Some(ColumnarCommand::Ingest {
1035                    file,
1036                    table,
1037                    delimiter,
1038                    header,
1039                    compression,
1040                    row_group_size,
1041                })
1042            }) if file == std::path::Path::new("data.csv")
1043                && table == "events"
1044                && delimiter == ';'
1045                && !header
1046                && compression == "zstd"
1047                && row_group_size == Some(500)
1048        ));
1049    }
1050
1051    #[test]
1052    fn test_parse_columnar_index_create() {
1053        let args = vec![
1054            "alopex",
1055            "--in-memory",
1056            "columnar",
1057            "index",
1058            "create",
1059            "--segment",
1060            "123:1",
1061            "--column",
1062            "col1",
1063            "--type",
1064            "bloom",
1065        ];
1066        let cli = Cli::try_parse_from(args).unwrap();
1067
1068        assert!(matches!(
1069            cli.command,
1070            Some(Command::Columnar {
1071                command: Some(ColumnarCommand::Index(IndexCommand::Create {
1072                    segment,
1073                    column,
1074                    index_type,
1075                }))
1076            }) if segment == "123:1"
1077                && column == "col1"
1078                && index_type == "bloom"
1079        ));
1080    }
1081
1082    #[test]
1083    fn test_output_format_supports_streaming() {
1084        assert!(!OutputFormat::Table.supports_streaming());
1085        assert!(OutputFormat::Json.supports_streaming());
1086        assert!(OutputFormat::Jsonl.supports_streaming());
1087        assert!(OutputFormat::Csv.supports_streaming());
1088        assert!(OutputFormat::Tsv.supports_streaming());
1089    }
1090
1091    #[test]
1092    fn test_default_values() {
1093        let args = vec!["alopex", "--in-memory", "kv", "list"];
1094        let cli = Cli::try_parse_from(args).unwrap();
1095
1096        assert_eq!(cli.output_format(), OutputFormat::Table);
1097        assert!(!cli.output_is_explicit());
1098        assert_eq!(cli.thread_mode, ThreadMode::Multi);
1099        assert!(cli.limit.is_none());
1100        assert!(!cli.quiet);
1101        assert!(!cli.verbose);
1102    }
1103
1104    #[test]
1105    fn test_s3_data_dir() {
1106        let args = vec![
1107            "alopex",
1108            "--data-dir",
1109            "s3://my-bucket/prefix",
1110            "kv",
1111            "list",
1112        ];
1113        let cli = Cli::try_parse_from(args).unwrap();
1114
1115        assert_eq!(cli.data_dir, Some("s3://my-bucket/prefix".to_string()));
1116    }
1117}