Skip to main content

kjarni_cli/
lib.rs

1use clap::{Parser, Subcommand};
2
3/// Default model for `kjarni chat`.
4///
5/// Must be a CLI name that `ModelType::from_cli_name` resolves. Both this and
6/// [`DEFAULT_GENERATE_MODEL`] previously named models that do not exist
7/// (`llama-3.2-8b-instruct`, `llama-3.2-1b`), so the bare `kjarni chat` and
8/// `kjarni generate` commands failed for everyone. `tests::default_models_exist`
9/// now fails the build if either drifts from the registry again.
10pub const DEFAULT_CHAT_MODEL: &str = "llama3.2-3b-instruct";
11
12/// Default model for `kjarni generate`.
13pub 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    /// Verbosity level (-v, -vv, -vvv)
24    #[arg(short, long, action = clap::ArgAction::Count, global = true)]
25    pub verbose: u8,
26}
27
28#[derive(Subcommand, Debug, PartialEq)]
29pub enum Commands {
30    /// Manage models (list, download, info)
31    Model {
32        #[command(subcommand)]
33        action: ModelCommands,
34    },
35
36    /// Generate text from a prompt
37    Generate {
38        /// The prompt (or file path, or stdin if not provided)
39        prompt: Option<String>,
40
41        #[arg(short, long, default_value = DEFAULT_GENERATE_MODEL)]
42        model: String,
43
44        /// Load weights from a local file or directory instead of the registry
45        #[arg(long)]
46        model_path: Option<String>,
47
48        /// Maximum tokens to generate
49        #[arg(short = 'n', long, default_value_t = 100)]
50        max_tokens: usize,
51
52        /// Sampling temperature (0.0 = greedy, higher = more random)
53        #[arg(short, long, default_value_t = 0.7)]
54        temperature: f32,
55
56        /// Top-K sampling (limits to K most likely tokens)
57        #[arg(long)]
58        top_k: Option<usize>,
59
60        /// Top-P (nucleus) sampling threshold
61        #[arg(long)]
62        top_p: Option<f32>,
63
64        /// Min-P sampling threshold  
65        #[arg(long)]
66        min_p: Option<f32>,
67
68        /// Repetition penalty (1.0 = no penalty)
69        #[arg(long, default_value_t = 1.1)]
70        repetition_penalty: f32,
71
72        /// Use greedy decoding (ignores temperature)
73        #[arg(long)]
74        greedy: bool,
75
76        /// Use GPU
77        #[arg(long)]
78        gpu: bool,
79
80        /// Disable streaming output
81        #[arg(long)]
82        no_stream: bool,
83
84        /// Suppress status messages
85        #[arg(short, long)]
86        quiet: bool,
87
88        /// Draft model for speculative decoding. Must share the target's
89        /// vocabulary: a small model of the same family, such as
90        /// qwen2.5-0.5b-instruct drafting for qwen2.5-1.5b.
91        #[arg(long)]
92        draft: Option<String>,
93
94        /// Tokens the draft proposes per round. Higher trades wasted draft work
95        /// against fewer passes over the target's weights.
96        #[arg(long, default_value_t = 4)]
97        draft_tokens: usize,
98    },
99
100    /// Summarize text
101    Summarize {
102        /// Input text (or read from stdin)
103        #[arg(short, long)]
104        input: Option<String>,
105
106        /// Model to use
107        #[arg(short, long, default_value = "distilbart-cnn")]
108        model: String,
109
110        /// Path to local model
111        /// Load weights from a local file or directory instead of the registry
112        #[arg(long)]
113        model_path: Option<String>,
114
115        /// Minimum summary length
116        #[arg(long)]
117        min_length: Option<usize>,
118
119        /// Maximum summary length
120        #[arg(long)]
121        max_length: Option<usize>,
122
123        /// Number of beams for beam search
124        #[arg(long)]
125        num_beams: Option<usize>,
126
127        /// Length penalty for beam search (< 1 shorter, > 1 longer)
128        #[arg(long)]
129        length_penalty: Option<f32>,
130
131        /// Block repeated n-grams of this size
132        #[arg(long)]
133        no_repeat_ngram: Option<usize>,
134
135        /// Use greedy decoding (deterministic, fastest)
136        #[arg(long)]
137        greedy: bool,
138
139        /// Disable streaming output
140        #[arg(long)]
141        no_stream: bool,
142
143        /// Use GPU
144        #[arg(long)]
145        gpu: bool,
146
147        /// Suppress progress messages
148        #[arg(short, long)]
149        quiet: bool,
150    },
151
152    /// Translate text between languages
153    Translate {
154        /// Input text (or read from stdin)
155        #[arg(short, long)]
156        input: Option<String>,
157
158        /// Model to use
159        #[arg(short, long, default_value = "flan-t5-base")]
160        model: String,
161
162        /// Path to local model
163        /// Load weights from a local file or directory instead of the registry
164        #[arg(long)]
165        model_path: Option<String>,
166
167        /// Source language (e.g., en, de, fr)
168        #[arg(long)]
169        src: Option<String>,
170
171        /// Target language (e.g., en, de, fr)
172        #[arg(long)]
173        dst: Option<String>,
174
175        /// Maximum output length
176        #[arg(long)]
177        max_length: Option<usize>,
178
179        /// Number of beams for beam search
180        #[arg(long)]
181        num_beams: Option<usize>,
182
183        /// Length penalty for beam search (< 1 shorter, > 1 longer)
184        #[arg(long)]
185        length_penalty: Option<f32>,
186
187        /// Block repeated n-grams of this size
188        #[arg(long)]
189        no_repeat_ngram: Option<usize>,
190
191        /// Use greedy decoding (deterministic, fastest)
192        #[arg(long)]
193        greedy: bool,
194
195        /// Disable streaming output
196        #[arg(long)]
197        no_stream: bool,
198
199        /// Use GPU
200        #[arg(long)]
201        gpu: bool,
202
203        /// Suppress progress messages
204        #[arg(short, long)]
205        quiet: bool,
206    },
207
208    /// Show a model's metadata, config and tensor layout
209    Inspect {
210        /// Path to a .gguf, a .safetensors directory, or a name in ~/.cache/kjarni
211        path: String,
212    },
213
214    /// Generate embeddings for text
215    Embed {
216        /// Input text, file path, or stdin if not provided
217        input: Option<String>,
218
219        /// Embedding model
220        #[arg(short, long, default_value = "minilm-l6-v2")]
221        model: String,
222
223        /// Load weights from a local file or directory instead of the registry
224        #[arg(long)]
225        model_path: Option<String>,
226
227        /// Output format: raw, json
228        #[arg(long, default_value = "raw")]
229        format: String,
230
231        /// Scale each vector to unit length
232        #[arg(long)]
233        normalize: bool,
234
235        /// Pooling strategy: mean, cls, max
236        ///
237        /// Mean matches what sentence-transformers does for these models, and what the
238        /// library bindings and presets already default to. Pooling changes the vector
239        /// itself, so a different choice here does not produce a slightly different
240        /// answer, it produces one that cannot be compared against the others.
241        #[arg(long, default_value = "mean")]
242        pooling: String,
243
244        /// Run on the GPU
245        #[arg(long)]
246        gpu: bool,
247
248        /// Suppress status messages
249        #[arg(short, long)]
250        quiet: bool,
251    },
252
253    /// Transcribe audio to text
254    Transcribe {
255        /// Path to audio file (wav, mp3, flac, ogg)
256        file: String,
257
258        /// Model to use (whisper-small, whisper-large-v3)
259        #[arg(short, long, default_value = "whisper-small")]
260        model: String,
261
262        /// Path to local model directory (not yet implemented)
263        /// Load weights from a local file or directory instead of the registry
264        #[arg(long)]
265        model_path: Option<String>,
266
267        /// Language code (e.g., en, fr, de). Omit for auto-detect.
268        #[arg(short, long)]
269        language: Option<String>,
270
271        /// Translate to English instead of transcribing
272        #[arg(long)]
273        translate: bool,
274
275        /// Include timestamps in output
276        #[arg(short, long)]
277        timestamps: bool,
278
279        /// Maximum tokens per 30-second chunk
280        #[arg(long)]
281        max_tokens: Option<usize>,
282
283        /// Disable streaming (wait for full result)
284        #[arg(long)]
285        no_stream: bool,
286
287        /// Use GPU acceleration
288        #[arg(long)]
289        gpu: bool,
290
291        /// Suppress progress output
292        #[arg(short, long)]
293        quiet: bool,
294    },
295
296    /// Classify text using a classification model
297    Classify {
298        /// Input text(s) to classify. Use - for stdin.
299        input: Vec<String>,
300
301        /// Model name from registry
302        #[arg(short, long, default_value = "distilbert-sentiment")]
303        model: String,
304
305        /// Load model from local path instead of registry
306        #[arg(long, value_name = "PATH")]
307        model_path: Option<String>,
308
309        /// Custom labels (comma-separated, order must match model output)
310        /// Example: --labels "negative,positive" or --labels "neikvætt,jákvætt"
311        #[arg(long, value_name = "LABELS")]
312        labels: Option<String>,
313
314        /// Return top K predictions
315        #[arg(long, default_value = "5")]
316        top_k: usize,
317
318        /// Minimum confidence threshold (0.0-1.0)
319        #[arg(long)]
320        threshold: Option<f32>,
321
322        /// Maximum sequence length (truncates longer inputs)
323        #[arg(long)]
324        max_length: Option<usize>,
325
326        /// Batch size for inference
327        #[arg(long)]
328        batch_size: Option<usize>,
329
330        /// Use multi-label classification (sigmoid instead of softmax)
331        #[arg(long)]
332        multi_label: bool,
333
334        /// Output format: json, jsonl, text
335        #[arg(short, long, default_value = "text")]
336        format: String,
337
338        /// Run on GPU
339        #[arg(long)]
340        gpu: bool,
341
342        /// Model precision: f32, f16, bf16
343        #[arg(long)]
344        dtype: Option<String>,
345
346        /// Suppress progress output
347        #[arg(short, long)]
348        quiet: bool,
349    },
350
351    /// Rerank documents by relevance to a query
352    Rerank {
353        /// The query to rank against
354        query: String,
355
356        /// Documents to rerank (or read from stdin, one per line)
357        documents: Vec<String>,
358
359        #[arg(short, long, default_value = "minilm-l6-v2-cross-encoder")]
360        model: String,
361
362        /// Load weights from a local file or directory instead of the registry
363        #[arg(long)]
364        model_path: Option<String>,
365
366        /// Return only top K results
367        #[arg(short = 'k', long)]
368        top_k: Option<usize>,
369
370        /// Output format: json, jsonl, text, docs
371        #[arg(short, long, default_value = "text")]
372        format: String,
373
374        /// Run on the GPU
375        #[arg(long)]
376        gpu: bool,
377
378        /// Suppress progress output
379        #[arg(short, long)]
380        quiet: bool,
381    },
382
383    /// Interactive chat mode
384    Chat {
385        #[arg(short, long, default_value = DEFAULT_CHAT_MODEL)]
386        model: String,
387
388        /// Load weights from a local file or directory instead of the registry
389        #[arg(long)]
390        model_path: Option<String>,
391
392        /// System prompt to set assistant behavior
393        #[arg(short, long)]
394        system: Option<String>,
395
396        /// Sampling temperature
397        #[arg(short, long, default_value_t = 0.7)]
398        temperature: f32,
399
400        /// Max tokens per response
401        #[arg(short = 'n', long, default_value_t = 512)]
402        max_tokens: usize,
403
404        /// Run on the GPU
405        #[arg(long)]
406        gpu: bool,
407
408        /// Suppress progress output
409        #[arg(short, long)]
410        quiet: bool,
411
412        /// Draft model for speculative decoding. Must share the target's
413        /// vocabulary, such as qwen2.5-0.5b-instruct drafting for qwen2.5-1.5b.
414        #[arg(long)]
415        draft: Option<String>,
416
417        /// Tokens the draft proposes per round.
418        #[arg(long, default_value_t = 4)]
419        draft_tokens: usize,
420    },
421
422    /// Create or manage search indexes
423    Index {
424        #[command(subcommand)]
425        action: IndexCommands,
426    },
427
428    /// Index and search images by description
429    #[cfg(feature = "image-io")]
430    Image {
431        #[command(subcommand)]
432        action: ImageCommands,
433    },
434
435    /// Search an index
436    Search {
437        /// Path to the index file
438        index_path: String,
439
440        /// Search query
441        query: String,
442
443        /// Number of results to return
444        #[arg(short = 'k', long, default_value_t = 10)]
445        top_k: usize,
446
447        /// Search mode: hybrid, semantic, keyword
448        #[arg(long, default_value = "hybrid")]
449        mode: String,
450
451        /// Encoder model for semantic search
452        #[arg(short, long, default_value = "minilm-l6-v2")]
453        model: String,
454
455        /// Reranking model (optional)
456        /// Use a cross-encoder model to rerank initial results
457        /// Example: --rerank-model "ms-marco-minilm"
458        #[arg(long, default_value = None)]
459        rerank_model: Option<String>,
460
461        /// Output format: json, jsonl, text
462        #[arg(short, long, default_value = "text")]
463        format: String,
464
465        /// Run on the GPU
466        #[arg(long)]
467        gpu: bool,
468
469        /// Suppress progress output
470        #[arg(short, long)]
471        quiet: bool,
472    },
473
474    /// Compute similarity between two texts
475    Similarity {
476        /// First text (or file path)
477        text1: String,
478
479        /// Second text (or file path)
480        text2: String,
481
482        /// Encoder model
483        #[arg(short, long, default_value = "minilm-l6-v2")]
484        model: String,
485
486        /// Run on the GPU
487        #[arg(long)]
488        gpu: bool,
489
490        /// Suppress progress output
491        #[arg(short, long)]
492        quiet: bool,
493    },
494}
495
496#[derive(Subcommand, Debug, PartialEq)]
497pub enum ModelCommands {
498    /// List all available models
499    List {
500        /// Filter by architecture (e.g., llama, bert, t5)
501        #[arg(short, long)]
502        arch: Option<String>,
503
504        /// Filter by task (e.g., chat, embedding, classification, summarization)
505        #[arg(short, long)]
506        task: Option<String>,
507
508        /// Show only downloaded models
509        #[arg(short, long)]
510        downloaded: bool,
511    },
512
513    /// Download a model
514    Download {
515        name: String,
516
517        #[arg(long)]
518        gguf: bool,
519
520        #[arg(short, long)]
521        quiet: bool,
522    },
523
524    /// Remove a downloaded model
525    Remove { name: String },
526
527    /// Show detailed info about a model
528    Info { name: String },
529
530    /// Search for models by name or description
531    Search { query: String },
532}
533
534#[derive(Subcommand, Debug, PartialEq)]
535pub enum InspectCommands {
536    /// Inspect a model file
537    Model {
538        /// Path to the model file
539        path: String,
540    },
541}
542
543/// Image search, backed by CLIP.
544///
545/// Separate from `index`/`search` because the vectors are not interchangeable:
546/// a CLIP image vector and a MiniLM text vector live in different spaces, and
547/// comparing them would return confident nonsense.
548#[cfg(feature = "image-io")]
549#[derive(Subcommand, Debug, PartialEq)]
550pub enum ImageCommands {
551    /// Embed images into a searchable index
552    Index {
553        /// Files or directories of images
554        #[arg(required = true)]
555        inputs: Vec<String>,
556
557        /// Where to write the index
558        #[arg(short, long, default_value = "images.json")]
559        output: String,
560
561        /// Suppress progress output
562        #[arg(short, long)]
563        quiet: bool,
564    },
565
566    /// Find images matching a description
567    Search {
568        /// Path to the image index
569        index_path: String,
570
571        /// What to look for, in words
572        query: String,
573
574        /// Number of results
575        #[arg(short = 'k', long, default_value_t = 5)]
576        top_k: usize,
577
578        /// Suppress progress output
579        #[arg(short, long)]
580        quiet: bool,
581    },
582}
583
584#[derive(Subcommand, Debug, PartialEq)]
585pub enum IndexCommands {
586    /// Create a new index from documents
587    Create {
588        /// Output index file path
589        output: String,
590
591        /// Input files/directories (uses built-in chunking)
592        #[arg(conflicts_with = "from_chunks")]
593        inputs: Vec<String>,
594
595        /// Pre-chunked JSONL file (bypass chunking)
596        #[arg(long, conflicts_with = "inputs")]
597        from_chunks: Option<String>,
598
599        /// Chunk size in characters
600        ///
601        /// The default fits inside the encoder's window. minilm-l6-v2 reads 256
602        /// tokens, roughly 900 characters, so the previous default of 1000 left
603        /// about nine chunks in ten longer than the model reads, with the tail
604        /// dropped silently. The C# Indexer has always used 512; this matches it.
605        #[arg(long, default_value_t = 512)]
606        chunk_size: usize,
607
608        /// Chunk overlap in characters
609        #[arg(long, default_value_t = 100)]
610        chunk_overlap: usize,
611
612        /// Encoder model for embeddings
613        #[arg(short, long, default_value = "minilm-l6-v2")]
614        model: String,
615
616        /// Run on the GPU
617        #[arg(long)]
618        gpu: bool,
619
620        /// Suppress progress output
621        #[arg(short, long)]
622        quiet: bool,
623    },
624
625    /// Add documents to an existing index
626    Add {
627        /// Index file path
628        index_path: String,
629
630        /// Input files or directories to add
631        inputs: Vec<String>,
632
633        /// Chunk size in characters
634        ///
635        /// Matches `index create`; see the note there.
636        #[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        /// Run on the GPU
646        #[arg(long)]
647        gpu: bool,
648
649        /// Suppress progress output
650        #[arg(short, long)]
651        quiet: bool,
652    },
653
654    /// Show index info
655    Info {
656        /// Index file path
657        index_path: String,
658    },
659}
660
661/// Convert verbosity count to log level string
662pub 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    /// Every model name hardcoded outside the registry must actually resolve.
677    ///
678    /// This exists because three separate lists had drifted from the registry at
679    /// once: both CLI defaults, and four of the seven entries in
680    /// `kjarni::chat::suggest_chat_models`. Each was presumably correct when
681    /// written and went stale when models gained an `-instruct` suffix, with
682    /// nothing to notice. A name that does not resolve is a user-facing failure
683    /// on the very first command someone types, so it should break the build
684    /// instead.
685    #[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    /// The chat default must be usable for chat, not merely a real model.
713    ///
714    /// `kjarni chat` with no arguments should work; defaulting to an embedding
715    /// model would resolve fine here and still fail at runtime.
716    #[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        // Global flag can come after command
1451        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        // transcribe requires a file
1470        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    // Compile time verificatio
1692    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}