1use 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#[derive(Parser, Debug)]
26#[command(name = "alopex")]
27#[command(version, about, long_about = None)]
28pub struct Cli {
29 #[arg(long)]
31 pub data_dir: Option<String>,
32
33 #[arg(long)]
35 pub profile: Option<String>,
36
37 #[arg(long, conflicts_with = "data_dir")]
39 pub in_memory: bool,
40
41 #[arg(long, value_enum)]
43 pub output: Option<OutputFormat>,
44
45 #[arg(long)]
47 pub limit: Option<usize>,
48
49 #[arg(long)]
51 pub quiet: bool,
52
53 #[arg(long)]
55 pub verbose: bool,
56
57 #[arg(long)]
59 pub insecure: bool,
60
61 #[arg(long, value_enum, default_value = "multi")]
63 pub thread_mode: ThreadMode,
64
65 #[arg(long, short = 'b')]
67 pub batch: bool,
68
69 #[arg(long)]
71 pub yes: bool,
72
73 #[command(subcommand)]
75 pub command: Option<Command>,
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
80pub enum OutputFormat {
81 Table,
83 Json,
85 Jsonl,
87 Csv,
89 Tsv,
91}
92
93#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
109pub enum RoutingReportFormat {
110 Human,
111 Json,
112}
113
114impl OutputFormat {
115 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
134pub enum ThreadMode {
135 Multi,
137 Single,
139}
140
141#[derive(Subcommand, Debug)]
143pub enum Command {
144 Profile {
146 #[command(subcommand)]
147 command: Option<ProfileCommand>,
148 },
149 Kv {
151 #[command(subcommand)]
152 command: Option<KvCommand>,
153 },
154 Sql(SqlCommand),
156 Vector {
158 #[command(subcommand)]
159 command: Option<VectorCommand>,
160 },
161 Hnsw {
163 #[command(subcommand)]
164 command: Option<HnswCommand>,
165 },
166 Columnar {
168 #[command(subcommand)]
169 command: Option<ColumnarCommand>,
170 },
171 Server {
173 #[command(subcommand)]
174 command: Option<ServerCommand>,
175 },
176 Lifecycle {
178 #[command(subcommand)]
179 command: Option<LifecycleCommand>,
180 },
181 Version,
183 Completions {
185 #[arg(value_parser = parse_shell, value_name = "SHELL")]
187 shell: Shell,
188 },
189}
190
191#[derive(Subcommand, Debug, Clone)]
193pub enum ProfileCommand {
194 Create {
196 name: String,
198 #[arg(long)]
200 data_dir: String,
201 },
202 List,
204 Show {
206 name: String,
208 },
209 Delete {
211 name: String,
213 },
214 SetDefault {
216 name: String,
218 },
219}
220
221#[derive(Subcommand, Debug)]
223pub enum KvCommand {
224 Get {
226 key: String,
228 },
229 Put {
231 key: String,
233 value: String,
235 },
236 Delete {
238 key: String,
240 },
241 List {
243 #[arg(long)]
245 prefix: Option<String>,
246 },
247 Search {
249 #[arg(long, value_enum)]
251 mode: KvSearchMode,
252 pattern: String,
254 #[arg(long, requires = "mode")]
256 pattern_hex: bool,
257 #[arg(long)]
259 cursor_hex: Option<String>,
260 #[arg(long, default_value_t = 100)]
262 page_size: usize,
263 #[arg(long, default_value_t = 10_000)]
265 scan_budget: usize,
266 #[arg(long, default_value_t = 16 * 1024 * 1024)]
268 max_bytes: usize,
269 },
270 #[command(subcommand)]
272 Txn(KvTxnCommand),
273}
274
275#[derive(Clone, Copy, Debug, ValueEnum)]
277pub enum KvSearchMode {
278 Glob,
280 Regex,
282}
283
284#[derive(Subcommand, Debug)]
286pub enum KvTxnCommand {
287 Begin {
289 #[arg(long)]
291 timeout_secs: Option<u64>,
292 },
293 Get {
295 key: String,
297 #[arg(long)]
299 txn_id: String,
300 },
301 Put {
303 key: String,
305 value: String,
307 #[arg(long)]
309 txn_id: String,
310 },
311 Delete {
313 key: String,
315 #[arg(long)]
317 txn_id: String,
318 },
319 Commit {
321 #[arg(long)]
323 txn_id: String,
324 },
325 Rollback {
327 #[arg(long)]
329 txn_id: String,
330 },
331}
332
333#[derive(Parser, Debug)]
341pub struct SqlCommand {
342 #[arg(conflicts_with = "file")]
344 pub query: Option<String>,
345
346 #[arg(long, short = 'f')]
348 pub file: Option<String>,
349
350 #[arg(long)]
352 pub fetch_size: Option<usize>,
353
354 #[arg(long)]
356 pub max_rows: Option<usize>,
357
358 #[arg(long)]
360 pub deadline: Option<String>,
361
362 #[arg(long, value_enum)]
364 pub read_mode: Option<SqlReadMode>,
365
366 #[arg(long, value_enum)]
369 pub routing_report: Option<RoutingReportFormat>,
370
371 #[arg(long)]
373 pub tui: bool,
374}
375
376#[derive(Subcommand, Debug)]
378pub enum VectorCommand {
379 Search {
381 #[arg(long)]
383 index: String,
384 #[arg(long)]
386 query: String,
387 #[arg(long, short = 'k', default_value = "10")]
389 k: usize,
390 #[arg(long)]
392 progress: bool,
393 },
394 Upsert {
396 #[arg(long)]
398 index: String,
399 #[arg(long)]
401 key: String,
402 #[arg(long)]
404 vector: String,
405 },
406 Delete {
408 #[arg(long)]
410 index: String,
411 #[arg(long)]
413 key: String,
414 },
415}
416
417#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Default)]
419pub enum DistanceMetric {
420 #[default]
422 Cosine,
423 L2,
425 Ip,
427}
428
429#[derive(Subcommand, Debug)]
431pub enum HnswCommand {
432 Create {
434 name: String,
436 #[arg(long)]
438 dim: usize,
439 #[arg(long, value_enum, default_value = "cosine")]
441 metric: DistanceMetric,
442 },
443 Stats {
445 name: String,
447 },
448 Drop {
450 name: String,
452 },
453}
454
455#[derive(Subcommand, Debug)]
457pub enum ColumnarCommand {
458 Scan {
460 #[arg(long)]
462 segment: String,
463 #[arg(long)]
465 progress: bool,
466 },
467 Stats {
469 #[arg(long)]
471 segment: String,
472 },
473 List,
475 Ingest {
477 #[arg(long)]
479 file: PathBuf,
480 #[arg(long)]
482 table: String,
483 #[arg(long, default_value = ",", value_parser = clap::value_parser!(char))]
485 delimiter: char,
486 #[arg(
488 long,
489 default_value = "true",
490 value_parser = clap::value_parser!(bool),
491 action = clap::ArgAction::Set
492 )]
493 header: bool,
494 #[arg(long, default_value = "zstd")]
496 compression: String,
497 #[arg(long)]
499 row_group_size: Option<usize>,
500 },
501 #[command(subcommand)]
503 Index(IndexCommand),
504}
505
506#[derive(Subcommand, Debug)]
508pub enum IndexCommand {
509 Create {
511 #[arg(long)]
513 segment: String,
514 #[arg(long)]
516 column: String,
517 #[arg(long = "type")]
519 index_type: String,
520 },
521 List {
523 #[arg(long)]
525 segment: String,
526 },
527 Drop {
529 #[arg(long)]
531 segment: String,
532 #[arg(long)]
534 column: String,
535 },
536}
537
538#[derive(Subcommand, Debug)]
540pub enum ServerCommand {
541 Status,
543 Metrics,
545 Health,
547 Join,
549 Leave,
551 Compaction {
553 #[command(subcommand)]
554 command: CompactionCommand,
555 },
556 Cluster {
558 #[command(subcommand)]
559 command: ClusterCommand,
560 },
561}
562
563#[derive(Args, Debug)]
567pub struct ClusterOperationRequest {
568 #[arg(long, value_name = "REQUEST_ID")]
570 pub request_id: String,
571 #[arg(long)]
573 pub expected_version: Option<u64>,
574}
575
576#[derive(Args, Debug)]
578pub struct ClusterTargetedReadRequest {
579 #[command(flatten)]
580 pub operation: ClusterOperationRequest,
581 #[arg(long, value_name = "JSON")]
583 pub target: String,
584}
585
586#[derive(Args, Debug)]
590pub struct ClusterMutationRequest {
591 #[command(flatten)]
592 pub operation: ClusterOperationRequest,
593 #[arg(long, value_name = "JSON")]
595 pub target: String,
596 #[arg(long, required = true)]
598 pub confirm: bool,
599}
600
601#[derive(Subcommand, Debug)]
603pub enum ClusterCommand {
604 Metadata {
606 #[command(subcommand)]
607 command: ClusterMetadataCommand,
608 },
609 #[command(visible_alias = "member")]
611 Members {
612 #[command(subcommand)]
613 command: ClusterMembersCommand,
614 },
615 #[command(visible_alias = "range")]
617 Ranges {
618 #[command(subcommand)]
619 command: ClusterRangesCommand,
620 },
621 Placement {
623 #[command(subcommand)]
624 command: ClusterPlacementCommand,
625 },
626 ReadPolicy {
628 #[command(subcommand)]
629 command: ClusterReadPolicyCommand,
630 },
631 Schema {
633 #[command(subcommand)]
634 command: ClusterSchemaCommand,
635 },
636 Recovery {
638 #[command(subcommand)]
639 command: ClusterRecoveryCommand,
640 },
641 Upgrade {
643 #[command(subcommand)]
644 command: ClusterUpgradeCommand,
645 },
646}
647
648#[derive(Subcommand, Debug)]
650pub enum ClusterMetadataCommand {
651 Show {
653 #[command(flatten)]
654 request: ClusterOperationRequest,
655 },
656}
657
658#[derive(Subcommand, Debug)]
660pub enum ClusterMembersCommand {
661 List {
663 #[command(flatten)]
664 request: ClusterOperationRequest,
665 },
666 Replace {
668 #[command(flatten)]
669 request: ClusterMutationRequest,
670 },
671}
672
673#[derive(Subcommand, Debug)]
675pub enum ClusterRangesCommand {
676 List {
678 #[command(flatten)]
679 request: ClusterOperationRequest,
680 },
681 Show {
683 #[command(flatten)]
684 request: ClusterTargetedReadRequest,
685 },
686 Register {
688 #[command(flatten)]
689 request: ClusterMutationRequest,
690 },
691 Update {
693 #[command(flatten)]
694 request: ClusterMutationRequest,
695 },
696 Retire {
698 #[command(flatten)]
699 request: ClusterMutationRequest,
700 },
701}
702
703#[derive(Subcommand, Debug)]
705pub enum ClusterPlacementCommand {
706 Get {
708 #[command(flatten)]
709 request: ClusterTargetedReadRequest,
710 },
711 Set {
713 #[command(flatten)]
714 request: ClusterMutationRequest,
715 },
716 Replace {
718 #[command(flatten)]
719 request: ClusterMutationRequest,
720 },
721}
722
723#[derive(Subcommand, Debug)]
725pub enum ClusterReadPolicyCommand {
726 Get {
728 #[command(flatten)]
729 request: ClusterOperationRequest,
730 },
731 Set {
733 #[command(flatten)]
734 request: ClusterMutationRequest,
735 },
736}
737
738#[derive(Subcommand, Debug)]
740pub enum ClusterSchemaCommand {
741 Owner {
743 #[command(subcommand)]
744 command: ClusterSchemaOwnerCommand,
745 },
746 Rollout {
748 #[command(subcommand)]
749 command: ClusterSchemaRolloutCommand,
750 },
751}
752
753#[derive(Subcommand, Debug)]
755pub enum ClusterSchemaOwnerCommand {
756 Get {
758 #[command(flatten)]
759 request: ClusterOperationRequest,
760 },
761 Set {
763 #[command(flatten)]
764 request: ClusterMutationRequest,
765 },
766}
767
768#[derive(Subcommand, Debug)]
770pub enum ClusterSchemaRolloutCommand {
771 Start {
773 #[command(flatten)]
774 request: ClusterMutationRequest,
775 },
776 Status {
778 #[command(flatten)]
779 request: ClusterOperationRequest,
780 },
781}
782
783#[derive(Subcommand, Debug)]
785pub enum ClusterRecoveryCommand {
786 Status {
788 #[command(flatten)]
789 request: ClusterOperationRequest,
790 },
791 Restore {
793 #[command(flatten)]
794 request: ClusterMutationRequest,
795 },
796}
797
798#[derive(Subcommand, Debug)]
800pub enum ClusterUpgradeCommand {
801 Status {
803 #[command(flatten)]
804 request: ClusterOperationRequest,
805 },
806 Start {
808 #[command(flatten)]
809 request: ClusterMutationRequest,
810 },
811}
812
813#[derive(Subcommand, Debug)]
815pub enum LifecycleCommand {
816 Archive,
818 Restore {
820 #[arg(long)]
822 source: Option<String>,
823 #[command(subcommand)]
825 command: Option<LifecycleRestoreCommand>,
826 },
827 Backup {
829 #[command(subcommand)]
831 command: Option<LifecycleBackupCommand>,
832 },
833 Export,
835}
836
837#[derive(Subcommand, Debug)]
839pub enum LifecycleBackupCommand {
840 Status {
842 #[arg(long)]
844 handle: String,
845 },
846}
847
848#[derive(Subcommand, Debug)]
850pub enum LifecycleRestoreCommand {
851 Status {
853 #[arg(long)]
855 handle: String,
856 },
857}
858
859#[derive(Subcommand, Debug)]
861pub enum CompactionCommand {
862 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}