1use std::path::PathBuf;
6
7use clap::{Parser, Subcommand, ValueEnum};
8use clap_complete::Shell;
9
10fn parse_shell(value: &str) -> Result<Shell, String> {
11 match value {
12 "bash" => Ok(Shell::Bash),
13 "zsh" => Ok(Shell::Zsh),
14 "fish" => Ok(Shell::Fish),
15 "pwsh" | "powershell" => Ok(Shell::PowerShell),
16 _ => Err(format!(
17 "Unsupported shell: {}. Use bash, zsh, fish, or pwsh.",
18 value
19 )),
20 }
21}
22
23#[derive(Parser, Debug)]
25#[command(name = "alopex")]
26#[command(version, about, long_about = None)]
27pub struct Cli {
28 #[arg(long)]
30 pub data_dir: Option<String>,
31
32 #[arg(long)]
34 pub profile: Option<String>,
35
36 #[arg(long, conflicts_with = "data_dir")]
38 pub in_memory: bool,
39
40 #[arg(long, value_enum, default_value = "table")]
42 pub output: OutputFormat,
43
44 #[arg(long)]
46 pub limit: Option<usize>,
47
48 #[arg(long)]
50 pub quiet: bool,
51
52 #[arg(long)]
54 pub verbose: bool,
55
56 #[arg(long, value_enum, default_value = "multi")]
58 pub thread_mode: ThreadMode,
59
60 #[arg(long, short = 'b')]
62 pub batch: bool,
63
64 #[arg(long)]
66 pub yes: bool,
67
68 #[command(subcommand)]
70 pub command: Command,
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
75pub enum OutputFormat {
76 Table,
78 Json,
80 Jsonl,
82 Csv,
84 Tsv,
86}
87
88impl OutputFormat {
89 #[allow(dead_code)]
91 pub fn supports_streaming(&self) -> bool {
92 matches!(self, Self::Json | Self::Jsonl | Self::Csv | Self::Tsv)
93 }
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
98pub enum ThreadMode {
99 Multi,
101 Single,
103}
104
105#[derive(Subcommand, Debug)]
107pub enum Command {
108 Profile {
110 #[command(subcommand)]
111 command: ProfileCommand,
112 },
113 Kv {
115 #[command(subcommand)]
116 command: KvCommand,
117 },
118 Sql(SqlCommand),
120 Vector {
122 #[command(subcommand)]
123 command: VectorCommand,
124 },
125 Hnsw {
127 #[command(subcommand)]
128 command: HnswCommand,
129 },
130 Columnar {
132 #[command(subcommand)]
133 command: ColumnarCommand,
134 },
135 Server {
137 #[command(subcommand)]
138 command: ServerCommand,
139 },
140 Version,
142 Completions {
144 #[arg(value_parser = parse_shell, value_name = "SHELL")]
146 shell: Shell,
147 },
148}
149
150#[derive(Subcommand, Debug, Clone)]
152pub enum ProfileCommand {
153 Create {
155 name: String,
157 #[arg(long)]
159 data_dir: String,
160 },
161 List,
163 Show {
165 name: String,
167 },
168 Delete {
170 name: String,
172 },
173 SetDefault {
175 name: String,
177 },
178}
179
180#[derive(Subcommand, Debug)]
182pub enum KvCommand {
183 Get {
185 key: String,
187 },
188 Put {
190 key: String,
192 value: String,
194 },
195 Delete {
197 key: String,
199 },
200 List {
202 #[arg(long)]
204 prefix: Option<String>,
205 },
206 #[command(subcommand)]
208 Txn(KvTxnCommand),
209}
210
211#[derive(Subcommand, Debug)]
213pub enum KvTxnCommand {
214 Begin {
216 #[arg(long)]
218 timeout_secs: Option<u64>,
219 },
220 Get {
222 key: String,
224 #[arg(long)]
226 txn_id: String,
227 },
228 Put {
230 key: String,
232 value: String,
234 #[arg(long)]
236 txn_id: String,
237 },
238 Delete {
240 key: String,
242 #[arg(long)]
244 txn_id: String,
245 },
246 Commit {
248 #[arg(long)]
250 txn_id: String,
251 },
252 Rollback {
254 #[arg(long)]
256 txn_id: String,
257 },
258}
259
260#[derive(Parser, Debug)]
262pub struct SqlCommand {
263 #[arg(conflicts_with = "file")]
265 pub query: Option<String>,
266
267 #[arg(long, short = 'f')]
269 pub file: Option<String>,
270
271 #[arg(long)]
273 pub fetch_size: Option<usize>,
274
275 #[arg(long)]
277 pub max_rows: Option<usize>,
278
279 #[arg(long)]
281 pub deadline: Option<String>,
282
283 #[arg(long)]
285 pub tui: bool,
286}
287
288#[derive(Subcommand, Debug)]
290pub enum VectorCommand {
291 Search {
293 #[arg(long)]
295 index: String,
296 #[arg(long)]
298 query: String,
299 #[arg(long, short = 'k', default_value = "10")]
301 k: usize,
302 #[arg(long)]
304 progress: bool,
305 },
306 Upsert {
308 #[arg(long)]
310 index: String,
311 #[arg(long)]
313 key: String,
314 #[arg(long)]
316 vector: String,
317 },
318 Delete {
320 #[arg(long)]
322 index: String,
323 #[arg(long)]
325 key: String,
326 },
327}
328
329#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Default)]
331pub enum DistanceMetric {
332 #[default]
334 Cosine,
335 L2,
337 Ip,
339}
340
341#[derive(Subcommand, Debug)]
343pub enum HnswCommand {
344 Create {
346 name: String,
348 #[arg(long)]
350 dim: usize,
351 #[arg(long, value_enum, default_value = "cosine")]
353 metric: DistanceMetric,
354 },
355 Stats {
357 name: String,
359 },
360 Drop {
362 name: String,
364 },
365}
366
367#[derive(Subcommand, Debug)]
369pub enum ColumnarCommand {
370 Scan {
372 #[arg(long)]
374 segment: String,
375 #[arg(long)]
377 progress: bool,
378 },
379 Stats {
381 #[arg(long)]
383 segment: String,
384 },
385 List,
387 Ingest {
389 #[arg(long)]
391 file: PathBuf,
392 #[arg(long)]
394 table: String,
395 #[arg(long, default_value = ",", value_parser = clap::value_parser!(char))]
397 delimiter: char,
398 #[arg(
400 long,
401 default_value = "true",
402 value_parser = clap::value_parser!(bool),
403 action = clap::ArgAction::Set
404 )]
405 header: bool,
406 #[arg(long, default_value = "lz4")]
408 compression: String,
409 #[arg(long)]
411 row_group_size: Option<usize>,
412 },
413 #[command(subcommand)]
415 Index(IndexCommand),
416}
417
418#[derive(Subcommand, Debug)]
420pub enum IndexCommand {
421 Create {
423 #[arg(long)]
425 segment: String,
426 #[arg(long)]
428 column: String,
429 #[arg(long = "type")]
431 index_type: String,
432 },
433 List {
435 #[arg(long)]
437 segment: String,
438 },
439 Drop {
441 #[arg(long)]
443 segment: String,
444 #[arg(long)]
446 column: String,
447 },
448}
449
450#[derive(Subcommand, Debug)]
452pub enum ServerCommand {
453 Status,
455 Metrics,
457 Health,
459 Compaction {
461 #[command(subcommand)]
462 command: CompactionCommand,
463 },
464}
465
466#[derive(Subcommand, Debug)]
468pub enum CompactionCommand {
469 Trigger,
471}
472
473#[cfg(test)]
474mod tests {
475 use super::*;
476
477 #[test]
478 fn test_parse_in_memory_kv_get() {
479 let args = vec!["alopex", "--in-memory", "kv", "get", "mykey"];
480 let cli = Cli::try_parse_from(args).unwrap();
481
482 assert!(cli.in_memory);
483 assert!(cli.data_dir.is_none());
484 assert_eq!(cli.output, OutputFormat::Table);
485 assert!(matches!(
486 cli.command,
487 Command::Kv {
488 command: KvCommand::Get { key }
489 } if key == "mykey"
490 ));
491 }
492
493 #[test]
494 fn test_parse_data_dir_sql() {
495 let args = vec![
496 "alopex",
497 "--data-dir",
498 "/path/to/db",
499 "sql",
500 "SELECT * FROM users",
501 ];
502 let cli = Cli::try_parse_from(args).unwrap();
503
504 assert!(!cli.in_memory);
505 assert_eq!(cli.data_dir, Some("/path/to/db".to_string()));
506 assert!(matches!(
507 cli.command,
508 Command::Sql(SqlCommand { query: Some(q), file: None, .. }) if q == "SELECT * FROM users"
509 ));
510 }
511
512 #[test]
513 fn test_parse_output_format() {
514 let args = vec!["alopex", "--in-memory", "--output", "jsonl", "kv", "list"];
515 let cli = Cli::try_parse_from(args).unwrap();
516
517 assert_eq!(cli.output, OutputFormat::Jsonl);
518 }
519
520 #[test]
521 fn test_parse_limit() {
522 let args = vec!["alopex", "--in-memory", "--limit", "100", "kv", "list"];
523 let cli = Cli::try_parse_from(args).unwrap();
524
525 assert_eq!(cli.limit, Some(100));
526 }
527
528 #[test]
529 fn test_parse_sql_streaming_options() {
530 let args = vec![
531 "alopex",
532 "sql",
533 "--fetch-size",
534 "500",
535 "--max-rows",
536 "250",
537 "--deadline",
538 "30s",
539 "SELECT 1",
540 ];
541 let cli = Cli::try_parse_from(args).unwrap();
542
543 match cli.command {
544 Command::Sql(cmd) => {
545 assert_eq!(cmd.fetch_size, Some(500));
546 assert_eq!(cmd.max_rows, Some(250));
547 assert_eq!(cmd.deadline.as_deref(), Some("30s"));
548 assert!(!cmd.tui);
549 }
550 _ => panic!("expected sql command"),
551 }
552 }
553
554 #[test]
555 fn test_parse_sql_tui_flag() {
556 let args = vec!["alopex", "sql", "--tui", "SELECT 1"];
557 let cli = Cli::try_parse_from(args).unwrap();
558
559 match cli.command {
560 Command::Sql(cmd) => {
561 assert!(cmd.tui);
562 assert_eq!(cmd.query.as_deref(), Some("SELECT 1"));
563 }
564 _ => panic!("expected sql command"),
565 }
566 }
567
568 #[test]
569 fn test_parse_server_status() {
570 let args = vec!["alopex", "server", "status"];
571 let cli = Cli::try_parse_from(args).unwrap();
572
573 assert!(matches!(
574 cli.command,
575 Command::Server {
576 command: ServerCommand::Status
577 }
578 ));
579 }
580
581 #[test]
582 fn test_parse_server_compaction_trigger() {
583 let args = vec!["alopex", "server", "compaction", "trigger"];
584 let cli = Cli::try_parse_from(args).unwrap();
585
586 assert!(matches!(
587 cli.command,
588 Command::Server {
589 command: ServerCommand::Compaction {
590 command: CompactionCommand::Trigger
591 }
592 }
593 ));
594 }
595
596 #[test]
597 fn test_parse_verbose_quiet() {
598 let args = vec!["alopex", "--in-memory", "--verbose", "kv", "list"];
599 let cli = Cli::try_parse_from(args).unwrap();
600
601 assert!(cli.verbose);
602 assert!(!cli.quiet);
603 }
604
605 #[test]
606 fn test_parse_thread_mode() {
607 let args = vec![
608 "alopex",
609 "--in-memory",
610 "--thread-mode",
611 "single",
612 "kv",
613 "list",
614 ];
615 let cli = Cli::try_parse_from(args).unwrap();
616
617 assert_eq!(cli.thread_mode, ThreadMode::Single);
618 }
619
620 #[test]
621 fn test_parse_profile_option_batch_yes() {
622 let args = vec![
623 "alopex",
624 "--profile",
625 "dev",
626 "--batch",
627 "--yes",
628 "--in-memory",
629 "kv",
630 "list",
631 ];
632 let cli = Cli::try_parse_from(args).unwrap();
633
634 assert_eq!(cli.profile.as_deref(), Some("dev"));
635 assert!(cli.batch);
636 assert!(cli.yes);
637 }
638
639 #[test]
640 fn test_parse_batch_short_flag() {
641 let args = vec!["alopex", "-b", "--in-memory", "kv", "list"];
642 let cli = Cli::try_parse_from(args).unwrap();
643
644 assert!(cli.batch);
645 }
646
647 #[test]
648 fn test_parse_profile_create_subcommand() {
649 let args = vec![
650 "alopex",
651 "profile",
652 "create",
653 "dev",
654 "--data-dir",
655 "/path/to/db",
656 ];
657 let cli = Cli::try_parse_from(args).unwrap();
658
659 assert!(matches!(
660 cli.command,
661 Command::Profile {
662 command: ProfileCommand::Create { name, data_dir }
663 }
664 if name == "dev" && data_dir == "/path/to/db"
665 ));
666 }
667
668 #[test]
669 fn test_parse_completions_bash() {
670 let args = vec!["alopex", "completions", "bash"];
671 let cli = Cli::try_parse_from(args).unwrap();
672
673 assert!(matches!(
674 cli.command,
675 Command::Completions { shell } if shell == Shell::Bash
676 ));
677 }
678
679 #[test]
680 fn test_parse_completions_pwsh() {
681 let args = vec!["alopex", "completions", "pwsh"];
682 let cli = Cli::try_parse_from(args).unwrap();
683
684 assert!(matches!(
685 cli.command,
686 Command::Completions { shell } if shell == Shell::PowerShell
687 ));
688 }
689
690 #[test]
691 fn test_parse_kv_put() {
692 let args = vec!["alopex", "--in-memory", "kv", "put", "mykey", "myvalue"];
693 let cli = Cli::try_parse_from(args).unwrap();
694
695 assert!(matches!(
696 cli.command,
697 Command::Kv {
698 command: KvCommand::Put { key, value }
699 } if key == "mykey" && value == "myvalue"
700 ));
701 }
702
703 #[test]
704 fn test_parse_kv_delete() {
705 let args = vec!["alopex", "--in-memory", "kv", "delete", "mykey"];
706 let cli = Cli::try_parse_from(args).unwrap();
707
708 assert!(matches!(
709 cli.command,
710 Command::Kv {
711 command: KvCommand::Delete { key }
712 } if key == "mykey"
713 ));
714 }
715
716 #[test]
717 fn test_parse_kv_txn_begin() {
718 let args = vec!["alopex", "kv", "txn", "begin", "--timeout-secs", "30"];
719 let cli = Cli::try_parse_from(args).unwrap();
720
721 assert!(matches!(
722 cli.command,
723 Command::Kv {
724 command: KvCommand::Txn(KvTxnCommand::Begin {
725 timeout_secs: Some(30)
726 })
727 }
728 ));
729 }
730
731 #[test]
732 fn test_parse_kv_txn_get_requires_txn_id() {
733 let args = vec!["alopex", "kv", "txn", "get", "mykey"];
734
735 assert!(Cli::try_parse_from(args).is_err());
736 }
737
738 #[test]
739 fn test_parse_kv_txn_get() {
740 let args = vec!["alopex", "kv", "txn", "get", "mykey", "--txn-id", "txn123"];
741 let cli = Cli::try_parse_from(args).unwrap();
742
743 assert!(matches!(
744 cli.command,
745 Command::Kv {
746 command: KvCommand::Txn(KvTxnCommand::Get { key, txn_id })
747 } if key == "mykey" && txn_id == "txn123"
748 ));
749 }
750
751 #[test]
752 fn test_parse_kv_list_with_prefix() {
753 let args = vec!["alopex", "--in-memory", "kv", "list", "--prefix", "user:"];
754 let cli = Cli::try_parse_from(args).unwrap();
755
756 assert!(matches!(
757 cli.command,
758 Command::Kv {
759 command: KvCommand::List { prefix: Some(p) }
760 } if p == "user:"
761 ));
762 }
763
764 #[test]
765 fn test_parse_sql_from_file() {
766 let args = vec!["alopex", "--in-memory", "sql", "-f", "query.sql"];
767 let cli = Cli::try_parse_from(args).unwrap();
768
769 assert!(matches!(
770 cli.command,
771 Command::Sql(SqlCommand { query: None, file: Some(f), .. }) if f == "query.sql"
772 ));
773 }
774
775 #[test]
776 fn test_parse_vector_search() {
777 let args = vec![
778 "alopex",
779 "--in-memory",
780 "vector",
781 "search",
782 "--index",
783 "my_index",
784 "--query",
785 "[1.0,2.0,3.0]",
786 "-k",
787 "5",
788 ];
789 let cli = Cli::try_parse_from(args).unwrap();
790
791 assert!(matches!(
792 cli.command,
793 Command::Vector {
794 command: VectorCommand::Search { index, query, k, progress }
795 } if index == "my_index" && query == "[1.0,2.0,3.0]" && k == 5 && !progress
796 ));
797 }
798
799 #[test]
800 fn test_parse_vector_upsert() {
801 let args = vec![
802 "alopex",
803 "--in-memory",
804 "vector",
805 "upsert",
806 "--index",
807 "my_index",
808 "--key",
809 "vec1",
810 "--vector",
811 "[1.0,2.0,3.0]",
812 ];
813 let cli = Cli::try_parse_from(args).unwrap();
814
815 assert!(matches!(
816 cli.command,
817 Command::Vector {
818 command: VectorCommand::Upsert { index, key, vector }
819 } if index == "my_index" && key == "vec1" && vector == "[1.0,2.0,3.0]"
820 ));
821 }
822
823 #[test]
824 fn test_parse_vector_delete() {
825 let args = vec![
826 "alopex",
827 "--in-memory",
828 "vector",
829 "delete",
830 "--index",
831 "my_index",
832 "--key",
833 "vec1",
834 ];
835 let cli = Cli::try_parse_from(args).unwrap();
836
837 assert!(matches!(
838 cli.command,
839 Command::Vector {
840 command: VectorCommand::Delete { index, key }
841 } if index == "my_index" && key == "vec1"
842 ));
843 }
844
845 #[test]
846 fn test_parse_hnsw_create() {
847 let args = vec![
848 "alopex",
849 "--in-memory",
850 "hnsw",
851 "create",
852 "my_index",
853 "--dim",
854 "128",
855 "--metric",
856 "l2",
857 ];
858 let cli = Cli::try_parse_from(args).unwrap();
859
860 assert!(matches!(
861 cli.command,
862 Command::Hnsw {
863 command: HnswCommand::Create { name, dim, metric }
864 } if name == "my_index" && dim == 128 && metric == DistanceMetric::L2
865 ));
866 }
867
868 #[test]
869 fn test_parse_hnsw_create_default_metric() {
870 let args = vec![
871 "alopex",
872 "--in-memory",
873 "hnsw",
874 "create",
875 "my_index",
876 "--dim",
877 "128",
878 ];
879 let cli = Cli::try_parse_from(args).unwrap();
880
881 assert!(matches!(
882 cli.command,
883 Command::Hnsw {
884 command: HnswCommand::Create { name, dim, metric }
885 } if name == "my_index" && dim == 128 && metric == DistanceMetric::Cosine
886 ));
887 }
888
889 #[test]
890 fn test_parse_columnar_scan() {
891 let args = vec![
892 "alopex",
893 "--in-memory",
894 "columnar",
895 "scan",
896 "--segment",
897 "seg_001",
898 ];
899 let cli = Cli::try_parse_from(args).unwrap();
900
901 assert!(matches!(
902 cli.command,
903 Command::Columnar {
904 command: ColumnarCommand::Scan { segment, progress }
905 } if segment == "seg_001" && !progress
906 ));
907 }
908
909 #[test]
910 fn test_parse_columnar_stats() {
911 let args = vec![
912 "alopex",
913 "--in-memory",
914 "columnar",
915 "stats",
916 "--segment",
917 "seg_001",
918 ];
919 let cli = Cli::try_parse_from(args).unwrap();
920
921 assert!(matches!(
922 cli.command,
923 Command::Columnar {
924 command: ColumnarCommand::Stats { segment }
925 } if segment == "seg_001"
926 ));
927 }
928
929 #[test]
930 fn test_parse_columnar_list() {
931 let args = vec!["alopex", "--in-memory", "columnar", "list"];
932 let cli = Cli::try_parse_from(args).unwrap();
933
934 assert!(matches!(
935 cli.command,
936 Command::Columnar {
937 command: ColumnarCommand::List
938 }
939 ));
940 }
941
942 #[test]
943 fn test_parse_columnar_ingest_defaults() {
944 let args = vec![
945 "alopex",
946 "--in-memory",
947 "columnar",
948 "ingest",
949 "--file",
950 "data.csv",
951 "--table",
952 "events",
953 ];
954 let cli = Cli::try_parse_from(args).unwrap();
955
956 assert!(matches!(
957 cli.command,
958 Command::Columnar {
959 command: ColumnarCommand::Ingest {
960 file,
961 table,
962 delimiter,
963 header,
964 compression,
965 row_group_size,
966 }
967 } if file == std::path::Path::new("data.csv")
968 && table == "events"
969 && delimiter == ','
970 && header
971 && compression == "lz4"
972 && row_group_size.is_none()
973 ));
974 }
975
976 #[test]
977 fn test_parse_columnar_ingest_custom_options() {
978 let args = vec![
979 "alopex",
980 "--in-memory",
981 "columnar",
982 "ingest",
983 "--file",
984 "data.csv",
985 "--table",
986 "events",
987 "--delimiter",
988 ";",
989 "--header",
990 "false",
991 "--compression",
992 "zstd",
993 "--row-group-size",
994 "500",
995 ];
996 let cli = Cli::try_parse_from(args).unwrap();
997
998 assert!(matches!(
999 cli.command,
1000 Command::Columnar {
1001 command: ColumnarCommand::Ingest {
1002 file,
1003 table,
1004 delimiter,
1005 header,
1006 compression,
1007 row_group_size,
1008 }
1009 } if file == std::path::Path::new("data.csv")
1010 && table == "events"
1011 && delimiter == ';'
1012 && !header
1013 && compression == "zstd"
1014 && row_group_size == Some(500)
1015 ));
1016 }
1017
1018 #[test]
1019 fn test_parse_columnar_index_create() {
1020 let args = vec![
1021 "alopex",
1022 "--in-memory",
1023 "columnar",
1024 "index",
1025 "create",
1026 "--segment",
1027 "123:1",
1028 "--column",
1029 "col1",
1030 "--type",
1031 "bloom",
1032 ];
1033 let cli = Cli::try_parse_from(args).unwrap();
1034
1035 assert!(matches!(
1036 cli.command,
1037 Command::Columnar {
1038 command: ColumnarCommand::Index(IndexCommand::Create {
1039 segment,
1040 column,
1041 index_type,
1042 })
1043 } if segment == "123:1"
1044 && column == "col1"
1045 && index_type == "bloom"
1046 ));
1047 }
1048
1049 #[test]
1050 fn test_output_format_supports_streaming() {
1051 assert!(!OutputFormat::Table.supports_streaming());
1052 assert!(OutputFormat::Json.supports_streaming());
1053 assert!(OutputFormat::Jsonl.supports_streaming());
1054 assert!(OutputFormat::Csv.supports_streaming());
1055 assert!(OutputFormat::Tsv.supports_streaming());
1056 }
1057
1058 #[test]
1059 fn test_default_values() {
1060 let args = vec!["alopex", "--in-memory", "kv", "list"];
1061 let cli = Cli::try_parse_from(args).unwrap();
1062
1063 assert_eq!(cli.output, OutputFormat::Table);
1064 assert_eq!(cli.thread_mode, ThreadMode::Multi);
1065 assert!(cli.limit.is_none());
1066 assert!(!cli.quiet);
1067 assert!(!cli.verbose);
1068 }
1069
1070 #[test]
1071 fn test_s3_data_dir() {
1072 let args = vec![
1073 "alopex",
1074 "--data-dir",
1075 "s3://my-bucket/prefix",
1076 "kv",
1077 "list",
1078 ];
1079 let cli = Cli::try_parse_from(args).unwrap();
1080
1081 assert_eq!(cli.data_dir, Some("s3://my-bucket/prefix".to_string()));
1082 }
1083}