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 #[command(subcommand)]
249 Txn(KvTxnCommand),
250}
251
252#[derive(Subcommand, Debug)]
254pub enum KvTxnCommand {
255 Begin {
257 #[arg(long)]
259 timeout_secs: Option<u64>,
260 },
261 Get {
263 key: String,
265 #[arg(long)]
267 txn_id: String,
268 },
269 Put {
271 key: String,
273 value: String,
275 #[arg(long)]
277 txn_id: String,
278 },
279 Delete {
281 key: String,
283 #[arg(long)]
285 txn_id: String,
286 },
287 Commit {
289 #[arg(long)]
291 txn_id: String,
292 },
293 Rollback {
295 #[arg(long)]
297 txn_id: String,
298 },
299}
300
301#[derive(Parser, Debug)]
309pub struct SqlCommand {
310 #[arg(conflicts_with = "file")]
312 pub query: Option<String>,
313
314 #[arg(long, short = 'f')]
316 pub file: Option<String>,
317
318 #[arg(long)]
320 pub fetch_size: Option<usize>,
321
322 #[arg(long)]
324 pub max_rows: Option<usize>,
325
326 #[arg(long)]
328 pub deadline: Option<String>,
329
330 #[arg(long, value_enum)]
332 pub read_mode: Option<SqlReadMode>,
333
334 #[arg(long, value_enum)]
337 pub routing_report: Option<RoutingReportFormat>,
338
339 #[arg(long)]
341 pub tui: bool,
342}
343
344#[derive(Subcommand, Debug)]
346pub enum VectorCommand {
347 Search {
349 #[arg(long)]
351 index: String,
352 #[arg(long)]
354 query: String,
355 #[arg(long, short = 'k', default_value = "10")]
357 k: usize,
358 #[arg(long)]
360 progress: bool,
361 },
362 Upsert {
364 #[arg(long)]
366 index: String,
367 #[arg(long)]
369 key: String,
370 #[arg(long)]
372 vector: String,
373 },
374 Delete {
376 #[arg(long)]
378 index: String,
379 #[arg(long)]
381 key: String,
382 },
383}
384
385#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Default)]
387pub enum DistanceMetric {
388 #[default]
390 Cosine,
391 L2,
393 Ip,
395}
396
397#[derive(Subcommand, Debug)]
399pub enum HnswCommand {
400 Create {
402 name: String,
404 #[arg(long)]
406 dim: usize,
407 #[arg(long, value_enum, default_value = "cosine")]
409 metric: DistanceMetric,
410 },
411 Stats {
413 name: String,
415 },
416 Drop {
418 name: String,
420 },
421}
422
423#[derive(Subcommand, Debug)]
425pub enum ColumnarCommand {
426 Scan {
428 #[arg(long)]
430 segment: String,
431 #[arg(long)]
433 progress: bool,
434 },
435 Stats {
437 #[arg(long)]
439 segment: String,
440 },
441 List,
443 Ingest {
445 #[arg(long)]
447 file: PathBuf,
448 #[arg(long)]
450 table: String,
451 #[arg(long, default_value = ",", value_parser = clap::value_parser!(char))]
453 delimiter: char,
454 #[arg(
456 long,
457 default_value = "true",
458 value_parser = clap::value_parser!(bool),
459 action = clap::ArgAction::Set
460 )]
461 header: bool,
462 #[arg(long, default_value = "zstd")]
464 compression: String,
465 #[arg(long)]
467 row_group_size: Option<usize>,
468 },
469 #[command(subcommand)]
471 Index(IndexCommand),
472}
473
474#[derive(Subcommand, Debug)]
476pub enum IndexCommand {
477 Create {
479 #[arg(long)]
481 segment: String,
482 #[arg(long)]
484 column: String,
485 #[arg(long = "type")]
487 index_type: String,
488 },
489 List {
491 #[arg(long)]
493 segment: String,
494 },
495 Drop {
497 #[arg(long)]
499 segment: String,
500 #[arg(long)]
502 column: String,
503 },
504}
505
506#[derive(Subcommand, Debug)]
508pub enum ServerCommand {
509 Status,
511 Metrics,
513 Health,
515 Join,
517 Leave,
519 Compaction {
521 #[command(subcommand)]
522 command: CompactionCommand,
523 },
524 Cluster {
526 #[command(subcommand)]
527 command: ClusterCommand,
528 },
529}
530
531#[derive(Args, Debug)]
535pub struct ClusterOperationRequest {
536 #[arg(long, value_name = "REQUEST_ID")]
538 pub request_id: String,
539 #[arg(long)]
541 pub expected_version: Option<u64>,
542}
543
544#[derive(Args, Debug)]
546pub struct ClusterTargetedReadRequest {
547 #[command(flatten)]
548 pub operation: ClusterOperationRequest,
549 #[arg(long, value_name = "JSON")]
551 pub target: String,
552}
553
554#[derive(Args, Debug)]
558pub struct ClusterMutationRequest {
559 #[command(flatten)]
560 pub operation: ClusterOperationRequest,
561 #[arg(long, value_name = "JSON")]
563 pub target: String,
564 #[arg(long, required = true)]
566 pub confirm: bool,
567}
568
569#[derive(Subcommand, Debug)]
571pub enum ClusterCommand {
572 Metadata {
574 #[command(subcommand)]
575 command: ClusterMetadataCommand,
576 },
577 #[command(visible_alias = "member")]
579 Members {
580 #[command(subcommand)]
581 command: ClusterMembersCommand,
582 },
583 #[command(visible_alias = "range")]
585 Ranges {
586 #[command(subcommand)]
587 command: ClusterRangesCommand,
588 },
589 Placement {
591 #[command(subcommand)]
592 command: ClusterPlacementCommand,
593 },
594 ReadPolicy {
596 #[command(subcommand)]
597 command: ClusterReadPolicyCommand,
598 },
599 Schema {
601 #[command(subcommand)]
602 command: ClusterSchemaCommand,
603 },
604 Recovery {
606 #[command(subcommand)]
607 command: ClusterRecoveryCommand,
608 },
609 Upgrade {
611 #[command(subcommand)]
612 command: ClusterUpgradeCommand,
613 },
614}
615
616#[derive(Subcommand, Debug)]
618pub enum ClusterMetadataCommand {
619 Show {
621 #[command(flatten)]
622 request: ClusterOperationRequest,
623 },
624}
625
626#[derive(Subcommand, Debug)]
628pub enum ClusterMembersCommand {
629 List {
631 #[command(flatten)]
632 request: ClusterOperationRequest,
633 },
634 Replace {
636 #[command(flatten)]
637 request: ClusterMutationRequest,
638 },
639}
640
641#[derive(Subcommand, Debug)]
643pub enum ClusterRangesCommand {
644 List {
646 #[command(flatten)]
647 request: ClusterOperationRequest,
648 },
649 Show {
651 #[command(flatten)]
652 request: ClusterTargetedReadRequest,
653 },
654 Register {
656 #[command(flatten)]
657 request: ClusterMutationRequest,
658 },
659 Update {
661 #[command(flatten)]
662 request: ClusterMutationRequest,
663 },
664 Retire {
666 #[command(flatten)]
667 request: ClusterMutationRequest,
668 },
669}
670
671#[derive(Subcommand, Debug)]
673pub enum ClusterPlacementCommand {
674 Get {
676 #[command(flatten)]
677 request: ClusterTargetedReadRequest,
678 },
679 Set {
681 #[command(flatten)]
682 request: ClusterMutationRequest,
683 },
684 Replace {
686 #[command(flatten)]
687 request: ClusterMutationRequest,
688 },
689}
690
691#[derive(Subcommand, Debug)]
693pub enum ClusterReadPolicyCommand {
694 Get {
696 #[command(flatten)]
697 request: ClusterOperationRequest,
698 },
699 Set {
701 #[command(flatten)]
702 request: ClusterMutationRequest,
703 },
704}
705
706#[derive(Subcommand, Debug)]
708pub enum ClusterSchemaCommand {
709 Owner {
711 #[command(subcommand)]
712 command: ClusterSchemaOwnerCommand,
713 },
714 Rollout {
716 #[command(subcommand)]
717 command: ClusterSchemaRolloutCommand,
718 },
719}
720
721#[derive(Subcommand, Debug)]
723pub enum ClusterSchemaOwnerCommand {
724 Get {
726 #[command(flatten)]
727 request: ClusterOperationRequest,
728 },
729 Set {
731 #[command(flatten)]
732 request: ClusterMutationRequest,
733 },
734}
735
736#[derive(Subcommand, Debug)]
738pub enum ClusterSchemaRolloutCommand {
739 Start {
741 #[command(flatten)]
742 request: ClusterMutationRequest,
743 },
744 Status {
746 #[command(flatten)]
747 request: ClusterOperationRequest,
748 },
749}
750
751#[derive(Subcommand, Debug)]
753pub enum ClusterRecoveryCommand {
754 Status {
756 #[command(flatten)]
757 request: ClusterOperationRequest,
758 },
759 Restore {
761 #[command(flatten)]
762 request: ClusterMutationRequest,
763 },
764}
765
766#[derive(Subcommand, Debug)]
768pub enum ClusterUpgradeCommand {
769 Status {
771 #[command(flatten)]
772 request: ClusterOperationRequest,
773 },
774 Start {
776 #[command(flatten)]
777 request: ClusterMutationRequest,
778 },
779}
780
781#[derive(Subcommand, Debug)]
783pub enum LifecycleCommand {
784 Archive,
786 Restore {
788 #[arg(long)]
790 source: Option<String>,
791 #[command(subcommand)]
793 command: Option<LifecycleRestoreCommand>,
794 },
795 Backup {
797 #[command(subcommand)]
799 command: Option<LifecycleBackupCommand>,
800 },
801 Export,
803}
804
805#[derive(Subcommand, Debug)]
807pub enum LifecycleBackupCommand {
808 Status {
810 #[arg(long)]
812 handle: String,
813 },
814}
815
816#[derive(Subcommand, Debug)]
818pub enum LifecycleRestoreCommand {
819 Status {
821 #[arg(long)]
823 handle: String,
824 },
825}
826
827#[derive(Subcommand, Debug)]
829pub enum CompactionCommand {
830 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}