Skip to main content

kbolt_cli/
args.rs

1use std::path::PathBuf;
2
3use clap::{ArgGroup, Args, Parser, Subcommand, ValueEnum};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
6pub enum OutputFormat {
7    Cli,
8    Json,
9}
10
11#[derive(Debug, Parser)]
12#[command(name = "kbolt", version, about = "local-first retrieval engine")]
13pub struct Cli {
14    #[arg(
15        short = 's',
16        long = "space",
17        value_name = "name",
18        help = "Active space (overrides KBOLT_SPACE and the default space)"
19    )]
20    pub space: Option<String>,
21
22    #[arg(
23        short = 'f',
24        long = "format",
25        value_enum,
26        default_value_t = OutputFormat::Cli,
27        help = "Output format"
28    )]
29    pub format: OutputFormat,
30
31    #[command(subcommand)]
32    pub command: Command,
33}
34
35#[derive(Debug, Subcommand)]
36pub enum Command {
37    #[command(about = "Check system configuration and model readiness")]
38    Doctor,
39    #[command(about = "Configure kbolt with a local inference stack")]
40    Setup(SetupArgs),
41    #[command(about = "Manage local llama-server processes")]
42    Local(LocalArgs),
43    #[command(about = "Create, list, and manage spaces")]
44    Space(SpaceArgs),
45    #[command(about = "Add, list, and manage document collections")]
46    Collection(CollectionArgs),
47    #[command(about = "Manage file ignore patterns for collections")]
48    Ignore(IgnoreArgs),
49    #[command(about = "Show configured model bindings")]
50    Models(ModelsArgs),
51    #[command(about = "Run retrieval benchmarks")]
52    Eval(EvalArgs),
53    #[command(about = "Manage automatic re-indexing schedules")]
54    Schedule(ScheduleArgs),
55    #[command(about = "Keep collections fresh as files change")]
56    Watch(WatchArgs),
57    #[command(about = "Start the MCP server for AI agent integration")]
58    Mcp,
59    #[command(about = "Search indexed documents")]
60    Search(SearchArgs),
61    #[command(about = "Re-scan and re-index collections")]
62    Update(UpdateArgs),
63    #[command(about = "Show index status, disk usage, and model readiness")]
64    Status,
65    #[command(about = "List files in a collection")]
66    Ls(LsArgs),
67    #[command(about = "Retrieve a document by path or docid")]
68    Get(GetArgs),
69    #[command(about = "Retrieve multiple documents at once")]
70    MultiGet(MultiGetArgs),
71}
72
73#[derive(Debug, Args)]
74pub struct SpaceArgs {
75    #[command(subcommand)]
76    pub command: SpaceCommand,
77}
78
79#[derive(Debug, Args)]
80pub struct SetupArgs {
81    #[command(subcommand)]
82    pub command: SetupCommand,
83}
84
85#[derive(Debug, Args)]
86pub struct LocalArgs {
87    #[command(subcommand)]
88    pub command: LocalCommand,
89}
90
91#[derive(Debug, Args)]
92pub struct CollectionArgs {
93    #[command(subcommand)]
94    pub command: CollectionCommand,
95}
96
97#[derive(Debug, Args)]
98pub struct IgnoreArgs {
99    #[command(subcommand)]
100    pub command: IgnoreCommand,
101}
102
103#[derive(Debug, Args)]
104pub struct ModelsArgs {
105    #[command(subcommand)]
106    pub command: ModelsCommand,
107}
108
109#[derive(Debug, Args)]
110pub struct EvalArgs {
111    #[command(subcommand)]
112    pub command: EvalCommand,
113}
114
115#[derive(Debug, Args, PartialEq, Eq)]
116pub struct EvalImportArgs {
117    #[command(subcommand)]
118    pub dataset: EvalImportCommand,
119}
120
121#[derive(Debug, Args, PartialEq, Eq)]
122pub struct EvalRunArgs {
123    #[arg(
124        long,
125        value_name = "path",
126        help = "Path to an eval.toml manifest (defaults to the configured eval set)"
127    )]
128    pub file: Option<PathBuf>,
129}
130
131#[derive(Debug, Args)]
132pub struct ScheduleArgs {
133    #[command(subcommand)]
134    pub command: ScheduleCommand,
135}
136
137#[derive(Debug, Args)]
138pub struct WatchArgs {
139    #[arg(
140        long,
141        help = "Run the watcher attached to this terminal for debugging or custom supervision"
142    )]
143    pub foreground: bool,
144    #[command(subcommand)]
145    pub command: Option<WatchCommand>,
146}
147
148#[derive(Debug, Args, PartialEq, Eq)]
149pub struct UpdateArgs {
150    #[arg(
151        long = "collection",
152        value_delimiter = ',',
153        help = "Restrict update to specific collections (comma-separated)"
154    )]
155    pub collections: Vec<String>,
156    #[arg(long, help = "Skip embedding; only refresh keyword index and metadata")]
157    pub no_embed: bool,
158    #[arg(long, help = "Show what would change without writing to the index")]
159    pub dry_run: bool,
160    #[arg(
161        long,
162        help = "Include per-file decisions and the full error list in the final report"
163    )]
164    pub verbose: bool,
165}
166
167#[derive(Debug, Args, PartialEq, Eq)]
168pub struct LsArgs {
169    #[arg(help = "Collection to list files from")]
170    pub collection: String,
171    #[arg(help = "Only show files whose path starts with this prefix")]
172    pub prefix: Option<String>,
173    #[arg(long, help = "Include deactivated files")]
174    pub all: bool,
175}
176
177#[derive(Debug, Args, PartialEq, Eq)]
178pub struct GetArgs {
179    #[arg(help = "Document path (collection/relative/path) or docid (#abc123)")]
180    pub identifier: String,
181    #[arg(long, help = "Start reading at this line number")]
182    pub offset: Option<usize>,
183    #[arg(long, help = "Maximum number of lines to return")]
184    pub limit: Option<usize>,
185}
186
187#[derive(Debug, Args, PartialEq, Eq)]
188pub struct MultiGetArgs {
189    #[arg(
190        value_delimiter = ',',
191        help = "Comma-separated list of document paths or docids (#abc123)"
192    )]
193    pub locators: Vec<String>,
194    #[arg(
195        long,
196        default_value_t = 20,
197        help = "Maximum number of documents to return"
198    )]
199    pub max_files: usize,
200    #[arg(
201        long,
202        default_value_t = 51_200,
203        help = "Maximum total bytes to return across all documents"
204    )]
205    pub max_bytes: usize,
206}
207
208#[derive(Debug, Args, PartialEq)]
209pub struct SearchArgs {
210    #[arg(help = "The search query")]
211    pub query: String,
212    #[arg(
213        long = "collection",
214        value_delimiter = ',',
215        help = "Restrict search to specific collections (comma-separated)"
216    )]
217    pub collections: Vec<String>,
218    #[arg(
219        long,
220        default_value_t = 10,
221        help = "Maximum number of results to return"
222    )]
223    pub limit: usize,
224    #[arg(
225        long,
226        default_value_t = 0.0,
227        help = "Filter out results below this score (0.0-1.0)"
228    )]
229    pub min_score: f32,
230    #[arg(
231        long,
232        help = "Query expansion for vocabulary-mismatch or underspecified queries (slower)"
233    )]
234    pub deep: bool,
235    #[arg(long, help = "Keyword-only (BM25) search; skips dense retrieval")]
236    pub keyword: bool,
237    #[arg(long, help = "Dense-vector-only search; skips keyword retrieval")]
238    pub semantic: bool,
239    #[arg(
240        long,
241        conflicts_with = "rerank",
242        help = "Skip cross-encoder reranking (faster, lower quality)"
243    )]
244    pub no_rerank: bool,
245    #[arg(
246        long,
247        conflicts_with = "no_rerank",
248        help = "Enable cross-encoder reranking on auto mode (slower, higher quality)"
249    )]
250    pub rerank: bool,
251    #[arg(
252        long,
253        help = "Show pipeline stages and per-signal scores for each result"
254    )]
255    pub debug: bool,
256}
257
258#[derive(Debug, Subcommand, PartialEq, Eq)]
259pub enum SpaceCommand {
260    #[command(about = "Create a new space")]
261    Add {
262        #[arg(help = "Name of the new space")]
263        name: String,
264        #[arg(long, help = "Human-readable space description")]
265        description: Option<String>,
266        #[arg(
267            long,
268            help = "Validate all directories up-front and roll back the space if any collection registration fails"
269        )]
270        strict: bool,
271        #[arg(help = "Directories to register as collections in this space")]
272        dirs: Vec<PathBuf>,
273    },
274    #[command(about = "Set a space description")]
275    Describe {
276        #[arg(help = "Space name")]
277        name: String,
278        #[arg(help = "New description text")]
279        text: String,
280    },
281    #[command(about = "Rename a space")]
282    Rename {
283        #[arg(help = "Current space name")]
284        old: String,
285        #[arg(help = "New space name")]
286        new: String,
287    },
288    #[command(about = "Remove a space and all its data")]
289    Remove {
290        #[arg(help = "Space to delete (all collections and indexes are removed)")]
291        name: String,
292    },
293    #[command(about = "Show the active space")]
294    Current,
295    #[command(about = "Get or set the default space")]
296    Default {
297        #[arg(help = "Space to set as default (omit to show the current default)")]
298        name: Option<String>,
299    },
300    #[command(about = "List all spaces")]
301    List,
302    #[command(about = "Show details about a space")]
303    Info {
304        #[arg(help = "Space name")]
305        name: String,
306    },
307}
308
309#[derive(Debug, Subcommand, PartialEq, Eq)]
310pub enum SetupCommand {
311    #[command(about = "Set up local embedder and reranker using llama-server")]
312    Local,
313}
314
315#[derive(Debug, Subcommand, PartialEq, Eq)]
316pub enum LocalCommand {
317    #[command(about = "Show local server status")]
318    Status,
319    #[command(about = "Start local inference servers")]
320    Start,
321    #[command(about = "Stop local inference servers")]
322    Stop,
323    #[command(about = "Enable an optional local feature")]
324    Enable {
325        #[arg(
326            help = "Feature to enable (`deep` downloads the expander model for query expansion)"
327        )]
328        feature: LocalFeature,
329    },
330}
331
332#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
333pub enum LocalFeature {
334    Deep,
335}
336
337#[derive(Debug, Subcommand, PartialEq, Eq)]
338pub enum CollectionCommand {
339    #[command(about = "Add a directory as a document collection")]
340    Add {
341        #[arg(help = "Directory to index")]
342        path: PathBuf,
343        #[arg(long, help = "Collection name (defaults to the directory basename)")]
344        name: Option<String>,
345        #[arg(long, help = "Human-readable collection description")]
346        description: Option<String>,
347        #[arg(
348            long,
349            value_delimiter = ',',
350            help = "Only index files with these extensions (comma-separated)"
351        )]
352        extensions: Option<Vec<String>>,
353        #[arg(
354            long,
355            help = "Register the collection without running an initial indexing pass"
356        )]
357        no_index: bool,
358    },
359    #[command(about = "List all collections")]
360    List,
361    #[command(about = "Show details about a collection")]
362    Info {
363        #[arg(help = "Collection name")]
364        name: String,
365    },
366    #[command(about = "Set a collection description")]
367    Describe {
368        #[arg(help = "Collection name")]
369        name: String,
370        #[arg(help = "New description text")]
371        text: String,
372    },
373    #[command(about = "Rename a collection")]
374    Rename {
375        #[arg(help = "Current collection name")]
376        old: String,
377        #[arg(help = "New collection name")]
378        new: String,
379    },
380    #[command(about = "Remove a collection and its indexed data")]
381    Remove {
382        #[arg(help = "Collection to delete (chunks and embeddings are removed)")]
383        name: String,
384    },
385}
386
387#[derive(Debug, Subcommand, PartialEq, Eq)]
388pub enum IgnoreCommand {
389    #[command(about = "Show ignore patterns for a collection")]
390    Show {
391        #[arg(help = "Collection name")]
392        collection: String,
393    },
394    #[command(about = "Add an ignore pattern to a collection")]
395    Add {
396        #[arg(help = "Collection name")]
397        collection: String,
398        #[arg(help = "Gitignore-style pattern to add")]
399        pattern: String,
400    },
401    #[command(about = "Remove an ignore pattern from a collection")]
402    Remove {
403        #[arg(help = "Collection name")]
404        collection: String,
405        #[arg(help = "Exact pattern text to remove")]
406        pattern: String,
407    },
408    #[command(about = "Open ignore patterns in an editor")]
409    Edit {
410        #[arg(help = "Collection name")]
411        collection: String,
412    },
413    #[command(about = "List all collections with ignore patterns")]
414    List,
415}
416
417#[derive(Debug, Subcommand, PartialEq, Eq)]
418pub enum ModelsCommand {
419    #[command(about = "List configured models and their status")]
420    List,
421}
422
423#[derive(Debug, Subcommand, PartialEq, Eq)]
424pub enum EvalCommand {
425    #[command(about = "Run a retrieval evaluation")]
426    Run(EvalRunArgs),
427    #[command(about = "Import a benchmark dataset")]
428    Import(EvalImportArgs),
429}
430
431#[derive(Debug, Subcommand, PartialEq, Eq)]
432pub enum EvalImportCommand {
433    #[command(
434        about = "import a canonical BEIR dataset from an extracted directory",
435        long_about = "Import a canonical BEIR dataset from an extracted directory.\n\nExpected source layout:\n  corpus.jsonl\n  queries.jsonl\n  qrels/test.tsv\n\nThis command always imports the test split."
436    )]
437    Beir(EvalImportBeirArgs),
438}
439
440#[derive(Debug, Args, PartialEq, Eq)]
441pub struct EvalImportBeirArgs {
442    #[arg(
443        long,
444        value_name = "name",
445        help = "Dataset identifier used in eval reports (e.g. fiqa, scifact)"
446    )]
447    pub dataset: String,
448    #[arg(
449        long,
450        value_name = "dir",
451        help = "Extracted BEIR dataset directory (corpus.jsonl, queries.jsonl, qrels/)"
452    )]
453    pub source: PathBuf,
454    #[arg(
455        long,
456        value_name = "dir",
457        help = "Directory where the imported corpus and eval.toml will be written"
458    )]
459    pub output: PathBuf,
460    #[arg(
461        long,
462        value_name = "name",
463        help = "Override the collection name (defaults to the dataset name)"
464    )]
465    pub collection: Option<String>,
466}
467
468#[derive(Debug, Subcommand, PartialEq, Eq)]
469pub enum ScheduleCommand {
470    #[command(about = "Create a new re-indexing schedule")]
471    Add(ScheduleAddArgs),
472    #[command(about = "Show schedule status and last run info")]
473    Status,
474    #[command(about = "Remove a schedule")]
475    Remove(ScheduleRemoveArgs),
476}
477
478#[derive(Debug, Subcommand, PartialEq, Eq)]
479pub enum WatchCommand {
480    #[command(about = "Enable and start the background watcher")]
481    Enable,
482    #[command(about = "Disable the background watcher")]
483    Disable,
484    #[command(about = "Show watcher service and runtime status")]
485    Status,
486    #[command(about = "Show recent watcher activity")]
487    Logs {
488        #[arg(long, default_value_t = 80, help = "Number of log lines to show")]
489        lines: usize,
490    },
491}
492
493#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
494pub enum ScheduleDayArg {
495    Mon,
496    Tue,
497    Wed,
498    Thu,
499    Fri,
500    Sat,
501    Sun,
502}
503
504#[derive(Debug, Args, PartialEq, Eq)]
505#[command(group(
506    ArgGroup::new("trigger")
507        .required(true)
508        .args(["every", "at"])
509))]
510pub struct ScheduleAddArgs {
511    #[arg(
512        long,
513        conflicts_with = "at",
514        help = "Interval trigger (e.g. 30m, 2h); minimum 5 minutes"
515    )]
516    pub every: Option<String>,
517    #[arg(
518        long,
519        conflicts_with = "every",
520        help = "Daily trigger time in HH:MM (24-hour)"
521    )]
522    pub at: Option<String>,
523    #[arg(
524        long = "on",
525        value_delimiter = ',',
526        requires = "at",
527        value_enum,
528        help = "Days for the weekly trigger (comma-separated: Mon,Tue,...); requires --at"
529    )]
530    pub on: Vec<ScheduleDayArg>,
531    #[arg(long, help = "Restrict the schedule to a specific space")]
532    pub space: Option<String>,
533    #[arg(
534        long = "collection",
535        requires = "space",
536        help = "Restrict the schedule to specific collections; requires --space"
537    )]
538    pub collections: Vec<String>,
539}
540
541#[derive(Debug, Args, PartialEq, Eq)]
542#[command(group(
543    ArgGroup::new("selector")
544        .required(true)
545        .args(["id", "all", "space"])
546))]
547pub struct ScheduleRemoveArgs {
548    #[arg(help = "Schedule ID to remove (from `kbolt schedule status`)")]
549    pub id: Option<String>,
550    #[arg(
551        long,
552        conflicts_with_all = ["id", "space", "collections"],
553        help = "Remove every configured schedule"
554    )]
555    pub all: bool,
556    #[arg(
557        long,
558        conflicts_with = "id",
559        help = "Remove all schedules for a specific space"
560    )]
561    pub space: Option<String>,
562    #[arg(
563        long = "collection",
564        requires = "space",
565        conflicts_with = "id",
566        help = "Remove schedules for specific collections; requires --space"
567    )]
568    pub collections: Vec<String>,
569}
570
571#[cfg(test)]
572mod tests {
573    use std::path::PathBuf;
574
575    use clap::Parser;
576
577    use super::{
578        Cli, CollectionCommand, Command, EvalCommand, EvalImportArgs, EvalImportBeirArgs,
579        EvalImportCommand, EvalRunArgs, GetArgs, LocalCommand, LocalFeature, MultiGetArgs,
580        OutputFormat, ScheduleAddArgs, ScheduleCommand, ScheduleDayArg, ScheduleRemoveArgs,
581        SearchArgs, SetupCommand, SpaceCommand, UpdateArgs, WatchArgs, WatchCommand,
582    };
583
584    fn parse<const N: usize>(args: [&str; N]) -> Cli {
585        Cli::try_parse_from(args).expect("parse cli")
586    }
587
588    #[test]
589    fn parses_output_format_variants() {
590        let parsed = parse(["kbolt", "status"]);
591        assert_eq!(parsed.format, OutputFormat::Cli);
592        let parsed = parse(["kbolt", "--format", "json", "status"]);
593        assert_eq!(parsed.format, OutputFormat::Json);
594    }
595
596    #[test]
597    fn parses_doctor_command() {
598        let parsed = parse(["kbolt", "doctor"]);
599        assert!(matches!(parsed.command, Command::Doctor));
600    }
601
602    #[test]
603    fn parses_setup_local_command() {
604        let parsed = parse(["kbolt", "setup", "local"]);
605        assert!(matches!(
606            parsed.command,
607            Command::Setup(args) if args.command == SetupCommand::Local
608        ));
609    }
610
611    #[test]
612    fn parses_local_enable_deep_command() {
613        let parsed = parse(["kbolt", "local", "enable", "deep"]);
614        assert!(matches!(
615            parsed.command,
616            Command::Local(args)
617                if args.command == LocalCommand::Enable {
618                    feature: LocalFeature::Deep
619                }
620        ));
621    }
622
623    #[test]
624    fn parses_global_space_override() {
625        let parsed = parse(["kbolt", "--space", "work", "space", "current"]);
626        assert_eq!(parsed.space.as_deref(), Some("work"));
627        assert!(matches!(
628            parsed.command,
629            Command::Space(space) if space.command == SpaceCommand::Current
630        ));
631    }
632
633    #[test]
634    fn parses_collection_add_with_options() {
635        let parsed = parse([
636            "kbolt",
637            "collection",
638            "add",
639            "/tmp/work-api",
640            "--name",
641            "api",
642            "--description",
643            "api docs",
644            "--extensions",
645            "rs,md",
646            "--no-index",
647        ]);
648        assert_eq!(parsed.space, None);
649
650        assert!(matches!(
651            parsed.command,
652            Command::Collection(collection)
653                if collection.command
654                    == CollectionCommand::Add {
655                        path: PathBuf::from("/tmp/work-api"),
656                        name: Some("api".to_string()),
657                        description: Some("api docs".to_string()),
658                        extensions: Some(vec!["rs".to_string(), "md".to_string()]),
659                        no_index: true
660                    }
661        ));
662    }
663
664    #[test]
665    fn parses_update_with_defaults() {
666        let parsed = parse(["kbolt", "update"]);
667        assert_eq!(parsed.space, None);
668        assert!(matches!(
669            parsed.command,
670            Command::Update(UpdateArgs {
671                collections,
672                no_embed: false,
673                dry_run: false,
674                verbose: false,
675            }) if collections.is_empty()
676        ));
677    }
678
679    #[test]
680    fn parses_update_with_flags() {
681        let parsed = parse([
682            "kbolt",
683            "--space",
684            "work",
685            "update",
686            "--collection",
687            "api,wiki",
688            "--no-embed",
689            "--dry-run",
690            "--verbose",
691        ]);
692        assert_eq!(parsed.space.as_deref(), Some("work"));
693        assert!(matches!(
694            parsed.command,
695            Command::Update(UpdateArgs {
696                collections,
697                no_embed: true,
698                dry_run: true,
699                verbose: true,
700            }) if collections == vec!["api".to_string(), "wiki".to_string()]
701        ));
702    }
703
704    #[test]
705    fn parses_get_with_options() {
706        let parsed = parse(["kbolt", "get", "api/src/lib.rs"]);
707        assert_eq!(parsed.space, None);
708        assert!(matches!(
709            parsed.command,
710            Command::Get(GetArgs {
711                identifier,
712                offset: None,
713                limit: None,
714            }) if identifier == "api/src/lib.rs"
715        ));
716
717        let parsed = parse([
718            "kbolt", "--space", "work", "get", "#abc123", "--offset", "10", "--limit", "25",
719        ]);
720        assert_eq!(parsed.space.as_deref(), Some("work"));
721        assert!(matches!(
722            parsed.command,
723            Command::Get(GetArgs {
724                identifier,
725                offset: Some(10),
726                limit: Some(25),
727            }) if identifier == "#abc123"
728        ));
729    }
730
731    #[test]
732    fn parses_multi_get_with_options() {
733        let parsed = parse(["kbolt", "multi-get", "api/a.md,#abc123"]);
734        assert_eq!(parsed.space, None);
735        assert!(matches!(
736            parsed.command,
737            Command::MultiGet(MultiGetArgs {
738                locators,
739                max_files: 20,
740                max_bytes: 51_200,
741            }) if locators == vec!["api/a.md".to_string(), "#abc123".to_string()]
742        ));
743
744        let parsed = parse([
745            "kbolt",
746            "--space",
747            "work",
748            "multi-get",
749            "api/a.md,api/b.md",
750            "--max-files",
751            "5",
752            "--max-bytes",
753            "1024",
754        ]);
755        assert_eq!(parsed.space.as_deref(), Some("work"));
756        assert!(matches!(
757            parsed.command,
758            Command::MultiGet(MultiGetArgs {
759                locators,
760                max_files: 5,
761                max_bytes: 1024,
762            }) if locators == vec!["api/a.md".to_string(), "api/b.md".to_string()]
763        ));
764    }
765
766    #[test]
767    fn parses_search_with_defaults_and_flags() {
768        let parsed = parse(["kbolt", "search", "alpha"]);
769        assert_eq!(parsed.space, None);
770        assert!(matches!(
771            parsed.command,
772            Command::Search(SearchArgs {
773                query,
774                collections,
775                limit: 10,
776                min_score,
777                deep: false,
778                keyword: false,
779                semantic: false,
780                no_rerank: false,
781                rerank: false,
782                debug: false,
783            }) if query == "alpha" && collections.is_empty() && min_score == 0.0
784        ));
785
786        let parsed = parse([
787            "kbolt",
788            "--space",
789            "work",
790            "search",
791            "alpha beta",
792            "--collection",
793            "api,wiki",
794            "--limit",
795            "7",
796            "--min-score",
797            "0.25",
798            "--keyword",
799            "--no-rerank",
800            "--debug",
801        ]);
802        assert_eq!(parsed.space.as_deref(), Some("work"));
803        assert!(matches!(
804            parsed.command,
805            Command::Search(SearchArgs {
806                query,
807                collections,
808                limit: 7,
809                min_score,
810                deep: false,
811                keyword: true,
812                semantic: false,
813                no_rerank: true,
814                rerank: false,
815                debug: true,
816            }) if query == "alpha beta"
817                && collections == vec!["api".to_string(), "wiki".to_string()]
818                && min_score == 0.25
819        ));
820    }
821
822    #[test]
823    fn parses_search_rerank_opt_in_flag() {
824        let parsed = parse(["kbolt", "search", "alpha", "--rerank"]);
825        assert!(matches!(
826            parsed.command,
827            Command::Search(SearchArgs {
828                rerank: true,
829                no_rerank: false,
830                ..
831            })
832        ));
833    }
834
835    #[test]
836    fn parses_schedule_add_interval_and_weekly_variants() {
837        let parsed = parse(["kbolt", "schedule", "add", "--every", "30m"]);
838        assert!(matches!(
839            parsed.command,
840            Command::Schedule(schedule)
841                if schedule.command
842                    == ScheduleCommand::Add(ScheduleAddArgs {
843                        every: Some("30m".to_string()),
844                        at: None,
845                        on: vec![],
846                        space: None,
847                        collections: vec![],
848                    })
849        ));
850
851        let parsed = parse([
852            "kbolt",
853            "schedule",
854            "add",
855            "--at",
856            "3pm",
857            "--on",
858            "mon,fri",
859            "--space",
860            "work",
861            "--collection",
862            "api",
863            "--collection",
864            "docs",
865        ]);
866        assert!(matches!(
867            parsed.command,
868            Command::Schedule(schedule)
869                if schedule.command
870                    == ScheduleCommand::Add(ScheduleAddArgs {
871                        every: None,
872                        at: Some("3pm".to_string()),
873                        on: vec![ScheduleDayArg::Mon, ScheduleDayArg::Fri],
874                        space: Some("work".to_string()),
875                        collections: vec!["api".to_string(), "docs".to_string()],
876                    })
877        ));
878    }
879
880    #[test]
881    fn parses_schedule_remove_selectors() {
882        let parsed = parse(["kbolt", "schedule", "remove", "s2"]);
883        assert!(matches!(
884            parsed.command,
885            Command::Schedule(schedule)
886                if schedule.command
887                    == ScheduleCommand::Remove(ScheduleRemoveArgs {
888                        id: Some("s2".to_string()),
889                        all: false,
890                        space: None,
891                        collections: vec![],
892                    })
893        ));
894
895        let parsed = parse([
896            "kbolt",
897            "schedule",
898            "remove",
899            "--space",
900            "work",
901            "--collection",
902            "api",
903        ]);
904        assert!(matches!(
905            parsed.command,
906            Command::Schedule(schedule)
907                if schedule.command
908                    == ScheduleCommand::Remove(ScheduleRemoveArgs {
909                        id: None,
910                        all: false,
911                        space: Some("work".to_string()),
912                        collections: vec!["api".to_string()],
913                    })
914        ));
915    }
916
917    #[test]
918    fn parses_watch_commands_and_foreground_flag() {
919        let parsed = parse(["kbolt", "watch"]);
920        assert!(matches!(
921            parsed.command,
922            Command::Watch(WatchArgs {
923                foreground: false,
924                command: None,
925            })
926        ));
927
928        let parsed = parse(["kbolt", "watch", "enable"]);
929        assert!(matches!(
930            parsed.command,
931            Command::Watch(WatchArgs {
932                foreground: false,
933                command: Some(WatchCommand::Enable),
934            })
935        ));
936
937        let parsed = parse(["kbolt", "watch", "--foreground"]);
938        assert!(matches!(
939            parsed.command,
940            Command::Watch(WatchArgs {
941                foreground: true,
942                command: None,
943            })
944        ));
945
946        let parsed = parse(["kbolt", "watch", "logs", "--lines", "20"]);
947        assert!(matches!(
948            parsed.command,
949            Command::Watch(WatchArgs {
950                foreground: false,
951                command: Some(WatchCommand::Logs { lines: 20 }),
952            })
953        ));
954    }
955
956    #[test]
957    fn parses_eval_run_with_optional_manifest_path() {
958        let parsed = parse(["kbolt", "eval", "run"]);
959        assert!(matches!(
960            parsed.command,
961            Command::Eval(eval) if eval.command == EvalCommand::Run(EvalRunArgs { file: None })
962        ));
963
964        let parsed = parse(["kbolt", "eval", "run", "--file", "/tmp/scifact.toml"]);
965        assert!(matches!(
966            parsed.command,
967            Command::Eval(eval)
968                if eval.command
969                    == EvalCommand::Run(EvalRunArgs {
970                        file: Some(PathBuf::from("/tmp/scifact.toml"))
971                    })
972        ));
973    }
974
975    #[test]
976    fn parses_eval_import_beir_with_required_paths() {
977        let parsed = parse([
978            "kbolt",
979            "eval",
980            "import",
981            "beir",
982            "--dataset",
983            "fiqa",
984            "--source",
985            "/tmp/fiqa-source",
986            "--output",
987            "/tmp/fiqa-bench",
988        ]);
989
990        let Command::Eval(eval) = parsed.command else {
991            panic!("expected eval command");
992        };
993        assert_eq!(
994            eval.command,
995            EvalCommand::Import(EvalImportArgs {
996                dataset: EvalImportCommand::Beir(EvalImportBeirArgs {
997                    dataset: "fiqa".to_string(),
998                    source: PathBuf::from("/tmp/fiqa-source"),
999                    output: PathBuf::from("/tmp/fiqa-bench"),
1000                    collection: None,
1001                })
1002            })
1003        );
1004    }
1005
1006    #[test]
1007    fn parses_eval_import_beir_with_collection_override() {
1008        let parsed = parse([
1009            "kbolt",
1010            "eval",
1011            "import",
1012            "beir",
1013            "--dataset",
1014            "fiqa",
1015            "--source",
1016            "/tmp/fiqa-source",
1017            "--output",
1018            "/tmp/fiqa-bench",
1019            "--collection",
1020            "finance",
1021        ]);
1022
1023        let Command::Eval(eval) = parsed.command else {
1024            panic!("expected eval command");
1025        };
1026        assert_eq!(
1027            eval.command,
1028            EvalCommand::Import(EvalImportArgs {
1029                dataset: EvalImportCommand::Beir(EvalImportBeirArgs {
1030                    dataset: "fiqa".to_string(),
1031                    source: PathBuf::from("/tmp/fiqa-source"),
1032                    output: PathBuf::from("/tmp/fiqa-bench"),
1033                    collection: Some("finance".to_string()),
1034                })
1035            })
1036        );
1037    }
1038}