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