1use clap::{Parser, Subcommand};
2
3pub const DEFAULT_CHAT_MODEL: &str = "llama3.2-3b-instruct";
11
12pub const DEFAULT_GENERATE_MODEL: &str = "llama3.2-1b-instruct";
14
15#[derive(Parser)]
16#[command(name = "kjarni")]
17#[command(about = "Kjarni: The SQLite of AI", long_about = None)]
18#[command(version)]
19pub struct Cli {
20 #[command(subcommand)]
21 pub command: Commands,
22
23 #[arg(short, long, action = clap::ArgAction::Count, global = true)]
25 pub verbose: u8,
26}
27
28#[derive(Subcommand, Debug, PartialEq)]
29pub enum Commands {
30 Model {
32 #[command(subcommand)]
33 action: ModelCommands,
34 },
35
36 Generate {
38 prompt: Option<String>,
40
41 #[arg(short, long, default_value = DEFAULT_GENERATE_MODEL)]
42 model: String,
43
44 #[arg(long)]
46 model_path: Option<String>,
47
48 #[arg(short = 'n', long, default_value_t = 100)]
50 max_tokens: usize,
51
52 #[arg(short, long, default_value_t = 0.7)]
54 temperature: f32,
55
56 #[arg(long)]
58 top_k: Option<usize>,
59
60 #[arg(long)]
62 top_p: Option<f32>,
63
64 #[arg(long)]
66 min_p: Option<f32>,
67
68 #[arg(long, default_value_t = 1.1)]
70 repetition_penalty: f32,
71
72 #[arg(long)]
74 greedy: bool,
75
76 #[arg(long)]
78 gpu: bool,
79
80 #[arg(long)]
82 no_stream: bool,
83
84 #[arg(short, long)]
86 quiet: bool,
87
88 #[arg(long)]
92 draft: Option<String>,
93
94 #[arg(long, default_value_t = 4)]
97 draft_tokens: usize,
98 },
99
100 Summarize {
102 #[arg(short, long)]
104 input: Option<String>,
105
106 #[arg(short, long, default_value = "distilbart-cnn")]
108 model: String,
109
110 #[arg(long)]
113 model_path: Option<String>,
114
115 #[arg(long)]
117 min_length: Option<usize>,
118
119 #[arg(long)]
121 max_length: Option<usize>,
122
123 #[arg(long)]
125 num_beams: Option<usize>,
126
127 #[arg(long)]
129 length_penalty: Option<f32>,
130
131 #[arg(long)]
133 no_repeat_ngram: Option<usize>,
134
135 #[arg(long)]
137 greedy: bool,
138
139 #[arg(long)]
141 no_stream: bool,
142
143 #[arg(long)]
145 gpu: bool,
146
147 #[arg(short, long)]
149 quiet: bool,
150 },
151
152 Translate {
154 #[arg(short, long)]
156 input: Option<String>,
157
158 #[arg(short, long, default_value = "flan-t5-base")]
160 model: String,
161
162 #[arg(long)]
165 model_path: Option<String>,
166
167 #[arg(long)]
169 src: Option<String>,
170
171 #[arg(long)]
173 dst: Option<String>,
174
175 #[arg(long)]
177 max_length: Option<usize>,
178
179 #[arg(long)]
181 num_beams: Option<usize>,
182
183 #[arg(long)]
185 length_penalty: Option<f32>,
186
187 #[arg(long)]
189 no_repeat_ngram: Option<usize>,
190
191 #[arg(long)]
193 greedy: bool,
194
195 #[arg(long)]
197 no_stream: bool,
198
199 #[arg(long)]
201 gpu: bool,
202
203 #[arg(short, long)]
205 quiet: bool,
206 },
207
208 Inspect {
210 path: String,
212 },
213
214 Embed {
216 input: Option<String>,
218
219 #[arg(short, long, default_value = "minilm-l6-v2")]
221 model: String,
222
223 #[arg(long)]
225 model_path: Option<String>,
226
227 #[arg(long, default_value = "raw")]
229 format: String,
230
231 #[arg(long)]
233 normalize: bool,
234
235 #[arg(long, default_value = "mean")]
242 pooling: String,
243
244 #[arg(long)]
246 gpu: bool,
247
248 #[arg(short, long)]
250 quiet: bool,
251 },
252
253 Transcribe {
255 file: String,
257
258 #[arg(short, long, default_value = "whisper-small")]
260 model: String,
261
262 #[arg(long)]
265 model_path: Option<String>,
266
267 #[arg(short, long)]
269 language: Option<String>,
270
271 #[arg(long)]
273 translate: bool,
274
275 #[arg(short, long)]
277 timestamps: bool,
278
279 #[arg(long)]
281 max_tokens: Option<usize>,
282
283 #[arg(long)]
285 no_stream: bool,
286
287 #[arg(long)]
289 gpu: bool,
290
291 #[arg(short, long)]
293 quiet: bool,
294 },
295
296 Classify {
298 input: Vec<String>,
300
301 #[arg(short, long, default_value = "distilbert-sentiment")]
303 model: String,
304
305 #[arg(long, value_name = "PATH")]
307 model_path: Option<String>,
308
309 #[arg(long, value_name = "LABELS")]
312 labels: Option<String>,
313
314 #[arg(long, default_value = "5")]
316 top_k: usize,
317
318 #[arg(long)]
320 threshold: Option<f32>,
321
322 #[arg(long)]
324 max_length: Option<usize>,
325
326 #[arg(long)]
328 batch_size: Option<usize>,
329
330 #[arg(long)]
332 multi_label: bool,
333
334 #[arg(short, long, default_value = "text")]
336 format: String,
337
338 #[arg(long)]
340 gpu: bool,
341
342 #[arg(long)]
344 dtype: Option<String>,
345
346 #[arg(short, long)]
348 quiet: bool,
349 },
350
351 Rerank {
353 query: String,
355
356 documents: Vec<String>,
358
359 #[arg(short, long, default_value = "minilm-l6-v2-cross-encoder")]
360 model: String,
361
362 #[arg(long)]
364 model_path: Option<String>,
365
366 #[arg(short = 'k', long)]
368 top_k: Option<usize>,
369
370 #[arg(short, long, default_value = "text")]
372 format: String,
373
374 #[arg(long)]
376 gpu: bool,
377
378 #[arg(short, long)]
380 quiet: bool,
381 },
382
383 Chat {
385 #[arg(short, long, default_value = DEFAULT_CHAT_MODEL)]
386 model: String,
387
388 #[arg(long)]
390 model_path: Option<String>,
391
392 #[arg(short, long)]
394 system: Option<String>,
395
396 #[arg(short, long, default_value_t = 0.7)]
398 temperature: f32,
399
400 #[arg(short = 'n', long, default_value_t = 512)]
402 max_tokens: usize,
403
404 #[arg(long)]
406 gpu: bool,
407
408 #[arg(short, long)]
410 quiet: bool,
411
412 #[arg(long)]
415 draft: Option<String>,
416
417 #[arg(long, default_value_t = 4)]
419 draft_tokens: usize,
420 },
421
422 Index {
424 #[command(subcommand)]
425 action: IndexCommands,
426 },
427
428 #[cfg(feature = "image-io")]
430 Image {
431 #[command(subcommand)]
432 action: ImageCommands,
433 },
434
435 Search {
437 index_path: String,
439
440 query: String,
442
443 #[arg(short = 'k', long, default_value_t = 10)]
445 top_k: usize,
446
447 #[arg(long, default_value = "hybrid")]
449 mode: String,
450
451 #[arg(short, long, default_value = "minilm-l6-v2")]
453 model: String,
454
455 #[arg(long, default_value = None)]
459 rerank_model: Option<String>,
460
461 #[arg(short, long, default_value = "text")]
463 format: String,
464
465 #[arg(long)]
467 gpu: bool,
468
469 #[arg(short, long)]
471 quiet: bool,
472 },
473
474 Similarity {
476 text1: String,
478
479 text2: String,
481
482 #[arg(short, long, default_value = "minilm-l6-v2")]
484 model: String,
485
486 #[arg(long)]
488 gpu: bool,
489
490 #[arg(short, long)]
492 quiet: bool,
493 },
494}
495
496#[derive(Subcommand, Debug, PartialEq)]
497pub enum ModelCommands {
498 List {
500 #[arg(short, long)]
502 arch: Option<String>,
503
504 #[arg(short, long)]
506 task: Option<String>,
507
508 #[arg(short, long)]
510 downloaded: bool,
511 },
512
513 Download {
515 name: String,
516
517 #[arg(long)]
518 gguf: bool,
519
520 #[arg(short, long)]
521 quiet: bool,
522 },
523
524 Remove { name: String },
526
527 Info { name: String },
529
530 Search { query: String },
532}
533
534#[derive(Subcommand, Debug, PartialEq)]
535pub enum InspectCommands {
536 Model {
538 path: String,
540 },
541}
542
543#[cfg(feature = "image-io")]
549#[derive(Subcommand, Debug, PartialEq)]
550pub enum ImageCommands {
551 Index {
553 #[arg(required = true)]
555 inputs: Vec<String>,
556
557 #[arg(short, long, default_value = "images.json")]
559 output: String,
560
561 #[arg(short, long)]
563 quiet: bool,
564 },
565
566 Search {
568 index_path: String,
570
571 query: String,
573
574 #[arg(short = 'k', long, default_value_t = 5)]
576 top_k: usize,
577
578 #[arg(short, long)]
580 quiet: bool,
581 },
582}
583
584#[derive(Subcommand, Debug, PartialEq)]
585pub enum IndexCommands {
586 Create {
588 output: String,
590
591 #[arg(conflicts_with = "from_chunks")]
593 inputs: Vec<String>,
594
595 #[arg(long, conflicts_with = "inputs")]
597 from_chunks: Option<String>,
598
599 #[arg(long, default_value_t = 512)]
606 chunk_size: usize,
607
608 #[arg(long, default_value_t = 100)]
610 chunk_overlap: usize,
611
612 #[arg(short, long, default_value = "minilm-l6-v2")]
614 model: String,
615
616 #[arg(long)]
618 gpu: bool,
619
620 #[arg(short, long)]
622 quiet: bool,
623 },
624
625 Add {
627 index_path: String,
629
630 inputs: Vec<String>,
632
633 #[arg(long, default_value_t = 512)]
637 chunk_size: usize,
638
639 #[arg(long, default_value_t = 100)]
640 chunk_overlap: usize,
641
642 #[arg(short, long, default_value = "minilm-l6-v2")]
643 model: String,
644
645 #[arg(long)]
647 gpu: bool,
648
649 #[arg(short, long)]
651 quiet: bool,
652 },
653
654 Info {
656 index_path: String,
658 },
659}
660
661pub fn verbosity_to_log_level(verbose: u8) -> &'static str {
663 match verbose {
664 0 => "warn",
665 1 => "info",
666 2 => "debug",
667 _ => "trace",
668 }
669}
670
671#[cfg(test)]
672mod tests {
673 use super::*;
674 use clap::Parser;
675
676 #[test]
686 fn hardcoded_model_names_resolve() {
687 use kjarni::ModelType;
688
689 let mut bad = Vec::new();
690
691 for name in [DEFAULT_CHAT_MODEL, DEFAULT_GENERATE_MODEL] {
692 if ModelType::from_cli_name(name).is_none() {
693 bad.push(format!("CLI default '{name}'"));
694 }
695 }
696
697 for name in kjarni::chat::suggested_models() {
698 if ModelType::from_cli_name(name).is_none() {
699 bad.push(format!("chat suggestion '{name}'"));
700 }
701 }
702
703 assert!(
704 bad.is_empty(),
705 "these names are not in the model registry, so anyone who uses them \
706 gets an error:\n {}\n\
707 Run `kjarni model list` for the valid names.",
708 bad.join("\n ")
709 );
710 }
711
712 #[test]
717 fn default_chat_model_is_a_chat_model() {
718 assert!(
719 kjarni::chat::is_chat_model(DEFAULT_CHAT_MODEL).is_ok(),
720 "DEFAULT_CHAT_MODEL '{DEFAULT_CHAT_MODEL}' cannot be used for chat"
721 );
722 }
723 fn parse_args(args: &[&str]) -> Result<Cli, clap::Error> {
724 let mut full_args = vec!["kjarni"];
725 full_args.extend(args);
726 Cli::try_parse_from(full_args)
727 }
728
729 #[test]
730 fn test_verbosity_to_log_level_zero() {
731 assert_eq!(verbosity_to_log_level(0), "warn");
732 }
733
734 #[test]
735 fn test_verbosity_to_log_level_one() {
736 assert_eq!(verbosity_to_log_level(1), "info");
737 }
738
739 #[test]
740 fn test_verbosity_to_log_level_two() {
741 assert_eq!(verbosity_to_log_level(2), "debug");
742 }
743
744 #[test]
745 fn test_verbosity_to_log_level_three() {
746 assert_eq!(verbosity_to_log_level(3), "trace");
747 }
748
749 #[test]
750 fn test_verbosity_to_log_level_high() {
751 assert_eq!(verbosity_to_log_level(10), "trace");
752 assert_eq!(verbosity_to_log_level(255), "trace");
753 }
754
755 #[test]
756 fn test_generate_minimal() {
757 let cli = parse_args(&["generate"]).unwrap();
758
759 match cli.command {
760 Commands::Generate {
761 prompt,
762 model,
763 max_tokens,
764 temperature,
765 greedy,
766 gpu,
767 quiet,
768 ..
769 } => {
770 assert!(prompt.is_none());
771 assert_eq!(model, DEFAULT_GENERATE_MODEL);
772 assert_eq!(max_tokens, 100);
773 assert!((temperature - 0.7).abs() < 0.001);
774 assert!(!greedy);
775 assert!(!gpu);
776 assert!(!quiet);
777 }
778 _ => panic!("Expected Generate command"),
779 }
780 }
781
782 #[test]
783 fn test_generate_with_prompt() {
784 let cli = parse_args(&["generate", "Hello world"]).unwrap();
785
786 match cli.command {
787 Commands::Generate { prompt, .. } => {
788 assert_eq!(prompt, Some("Hello world".to_string()));
789 }
790 _ => panic!("Expected Generate command"),
791 }
792 }
793
794 #[test]
795 fn test_generate_with_model() {
796 let cli = parse_args(&["generate", "-m", "phi3.5-mini"]).unwrap();
797
798 match cli.command {
799 Commands::Generate { model, .. } => {
800 assert_eq!(model, "phi3.5-mini");
801 }
802 _ => panic!("Expected Generate command"),
803 }
804 }
805
806 #[test]
807 fn test_generate_with_max_tokens() {
808 let cli = parse_args(&["generate", "-n", "500"]).unwrap();
809
810 match cli.command {
811 Commands::Generate { max_tokens, .. } => {
812 assert_eq!(max_tokens, 500);
813 }
814 _ => panic!("Expected Generate command"),
815 }
816 }
817
818 #[test]
819 fn test_generate_with_temperature() {
820 let cli = parse_args(&["generate", "-t", "1.5"]).unwrap();
821
822 match cli.command {
823 Commands::Generate { temperature, .. } => {
824 assert!((temperature - 1.5).abs() < 0.001);
825 }
826 _ => panic!("Expected Generate command"),
827 }
828 }
829
830 #[test]
831 fn test_generate_with_sampling_params() {
832 let cli = parse_args(&[
833 "generate", "--top-k", "50", "--top-p", "0.9", "--min-p", "0.05",
834 ])
835 .unwrap();
836
837 match cli.command {
838 Commands::Generate {
839 top_k,
840 top_p,
841 min_p,
842 ..
843 } => {
844 assert_eq!(top_k, Some(50));
845 assert_eq!(top_p, Some(0.9));
846 assert_eq!(min_p, Some(0.05));
847 }
848 _ => panic!("Expected Generate command"),
849 }
850 }
851
852 #[test]
853 fn test_generate_with_greedy() {
854 let cli = parse_args(&["generate", "--greedy"]).unwrap();
855
856 match cli.command {
857 Commands::Generate { greedy, .. } => {
858 assert!(greedy);
859 }
860 _ => panic!("Expected Generate command"),
861 }
862 }
863
864 #[test]
865 fn test_generate_with_gpu() {
866 let cli = parse_args(&["generate", "--gpu"]).unwrap();
867
868 match cli.command {
869 Commands::Generate { gpu, .. } => {
870 assert!(gpu);
871 }
872 _ => panic!("Expected Generate command"),
873 }
874 }
875
876 #[test]
877 fn test_generate_with_no_stream() {
878 let cli = parse_args(&["generate", "--no-stream"]).unwrap();
879
880 match cli.command {
881 Commands::Generate { no_stream, .. } => {
882 assert!(no_stream);
883 }
884 _ => panic!("Expected Generate command"),
885 }
886 }
887
888 #[test]
889 fn test_generate_with_quiet() {
890 let cli = parse_args(&["generate", "-q"]).unwrap();
891
892 match cli.command {
893 Commands::Generate { quiet, .. } => {
894 assert!(quiet);
895 }
896 _ => panic!("Expected Generate command"),
897 }
898 }
899
900 #[test]
901 fn test_generate_all_options() {
902 let cli = parse_args(&[
903 "generate",
904 "test prompt",
905 "-m",
906 "llama3.2-3b-instruct",
907 "-n",
908 "256",
909 "-t",
910 "0.8",
911 "--top-k",
912 "40",
913 "--top-p",
914 "0.95",
915 "--repetition-penalty",
916 "1.2",
917 "--greedy",
918 "--gpu",
919 "--no-stream",
920 "-q",
921 ])
922 .unwrap();
923
924 match cli.command {
925 Commands::Generate {
926 prompt,
927 model,
928 max_tokens,
929 temperature,
930 top_k,
931 top_p,
932 repetition_penalty,
933 greedy,
934 gpu,
935 no_stream,
936 quiet,
937 ..
938 } => {
939 assert_eq!(prompt, Some("test prompt".to_string()));
940 assert_eq!(model, "llama3.2-3b-instruct");
941 assert_eq!(max_tokens, 256);
942 assert!((temperature - 0.8).abs() < 0.001);
943 assert_eq!(top_k, Some(40));
944 assert_eq!(top_p, Some(0.95));
945 assert!((repetition_penalty - 1.2).abs() < 0.001);
946 assert!(greedy);
947 assert!(gpu);
948 assert!(no_stream);
949 assert!(quiet);
950 }
951 _ => panic!("Expected Generate command"),
952 }
953 }
954
955 #[test]
956 fn test_chat_defaults() {
957 let cli = parse_args(&["chat"]).unwrap();
958
959 match cli.command {
960 Commands::Chat {
961 model,
962 system,
963 temperature,
964 max_tokens,
965 gpu,
966 quiet,
967 ..
968 } => {
969 assert_eq!(model, DEFAULT_CHAT_MODEL);
970 assert!(system.is_none());
971 assert!((temperature - 0.7).abs() < 0.001);
972 assert_eq!(max_tokens, 512);
973 assert!(!gpu);
974 assert!(!quiet);
975 }
976 _ => panic!("Expected Chat command"),
977 }
978 }
979
980 #[test]
981 fn test_chat_with_system_prompt() {
982 let cli = parse_args(&["chat", "-s", "You are a helpful assistant"]).unwrap();
983
984 match cli.command {
985 Commands::Chat { system, .. } => {
986 assert_eq!(system, Some("You are a helpful assistant".to_string()));
987 }
988 _ => panic!("Expected Chat command"),
989 }
990 }
991
992 #[test]
993 fn test_chat_with_model() {
994 let cli = parse_args(&["chat", "-m", "phi3.5-mini"]).unwrap();
995
996 match cli.command {
997 Commands::Chat { model, .. } => {
998 assert_eq!(model, "phi3.5-mini");
999 }
1000 _ => panic!("Expected Chat command"),
1001 }
1002 }
1003
1004 #[test]
1005 fn test_classify_defaults() {
1006 let cli = parse_args(&["classify"]).unwrap();
1007
1008 match cli.command {
1009 Commands::Classify {
1010 input,
1011 model,
1012 top_k,
1013 format,
1014 multi_label,
1015 gpu,
1016 quiet,
1017 ..
1018 } => {
1019 assert!(input.is_empty());
1020 assert_eq!(model, "distilbert-sentiment");
1021 assert_eq!(top_k, 5);
1022 assert_eq!(format, "text");
1023 assert!(!multi_label);
1024 assert!(!gpu);
1025 assert!(!quiet);
1026 }
1027 _ => panic!("Expected Classify command"),
1028 }
1029 }
1030
1031 #[test]
1032 fn test_classify_with_input() {
1033 let cli = parse_args(&["classify", "This is great!"]).unwrap();
1034
1035 match cli.command {
1036 Commands::Classify { input, .. } => {
1037 assert_eq!(input, vec!["This is great!".to_string()]);
1038 }
1039 _ => panic!("Expected Classify command"),
1040 }
1041 }
1042
1043 #[test]
1044 fn test_classify_with_multiple_inputs() {
1045 let cli = parse_args(&["classify", "text one", "text two", "text three"]).unwrap();
1046
1047 match cli.command {
1048 Commands::Classify { input, .. } => {
1049 assert_eq!(input.len(), 3);
1050 assert_eq!(input[0], "text one");
1051 assert_eq!(input[1], "text two");
1052 assert_eq!(input[2], "text three");
1053 }
1054 _ => panic!("Expected Classify command"),
1055 }
1056 }
1057
1058 #[test]
1059 fn test_classify_with_labels() {
1060 let cli = parse_args(&["classify", "--labels", "bad,good"]).unwrap();
1061
1062 match cli.command {
1063 Commands::Classify { labels, .. } => {
1064 assert_eq!(labels, Some("bad,good".to_string()));
1065 }
1066 _ => panic!("Expected Classify command"),
1067 }
1068 }
1069
1070 #[test]
1071 fn test_classify_with_multi_label() {
1072 let cli = parse_args(&["classify", "--multi-label"]).unwrap();
1073
1074 match cli.command {
1075 Commands::Classify { multi_label, .. } => {
1076 assert!(multi_label);
1077 }
1078 _ => panic!("Expected Classify command"),
1079 }
1080 }
1081
1082 #[test]
1083 fn test_search_minimal() {
1084 let cli = parse_args(&["search", "./index", "my query"]).unwrap();
1085
1086 match cli.command {
1087 Commands::Search {
1088 index_path,
1089 query,
1090 top_k,
1091 mode,
1092 model,
1093 format,
1094 ..
1095 } => {
1096 assert_eq!(index_path, "./index");
1097 assert_eq!(query, "my query");
1098 assert_eq!(top_k, 10);
1099 assert_eq!(mode, "hybrid");
1100 assert_eq!(model, "minilm-l6-v2");
1101 assert_eq!(format, "text");
1102 }
1103 _ => panic!("Expected Search command"),
1104 }
1105 }
1106
1107 #[test]
1108 fn test_search_with_top_k() {
1109 let cli = parse_args(&["search", "./index", "query", "-k", "20"]).unwrap();
1110
1111 match cli.command {
1112 Commands::Search { top_k, .. } => {
1113 assert_eq!(top_k, 20);
1114 }
1115 _ => panic!("Expected Search command"),
1116 }
1117 }
1118
1119 #[test]
1120 fn test_search_with_mode() {
1121 let cli = parse_args(&["search", "./index", "query", "--mode", "semantic"]).unwrap();
1122
1123 match cli.command {
1124 Commands::Search { mode, .. } => {
1125 assert_eq!(mode, "semantic");
1126 }
1127 _ => panic!("Expected Search command"),
1128 }
1129 }
1130
1131 #[test]
1132 fn test_search_with_rerank_model() {
1133 let cli = parse_args(&[
1134 "search",
1135 "./index",
1136 "query",
1137 "--rerank-model",
1138 "ms-marco-minilm",
1139 ])
1140 .unwrap();
1141
1142 match cli.command {
1143 Commands::Search { rerank_model, .. } => {
1144 assert_eq!(rerank_model, Some("ms-marco-minilm".to_string()));
1145 }
1146 _ => panic!("Expected Search command"),
1147 }
1148 }
1149
1150 #[test]
1151 fn test_similarity_minimal() {
1152 let cli = parse_args(&["similarity", "text one", "text two"]).unwrap();
1153
1154 match cli.command {
1155 Commands::Similarity {
1156 text1,
1157 text2,
1158 model,
1159 gpu,
1160 quiet,
1161 } => {
1162 assert_eq!(text1, "text one");
1163 assert_eq!(text2, "text two");
1164 assert_eq!(model, "minilm-l6-v2");
1165 assert!(!gpu);
1166 assert!(!quiet);
1167 }
1168 _ => panic!("Expected Similarity command"),
1169 }
1170 }
1171
1172 #[test]
1173 fn test_model_list_defaults() {
1174 let cli = parse_args(&["model", "list"]).unwrap();
1175
1176 match cli.command {
1177 Commands::Model {
1178 action:
1179 ModelCommands::List {
1180 arch,
1181 task,
1182 downloaded,
1183 },
1184 } => {
1185 assert!(arch.is_none());
1186 assert!(task.is_none());
1187 assert!(!downloaded);
1188 }
1189 _ => panic!("Expected Model List command"),
1190 }
1191 }
1192
1193 #[test]
1194 fn test_model_list_with_filters() {
1195 let cli = parse_args(&[
1196 "model",
1197 "list",
1198 "--arch",
1199 "bert",
1200 "--task",
1201 "embedding",
1202 "--downloaded",
1203 ])
1204 .unwrap();
1205
1206 match cli.command {
1207 Commands::Model {
1208 action:
1209 ModelCommands::List {
1210 arch,
1211 task,
1212 downloaded,
1213 },
1214 } => {
1215 assert_eq!(arch, Some("bert".to_string()));
1216 assert_eq!(task, Some("embedding".to_string()));
1217 assert!(downloaded);
1218 }
1219 _ => panic!("Expected Model List command"),
1220 }
1221 }
1222
1223 #[test]
1224 fn test_model_download() {
1225 let cli = parse_args(&["model", "download", "minilm-l6-v2"]).unwrap();
1226
1227 match cli.command {
1228 Commands::Model {
1229 action: ModelCommands::Download { name, gguf, quiet },
1230 } => {
1231 assert_eq!(name, "minilm-l6-v2");
1232 assert!(!gguf);
1233 assert!(!quiet);
1234 }
1235 _ => panic!("Expected Model Download command"),
1236 }
1237 }
1238
1239 #[test]
1240 fn test_model_download_gguf() {
1241 let cli = parse_args(&["model", "download", "llama3.2-1b", "--gguf"]).unwrap();
1242
1243 match cli.command {
1244 Commands::Model {
1245 action: ModelCommands::Download { name, gguf, .. },
1246 } => {
1247 assert_eq!(name, "llama3.2-1b");
1248 assert!(gguf);
1249 }
1250 _ => panic!("Expected Model Download command"),
1251 }
1252 }
1253
1254 #[test]
1255 fn test_model_info() {
1256 let cli = parse_args(&["model", "info", "phi3.5-mini"]).unwrap();
1257
1258 match cli.command {
1259 Commands::Model {
1260 action: ModelCommands::Info { name },
1261 } => {
1262 assert_eq!(name, "phi3.5-mini");
1263 }
1264 _ => panic!("Expected Model Info command"),
1265 }
1266 }
1267
1268 #[test]
1269 fn test_model_remove() {
1270 let cli = parse_args(&["model", "remove", "old-model"]).unwrap();
1271
1272 match cli.command {
1273 Commands::Model {
1274 action: ModelCommands::Remove { name },
1275 } => {
1276 assert_eq!(name, "old-model");
1277 }
1278 _ => panic!("Expected Model Remove command"),
1279 }
1280 }
1281
1282 #[test]
1283 fn test_model_search() {
1284 let cli = parse_args(&["model", "search", "llama"]).unwrap();
1285
1286 match cli.command {
1287 Commands::Model {
1288 action: ModelCommands::Search { query },
1289 } => {
1290 assert_eq!(query, "llama");
1291 }
1292 _ => panic!("Expected Model Search command"),
1293 }
1294 }
1295
1296 #[test]
1297 fn test_index_create_minimal() {
1298 let cli = parse_args(&["index", "create", "output.idx"]).unwrap();
1299
1300 match cli.command {
1301 Commands::Index {
1302 action:
1303 IndexCommands::Create {
1304 output,
1305 inputs,
1306 chunk_size,
1307 chunk_overlap,
1308 model,
1309 ..
1310 },
1311 } => {
1312 assert_eq!(output, "output.idx");
1313 assert!(inputs.is_empty());
1314 assert_eq!(chunk_size, 512);
1315 assert_eq!(chunk_overlap, 100);
1316 assert_eq!(model, "minilm-l6-v2");
1317 }
1318 _ => panic!("Expected Index Create command"),
1319 }
1320 }
1321
1322 #[test]
1323 fn test_index_create_with_inputs() {
1324 let cli = parse_args(&[
1325 "index",
1326 "create",
1327 "out.idx",
1328 "file1.txt",
1329 "file2.txt",
1330 "dir/",
1331 ])
1332 .unwrap();
1333
1334 match cli.command {
1335 Commands::Index {
1336 action: IndexCommands::Create { inputs, .. },
1337 } => {
1338 assert_eq!(inputs.len(), 3);
1339 assert_eq!(inputs[0], "file1.txt");
1340 assert_eq!(inputs[1], "file2.txt");
1341 assert_eq!(inputs[2], "dir/");
1342 }
1343 _ => panic!("Expected Index Create command"),
1344 }
1345 }
1346
1347 #[test]
1348 fn test_index_create_with_options() {
1349 let cli = parse_args(&[
1350 "index",
1351 "create",
1352 "out.idx",
1353 "--chunk-size",
1354 "500",
1355 "--chunk-overlap",
1356 "100",
1357 "-m",
1358 "nomic-embed-text",
1359 "--gpu",
1360 "-q",
1361 ])
1362 .unwrap();
1363
1364 match cli.command {
1365 Commands::Index {
1366 action:
1367 IndexCommands::Create {
1368 chunk_size,
1369 chunk_overlap,
1370 model,
1371 gpu,
1372 quiet,
1373 ..
1374 },
1375 } => {
1376 assert_eq!(chunk_size, 500);
1377 assert_eq!(chunk_overlap, 100);
1378 assert_eq!(model, "nomic-embed-text");
1379 assert!(gpu);
1380 assert!(quiet);
1381 }
1382 _ => panic!("Expected Index Create command"),
1383 }
1384 }
1385
1386 #[test]
1387 fn test_index_add() {
1388 let cli = parse_args(&["index", "add", "existing.idx", "newfile.txt"]).unwrap();
1389
1390 match cli.command {
1391 Commands::Index {
1392 action:
1393 IndexCommands::Add {
1394 index_path, inputs, ..
1395 },
1396 } => {
1397 assert_eq!(index_path, "existing.idx");
1398 assert_eq!(inputs, vec!["newfile.txt".to_string()]);
1399 }
1400 _ => panic!("Expected Index Add command"),
1401 }
1402 }
1403
1404 #[test]
1405 fn test_index_info() {
1406 let cli = parse_args(&["index", "info", "my.idx"]).unwrap();
1407
1408 match cli.command {
1409 Commands::Index {
1410 action: IndexCommands::Info { index_path },
1411 } => {
1412 assert_eq!(index_path, "my.idx");
1413 }
1414 _ => panic!("Expected Index Info command"),
1415 }
1416 }
1417
1418 #[test]
1419 fn test_verbose_zero() {
1420 let cli = parse_args(&["generate"]).unwrap();
1421 assert_eq!(cli.verbose, 0);
1422 }
1423
1424 #[test]
1425 fn test_verbose_one() {
1426 let cli = parse_args(&["-v", "generate"]).unwrap();
1427 assert_eq!(cli.verbose, 1);
1428 }
1429
1430 #[test]
1431 fn test_verbose_two() {
1432 let cli = parse_args(&["-vv", "generate"]).unwrap();
1433 assert_eq!(cli.verbose, 2);
1434 }
1435
1436 #[test]
1437 fn test_verbose_three() {
1438 let cli = parse_args(&["-vvv", "generate"]).unwrap();
1439 assert_eq!(cli.verbose, 3);
1440 }
1441
1442 #[test]
1443 fn test_verbose_long_form() {
1444 let cli = parse_args(&["--verbose", "--verbose", "generate"]).unwrap();
1445 assert_eq!(cli.verbose, 2);
1446 }
1447
1448 #[test]
1449 fn test_verbose_after_command() {
1450 let cli = parse_args(&["generate", "-v"]).unwrap();
1452 assert_eq!(cli.verbose, 1);
1453 }
1454
1455 #[test]
1456 fn test_missing_command() {
1457 let result = parse_args(&[]);
1458 assert!(result.is_err());
1459 }
1460
1461 #[test]
1462 fn test_unknown_command() {
1463 let result = parse_args(&["unknown"]);
1464 assert!(result.is_err());
1465 }
1466
1467 #[test]
1468 fn test_missing_required_arg() {
1469 let result = parse_args(&["transcribe"]);
1471 assert!(result.is_err());
1472 }
1473
1474 #[test]
1475 fn test_invalid_number() {
1476 let result = parse_args(&["generate", "-n", "not_a_number"]);
1477 assert!(result.is_err());
1478 }
1479
1480 #[test]
1481 fn test_invalid_float() {
1482 let result = parse_args(&["generate", "-t", "not_a_float"]);
1483 assert!(result.is_err());
1484 }
1485
1486 #[test]
1487 fn test_rerank_minimal() {
1488 let cli = parse_args(&["rerank", "my query"]).unwrap();
1489
1490 match cli.command {
1491 Commands::Rerank {
1492 query,
1493 documents,
1494 model,
1495 top_k,
1496 format,
1497 ..
1498 } => {
1499 assert_eq!(query, "my query");
1500 assert!(documents.is_empty());
1501 assert_eq!(model, "minilm-l6-v2-cross-encoder");
1502 assert!(top_k.is_none());
1503 assert_eq!(format, "text");
1504 }
1505 _ => panic!("Expected Rerank command"),
1506 }
1507 }
1508
1509 #[test]
1510 fn test_rerank_with_documents() {
1511 let cli = parse_args(&["rerank", "query", "doc1", "doc2", "doc3"]).unwrap();
1512
1513 match cli.command {
1514 Commands::Rerank {
1515 query, documents, ..
1516 } => {
1517 assert_eq!(query, "query");
1518 assert_eq!(documents.len(), 3);
1519 }
1520 _ => panic!("Expected Rerank command"),
1521 }
1522 }
1523
1524 #[test]
1525 fn test_rerank_with_top_k() {
1526 let cli = parse_args(&["rerank", "query", "-k", "5"]).unwrap();
1527
1528 match cli.command {
1529 Commands::Rerank { top_k, .. } => {
1530 assert_eq!(top_k, Some(5));
1531 }
1532 _ => panic!("Expected Rerank command"),
1533 }
1534 }
1535
1536 #[test]
1537 fn test_summarize_defaults() {
1538 let cli = parse_args(&["summarize"]).unwrap();
1539
1540 match cli.command {
1541 Commands::Summarize {
1542 input,
1543 model,
1544 min_length,
1545 max_length,
1546 num_beams,
1547 ..
1548 } => {
1549 assert!(input.is_none());
1550 assert_eq!(model, "distilbart-cnn");
1551 assert!(min_length.is_none());
1552 assert!(max_length.is_none());
1553 assert!(num_beams.is_none());
1554 }
1555 _ => panic!("Expected Summarize command"),
1556 }
1557 }
1558
1559 #[test]
1560 fn test_summarize_with_options() {
1561 let cli = parse_args(&[
1562 "summarize",
1563 "--input",
1564 "input.txt",
1565 "--min-length",
1566 "50",
1567 "--max-length",
1568 "200",
1569 "--num-beams",
1570 "4",
1571 "--length-penalty",
1572 "1.5",
1573 ])
1574 .unwrap();
1575
1576 match cli.command {
1577 Commands::Summarize {
1578 input,
1579 min_length,
1580 max_length,
1581 num_beams,
1582 length_penalty,
1583 ..
1584 } => {
1585 assert_eq!(input, Some("input.txt".to_string()));
1586 assert_eq!(min_length, Some(50));
1587 assert_eq!(max_length, Some(200));
1588 assert_eq!(num_beams, Some(4));
1589 assert_eq!(length_penalty, Some(1.5));
1590 }
1591 _ => panic!("Expected Summarize command"),
1592 }
1593 }
1594
1595 #[test]
1596 fn test_translate_defaults() {
1597 let cli = parse_args(&["translate"]).unwrap();
1598
1599 match cli.command {
1600 Commands::Translate {
1601 input,
1602 model,
1603 src,
1604 dst,
1605 ..
1606 } => {
1607 assert!(input.is_none());
1608 assert_eq!(model, "flan-t5-base");
1609 assert!(src.is_none());
1610 assert!(dst.is_none());
1611 }
1612 _ => panic!("Expected Translate command"),
1613 }
1614 }
1615
1616 #[test]
1617 fn test_translate_with_languages() {
1618 let cli = parse_args(&["translate", "--src", "en", "--dst", "is"]).unwrap();
1619
1620 match cli.command {
1621 Commands::Translate { src, dst, .. } => {
1622 assert_eq!(src, Some("en".to_string()));
1623 assert_eq!(dst, Some("is".to_string()));
1624 }
1625 _ => panic!("Expected Translate command"),
1626 }
1627 }
1628 #[test]
1629 fn test_transcribe_minimal() {
1630 let cli = parse_args(&["transcribe", "audio.wav"]).unwrap();
1631
1632 match cli.command {
1633 Commands::Transcribe {
1634 file,
1635 model,
1636 language,
1637 ..
1638 } => {
1639 assert_eq!(file, "audio.wav");
1640 assert_eq!(model, "whisper-small");
1641 assert!(language.is_none());
1642 }
1643 _ => panic!("Expected Transcribe command"),
1644 }
1645 }
1646
1647 #[test]
1648 fn test_classify_with_model() {
1649 let cli = parse_args(&["classify", "i hate mondays", "--model", "toxic-bert"]).unwrap();
1650 match cli.command {
1651 Commands::Classify { input, model, .. } => {
1652 assert_eq!(input, vec!["i hate mondays".to_string()]);
1653 assert_eq!(model, "toxic-bert");
1654 }
1655 _ => panic!("Expected Classify command"),
1656 }
1657 }
1658
1659 #[test]
1660 fn test_transcribe_with_options() {
1661 let cli = parse_args(&[
1662 "transcribe",
1663 "audio.mp3",
1664 "-m",
1665 "whisper-large-v3",
1666 "--language",
1667 "is",
1668 ])
1669 .unwrap();
1670
1671 match cli.command {
1672 Commands::Transcribe {
1673 file,
1674 model,
1675 language,
1676 ..
1677 } => {
1678 assert_eq!(file, "audio.mp3");
1679 assert_eq!(model, "whisper-large-v3");
1680 assert_eq!(language, Some("is".to_string()));
1681 }
1682 _ => panic!("Expected Transcribe command"),
1683 }
1684 }
1685}
1686
1687mod send_sync_tests {
1688 use kjarni::{
1689 Classifier, Embedder, Indexer, Reranker, Searcher, chat::Chat, generator::Generator,
1690 };
1691 const _: () = {
1693 const fn assert_send<T: Send>() {}
1694 const fn assert_sync<T: Sync>() {}
1695 assert_send::<Embedder>();
1696 assert_sync::<Embedder>();
1697
1698 assert_send::<Indexer>();
1699 assert_sync::<Indexer>();
1700
1701 assert_send::<Searcher>();
1702 assert_sync::<Searcher>();
1703
1704 assert_send::<Reranker>();
1705 assert_sync::<Reranker>();
1706
1707 assert_send::<Generator>();
1708 assert_sync::<Generator>();
1709
1710 assert_send::<Chat>();
1711 assert_sync::<Chat>();
1712
1713 assert_send::<Classifier>();
1714 assert_sync::<Classifier>();
1715 };
1716}