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::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 Version,
137 Completions {
139 #[arg(value_parser = parse_shell, value_name = "SHELL")]
141 shell: Shell,
142 },
143}
144
145#[derive(Subcommand, Debug, Clone)]
147pub enum ProfileCommand {
148 Create {
150 name: String,
152 #[arg(long)]
154 data_dir: String,
155 },
156 List,
158 Show {
160 name: String,
162 },
163 Delete {
165 name: String,
167 },
168 SetDefault {
170 name: String,
172 },
173}
174
175#[derive(Subcommand, Debug)]
177pub enum KvCommand {
178 Get {
180 key: String,
182 },
183 Put {
185 key: String,
187 value: String,
189 },
190 Delete {
192 key: String,
194 },
195 List {
197 #[arg(long)]
199 prefix: Option<String>,
200 },
201 #[command(subcommand)]
203 Txn(KvTxnCommand),
204}
205
206#[derive(Subcommand, Debug)]
208pub enum KvTxnCommand {
209 Begin {
211 #[arg(long)]
213 timeout_secs: Option<u64>,
214 },
215 Get {
217 key: String,
219 #[arg(long)]
221 txn_id: String,
222 },
223 Put {
225 key: String,
227 value: String,
229 #[arg(long)]
231 txn_id: String,
232 },
233 Delete {
235 key: String,
237 #[arg(long)]
239 txn_id: String,
240 },
241 Commit {
243 #[arg(long)]
245 txn_id: String,
246 },
247 Rollback {
249 #[arg(long)]
251 txn_id: String,
252 },
253}
254
255#[derive(Parser, Debug)]
257pub struct SqlCommand {
258 #[arg(conflicts_with = "file")]
260 pub query: Option<String>,
261
262 #[arg(long, short = 'f')]
264 pub file: Option<String>,
265}
266
267#[derive(Subcommand, Debug)]
269pub enum VectorCommand {
270 Search {
272 #[arg(long)]
274 index: String,
275 #[arg(long)]
277 query: String,
278 #[arg(long, short = 'k', default_value = "10")]
280 k: usize,
281 #[arg(long)]
283 progress: bool,
284 },
285 Upsert {
287 #[arg(long)]
289 index: String,
290 #[arg(long)]
292 key: String,
293 #[arg(long)]
295 vector: String,
296 },
297 Delete {
299 #[arg(long)]
301 index: String,
302 #[arg(long)]
304 key: String,
305 },
306}
307
308#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Default)]
310pub enum DistanceMetric {
311 #[default]
313 Cosine,
314 L2,
316 Ip,
318}
319
320#[derive(Subcommand, Debug)]
322pub enum HnswCommand {
323 Create {
325 name: String,
327 #[arg(long)]
329 dim: usize,
330 #[arg(long, value_enum, default_value = "cosine")]
332 metric: DistanceMetric,
333 },
334 Stats {
336 name: String,
338 },
339 Drop {
341 name: String,
343 },
344}
345
346#[derive(Subcommand, Debug)]
348pub enum ColumnarCommand {
349 Scan {
351 #[arg(long)]
353 segment: String,
354 #[arg(long)]
356 progress: bool,
357 },
358 Stats {
360 #[arg(long)]
362 segment: String,
363 },
364 List,
366 Ingest {
368 #[arg(long)]
370 file: PathBuf,
371 #[arg(long)]
373 table: String,
374 #[arg(long, default_value = ",", value_parser = clap::value_parser!(char))]
376 delimiter: char,
377 #[arg(
379 long,
380 default_value = "true",
381 value_parser = clap::value_parser!(bool),
382 action = clap::ArgAction::Set
383 )]
384 header: bool,
385 #[arg(long, default_value = "lz4")]
387 compression: String,
388 #[arg(long)]
390 row_group_size: Option<usize>,
391 },
392 #[command(subcommand)]
394 Index(IndexCommand),
395}
396
397#[derive(Subcommand, Debug)]
399pub enum IndexCommand {
400 Create {
402 #[arg(long)]
404 segment: String,
405 #[arg(long)]
407 column: String,
408 #[arg(long = "type")]
410 index_type: String,
411 },
412 List {
414 #[arg(long)]
416 segment: String,
417 },
418 Drop {
420 #[arg(long)]
422 segment: String,
423 #[arg(long)]
425 column: String,
426 },
427}
428
429#[cfg(test)]
430mod tests {
431 use super::*;
432
433 #[test]
434 fn test_parse_in_memory_kv_get() {
435 let args = vec!["alopex", "--in-memory", "kv", "get", "mykey"];
436 let cli = Cli::try_parse_from(args).unwrap();
437
438 assert!(cli.in_memory);
439 assert!(cli.data_dir.is_none());
440 assert_eq!(cli.output, OutputFormat::Table);
441 assert!(matches!(
442 cli.command,
443 Command::Kv {
444 command: KvCommand::Get { key }
445 } if key == "mykey"
446 ));
447 }
448
449 #[test]
450 fn test_parse_data_dir_sql() {
451 let args = vec![
452 "alopex",
453 "--data-dir",
454 "/path/to/db",
455 "sql",
456 "SELECT * FROM users",
457 ];
458 let cli = Cli::try_parse_from(args).unwrap();
459
460 assert!(!cli.in_memory);
461 assert_eq!(cli.data_dir, Some("/path/to/db".to_string()));
462 assert!(matches!(
463 cli.command,
464 Command::Sql(SqlCommand { query: Some(q), file: None }) if q == "SELECT * FROM users"
465 ));
466 }
467
468 #[test]
469 fn test_parse_output_format() {
470 let args = vec!["alopex", "--in-memory", "--output", "jsonl", "kv", "list"];
471 let cli = Cli::try_parse_from(args).unwrap();
472
473 assert_eq!(cli.output, OutputFormat::Jsonl);
474 }
475
476 #[test]
477 fn test_parse_limit() {
478 let args = vec!["alopex", "--in-memory", "--limit", "100", "kv", "list"];
479 let cli = Cli::try_parse_from(args).unwrap();
480
481 assert_eq!(cli.limit, Some(100));
482 }
483
484 #[test]
485 fn test_parse_verbose_quiet() {
486 let args = vec!["alopex", "--in-memory", "--verbose", "kv", "list"];
487 let cli = Cli::try_parse_from(args).unwrap();
488
489 assert!(cli.verbose);
490 assert!(!cli.quiet);
491 }
492
493 #[test]
494 fn test_parse_thread_mode() {
495 let args = vec![
496 "alopex",
497 "--in-memory",
498 "--thread-mode",
499 "single",
500 "kv",
501 "list",
502 ];
503 let cli = Cli::try_parse_from(args).unwrap();
504
505 assert_eq!(cli.thread_mode, ThreadMode::Single);
506 }
507
508 #[test]
509 fn test_parse_profile_option_batch_yes() {
510 let args = vec![
511 "alopex",
512 "--profile",
513 "dev",
514 "--batch",
515 "--yes",
516 "--in-memory",
517 "kv",
518 "list",
519 ];
520 let cli = Cli::try_parse_from(args).unwrap();
521
522 assert_eq!(cli.profile.as_deref(), Some("dev"));
523 assert!(cli.batch);
524 assert!(cli.yes);
525 }
526
527 #[test]
528 fn test_parse_batch_short_flag() {
529 let args = vec!["alopex", "-b", "--in-memory", "kv", "list"];
530 let cli = Cli::try_parse_from(args).unwrap();
531
532 assert!(cli.batch);
533 }
534
535 #[test]
536 fn test_parse_profile_create_subcommand() {
537 let args = vec![
538 "alopex",
539 "profile",
540 "create",
541 "dev",
542 "--data-dir",
543 "/path/to/db",
544 ];
545 let cli = Cli::try_parse_from(args).unwrap();
546
547 assert!(matches!(
548 cli.command,
549 Command::Profile {
550 command: ProfileCommand::Create { name, data_dir }
551 }
552 if name == "dev" && data_dir == "/path/to/db"
553 ));
554 }
555
556 #[test]
557 fn test_parse_completions_bash() {
558 let args = vec!["alopex", "completions", "bash"];
559 let cli = Cli::try_parse_from(args).unwrap();
560
561 assert!(matches!(
562 cli.command,
563 Command::Completions { shell } if shell == Shell::Bash
564 ));
565 }
566
567 #[test]
568 fn test_parse_completions_pwsh() {
569 let args = vec!["alopex", "completions", "pwsh"];
570 let cli = Cli::try_parse_from(args).unwrap();
571
572 assert!(matches!(
573 cli.command,
574 Command::Completions { shell } if shell == Shell::PowerShell
575 ));
576 }
577
578 #[test]
579 fn test_parse_kv_put() {
580 let args = vec!["alopex", "--in-memory", "kv", "put", "mykey", "myvalue"];
581 let cli = Cli::try_parse_from(args).unwrap();
582
583 assert!(matches!(
584 cli.command,
585 Command::Kv {
586 command: KvCommand::Put { key, value }
587 } if key == "mykey" && value == "myvalue"
588 ));
589 }
590
591 #[test]
592 fn test_parse_kv_delete() {
593 let args = vec!["alopex", "--in-memory", "kv", "delete", "mykey"];
594 let cli = Cli::try_parse_from(args).unwrap();
595
596 assert!(matches!(
597 cli.command,
598 Command::Kv {
599 command: KvCommand::Delete { key }
600 } if key == "mykey"
601 ));
602 }
603
604 #[test]
605 fn test_parse_kv_txn_begin() {
606 let args = vec!["alopex", "kv", "txn", "begin", "--timeout-secs", "30"];
607 let cli = Cli::try_parse_from(args).unwrap();
608
609 assert!(matches!(
610 cli.command,
611 Command::Kv {
612 command: KvCommand::Txn(KvTxnCommand::Begin {
613 timeout_secs: Some(30)
614 })
615 }
616 ));
617 }
618
619 #[test]
620 fn test_parse_kv_txn_get_requires_txn_id() {
621 let args = vec!["alopex", "kv", "txn", "get", "mykey"];
622
623 assert!(Cli::try_parse_from(args).is_err());
624 }
625
626 #[test]
627 fn test_parse_kv_txn_get() {
628 let args = vec!["alopex", "kv", "txn", "get", "mykey", "--txn-id", "txn123"];
629 let cli = Cli::try_parse_from(args).unwrap();
630
631 assert!(matches!(
632 cli.command,
633 Command::Kv {
634 command: KvCommand::Txn(KvTxnCommand::Get { key, txn_id })
635 } if key == "mykey" && txn_id == "txn123"
636 ));
637 }
638
639 #[test]
640 fn test_parse_kv_list_with_prefix() {
641 let args = vec!["alopex", "--in-memory", "kv", "list", "--prefix", "user:"];
642 let cli = Cli::try_parse_from(args).unwrap();
643
644 assert!(matches!(
645 cli.command,
646 Command::Kv {
647 command: KvCommand::List { prefix: Some(p) }
648 } if p == "user:"
649 ));
650 }
651
652 #[test]
653 fn test_parse_sql_from_file() {
654 let args = vec!["alopex", "--in-memory", "sql", "-f", "query.sql"];
655 let cli = Cli::try_parse_from(args).unwrap();
656
657 assert!(matches!(
658 cli.command,
659 Command::Sql(SqlCommand { query: None, file: Some(f) }) if f == "query.sql"
660 ));
661 }
662
663 #[test]
664 fn test_parse_vector_search() {
665 let args = vec![
666 "alopex",
667 "--in-memory",
668 "vector",
669 "search",
670 "--index",
671 "my_index",
672 "--query",
673 "[1.0,2.0,3.0]",
674 "-k",
675 "5",
676 ];
677 let cli = Cli::try_parse_from(args).unwrap();
678
679 assert!(matches!(
680 cli.command,
681 Command::Vector {
682 command: VectorCommand::Search { index, query, k, progress }
683 } if index == "my_index" && query == "[1.0,2.0,3.0]" && k == 5 && !progress
684 ));
685 }
686
687 #[test]
688 fn test_parse_vector_upsert() {
689 let args = vec![
690 "alopex",
691 "--in-memory",
692 "vector",
693 "upsert",
694 "--index",
695 "my_index",
696 "--key",
697 "vec1",
698 "--vector",
699 "[1.0,2.0,3.0]",
700 ];
701 let cli = Cli::try_parse_from(args).unwrap();
702
703 assert!(matches!(
704 cli.command,
705 Command::Vector {
706 command: VectorCommand::Upsert { index, key, vector }
707 } if index == "my_index" && key == "vec1" && vector == "[1.0,2.0,3.0]"
708 ));
709 }
710
711 #[test]
712 fn test_parse_vector_delete() {
713 let args = vec![
714 "alopex",
715 "--in-memory",
716 "vector",
717 "delete",
718 "--index",
719 "my_index",
720 "--key",
721 "vec1",
722 ];
723 let cli = Cli::try_parse_from(args).unwrap();
724
725 assert!(matches!(
726 cli.command,
727 Command::Vector {
728 command: VectorCommand::Delete { index, key }
729 } if index == "my_index" && key == "vec1"
730 ));
731 }
732
733 #[test]
734 fn test_parse_hnsw_create() {
735 let args = vec![
736 "alopex",
737 "--in-memory",
738 "hnsw",
739 "create",
740 "my_index",
741 "--dim",
742 "128",
743 "--metric",
744 "l2",
745 ];
746 let cli = Cli::try_parse_from(args).unwrap();
747
748 assert!(matches!(
749 cli.command,
750 Command::Hnsw {
751 command: HnswCommand::Create { name, dim, metric }
752 } if name == "my_index" && dim == 128 && metric == DistanceMetric::L2
753 ));
754 }
755
756 #[test]
757 fn test_parse_hnsw_create_default_metric() {
758 let args = vec![
759 "alopex",
760 "--in-memory",
761 "hnsw",
762 "create",
763 "my_index",
764 "--dim",
765 "128",
766 ];
767 let cli = Cli::try_parse_from(args).unwrap();
768
769 assert!(matches!(
770 cli.command,
771 Command::Hnsw {
772 command: HnswCommand::Create { name, dim, metric }
773 } if name == "my_index" && dim == 128 && metric == DistanceMetric::Cosine
774 ));
775 }
776
777 #[test]
778 fn test_parse_columnar_scan() {
779 let args = vec![
780 "alopex",
781 "--in-memory",
782 "columnar",
783 "scan",
784 "--segment",
785 "seg_001",
786 ];
787 let cli = Cli::try_parse_from(args).unwrap();
788
789 assert!(matches!(
790 cli.command,
791 Command::Columnar {
792 command: ColumnarCommand::Scan { segment, progress }
793 } if segment == "seg_001" && !progress
794 ));
795 }
796
797 #[test]
798 fn test_parse_columnar_stats() {
799 let args = vec![
800 "alopex",
801 "--in-memory",
802 "columnar",
803 "stats",
804 "--segment",
805 "seg_001",
806 ];
807 let cli = Cli::try_parse_from(args).unwrap();
808
809 assert!(matches!(
810 cli.command,
811 Command::Columnar {
812 command: ColumnarCommand::Stats { segment }
813 } if segment == "seg_001"
814 ));
815 }
816
817 #[test]
818 fn test_parse_columnar_list() {
819 let args = vec!["alopex", "--in-memory", "columnar", "list"];
820 let cli = Cli::try_parse_from(args).unwrap();
821
822 assert!(matches!(
823 cli.command,
824 Command::Columnar {
825 command: ColumnarCommand::List
826 }
827 ));
828 }
829
830 #[test]
831 fn test_parse_columnar_ingest_defaults() {
832 let args = vec![
833 "alopex",
834 "--in-memory",
835 "columnar",
836 "ingest",
837 "--file",
838 "data.csv",
839 "--table",
840 "events",
841 ];
842 let cli = Cli::try_parse_from(args).unwrap();
843
844 assert!(matches!(
845 cli.command,
846 Command::Columnar {
847 command: ColumnarCommand::Ingest {
848 file,
849 table,
850 delimiter,
851 header,
852 compression,
853 row_group_size,
854 }
855 } if file == std::path::Path::new("data.csv")
856 && table == "events"
857 && delimiter == ','
858 && header
859 && compression == "lz4"
860 && row_group_size.is_none()
861 ));
862 }
863
864 #[test]
865 fn test_parse_columnar_ingest_custom_options() {
866 let args = vec![
867 "alopex",
868 "--in-memory",
869 "columnar",
870 "ingest",
871 "--file",
872 "data.csv",
873 "--table",
874 "events",
875 "--delimiter",
876 ";",
877 "--header",
878 "false",
879 "--compression",
880 "zstd",
881 "--row-group-size",
882 "500",
883 ];
884 let cli = Cli::try_parse_from(args).unwrap();
885
886 assert!(matches!(
887 cli.command,
888 Command::Columnar {
889 command: ColumnarCommand::Ingest {
890 file,
891 table,
892 delimiter,
893 header,
894 compression,
895 row_group_size,
896 }
897 } if file == std::path::Path::new("data.csv")
898 && table == "events"
899 && delimiter == ';'
900 && !header
901 && compression == "zstd"
902 && row_group_size == Some(500)
903 ));
904 }
905
906 #[test]
907 fn test_parse_columnar_index_create() {
908 let args = vec![
909 "alopex",
910 "--in-memory",
911 "columnar",
912 "index",
913 "create",
914 "--segment",
915 "123:1",
916 "--column",
917 "col1",
918 "--type",
919 "bloom",
920 ];
921 let cli = Cli::try_parse_from(args).unwrap();
922
923 assert!(matches!(
924 cli.command,
925 Command::Columnar {
926 command: ColumnarCommand::Index(IndexCommand::Create {
927 segment,
928 column,
929 index_type,
930 })
931 } if segment == "123:1"
932 && column == "col1"
933 && index_type == "bloom"
934 ));
935 }
936
937 #[test]
938 fn test_output_format_supports_streaming() {
939 assert!(!OutputFormat::Table.supports_streaming());
940 assert!(!OutputFormat::Json.supports_streaming());
941 assert!(OutputFormat::Jsonl.supports_streaming());
942 assert!(OutputFormat::Csv.supports_streaming());
943 assert!(OutputFormat::Tsv.supports_streaming());
944 }
945
946 #[test]
947 fn test_default_values() {
948 let args = vec!["alopex", "--in-memory", "kv", "list"];
949 let cli = Cli::try_parse_from(args).unwrap();
950
951 assert_eq!(cli.output, OutputFormat::Table);
952 assert_eq!(cli.thread_mode, ThreadMode::Multi);
953 assert!(cli.limit.is_none());
954 assert!(!cli.quiet);
955 assert!(!cli.verbose);
956 }
957
958 #[test]
959 fn test_s3_data_dir() {
960 let args = vec![
961 "alopex",
962 "--data-dir",
963 "s3://my-bucket/prefix",
964 "kv",
965 "list",
966 ];
967 let cli = Cli::try_parse_from(args).unwrap();
968
969 assert_eq!(cli.data_dir, Some("s3://my-bucket/prefix".to_string()));
970 }
971}