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