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}