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::{Args, Parser, Subcommand, ValueEnum};
8use clap_complete::Shell;
9use serde::{Deserialize, Serialize};
10
11fn parse_shell(value: &str) -> Result<Shell, String> {
12    match value {
13        "bash" => Ok(Shell::Bash),
14        "zsh" => Ok(Shell::Zsh),
15        "fish" => Ok(Shell::Fish),
16        "pwsh" | "powershell" => Ok(Shell::PowerShell),
17        _ => Err(format!(
18            "Unsupported shell: {}. Use bash, zsh, fish, or pwsh.",
19            value
20        )),
21    }
22}
23
24/// Alopex CLI - Command-line interface for Alopex DB
25#[derive(Parser, Debug)]
26#[command(name = "alopex")]
27#[command(version, about, long_about = None)]
28pub struct Cli {
29    /// Path to the database directory (local path or S3 URI)
30    #[arg(long)]
31    pub data_dir: Option<String>,
32
33    /// Profile name to use for database configuration
34    #[arg(long)]
35    pub profile: Option<String>,
36
37    /// Run in in-memory mode (no persistence)
38    #[arg(long, conflicts_with = "data_dir")]
39    pub in_memory: bool,
40
41    /// Output format
42    #[arg(long, value_enum)]
43    pub output: Option<OutputFormat>,
44
45    /// Limit the number of output rows
46    #[arg(long)]
47    pub limit: Option<usize>,
48
49    /// Suppress informational messages
50    #[arg(long)]
51    pub quiet: bool,
52
53    /// Enable verbose output (includes stack traces for errors)
54    #[arg(long)]
55    pub verbose: bool,
56
57    /// Allow insecure HTTP connections for server profiles
58    #[arg(long)]
59    pub insecure: bool,
60
61    /// Thread mode (multi or single)
62    #[arg(long, value_enum, default_value = "multi")]
63    pub thread_mode: ThreadMode,
64
65    /// Enable batch mode (non-interactive)
66    #[arg(long, short = 'b')]
67    pub batch: bool,
68
69    /// Automatically answer yes to prompts
70    #[arg(long)]
71    pub yes: bool,
72
73    /// Subcommand to execute
74    #[command(subcommand)]
75    pub command: Option<Command>,
76}
77
78/// Output format for query results
79#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
80pub enum OutputFormat {
81    /// Human-readable table format
82    Table,
83    /// JSON array format (sql: array of per-statement result sets)
84    Json,
85    /// JSON Lines format (one JSON object per line)
86    Jsonl,
87    /// CSV format (RFC 4180)
88    Csv,
89    /// TSV format (tab-separated values)
90    Tsv,
91}
92
93/// Requested SQL read routing mode. `local` remains the compatibility default;
94/// all other modes require an explicitly configured cluster profile.
95#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Serialize, Deserialize, Default)]
96#[serde(rename_all = "snake_case")]
97pub enum SqlReadMode {
98    #[default]
99    Local,
100    Inherit,
101    Strong,
102    Stale,
103}
104
105/// Requested format for the distributed-read routing report. The report is
106/// emitted by the later output adapter and is intentionally separate from
107/// query stdout formats.
108#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
109pub enum RoutingReportFormat {
110    Human,
111    Json,
112}
113
114impl OutputFormat {
115    /// Returns true if this format supports streaming output.
116    #[allow(dead_code)]
117    pub fn supports_streaming(&self) -> bool {
118        matches!(self, Self::Json | Self::Jsonl | Self::Csv | Self::Tsv)
119    }
120}
121
122impl Cli {
123    pub fn output_format(&self) -> OutputFormat {
124        self.output.unwrap_or(OutputFormat::Table)
125    }
126
127    pub fn output_is_explicit(&self) -> bool {
128        self.output.is_some()
129    }
130}
131
132/// Thread mode for database operations
133#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
134pub enum ThreadMode {
135    /// Multi-threaded mode (default)
136    Multi,
137    /// Single-threaded mode (not supported in v0.3.2)
138    Single,
139}
140
141/// Top-level subcommands
142#[derive(Subcommand, Debug)]
143pub enum Command {
144    /// Profile management
145    Profile {
146        #[command(subcommand)]
147        command: Option<ProfileCommand>,
148    },
149    /// Key-Value operations
150    Kv {
151        #[command(subcommand)]
152        command: Option<KvCommand>,
153    },
154    /// SQL query execution
155    Sql(SqlCommand),
156    /// Vector operations
157    Vector {
158        #[command(subcommand)]
159        command: Option<VectorCommand>,
160    },
161    /// HNSW index management
162    Hnsw {
163        #[command(subcommand)]
164        command: Option<HnswCommand>,
165    },
166    /// Columnar segment operations
167    Columnar {
168        #[command(subcommand)]
169        command: Option<ColumnarCommand>,
170    },
171    /// Server management commands
172    Server {
173        #[command(subcommand)]
174        command: Option<ServerCommand>,
175    },
176    /// Data lifecycle management commands
177    Lifecycle {
178        #[command(subcommand)]
179        command: Option<LifecycleCommand>,
180    },
181    /// Show CLI and file format version information
182    Version,
183    /// Generate shell completion scripts
184    Completions {
185        /// Shell type (bash, zsh, fish, pwsh)
186        #[arg(value_parser = parse_shell, value_name = "SHELL")]
187        shell: Shell,
188    },
189}
190
191/// Profile subcommands
192#[derive(Subcommand, Debug, Clone)]
193pub enum ProfileCommand {
194    /// Create a profile
195    Create {
196        /// Profile name
197        name: String,
198        /// Path to the database directory (local path or S3 URI)
199        #[arg(long)]
200        data_dir: String,
201    },
202    /// List profiles
203    List,
204    /// Show profile details
205    Show {
206        /// Profile name
207        name: String,
208    },
209    /// Delete a profile
210    Delete {
211        /// Profile name
212        name: String,
213    },
214    /// Set the default profile
215    SetDefault {
216        /// Profile name
217        name: String,
218    },
219}
220
221/// KV subcommands
222#[derive(Subcommand, Debug)]
223pub enum KvCommand {
224    /// Get a value by key
225    Get {
226        /// The key to retrieve
227        key: String,
228    },
229    /// Put a key-value pair
230    Put {
231        /// The key to set
232        key: String,
233        /// The value to store
234        value: String,
235    },
236    /// Delete a key
237    Delete {
238        /// The key to delete
239        key: String,
240    },
241    /// List keys with optional prefix
242    List {
243        /// Filter keys by prefix
244        #[arg(long)]
245        prefix: Option<String>,
246    },
247    /// Transaction operations
248    #[command(subcommand)]
249    Txn(KvTxnCommand),
250}
251
252/// KV transaction subcommands
253#[derive(Subcommand, Debug)]
254pub enum KvTxnCommand {
255    /// Begin a transaction
256    Begin {
257        /// Transaction timeout in seconds (default: 60)
258        #[arg(long)]
259        timeout_secs: Option<u64>,
260    },
261    /// Get a value within a transaction
262    Get {
263        /// The key to retrieve
264        key: String,
265        /// Transaction ID
266        #[arg(long)]
267        txn_id: String,
268    },
269    /// Put a key-value pair within a transaction
270    Put {
271        /// The key to set
272        key: String,
273        /// The value to store
274        value: String,
275        /// Transaction ID
276        #[arg(long)]
277        txn_id: String,
278    },
279    /// Delete a key within a transaction
280    Delete {
281        /// The key to delete
282        key: String,
283        /// Transaction ID
284        #[arg(long)]
285        txn_id: String,
286    },
287    /// Commit a transaction
288    Commit {
289        /// Transaction ID
290        #[arg(long)]
291        txn_id: String,
292    },
293    /// Roll back a transaction
294    Rollback {
295        /// Transaction ID
296        #[arg(long)]
297        txn_id: String,
298    },
299}
300
301/// SQL subcommand
302///
303/// Multiple `;`-separated statements are executed in a single transaction and
304/// each statement emits its own result block. With `--output json` the output
305/// is always an array of per-statement result sets (a single statement yields
306/// a 1-element array); DDL/DML statements contribute a `status`/`message`
307/// result set unless `--quiet` is set.
308#[derive(Parser, Debug)]
309pub struct SqlCommand {
310    /// SQL query to execute (may contain multiple `;`-separated statements)
311    #[arg(conflicts_with = "file")]
312    pub query: Option<String>,
313
314    /// File containing SQL query
315    #[arg(long, short = 'f')]
316    pub file: Option<String>,
317
318    /// Fetch size for server streaming
319    #[arg(long)]
320    pub fetch_size: Option<usize>,
321
322    /// Max rows to return before stopping
323    #[arg(long)]
324    pub max_rows: Option<usize>,
325
326    /// Deadline for query execution (e.g. 60s, 5m)
327    #[arg(long)]
328    pub deadline: Option<String>,
329
330    /// Read routing mode. Non-local modes require an explicit cluster profile.
331    #[arg(long, value_enum)]
332    pub read_mode: Option<SqlReadMode>,
333
334    /// Emit a distributed-read routing report to stderr without changing SQL
335    /// row output on stdout.
336    #[arg(long, value_enum)]
337    pub routing_report: Option<RoutingReportFormat>,
338
339    /// Launch interactive TUI preview
340    #[arg(long)]
341    pub tui: bool,
342}
343
344/// Vector subcommands
345#[derive(Subcommand, Debug)]
346pub enum VectorCommand {
347    /// Search for similar vectors
348    Search {
349        /// Index name
350        #[arg(long)]
351        index: String,
352        /// Query vector as JSON array
353        #[arg(long)]
354        query: String,
355        /// Number of results to return
356        #[arg(long, short = 'k', default_value = "10")]
357        k: usize,
358        /// Show progress indicator
359        #[arg(long)]
360        progress: bool,
361    },
362    /// Upsert a single vector
363    Upsert {
364        /// Index name
365        #[arg(long)]
366        index: String,
367        /// Vector key/ID
368        #[arg(long)]
369        key: String,
370        /// Vector as JSON array
371        #[arg(long)]
372        vector: String,
373    },
374    /// Delete a single vector by key
375    Delete {
376        /// Index name
377        #[arg(long)]
378        index: String,
379        /// Vector key/ID to delete
380        #[arg(long)]
381        key: String,
382    },
383}
384
385/// Distance metric for HNSW index
386#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Default)]
387pub enum DistanceMetric {
388    /// Cosine similarity (default)
389    #[default]
390    Cosine,
391    /// Euclidean distance (L2)
392    L2,
393    /// Inner product
394    Ip,
395}
396
397/// HNSW subcommands
398#[derive(Subcommand, Debug)]
399pub enum HnswCommand {
400    /// Create a new HNSW index
401    Create {
402        /// Index name
403        name: String,
404        /// Vector dimensions
405        #[arg(long)]
406        dim: usize,
407        /// Distance metric
408        #[arg(long, value_enum, default_value = "cosine")]
409        metric: DistanceMetric,
410    },
411    /// Show index statistics
412    Stats {
413        /// Index name
414        name: String,
415    },
416    /// Drop an index
417    Drop {
418        /// Index name
419        name: String,
420    },
421}
422
423/// Columnar subcommands
424#[derive(Subcommand, Debug)]
425pub enum ColumnarCommand {
426    /// Scan a columnar segment
427    Scan {
428        /// Segment ID
429        #[arg(long)]
430        segment: String,
431        /// Show progress indicator
432        #[arg(long)]
433        progress: bool,
434    },
435    /// Show segment statistics
436    Stats {
437        /// Segment ID
438        #[arg(long)]
439        segment: String,
440    },
441    /// List all columnar segments
442    List,
443    /// Ingest a file into columnar storage
444    Ingest {
445        /// Input file path (CSV or Parquet)
446        #[arg(long)]
447        file: PathBuf,
448        /// Target table name
449        #[arg(long)]
450        table: String,
451        /// CSV delimiter character
452        #[arg(long, default_value = ",", value_parser = clap::value_parser!(char))]
453        delimiter: char,
454        /// Whether the CSV has a header row
455        #[arg(
456            long,
457            default_value = "true",
458            value_parser = clap::value_parser!(bool),
459            action = clap::ArgAction::Set
460        )]
461        header: bool,
462        /// Compression type (lz4, zstd, none)
463        #[arg(long, default_value = "zstd")]
464        compression: String,
465        /// Row group size (rows per group)
466        #[arg(long)]
467        row_group_size: Option<usize>,
468    },
469    /// Index management
470    #[command(subcommand)]
471    Index(IndexCommand),
472}
473
474/// Columnar index subcommands
475#[derive(Subcommand, Debug)]
476pub enum IndexCommand {
477    /// Create an index
478    Create {
479        /// Segment ID
480        #[arg(long)]
481        segment: String,
482        /// Column name
483        #[arg(long)]
484        column: String,
485        /// Index type (minmax, bloom)
486        #[arg(long = "type")]
487        index_type: String,
488    },
489    /// List indexes
490    List {
491        /// Segment ID
492        #[arg(long)]
493        segment: String,
494    },
495    /// Drop an index
496    Drop {
497        /// Segment ID
498        #[arg(long)]
499        segment: String,
500        /// Column name
501        #[arg(long)]
502        column: String,
503    },
504}
505
506/// Server management subcommands
507#[derive(Subcommand, Debug)]
508pub enum ServerCommand {
509    /// Show server status
510    Status,
511    /// Show server metrics
512    Metrics,
513    /// Show server health check results
514    Health,
515    /// Join the configured cluster membership
516    Join,
517    /// Leave the configured cluster membership
518    Leave,
519    /// Server compaction management
520    Compaction {
521        #[command(subcommand)]
522        command: CompactionCommand,
523    },
524    /// Cluster metadata management commands
525    Cluster {
526        #[command(subcommand)]
527        command: ClusterCommand,
528    },
529}
530
531/// Shared operation identity and optimistic-concurrency input for cluster
532/// management requests. The request ID is deliberately operator-supplied so a
533/// retry can use the same idempotency key.
534#[derive(Args, Debug)]
535pub struct ClusterOperationRequest {
536    /// Stable operation ID used for idempotency and status correlation
537    #[arg(long, value_name = "REQUEST_ID")]
538    pub request_id: String,
539    /// Expected committed metadata version
540    #[arg(long)]
541    pub expected_version: Option<u64>,
542}
543
544/// Read operation which addresses a specific public metadata target.
545#[derive(Args, Debug)]
546pub struct ClusterTargetedReadRequest {
547    #[command(flatten)]
548    pub operation: ClusterOperationRequest,
549    /// Public target encoded as JSON
550    #[arg(long, value_name = "JSON")]
551    pub target: String,
552}
553
554/// Mutation input. A target and explicit confirmation are kept in the grammar
555/// instead of being inferred from a positional argument or an interactive
556/// prompt, so automation and the HTTP contract cannot disagree.
557#[derive(Args, Debug)]
558pub struct ClusterMutationRequest {
559    #[command(flatten)]
560    pub operation: ClusterOperationRequest,
561    /// Public mutation target encoded as JSON
562    #[arg(long, value_name = "JSON")]
563    pub target: String,
564    /// Confirm this cluster metadata mutation
565    #[arg(long, required = true)]
566    pub confirm: bool,
567}
568
569/// Cluster metadata management areas.
570#[derive(Subcommand, Debug)]
571pub enum ClusterCommand {
572    /// Inspect cluster metadata control availability
573    Metadata {
574        #[command(subcommand)]
575        command: ClusterMetadataCommand,
576    },
577    /// Manage committed members
578    #[command(visible_alias = "member")]
579    Members {
580        #[command(subcommand)]
581        command: ClusterMembersCommand,
582    },
583    /// Inspect and manage registered ranges
584    #[command(visible_alias = "range")]
585    Ranges {
586        #[command(subcommand)]
587        command: ClusterRangesCommand,
588    },
589    /// Inspect and manage range placement
590    Placement {
591        #[command(subcommand)]
592        command: ClusterPlacementCommand,
593    },
594    /// Inspect and manage the cluster read policy
595    ReadPolicy {
596        #[command(subcommand)]
597        command: ClusterReadPolicyCommand,
598    },
599    /// Inspect and manage schema ownership and rollout
600    Schema {
601        #[command(subcommand)]
602        command: ClusterSchemaCommand,
603    },
604    /// Inspect or run recovery management operations
605    Recovery {
606        #[command(subcommand)]
607        command: ClusterRecoveryCommand,
608    },
609    /// Inspect or start a resumable upgrade
610    Upgrade {
611        #[command(subcommand)]
612        command: ClusterUpgradeCommand,
613    },
614}
615
616/// Metadata inspection commands.
617#[derive(Subcommand, Debug)]
618pub enum ClusterMetadataCommand {
619    /// Show committed metadata control status
620    Show {
621        #[command(flatten)]
622        request: ClusterOperationRequest,
623    },
624}
625
626/// Member management commands.
627#[derive(Subcommand, Debug)]
628pub enum ClusterMembersCommand {
629    /// List committed members
630    List {
631        #[command(flatten)]
632        request: ClusterOperationRequest,
633    },
634    /// Replace a member using an explicit public target
635    Replace {
636        #[command(flatten)]
637        request: ClusterMutationRequest,
638    },
639}
640
641/// Range management commands.
642#[derive(Subcommand, Debug)]
643pub enum ClusterRangesCommand {
644    /// List committed ranges
645    List {
646        #[command(flatten)]
647        request: ClusterOperationRequest,
648    },
649    /// Show one range using an explicit public target
650    Show {
651        #[command(flatten)]
652        request: ClusterTargetedReadRequest,
653    },
654    /// Register a provisioned range using an explicit public target
655    Register {
656        #[command(flatten)]
657        request: ClusterMutationRequest,
658    },
659    /// Update range metadata using an explicit public target
660    Update {
661        #[command(flatten)]
662        request: ClusterMutationRequest,
663    },
664    /// Retire a range using an explicit public target
665    Retire {
666        #[command(flatten)]
667        request: ClusterMutationRequest,
668    },
669}
670
671/// Range placement commands.
672#[derive(Subcommand, Debug)]
673pub enum ClusterPlacementCommand {
674    /// Get placement for an explicit range target
675    Get {
676        #[command(flatten)]
677        request: ClusterTargetedReadRequest,
678    },
679    /// Set placement using an explicit public target
680    Set {
681        #[command(flatten)]
682        request: ClusterMutationRequest,
683    },
684    /// Replace placement using an explicit public target
685    Replace {
686        #[command(flatten)]
687        request: ClusterMutationRequest,
688    },
689}
690
691/// Cluster read-policy commands.
692#[derive(Subcommand, Debug)]
693pub enum ClusterReadPolicyCommand {
694    /// Get the committed read policy
695    Get {
696        #[command(flatten)]
697        request: ClusterOperationRequest,
698    },
699    /// Set the committed read policy using an explicit public target
700    Set {
701        #[command(flatten)]
702        request: ClusterMutationRequest,
703    },
704}
705
706/// Cluster schema ownership and rollout commands.
707#[derive(Subcommand, Debug)]
708pub enum ClusterSchemaCommand {
709    /// Inspect schema owner
710    Owner {
711        #[command(subcommand)]
712        command: ClusterSchemaOwnerCommand,
713    },
714    /// Inspect or start schema rollout
715    Rollout {
716        #[command(subcommand)]
717        command: ClusterSchemaRolloutCommand,
718    },
719}
720
721/// Schema ownership commands.
722#[derive(Subcommand, Debug)]
723pub enum ClusterSchemaOwnerCommand {
724    /// Get the committed schema owner
725    Get {
726        #[command(flatten)]
727        request: ClusterOperationRequest,
728    },
729    /// Set the committed schema owner using an explicit public target
730    Set {
731        #[command(flatten)]
732        request: ClusterMutationRequest,
733    },
734}
735
736/// Schema rollout commands.
737#[derive(Subcommand, Debug)]
738pub enum ClusterSchemaRolloutCommand {
739    /// Start a schema rollout using an explicit public target
740    Start {
741        #[command(flatten)]
742        request: ClusterMutationRequest,
743    },
744    /// Get schema rollout status
745    Status {
746        #[command(flatten)]
747        request: ClusterOperationRequest,
748    },
749}
750
751/// Recovery management commands.
752#[derive(Subcommand, Debug)]
753pub enum ClusterRecoveryCommand {
754    /// Get recovery status
755    Status {
756        #[command(flatten)]
757        request: ClusterOperationRequest,
758    },
759    /// Restore from an explicit public target
760    Restore {
761        #[command(flatten)]
762        request: ClusterMutationRequest,
763    },
764}
765
766/// Upgrade management commands.
767#[derive(Subcommand, Debug)]
768pub enum ClusterUpgradeCommand {
769    /// Get resumable upgrade status
770    Status {
771        #[command(flatten)]
772        request: ClusterOperationRequest,
773    },
774    /// Start an upgrade from an explicit public target
775    Start {
776        #[command(flatten)]
777        request: ClusterMutationRequest,
778    },
779}
780
781/// Lifecycle subcommands
782#[derive(Subcommand, Debug)]
783pub enum LifecycleCommand {
784    /// Archive data (placeholder)
785    Archive,
786    /// Restore archived data (placeholder)
787    Restore {
788        /// Restore source (server mode only)
789        #[arg(long)]
790        source: Option<String>,
791        /// Restore subcommands
792        #[command(subcommand)]
793        command: Option<LifecycleRestoreCommand>,
794    },
795    /// Backup data (placeholder)
796    Backup {
797        /// Backup subcommands
798        #[command(subcommand)]
799        command: Option<LifecycleBackupCommand>,
800    },
801    /// Export data (placeholder)
802    Export,
803}
804
805/// Backup lifecycle subcommands
806#[derive(Subcommand, Debug)]
807pub enum LifecycleBackupCommand {
808    /// Show backup status for a handle
809    Status {
810        /// Backup handle
811        #[arg(long)]
812        handle: String,
813    },
814}
815
816/// Restore lifecycle subcommands
817#[derive(Subcommand, Debug)]
818pub enum LifecycleRestoreCommand {
819    /// Show restore status for a handle
820    Status {
821        /// Restore handle
822        #[arg(long)]
823        handle: String,
824    },
825}
826
827/// Server compaction subcommands
828#[derive(Subcommand, Debug)]
829pub enum CompactionCommand {
830    /// Trigger server compaction
831    Trigger,
832}
833
834#[cfg(test)]
835mod tests {
836    use super::*;
837
838    #[test]
839    fn test_parse_in_memory_kv_get() {
840        let args = vec!["alopex", "--in-memory", "kv", "get", "mykey"];
841        let cli = Cli::try_parse_from(args).unwrap();
842
843        assert!(cli.in_memory);
844        assert!(cli.data_dir.is_none());
845        assert_eq!(cli.output_format(), OutputFormat::Table);
846        assert!(matches!(
847            cli.command,
848            Some(Command::Kv {
849                command: Some(KvCommand::Get { key })
850            }) if key == "mykey"
851        ));
852    }
853
854    #[test]
855    fn test_parse_data_dir_sql() {
856        let args = vec![
857            "alopex",
858            "--data-dir",
859            "/path/to/db",
860            "sql",
861            "SELECT * FROM users",
862        ];
863        let cli = Cli::try_parse_from(args).unwrap();
864
865        assert!(!cli.in_memory);
866        assert_eq!(cli.data_dir, Some("/path/to/db".to_string()));
867        assert!(matches!(
868            cli.command,
869            Some(Command::Sql(SqlCommand { query: Some(q), file: None, .. })) if q == "SELECT * FROM users"
870        ));
871    }
872
873    #[test]
874    fn test_parse_output_format() {
875        let args = vec!["alopex", "--in-memory", "--output", "jsonl", "kv", "list"];
876        let cli = Cli::try_parse_from(args).unwrap();
877
878        assert_eq!(cli.output_format(), OutputFormat::Jsonl);
879        assert!(cli.output_is_explicit());
880    }
881
882    #[test]
883    fn test_parse_limit() {
884        let args = vec!["alopex", "--in-memory", "--limit", "100", "kv", "list"];
885        let cli = Cli::try_parse_from(args).unwrap();
886
887        assert_eq!(cli.limit, Some(100));
888    }
889
890    #[test]
891    fn test_parse_sql_streaming_options() {
892        let args = vec![
893            "alopex",
894            "sql",
895            "--fetch-size",
896            "500",
897            "--max-rows",
898            "250",
899            "--deadline",
900            "30s",
901            "SELECT 1",
902        ];
903        let cli = Cli::try_parse_from(args).unwrap();
904
905        match cli.command {
906            Some(Command::Sql(cmd)) => {
907                assert_eq!(cmd.fetch_size, Some(500));
908                assert_eq!(cmd.max_rows, Some(250));
909                assert_eq!(cmd.deadline.as_deref(), Some("30s"));
910                assert!(!cmd.tui);
911            }
912            _ => panic!("expected sql command"),
913        }
914    }
915
916    #[test]
917    fn test_parse_sql_distributed_read_options() {
918        let args = vec![
919            "alopex",
920            "sql",
921            "--read-mode",
922            "stale",
923            "--routing-report",
924            "json",
925            "SELECT 1",
926        ];
927        let cli = Cli::try_parse_from(args).unwrap();
928
929        match cli.command {
930            Some(Command::Sql(cmd)) => {
931                assert_eq!(cmd.read_mode, Some(SqlReadMode::Stale));
932                assert_eq!(cmd.routing_report, Some(RoutingReportFormat::Json));
933            }
934            _ => panic!("expected sql command"),
935        }
936    }
937
938    #[test]
939    fn test_parse_sql_tui_flag() {
940        let args = vec!["alopex", "sql", "--tui", "SELECT 1"];
941        let cli = Cli::try_parse_from(args).unwrap();
942
943        match cli.command {
944            Some(Command::Sql(cmd)) => {
945                assert!(cmd.tui);
946                assert_eq!(cmd.query.as_deref(), Some("SELECT 1"));
947            }
948            _ => panic!("expected sql command"),
949        }
950    }
951
952    #[test]
953    fn test_parse_server_status() {
954        let args = vec!["alopex", "server", "status"];
955        let cli = Cli::try_parse_from(args).unwrap();
956
957        assert!(matches!(
958            cli.command,
959            Some(Command::Server {
960                command: Some(ServerCommand::Status)
961            })
962        ));
963    }
964
965    #[test]
966    fn test_parse_server_compaction_trigger() {
967        let args = vec!["alopex", "server", "compaction", "trigger"];
968        let cli = Cli::try_parse_from(args).unwrap();
969
970        assert!(matches!(
971            cli.command,
972            Some(Command::Server {
973                command: Some(ServerCommand::Compaction {
974                    command: CompactionCommand::Trigger
975                })
976            })
977        ));
978    }
979
980    #[test]
981    fn test_parse_server_join_leave() {
982        let join = Cli::try_parse_from(vec!["alopex", "server", "join"]).unwrap();
983        assert!(matches!(
984            join.command,
985            Some(Command::Server {
986                command: Some(ServerCommand::Join)
987            })
988        ));
989
990        let leave = Cli::try_parse_from(vec!["alopex", "server", "leave"]).unwrap();
991        assert!(matches!(
992            leave.command,
993            Some(Command::Server {
994                command: Some(ServerCommand::Leave)
995            })
996        ));
997    }
998
999    #[test]
1000    fn test_parse_cluster_mutation_requires_explicit_target_and_confirmation() {
1001        let args = vec![
1002            "alopex",
1003            "server",
1004            "cluster",
1005            "ranges",
1006            "register",
1007            "--request-id",
1008            "range-register-1",
1009            "--expected-version",
1010            "8",
1011            "--target",
1012            r#"{"range_id":"primary/0"}"#,
1013            "--confirm",
1014        ];
1015        let cli = Cli::try_parse_from(args).unwrap();
1016        assert!(matches!(
1017            cli.command,
1018            Some(Command::Server {
1019                command: Some(ServerCommand::Cluster {
1020                    command: ClusterCommand::Ranges {
1021                        command: ClusterRangesCommand::Register { request }
1022                    }
1023                })
1024            }) if request.operation.request_id == "range-register-1"
1025                && request.operation.expected_version == Some(8)
1026                && request.target == r#"{"range_id":"primary/0"}"#
1027                && request.confirm
1028        ));
1029
1030        assert!(Cli::try_parse_from([
1031            "alopex",
1032            "server",
1033            "cluster",
1034            "ranges",
1035            "register",
1036            "--request-id",
1037            "range-register-1",
1038            "--confirm",
1039        ])
1040        .is_err());
1041
1042        assert!(Cli::try_parse_from([
1043            "alopex",
1044            "server",
1045            "cluster",
1046            "ranges",
1047            "register",
1048            "--request-id",
1049            "range-register-1",
1050            "--target",
1051            r#"{"range_id":"primary/0"}"#,
1052        ])
1053        .is_err());
1054    }
1055
1056    #[test]
1057    fn test_parse_verbose_quiet() {
1058        let args = vec!["alopex", "--in-memory", "--verbose", "kv", "list"];
1059        let cli = Cli::try_parse_from(args).unwrap();
1060
1061        assert!(cli.verbose);
1062        assert!(!cli.quiet);
1063    }
1064
1065    #[test]
1066    fn test_parse_thread_mode() {
1067        let args = vec![
1068            "alopex",
1069            "--in-memory",
1070            "--thread-mode",
1071            "single",
1072            "kv",
1073            "list",
1074        ];
1075        let cli = Cli::try_parse_from(args).unwrap();
1076
1077        assert_eq!(cli.thread_mode, ThreadMode::Single);
1078    }
1079
1080    #[test]
1081    fn test_parse_profile_option_batch_yes() {
1082        let args = vec![
1083            "alopex",
1084            "--profile",
1085            "dev",
1086            "--batch",
1087            "--yes",
1088            "--in-memory",
1089            "kv",
1090            "list",
1091        ];
1092        let cli = Cli::try_parse_from(args).unwrap();
1093
1094        assert_eq!(cli.profile.as_deref(), Some("dev"));
1095        assert!(cli.batch);
1096        assert!(cli.yes);
1097    }
1098
1099    #[test]
1100    fn test_parse_batch_short_flag() {
1101        let args = vec!["alopex", "-b", "--in-memory", "kv", "list"];
1102        let cli = Cli::try_parse_from(args).unwrap();
1103
1104        assert!(cli.batch);
1105    }
1106
1107    #[test]
1108    fn test_parse_profile_create_subcommand() {
1109        let args = vec![
1110            "alopex",
1111            "profile",
1112            "create",
1113            "dev",
1114            "--data-dir",
1115            "/path/to/db",
1116        ];
1117        let cli = Cli::try_parse_from(args).unwrap();
1118
1119        assert!(matches!(
1120            cli.command,
1121            Some(Command::Profile {
1122                command: Some(ProfileCommand::Create { name, data_dir })
1123            })
1124                if name == "dev" && data_dir == "/path/to/db"
1125        ));
1126    }
1127
1128    #[test]
1129    fn test_parse_completions_bash() {
1130        let args = vec!["alopex", "completions", "bash"];
1131        let cli = Cli::try_parse_from(args).unwrap();
1132
1133        assert!(matches!(
1134            cli.command,
1135            Some(Command::Completions { shell }) if shell == Shell::Bash
1136        ));
1137    }
1138
1139    #[test]
1140    fn test_parse_completions_pwsh() {
1141        let args = vec!["alopex", "completions", "pwsh"];
1142        let cli = Cli::try_parse_from(args).unwrap();
1143
1144        assert!(matches!(
1145            cli.command,
1146            Some(Command::Completions { shell }) if shell == Shell::PowerShell
1147        ));
1148    }
1149
1150    #[test]
1151    fn test_parse_kv_put() {
1152        let args = vec!["alopex", "--in-memory", "kv", "put", "mykey", "myvalue"];
1153        let cli = Cli::try_parse_from(args).unwrap();
1154
1155        assert!(matches!(
1156            cli.command,
1157            Some(Command::Kv {
1158                command: Some(KvCommand::Put { key, value })
1159            }) if key == "mykey" && value == "myvalue"
1160        ));
1161    }
1162
1163    #[test]
1164    fn test_parse_kv_delete() {
1165        let args = vec!["alopex", "--in-memory", "kv", "delete", "mykey"];
1166        let cli = Cli::try_parse_from(args).unwrap();
1167
1168        assert!(matches!(
1169            cli.command,
1170            Some(Command::Kv {
1171                command: Some(KvCommand::Delete { key })
1172            }) if key == "mykey"
1173        ));
1174    }
1175
1176    #[test]
1177    fn test_parse_kv_txn_begin() {
1178        let args = vec!["alopex", "kv", "txn", "begin", "--timeout-secs", "30"];
1179        let cli = Cli::try_parse_from(args).unwrap();
1180
1181        assert!(matches!(
1182            cli.command,
1183            Some(Command::Kv {
1184                command: Some(KvCommand::Txn(KvTxnCommand::Begin {
1185                    timeout_secs: Some(30)
1186                }))
1187            })
1188        ));
1189    }
1190
1191    #[test]
1192    fn test_parse_kv_txn_get_requires_txn_id() {
1193        let args = vec!["alopex", "kv", "txn", "get", "mykey"];
1194
1195        assert!(Cli::try_parse_from(args).is_err());
1196    }
1197
1198    #[test]
1199    fn test_parse_kv_txn_get() {
1200        let args = vec!["alopex", "kv", "txn", "get", "mykey", "--txn-id", "txn123"];
1201        let cli = Cli::try_parse_from(args).unwrap();
1202
1203        assert!(matches!(
1204            cli.command,
1205            Some(Command::Kv {
1206                command: Some(KvCommand::Txn(KvTxnCommand::Get { key, txn_id }))
1207            }) if key == "mykey" && txn_id == "txn123"
1208        ));
1209    }
1210
1211    #[test]
1212    fn test_parse_kv_list_with_prefix() {
1213        let args = vec!["alopex", "--in-memory", "kv", "list", "--prefix", "user:"];
1214        let cli = Cli::try_parse_from(args).unwrap();
1215
1216        assert!(matches!(
1217            cli.command,
1218            Some(Command::Kv {
1219                command: Some(KvCommand::List { prefix: Some(p) })
1220            }) if p == "user:"
1221        ));
1222    }
1223
1224    #[test]
1225    fn test_parse_sql_from_file() {
1226        let args = vec!["alopex", "--in-memory", "sql", "-f", "query.sql"];
1227        let cli = Cli::try_parse_from(args).unwrap();
1228
1229        assert!(matches!(
1230            cli.command,
1231            Some(Command::Sql(SqlCommand { query: None, file: Some(f), .. })) if f == "query.sql"
1232        ));
1233    }
1234
1235    #[test]
1236    fn test_parse_vector_search() {
1237        let args = vec![
1238            "alopex",
1239            "--in-memory",
1240            "vector",
1241            "search",
1242            "--index",
1243            "my_index",
1244            "--query",
1245            "[1.0,2.0,3.0]",
1246            "-k",
1247            "5",
1248        ];
1249        let cli = Cli::try_parse_from(args).unwrap();
1250
1251        assert!(matches!(
1252            cli.command,
1253            Some(Command::Vector {
1254                command: Some(VectorCommand::Search { index, query, k, progress })
1255            }) if index == "my_index" && query == "[1.0,2.0,3.0]" && k == 5 && !progress
1256        ));
1257    }
1258
1259    #[test]
1260    fn test_parse_vector_upsert() {
1261        let args = vec![
1262            "alopex",
1263            "--in-memory",
1264            "vector",
1265            "upsert",
1266            "--index",
1267            "my_index",
1268            "--key",
1269            "vec1",
1270            "--vector",
1271            "[1.0,2.0,3.0]",
1272        ];
1273        let cli = Cli::try_parse_from(args).unwrap();
1274
1275        assert!(matches!(
1276            cli.command,
1277            Some(Command::Vector {
1278                command: Some(VectorCommand::Upsert { index, key, vector })
1279            }) if index == "my_index" && key == "vec1" && vector == "[1.0,2.0,3.0]"
1280        ));
1281    }
1282
1283    #[test]
1284    fn test_parse_vector_delete() {
1285        let args = vec![
1286            "alopex",
1287            "--in-memory",
1288            "vector",
1289            "delete",
1290            "--index",
1291            "my_index",
1292            "--key",
1293            "vec1",
1294        ];
1295        let cli = Cli::try_parse_from(args).unwrap();
1296
1297        assert!(matches!(
1298            cli.command,
1299            Some(Command::Vector {
1300                command: Some(VectorCommand::Delete { index, key })
1301            }) if index == "my_index" && key == "vec1"
1302        ));
1303    }
1304
1305    #[test]
1306    fn test_parse_hnsw_create() {
1307        let args = vec![
1308            "alopex",
1309            "--in-memory",
1310            "hnsw",
1311            "create",
1312            "my_index",
1313            "--dim",
1314            "128",
1315            "--metric",
1316            "l2",
1317        ];
1318        let cli = Cli::try_parse_from(args).unwrap();
1319
1320        assert!(matches!(
1321            cli.command,
1322            Some(Command::Hnsw {
1323                command: Some(HnswCommand::Create { name, dim, metric })
1324            }) if name == "my_index" && dim == 128 && metric == DistanceMetric::L2
1325        ));
1326    }
1327
1328    #[test]
1329    fn test_parse_hnsw_create_default_metric() {
1330        let args = vec![
1331            "alopex",
1332            "--in-memory",
1333            "hnsw",
1334            "create",
1335            "my_index",
1336            "--dim",
1337            "128",
1338        ];
1339        let cli = Cli::try_parse_from(args).unwrap();
1340
1341        assert!(matches!(
1342            cli.command,
1343            Some(Command::Hnsw {
1344                command: Some(HnswCommand::Create { name, dim, metric })
1345            }) if name == "my_index" && dim == 128 && metric == DistanceMetric::Cosine
1346        ));
1347    }
1348
1349    #[test]
1350    fn test_parse_columnar_scan() {
1351        let args = vec![
1352            "alopex",
1353            "--in-memory",
1354            "columnar",
1355            "scan",
1356            "--segment",
1357            "seg_001",
1358        ];
1359        let cli = Cli::try_parse_from(args).unwrap();
1360
1361        assert!(matches!(
1362            cli.command,
1363            Some(Command::Columnar {
1364                command: Some(ColumnarCommand::Scan { segment, progress })
1365            }) if segment == "seg_001" && !progress
1366        ));
1367    }
1368
1369    #[test]
1370    fn test_parse_columnar_stats() {
1371        let args = vec![
1372            "alopex",
1373            "--in-memory",
1374            "columnar",
1375            "stats",
1376            "--segment",
1377            "seg_001",
1378        ];
1379        let cli = Cli::try_parse_from(args).unwrap();
1380
1381        assert!(matches!(
1382            cli.command,
1383            Some(Command::Columnar {
1384                command: Some(ColumnarCommand::Stats { segment })
1385            }) if segment == "seg_001"
1386        ));
1387    }
1388
1389    #[test]
1390    fn test_parse_columnar_list() {
1391        let args = vec!["alopex", "--in-memory", "columnar", "list"];
1392        let cli = Cli::try_parse_from(args).unwrap();
1393
1394        assert!(matches!(
1395            cli.command,
1396            Some(Command::Columnar {
1397                command: Some(ColumnarCommand::List)
1398            })
1399        ));
1400    }
1401
1402    #[test]
1403    fn test_parse_columnar_ingest_defaults() {
1404        let args = vec![
1405            "alopex",
1406            "--in-memory",
1407            "columnar",
1408            "ingest",
1409            "--file",
1410            "data.csv",
1411            "--table",
1412            "events",
1413        ];
1414        let cli = Cli::try_parse_from(args).unwrap();
1415
1416        assert!(matches!(
1417            cli.command,
1418            Some(Command::Columnar {
1419                command: Some(ColumnarCommand::Ingest {
1420                    file,
1421                    table,
1422                    delimiter,
1423                    header,
1424                    compression,
1425                    row_group_size,
1426                })
1427            }) if file == std::path::Path::new("data.csv")
1428                && table == "events"
1429                && delimiter == ','
1430                && header
1431                && compression == "zstd"
1432                && row_group_size.is_none()
1433        ));
1434    }
1435
1436    #[test]
1437    fn test_parse_columnar_ingest_custom_options() {
1438        let args = vec![
1439            "alopex",
1440            "--in-memory",
1441            "columnar",
1442            "ingest",
1443            "--file",
1444            "data.csv",
1445            "--table",
1446            "events",
1447            "--delimiter",
1448            ";",
1449            "--header",
1450            "false",
1451            "--compression",
1452            "zstd",
1453            "--row-group-size",
1454            "500",
1455        ];
1456        let cli = Cli::try_parse_from(args).unwrap();
1457
1458        assert!(matches!(
1459            cli.command,
1460            Some(Command::Columnar {
1461                command: Some(ColumnarCommand::Ingest {
1462                    file,
1463                    table,
1464                    delimiter,
1465                    header,
1466                    compression,
1467                    row_group_size,
1468                })
1469            }) if file == std::path::Path::new("data.csv")
1470                && table == "events"
1471                && delimiter == ';'
1472                && !header
1473                && compression == "zstd"
1474                && row_group_size == Some(500)
1475        ));
1476    }
1477
1478    #[test]
1479    fn test_parse_columnar_index_create() {
1480        let args = vec![
1481            "alopex",
1482            "--in-memory",
1483            "columnar",
1484            "index",
1485            "create",
1486            "--segment",
1487            "123:1",
1488            "--column",
1489            "col1",
1490            "--type",
1491            "bloom",
1492        ];
1493        let cli = Cli::try_parse_from(args).unwrap();
1494
1495        assert!(matches!(
1496            cli.command,
1497            Some(Command::Columnar {
1498                command: Some(ColumnarCommand::Index(IndexCommand::Create {
1499                    segment,
1500                    column,
1501                    index_type,
1502                }))
1503            }) if segment == "123:1"
1504                && column == "col1"
1505                && index_type == "bloom"
1506        ));
1507    }
1508
1509    #[test]
1510    fn test_output_format_supports_streaming() {
1511        assert!(!OutputFormat::Table.supports_streaming());
1512        assert!(OutputFormat::Json.supports_streaming());
1513        assert!(OutputFormat::Jsonl.supports_streaming());
1514        assert!(OutputFormat::Csv.supports_streaming());
1515        assert!(OutputFormat::Tsv.supports_streaming());
1516    }
1517
1518    #[test]
1519    fn test_default_values() {
1520        let args = vec!["alopex", "--in-memory", "kv", "list"];
1521        let cli = Cli::try_parse_from(args).unwrap();
1522
1523        assert_eq!(cli.output_format(), OutputFormat::Table);
1524        assert!(!cli.output_is_explicit());
1525        assert_eq!(cli.thread_mode, ThreadMode::Multi);
1526        assert!(cli.limit.is_none());
1527        assert!(!cli.quiet);
1528        assert!(!cli.verbose);
1529    }
1530
1531    #[test]
1532    fn test_s3_data_dir() {
1533        let args = vec![
1534            "alopex",
1535            "--data-dir",
1536            "s3://my-bucket/prefix",
1537            "kv",
1538            "list",
1539        ];
1540        let cli = Cli::try_parse_from(args).unwrap();
1541
1542        assert_eq!(cli.data_dir, Some("s3://my-bucket/prefix".to_string()));
1543    }
1544}