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 (sql: array of per-statement result sets)
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///
281/// Multiple `;`-separated statements are executed in a single transaction and
282/// each statement emits its own result block. With `--output json` the output
283/// is always an array of per-statement result sets (a single statement yields
284/// a 1-element array); DDL/DML statements contribute a `status`/`message`
285/// result set unless `--quiet` is set.
286#[derive(Parser, Debug)]
287pub struct SqlCommand {
288    /// SQL query to execute (may contain multiple `;`-separated statements)
289    #[arg(conflicts_with = "file")]
290    pub query: Option<String>,
291
292    /// File containing SQL query
293    #[arg(long, short = 'f')]
294    pub file: Option<String>,
295
296    /// Fetch size for server streaming
297    #[arg(long)]
298    pub fetch_size: Option<usize>,
299
300    /// Max rows to return before stopping
301    #[arg(long)]
302    pub max_rows: Option<usize>,
303
304    /// Deadline for query execution (e.g. 60s, 5m)
305    #[arg(long)]
306    pub deadline: Option<String>,
307
308    /// Launch interactive TUI preview
309    #[arg(long)]
310    pub tui: bool,
311}
312
313/// Vector subcommands
314#[derive(Subcommand, Debug)]
315pub enum VectorCommand {
316    /// Search for similar vectors
317    Search {
318        /// Index name
319        #[arg(long)]
320        index: String,
321        /// Query vector as JSON array
322        #[arg(long)]
323        query: String,
324        /// Number of results to return
325        #[arg(long, short = 'k', default_value = "10")]
326        k: usize,
327        /// Show progress indicator
328        #[arg(long)]
329        progress: bool,
330    },
331    /// Upsert a single vector
332    Upsert {
333        /// Index name
334        #[arg(long)]
335        index: String,
336        /// Vector key/ID
337        #[arg(long)]
338        key: String,
339        /// Vector as JSON array
340        #[arg(long)]
341        vector: String,
342    },
343    /// Delete a single vector by key
344    Delete {
345        /// Index name
346        #[arg(long)]
347        index: String,
348        /// Vector key/ID to delete
349        #[arg(long)]
350        key: String,
351    },
352}
353
354/// Distance metric for HNSW index
355#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Default)]
356pub enum DistanceMetric {
357    /// Cosine similarity (default)
358    #[default]
359    Cosine,
360    /// Euclidean distance (L2)
361    L2,
362    /// Inner product
363    Ip,
364}
365
366/// HNSW subcommands
367#[derive(Subcommand, Debug)]
368pub enum HnswCommand {
369    /// Create a new HNSW index
370    Create {
371        /// Index name
372        name: String,
373        /// Vector dimensions
374        #[arg(long)]
375        dim: usize,
376        /// Distance metric
377        #[arg(long, value_enum, default_value = "cosine")]
378        metric: DistanceMetric,
379    },
380    /// Show index statistics
381    Stats {
382        /// Index name
383        name: String,
384    },
385    /// Drop an index
386    Drop {
387        /// Index name
388        name: String,
389    },
390}
391
392/// Columnar subcommands
393#[derive(Subcommand, Debug)]
394pub enum ColumnarCommand {
395    /// Scan a columnar segment
396    Scan {
397        /// Segment ID
398        #[arg(long)]
399        segment: String,
400        /// Show progress indicator
401        #[arg(long)]
402        progress: bool,
403    },
404    /// Show segment statistics
405    Stats {
406        /// Segment ID
407        #[arg(long)]
408        segment: String,
409    },
410    /// List all columnar segments
411    List,
412    /// Ingest a file into columnar storage
413    Ingest {
414        /// Input file path (CSV or Parquet)
415        #[arg(long)]
416        file: PathBuf,
417        /// Target table name
418        #[arg(long)]
419        table: String,
420        /// CSV delimiter character
421        #[arg(long, default_value = ",", value_parser = clap::value_parser!(char))]
422        delimiter: char,
423        /// Whether the CSV has a header row
424        #[arg(
425            long,
426            default_value = "true",
427            value_parser = clap::value_parser!(bool),
428            action = clap::ArgAction::Set
429        )]
430        header: bool,
431        /// Compression type (lz4, zstd, none)
432        #[arg(long, default_value = "zstd")]
433        compression: String,
434        /// Row group size (rows per group)
435        #[arg(long)]
436        row_group_size: Option<usize>,
437    },
438    /// Index management
439    #[command(subcommand)]
440    Index(IndexCommand),
441}
442
443/// Columnar index subcommands
444#[derive(Subcommand, Debug)]
445pub enum IndexCommand {
446    /// Create an index
447    Create {
448        /// Segment ID
449        #[arg(long)]
450        segment: String,
451        /// Column name
452        #[arg(long)]
453        column: String,
454        /// Index type (minmax, bloom)
455        #[arg(long = "type")]
456        index_type: String,
457    },
458    /// List indexes
459    List {
460        /// Segment ID
461        #[arg(long)]
462        segment: String,
463    },
464    /// Drop an index
465    Drop {
466        /// Segment ID
467        #[arg(long)]
468        segment: String,
469        /// Column name
470        #[arg(long)]
471        column: String,
472    },
473}
474
475/// Server management subcommands
476#[derive(Subcommand, Debug)]
477pub enum ServerCommand {
478    /// Show server status
479    Status,
480    /// Show server metrics
481    Metrics,
482    /// Show server health check results
483    Health,
484    /// Join the configured cluster membership
485    Join,
486    /// Leave the configured cluster membership
487    Leave,
488    /// Server compaction management
489    Compaction {
490        #[command(subcommand)]
491        command: CompactionCommand,
492    },
493}
494
495/// Lifecycle subcommands
496#[derive(Subcommand, Debug)]
497pub enum LifecycleCommand {
498    /// Archive data (placeholder)
499    Archive,
500    /// Restore archived data (placeholder)
501    Restore {
502        /// Restore source (server mode only)
503        #[arg(long)]
504        source: Option<String>,
505        /// Restore subcommands
506        #[command(subcommand)]
507        command: Option<LifecycleRestoreCommand>,
508    },
509    /// Backup data (placeholder)
510    Backup {
511        /// Backup subcommands
512        #[command(subcommand)]
513        command: Option<LifecycleBackupCommand>,
514    },
515    /// Export data (placeholder)
516    Export,
517}
518
519/// Backup lifecycle subcommands
520#[derive(Subcommand, Debug)]
521pub enum LifecycleBackupCommand {
522    /// Show backup status for a handle
523    Status {
524        /// Backup handle
525        #[arg(long)]
526        handle: String,
527    },
528}
529
530/// Restore lifecycle subcommands
531#[derive(Subcommand, Debug)]
532pub enum LifecycleRestoreCommand {
533    /// Show restore status for a handle
534    Status {
535        /// Restore handle
536        #[arg(long)]
537        handle: String,
538    },
539}
540
541/// Server compaction subcommands
542#[derive(Subcommand, Debug)]
543pub enum CompactionCommand {
544    /// Trigger server compaction
545    Trigger,
546}
547
548#[cfg(test)]
549mod tests {
550    use super::*;
551
552    #[test]
553    fn test_parse_in_memory_kv_get() {
554        let args = vec!["alopex", "--in-memory", "kv", "get", "mykey"];
555        let cli = Cli::try_parse_from(args).unwrap();
556
557        assert!(cli.in_memory);
558        assert!(cli.data_dir.is_none());
559        assert_eq!(cli.output_format(), OutputFormat::Table);
560        assert!(matches!(
561            cli.command,
562            Some(Command::Kv {
563                command: Some(KvCommand::Get { key })
564            }) if key == "mykey"
565        ));
566    }
567
568    #[test]
569    fn test_parse_data_dir_sql() {
570        let args = vec![
571            "alopex",
572            "--data-dir",
573            "/path/to/db",
574            "sql",
575            "SELECT * FROM users",
576        ];
577        let cli = Cli::try_parse_from(args).unwrap();
578
579        assert!(!cli.in_memory);
580        assert_eq!(cli.data_dir, Some("/path/to/db".to_string()));
581        assert!(matches!(
582            cli.command,
583            Some(Command::Sql(SqlCommand { query: Some(q), file: None, .. })) if q == "SELECT * FROM users"
584        ));
585    }
586
587    #[test]
588    fn test_parse_output_format() {
589        let args = vec!["alopex", "--in-memory", "--output", "jsonl", "kv", "list"];
590        let cli = Cli::try_parse_from(args).unwrap();
591
592        assert_eq!(cli.output_format(), OutputFormat::Jsonl);
593        assert!(cli.output_is_explicit());
594    }
595
596    #[test]
597    fn test_parse_limit() {
598        let args = vec!["alopex", "--in-memory", "--limit", "100", "kv", "list"];
599        let cli = Cli::try_parse_from(args).unwrap();
600
601        assert_eq!(cli.limit, Some(100));
602    }
603
604    #[test]
605    fn test_parse_sql_streaming_options() {
606        let args = vec![
607            "alopex",
608            "sql",
609            "--fetch-size",
610            "500",
611            "--max-rows",
612            "250",
613            "--deadline",
614            "30s",
615            "SELECT 1",
616        ];
617        let cli = Cli::try_parse_from(args).unwrap();
618
619        match cli.command {
620            Some(Command::Sql(cmd)) => {
621                assert_eq!(cmd.fetch_size, Some(500));
622                assert_eq!(cmd.max_rows, Some(250));
623                assert_eq!(cmd.deadline.as_deref(), Some("30s"));
624                assert!(!cmd.tui);
625            }
626            _ => panic!("expected sql command"),
627        }
628    }
629
630    #[test]
631    fn test_parse_sql_tui_flag() {
632        let args = vec!["alopex", "sql", "--tui", "SELECT 1"];
633        let cli = Cli::try_parse_from(args).unwrap();
634
635        match cli.command {
636            Some(Command::Sql(cmd)) => {
637                assert!(cmd.tui);
638                assert_eq!(cmd.query.as_deref(), Some("SELECT 1"));
639            }
640            _ => panic!("expected sql command"),
641        }
642    }
643
644    #[test]
645    fn test_parse_server_status() {
646        let args = vec!["alopex", "server", "status"];
647        let cli = Cli::try_parse_from(args).unwrap();
648
649        assert!(matches!(
650            cli.command,
651            Some(Command::Server {
652                command: Some(ServerCommand::Status)
653            })
654        ));
655    }
656
657    #[test]
658    fn test_parse_server_compaction_trigger() {
659        let args = vec!["alopex", "server", "compaction", "trigger"];
660        let cli = Cli::try_parse_from(args).unwrap();
661
662        assert!(matches!(
663            cli.command,
664            Some(Command::Server {
665                command: Some(ServerCommand::Compaction {
666                    command: CompactionCommand::Trigger
667                })
668            })
669        ));
670    }
671
672    #[test]
673    fn test_parse_server_join_leave() {
674        let join = Cli::try_parse_from(vec!["alopex", "server", "join"]).unwrap();
675        assert!(matches!(
676            join.command,
677            Some(Command::Server {
678                command: Some(ServerCommand::Join)
679            })
680        ));
681
682        let leave = Cli::try_parse_from(vec!["alopex", "server", "leave"]).unwrap();
683        assert!(matches!(
684            leave.command,
685            Some(Command::Server {
686                command: Some(ServerCommand::Leave)
687            })
688        ));
689    }
690
691    #[test]
692    fn test_parse_verbose_quiet() {
693        let args = vec!["alopex", "--in-memory", "--verbose", "kv", "list"];
694        let cli = Cli::try_parse_from(args).unwrap();
695
696        assert!(cli.verbose);
697        assert!(!cli.quiet);
698    }
699
700    #[test]
701    fn test_parse_thread_mode() {
702        let args = vec![
703            "alopex",
704            "--in-memory",
705            "--thread-mode",
706            "single",
707            "kv",
708            "list",
709        ];
710        let cli = Cli::try_parse_from(args).unwrap();
711
712        assert_eq!(cli.thread_mode, ThreadMode::Single);
713    }
714
715    #[test]
716    fn test_parse_profile_option_batch_yes() {
717        let args = vec![
718            "alopex",
719            "--profile",
720            "dev",
721            "--batch",
722            "--yes",
723            "--in-memory",
724            "kv",
725            "list",
726        ];
727        let cli = Cli::try_parse_from(args).unwrap();
728
729        assert_eq!(cli.profile.as_deref(), Some("dev"));
730        assert!(cli.batch);
731        assert!(cli.yes);
732    }
733
734    #[test]
735    fn test_parse_batch_short_flag() {
736        let args = vec!["alopex", "-b", "--in-memory", "kv", "list"];
737        let cli = Cli::try_parse_from(args).unwrap();
738
739        assert!(cli.batch);
740    }
741
742    #[test]
743    fn test_parse_profile_create_subcommand() {
744        let args = vec![
745            "alopex",
746            "profile",
747            "create",
748            "dev",
749            "--data-dir",
750            "/path/to/db",
751        ];
752        let cli = Cli::try_parse_from(args).unwrap();
753
754        assert!(matches!(
755            cli.command,
756            Some(Command::Profile {
757                command: Some(ProfileCommand::Create { name, data_dir })
758            })
759                if name == "dev" && data_dir == "/path/to/db"
760        ));
761    }
762
763    #[test]
764    fn test_parse_completions_bash() {
765        let args = vec!["alopex", "completions", "bash"];
766        let cli = Cli::try_parse_from(args).unwrap();
767
768        assert!(matches!(
769            cli.command,
770            Some(Command::Completions { shell }) if shell == Shell::Bash
771        ));
772    }
773
774    #[test]
775    fn test_parse_completions_pwsh() {
776        let args = vec!["alopex", "completions", "pwsh"];
777        let cli = Cli::try_parse_from(args).unwrap();
778
779        assert!(matches!(
780            cli.command,
781            Some(Command::Completions { shell }) if shell == Shell::PowerShell
782        ));
783    }
784
785    #[test]
786    fn test_parse_kv_put() {
787        let args = vec!["alopex", "--in-memory", "kv", "put", "mykey", "myvalue"];
788        let cli = Cli::try_parse_from(args).unwrap();
789
790        assert!(matches!(
791            cli.command,
792            Some(Command::Kv {
793                command: Some(KvCommand::Put { key, value })
794            }) if key == "mykey" && value == "myvalue"
795        ));
796    }
797
798    #[test]
799    fn test_parse_kv_delete() {
800        let args = vec!["alopex", "--in-memory", "kv", "delete", "mykey"];
801        let cli = Cli::try_parse_from(args).unwrap();
802
803        assert!(matches!(
804            cli.command,
805            Some(Command::Kv {
806                command: Some(KvCommand::Delete { key })
807            }) if key == "mykey"
808        ));
809    }
810
811    #[test]
812    fn test_parse_kv_txn_begin() {
813        let args = vec!["alopex", "kv", "txn", "begin", "--timeout-secs", "30"];
814        let cli = Cli::try_parse_from(args).unwrap();
815
816        assert!(matches!(
817            cli.command,
818            Some(Command::Kv {
819                command: Some(KvCommand::Txn(KvTxnCommand::Begin {
820                    timeout_secs: Some(30)
821                }))
822            })
823        ));
824    }
825
826    #[test]
827    fn test_parse_kv_txn_get_requires_txn_id() {
828        let args = vec!["alopex", "kv", "txn", "get", "mykey"];
829
830        assert!(Cli::try_parse_from(args).is_err());
831    }
832
833    #[test]
834    fn test_parse_kv_txn_get() {
835        let args = vec!["alopex", "kv", "txn", "get", "mykey", "--txn-id", "txn123"];
836        let cli = Cli::try_parse_from(args).unwrap();
837
838        assert!(matches!(
839            cli.command,
840            Some(Command::Kv {
841                command: Some(KvCommand::Txn(KvTxnCommand::Get { key, txn_id }))
842            }) if key == "mykey" && txn_id == "txn123"
843        ));
844    }
845
846    #[test]
847    fn test_parse_kv_list_with_prefix() {
848        let args = vec!["alopex", "--in-memory", "kv", "list", "--prefix", "user:"];
849        let cli = Cli::try_parse_from(args).unwrap();
850
851        assert!(matches!(
852            cli.command,
853            Some(Command::Kv {
854                command: Some(KvCommand::List { prefix: Some(p) })
855            }) if p == "user:"
856        ));
857    }
858
859    #[test]
860    fn test_parse_sql_from_file() {
861        let args = vec!["alopex", "--in-memory", "sql", "-f", "query.sql"];
862        let cli = Cli::try_parse_from(args).unwrap();
863
864        assert!(matches!(
865            cli.command,
866            Some(Command::Sql(SqlCommand { query: None, file: Some(f), .. })) if f == "query.sql"
867        ));
868    }
869
870    #[test]
871    fn test_parse_vector_search() {
872        let args = vec![
873            "alopex",
874            "--in-memory",
875            "vector",
876            "search",
877            "--index",
878            "my_index",
879            "--query",
880            "[1.0,2.0,3.0]",
881            "-k",
882            "5",
883        ];
884        let cli = Cli::try_parse_from(args).unwrap();
885
886        assert!(matches!(
887            cli.command,
888            Some(Command::Vector {
889                command: Some(VectorCommand::Search { index, query, k, progress })
890            }) if index == "my_index" && query == "[1.0,2.0,3.0]" && k == 5 && !progress
891        ));
892    }
893
894    #[test]
895    fn test_parse_vector_upsert() {
896        let args = vec![
897            "alopex",
898            "--in-memory",
899            "vector",
900            "upsert",
901            "--index",
902            "my_index",
903            "--key",
904            "vec1",
905            "--vector",
906            "[1.0,2.0,3.0]",
907        ];
908        let cli = Cli::try_parse_from(args).unwrap();
909
910        assert!(matches!(
911            cli.command,
912            Some(Command::Vector {
913                command: Some(VectorCommand::Upsert { index, key, vector })
914            }) if index == "my_index" && key == "vec1" && vector == "[1.0,2.0,3.0]"
915        ));
916    }
917
918    #[test]
919    fn test_parse_vector_delete() {
920        let args = vec![
921            "alopex",
922            "--in-memory",
923            "vector",
924            "delete",
925            "--index",
926            "my_index",
927            "--key",
928            "vec1",
929        ];
930        let cli = Cli::try_parse_from(args).unwrap();
931
932        assert!(matches!(
933            cli.command,
934            Some(Command::Vector {
935                command: Some(VectorCommand::Delete { index, key })
936            }) if index == "my_index" && key == "vec1"
937        ));
938    }
939
940    #[test]
941    fn test_parse_hnsw_create() {
942        let args = vec![
943            "alopex",
944            "--in-memory",
945            "hnsw",
946            "create",
947            "my_index",
948            "--dim",
949            "128",
950            "--metric",
951            "l2",
952        ];
953        let cli = Cli::try_parse_from(args).unwrap();
954
955        assert!(matches!(
956            cli.command,
957            Some(Command::Hnsw {
958                command: Some(HnswCommand::Create { name, dim, metric })
959            }) if name == "my_index" && dim == 128 && metric == DistanceMetric::L2
960        ));
961    }
962
963    #[test]
964    fn test_parse_hnsw_create_default_metric() {
965        let args = vec![
966            "alopex",
967            "--in-memory",
968            "hnsw",
969            "create",
970            "my_index",
971            "--dim",
972            "128",
973        ];
974        let cli = Cli::try_parse_from(args).unwrap();
975
976        assert!(matches!(
977            cli.command,
978            Some(Command::Hnsw {
979                command: Some(HnswCommand::Create { name, dim, metric })
980            }) if name == "my_index" && dim == 128 && metric == DistanceMetric::Cosine
981        ));
982    }
983
984    #[test]
985    fn test_parse_columnar_scan() {
986        let args = vec![
987            "alopex",
988            "--in-memory",
989            "columnar",
990            "scan",
991            "--segment",
992            "seg_001",
993        ];
994        let cli = Cli::try_parse_from(args).unwrap();
995
996        assert!(matches!(
997            cli.command,
998            Some(Command::Columnar {
999                command: Some(ColumnarCommand::Scan { segment, progress })
1000            }) if segment == "seg_001" && !progress
1001        ));
1002    }
1003
1004    #[test]
1005    fn test_parse_columnar_stats() {
1006        let args = vec![
1007            "alopex",
1008            "--in-memory",
1009            "columnar",
1010            "stats",
1011            "--segment",
1012            "seg_001",
1013        ];
1014        let cli = Cli::try_parse_from(args).unwrap();
1015
1016        assert!(matches!(
1017            cli.command,
1018            Some(Command::Columnar {
1019                command: Some(ColumnarCommand::Stats { segment })
1020            }) if segment == "seg_001"
1021        ));
1022    }
1023
1024    #[test]
1025    fn test_parse_columnar_list() {
1026        let args = vec!["alopex", "--in-memory", "columnar", "list"];
1027        let cli = Cli::try_parse_from(args).unwrap();
1028
1029        assert!(matches!(
1030            cli.command,
1031            Some(Command::Columnar {
1032                command: Some(ColumnarCommand::List)
1033            })
1034        ));
1035    }
1036
1037    #[test]
1038    fn test_parse_columnar_ingest_defaults() {
1039        let args = vec![
1040            "alopex",
1041            "--in-memory",
1042            "columnar",
1043            "ingest",
1044            "--file",
1045            "data.csv",
1046            "--table",
1047            "events",
1048        ];
1049        let cli = Cli::try_parse_from(args).unwrap();
1050
1051        assert!(matches!(
1052            cli.command,
1053            Some(Command::Columnar {
1054                command: Some(ColumnarCommand::Ingest {
1055                    file,
1056                    table,
1057                    delimiter,
1058                    header,
1059                    compression,
1060                    row_group_size,
1061                })
1062            }) if file == std::path::Path::new("data.csv")
1063                && table == "events"
1064                && delimiter == ','
1065                && header
1066                && compression == "zstd"
1067                && row_group_size.is_none()
1068        ));
1069    }
1070
1071    #[test]
1072    fn test_parse_columnar_ingest_custom_options() {
1073        let args = vec![
1074            "alopex",
1075            "--in-memory",
1076            "columnar",
1077            "ingest",
1078            "--file",
1079            "data.csv",
1080            "--table",
1081            "events",
1082            "--delimiter",
1083            ";",
1084            "--header",
1085            "false",
1086            "--compression",
1087            "zstd",
1088            "--row-group-size",
1089            "500",
1090        ];
1091        let cli = Cli::try_parse_from(args).unwrap();
1092
1093        assert!(matches!(
1094            cli.command,
1095            Some(Command::Columnar {
1096                command: Some(ColumnarCommand::Ingest {
1097                    file,
1098                    table,
1099                    delimiter,
1100                    header,
1101                    compression,
1102                    row_group_size,
1103                })
1104            }) if file == std::path::Path::new("data.csv")
1105                && table == "events"
1106                && delimiter == ';'
1107                && !header
1108                && compression == "zstd"
1109                && row_group_size == Some(500)
1110        ));
1111    }
1112
1113    #[test]
1114    fn test_parse_columnar_index_create() {
1115        let args = vec![
1116            "alopex",
1117            "--in-memory",
1118            "columnar",
1119            "index",
1120            "create",
1121            "--segment",
1122            "123:1",
1123            "--column",
1124            "col1",
1125            "--type",
1126            "bloom",
1127        ];
1128        let cli = Cli::try_parse_from(args).unwrap();
1129
1130        assert!(matches!(
1131            cli.command,
1132            Some(Command::Columnar {
1133                command: Some(ColumnarCommand::Index(IndexCommand::Create {
1134                    segment,
1135                    column,
1136                    index_type,
1137                }))
1138            }) if segment == "123:1"
1139                && column == "col1"
1140                && index_type == "bloom"
1141        ));
1142    }
1143
1144    #[test]
1145    fn test_output_format_supports_streaming() {
1146        assert!(!OutputFormat::Table.supports_streaming());
1147        assert!(OutputFormat::Json.supports_streaming());
1148        assert!(OutputFormat::Jsonl.supports_streaming());
1149        assert!(OutputFormat::Csv.supports_streaming());
1150        assert!(OutputFormat::Tsv.supports_streaming());
1151    }
1152
1153    #[test]
1154    fn test_default_values() {
1155        let args = vec!["alopex", "--in-memory", "kv", "list"];
1156        let cli = Cli::try_parse_from(args).unwrap();
1157
1158        assert_eq!(cli.output_format(), OutputFormat::Table);
1159        assert!(!cli.output_is_explicit());
1160        assert_eq!(cli.thread_mode, ThreadMode::Multi);
1161        assert!(cli.limit.is_none());
1162        assert!(!cli.quiet);
1163        assert!(!cli.verbose);
1164    }
1165
1166    #[test]
1167    fn test_s3_data_dir() {
1168        let args = vec![
1169            "alopex",
1170            "--data-dir",
1171            "s3://my-bucket/prefix",
1172            "kv",
1173            "list",
1174        ];
1175        let cli = Cli::try_parse_from(args).unwrap();
1176
1177        assert_eq!(cli.data_dir, Some("s3://my-bucket/prefix".to_string()));
1178    }
1179}