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 under this directory-style path 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, docid (#abc123), or chunk locator (#abc123@2)")]
180 pub identifier: String,
181 #[arg(long, help = "Start reading at this zero-based line offset")]
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, docids (#abc123), or chunk locators (#abc123@2)"
192 )]
193 pub locators: Vec<String>,
194 #[arg(
195 long,
196 default_value_t = 20,
197 help = "Maximum number of documents or chunks 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 or chunks"
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(
312 about = "Set up local embedder and reranker using llama-server",
313 long_about = "Set up local embedder and reranker using llama-server.\n\nRequires llama-server from llama.cpp. Downloads local GGUF models into the kbolt cache, starts managed local inference services, writes index.toml, and verifies the services before reporting ready. The first run can take several minutes depending on model downloads."
314 )]
315 Local,
316}
317
318#[derive(Debug, Subcommand, PartialEq, Eq)]
319pub enum LocalCommand {
320 #[command(about = "Show local server status")]
321 Status,
322 #[command(about = "Start local inference servers")]
323 Start,
324 #[command(about = "Stop local inference servers")]
325 Stop,
326 #[command(about = "Enable an optional local feature")]
327 Enable {
328 #[arg(
329 help = "Feature to enable (`deep` downloads the expander model for query expansion)"
330 )]
331 feature: LocalFeature,
332 },
333}
334
335#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
336pub enum LocalFeature {
337 Deep,
338}
339
340#[derive(Debug, Subcommand, PartialEq, Eq)]
341pub enum CollectionCommand {
342 #[command(about = "Add a directory as a document collection")]
343 Add {
344 #[arg(help = "Directory to index")]
345 path: PathBuf,
346 #[arg(long, help = "Collection name (defaults to the directory basename)")]
347 name: Option<String>,
348 #[arg(long, help = "Human-readable collection description")]
349 description: Option<String>,
350 #[arg(
351 long,
352 value_delimiter = ',',
353 help = "Only index files with these extensions (comma-separated)"
354 )]
355 extensions: Option<Vec<String>>,
356 #[arg(
357 long,
358 help = "Register the collection without running an initial indexing pass"
359 )]
360 no_index: bool,
361 },
362 #[command(about = "List all collections")]
363 List,
364 #[command(about = "Show details about a collection")]
365 Info {
366 #[arg(help = "Collection name")]
367 name: String,
368 },
369 #[command(about = "Set a collection description")]
370 Describe {
371 #[arg(help = "Collection name")]
372 name: String,
373 #[arg(help = "New description text")]
374 text: String,
375 },
376 #[command(about = "Rename a collection")]
377 Rename {
378 #[arg(help = "Current collection name")]
379 old: String,
380 #[arg(help = "New collection name")]
381 new: String,
382 },
383 #[command(about = "Remove a collection and its indexed data")]
384 Remove {
385 #[arg(help = "Collection to delete (chunks and embeddings are removed)")]
386 name: String,
387 },
388}
389
390#[derive(Debug, Subcommand, PartialEq, Eq)]
391pub enum IgnoreCommand {
392 #[command(about = "Show ignore patterns for a collection")]
393 Show {
394 #[arg(help = "Collection name")]
395 collection: String,
396 },
397 #[command(about = "Add an ignore pattern to a collection")]
398 Add {
399 #[arg(help = "Collection name")]
400 collection: String,
401 #[arg(help = "Gitignore-style pattern to add")]
402 pattern: String,
403 },
404 #[command(about = "Remove an ignore pattern from a collection")]
405 Remove {
406 #[arg(help = "Collection name")]
407 collection: String,
408 #[arg(help = "Exact pattern text to remove")]
409 pattern: String,
410 },
411 #[command(about = "Open ignore patterns in an editor")]
412 Edit {
413 #[arg(help = "Collection name")]
414 collection: String,
415 },
416 #[command(about = "List all collections with ignore patterns")]
417 List,
418}
419
420#[derive(Debug, Subcommand, PartialEq, Eq)]
421pub enum ModelsCommand {
422 #[command(about = "List configured models and their status")]
423 List,
424}
425
426#[derive(Debug, Subcommand, PartialEq, Eq)]
427pub enum EvalCommand {
428 #[command(about = "Run a retrieval evaluation")]
429 Run(EvalRunArgs),
430 #[command(about = "Import a benchmark dataset")]
431 Import(EvalImportArgs),
432}
433
434#[derive(Debug, Subcommand, PartialEq, Eq)]
435pub enum EvalImportCommand {
436 #[command(
437 about = "import a canonical BEIR dataset from an extracted directory",
438 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."
439 )]
440 Beir(EvalImportBeirArgs),
441}
442
443#[derive(Debug, Args, PartialEq, Eq)]
444pub struct EvalImportBeirArgs {
445 #[arg(
446 long,
447 value_name = "name",
448 help = "Dataset identifier used in eval reports (e.g. fiqa, scifact)"
449 )]
450 pub dataset: String,
451 #[arg(
452 long,
453 value_name = "dir",
454 help = "Extracted BEIR dataset directory (corpus.jsonl, queries.jsonl, qrels/)"
455 )]
456 pub source: PathBuf,
457 #[arg(
458 long,
459 value_name = "dir",
460 help = "Directory where the imported corpus and eval.toml will be written"
461 )]
462 pub output: PathBuf,
463 #[arg(
464 long,
465 value_name = "name",
466 help = "Override the collection name (defaults to the dataset name)"
467 )]
468 pub collection: Option<String>,
469}
470
471#[derive(Debug, Subcommand, PartialEq, Eq)]
472pub enum ScheduleCommand {
473 #[command(about = "Create a new re-indexing schedule")]
474 Add(ScheduleAddArgs),
475 #[command(about = "Show schedule status and last run info")]
476 Status,
477 #[command(about = "Remove a schedule")]
478 Remove(ScheduleRemoveArgs),
479}
480
481#[derive(Debug, Subcommand, PartialEq, Eq)]
482pub enum WatchCommand {
483 #[command(about = "Enable and start the background watcher")]
484 Enable,
485 #[command(about = "Disable the background watcher")]
486 Disable,
487 #[command(about = "Show watcher service and runtime status")]
488 Status,
489 #[command(about = "Show recent watcher activity")]
490 Logs {
491 #[arg(long, default_value_t = 80, help = "Number of log lines to show")]
492 lines: usize,
493 },
494}
495
496#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
497pub enum ScheduleDayArg {
498 Mon,
499 Tue,
500 Wed,
501 Thu,
502 Fri,
503 Sat,
504 Sun,
505}
506
507#[derive(Debug, Args, PartialEq, Eq)]
508#[command(group(
509 ArgGroup::new("trigger")
510 .required(true)
511 .args(["every", "at"])
512))]
513pub struct ScheduleAddArgs {
514 #[arg(
515 long,
516 conflicts_with = "at",
517 help = "Interval trigger (e.g. 30m, 2h); minimum 5 minutes"
518 )]
519 pub every: Option<String>,
520 #[arg(
521 long,
522 conflicts_with = "every",
523 help = "Daily trigger time in HH:MM (24-hour)"
524 )]
525 pub at: Option<String>,
526 #[arg(
527 long = "on",
528 value_delimiter = ',',
529 requires = "at",
530 value_enum,
531 help = "Days for the weekly trigger (comma-separated: mon,tue,...); requires --at"
532 )]
533 pub on: Vec<ScheduleDayArg>,
534 #[arg(long, help = "Restrict the schedule to a specific space")]
535 pub space: Option<String>,
536 #[arg(
537 long = "collection",
538 help = "Restrict the schedule to specific collections; requires --space"
539 )]
540 pub collections: Vec<String>,
541}
542
543#[derive(Debug, Args, PartialEq, Eq)]
544#[command(group(
545 ArgGroup::new("selector")
546 .required(true)
547 .multiple(true)
548 .args(["id", "all", "space", "collections"])
549))]
550pub struct ScheduleRemoveArgs {
551 #[arg(help = "Schedule ID to remove (from `kbolt schedule status`)")]
552 pub id: Option<String>,
553 #[arg(
554 long,
555 conflicts_with_all = ["id", "space", "collections"],
556 help = "Remove every configured schedule"
557 )]
558 pub all: bool,
559 #[arg(
560 long,
561 conflicts_with = "id",
562 help = "Remove all schedules for a specific space"
563 )]
564 pub space: Option<String>,
565 #[arg(
566 long = "collection",
567 conflicts_with = "id",
568 help = "Remove schedules for specific collections; requires --space"
569 )]
570 pub collections: Vec<String>,
571}
572
573#[cfg(test)]
574mod tests {
575 use std::path::PathBuf;
576
577 use clap::{CommandFactory, Parser};
578
579 use super::{
580 Cli, CollectionCommand, Command, EvalCommand, EvalImportArgs, EvalImportBeirArgs,
581 EvalImportCommand, EvalRunArgs, GetArgs, LocalCommand, LocalFeature, MultiGetArgs,
582 OutputFormat, ScheduleAddArgs, ScheduleCommand, ScheduleDayArg, ScheduleRemoveArgs,
583 SearchArgs, SetupCommand, SpaceCommand, UpdateArgs, WatchArgs, WatchCommand,
584 };
585
586 fn parse<const N: usize>(args: [&str; N]) -> Cli {
587 Cli::try_parse_from(args).expect("parse cli")
588 }
589
590 #[test]
591 fn parses_output_format_variants() {
592 let parsed = parse(["kbolt", "status"]);
593 assert_eq!(parsed.format, OutputFormat::Cli);
594 let parsed = parse(["kbolt", "--format", "json", "status"]);
595 assert_eq!(parsed.format, OutputFormat::Json);
596 }
597
598 #[test]
599 fn parses_doctor_command() {
600 let parsed = parse(["kbolt", "doctor"]);
601 assert!(matches!(parsed.command, Command::Doctor));
602 }
603
604 #[test]
605 fn parses_setup_local_command() {
606 let parsed = parse(["kbolt", "setup", "local"]);
607 assert!(matches!(
608 parsed.command,
609 Command::Setup(args) if args.command == SetupCommand::Local
610 ));
611 }
612
613 #[test]
614 fn setup_local_help_describes_side_effects() {
615 let mut cmd = Cli::command();
616 let setup = cmd.find_subcommand_mut("setup").expect("setup subcommand");
617 let local = setup
618 .find_subcommand_mut("local")
619 .expect("local setup subcommand");
620 let help = local.render_long_help().to_string();
621
622 assert!(help.contains("Requires llama-server"), "{help}");
623 assert!(help.contains("Downloads local GGUF models"), "{help}");
624 assert!(
625 help.contains("starts managed local inference services"),
626 "{help}"
627 );
628 assert!(help.contains("can take several minutes"), "{help}");
629 }
630
631 #[test]
632 fn parses_local_enable_deep_command() {
633 let parsed = parse(["kbolt", "local", "enable", "deep"]);
634 assert!(matches!(
635 parsed.command,
636 Command::Local(args)
637 if args.command == LocalCommand::Enable {
638 feature: LocalFeature::Deep
639 }
640 ));
641 }
642
643 #[test]
644 fn parses_global_space_override() {
645 let parsed = parse(["kbolt", "--space", "work", "space", "current"]);
646 assert_eq!(parsed.space.as_deref(), Some("work"));
647 assert!(matches!(
648 parsed.command,
649 Command::Space(space) if space.command == SpaceCommand::Current
650 ));
651 }
652
653 #[test]
654 fn parses_collection_add_with_options() {
655 let parsed = parse([
656 "kbolt",
657 "collection",
658 "add",
659 "/tmp/work-api",
660 "--name",
661 "api",
662 "--description",
663 "api docs",
664 "--extensions",
665 "rs,md",
666 "--no-index",
667 ]);
668 assert_eq!(parsed.space, None);
669
670 assert!(matches!(
671 parsed.command,
672 Command::Collection(collection)
673 if collection.command
674 == CollectionCommand::Add {
675 path: PathBuf::from("/tmp/work-api"),
676 name: Some("api".to_string()),
677 description: Some("api docs".to_string()),
678 extensions: Some(vec!["rs".to_string(), "md".to_string()]),
679 no_index: true
680 }
681 ));
682 }
683
684 #[test]
685 fn parses_update_with_defaults() {
686 let parsed = parse(["kbolt", "update"]);
687 assert_eq!(parsed.space, None);
688 assert!(matches!(
689 parsed.command,
690 Command::Update(UpdateArgs {
691 collections,
692 no_embed: false,
693 dry_run: false,
694 verbose: false,
695 }) if collections.is_empty()
696 ));
697 }
698
699 #[test]
700 fn parses_update_with_flags() {
701 let parsed = parse([
702 "kbolt",
703 "--space",
704 "work",
705 "update",
706 "--collection",
707 "api,wiki",
708 "--no-embed",
709 "--dry-run",
710 "--verbose",
711 ]);
712 assert_eq!(parsed.space.as_deref(), Some("work"));
713 assert!(matches!(
714 parsed.command,
715 Command::Update(UpdateArgs {
716 collections,
717 no_embed: true,
718 dry_run: true,
719 verbose: true,
720 }) if collections == vec!["api".to_string(), "wiki".to_string()]
721 ));
722 }
723
724 #[test]
725 fn ls_help_describes_prefix_as_directory_style() {
726 let mut command = Cli::command();
727 let ls = command.find_subcommand_mut("ls").expect("ls subcommand");
728 let help = ls.render_help().to_string();
729
730 assert!(help.contains("Only show files under this directory-style path prefix"));
731 assert!(!help.contains("path starts with this prefix"));
732 }
733
734 #[test]
735 fn parses_get_with_options() {
736 let parsed = parse(["kbolt", "get", "api/src/lib.rs"]);
737 assert_eq!(parsed.space, None);
738 assert!(matches!(
739 parsed.command,
740 Command::Get(GetArgs {
741 identifier,
742 offset: None,
743 limit: None,
744 }) if identifier == "api/src/lib.rs"
745 ));
746
747 let parsed = parse([
748 "kbolt", "--space", "work", "get", "#abc123", "--offset", "10", "--limit", "25",
749 ]);
750 assert_eq!(parsed.space.as_deref(), Some("work"));
751 assert!(matches!(
752 parsed.command,
753 Command::Get(GetArgs {
754 identifier,
755 offset: Some(10),
756 limit: Some(25),
757 }) if identifier == "#abc123"
758 ));
759 }
760
761 #[test]
762 fn parses_multi_get_with_options() {
763 let parsed = parse(["kbolt", "multi-get", "api/a.md,#abc123"]);
764 assert_eq!(parsed.space, None);
765 assert!(matches!(
766 parsed.command,
767 Command::MultiGet(MultiGetArgs {
768 locators,
769 max_files: 20,
770 max_bytes: 51_200,
771 }) if locators == vec!["api/a.md".to_string(), "#abc123".to_string()]
772 ));
773
774 let parsed = parse([
775 "kbolt",
776 "--space",
777 "work",
778 "multi-get",
779 "api/a.md,api/b.md",
780 "--max-files",
781 "5",
782 "--max-bytes",
783 "1024",
784 ]);
785 assert_eq!(parsed.space.as_deref(), Some("work"));
786 assert!(matches!(
787 parsed.command,
788 Command::MultiGet(MultiGetArgs {
789 locators,
790 max_files: 5,
791 max_bytes: 1024,
792 }) if locators == vec!["api/a.md".to_string(), "api/b.md".to_string()]
793 ));
794 }
795
796 #[test]
797 fn parses_search_with_defaults_and_flags() {
798 let parsed = parse(["kbolt", "search", "alpha"]);
799 assert_eq!(parsed.space, None);
800 assert!(matches!(
801 parsed.command,
802 Command::Search(SearchArgs {
803 query,
804 collections,
805 limit: 10,
806 min_score,
807 deep: false,
808 keyword: false,
809 semantic: false,
810 no_rerank: false,
811 rerank: false,
812 debug: false,
813 }) if query == "alpha" && collections.is_empty() && min_score == 0.0
814 ));
815
816 let parsed = parse([
817 "kbolt",
818 "--space",
819 "work",
820 "search",
821 "alpha beta",
822 "--collection",
823 "api,wiki",
824 "--limit",
825 "7",
826 "--min-score",
827 "0.25",
828 "--keyword",
829 "--no-rerank",
830 "--debug",
831 ]);
832 assert_eq!(parsed.space.as_deref(), Some("work"));
833 assert!(matches!(
834 parsed.command,
835 Command::Search(SearchArgs {
836 query,
837 collections,
838 limit: 7,
839 min_score,
840 deep: false,
841 keyword: true,
842 semantic: false,
843 no_rerank: true,
844 rerank: false,
845 debug: true,
846 }) if query == "alpha beta"
847 && collections == vec!["api".to_string(), "wiki".to_string()]
848 && min_score == 0.25
849 ));
850 }
851
852 #[test]
853 fn parses_search_rerank_opt_in_flag() {
854 let parsed = parse(["kbolt", "search", "alpha", "--rerank"]);
855 assert!(matches!(
856 parsed.command,
857 Command::Search(SearchArgs {
858 rerank: true,
859 no_rerank: false,
860 ..
861 })
862 ));
863 }
864
865 #[test]
866 fn parses_schedule_add_interval_and_weekly_variants() {
867 let parsed = parse(["kbolt", "schedule", "add", "--every", "30m"]);
868 assert!(matches!(
869 parsed.command,
870 Command::Schedule(schedule)
871 if schedule.command
872 == ScheduleCommand::Add(ScheduleAddArgs {
873 every: Some("30m".to_string()),
874 at: None,
875 on: vec![],
876 space: None,
877 collections: vec![],
878 })
879 ));
880
881 let parsed = parse([
882 "kbolt",
883 "--space",
884 "uiux",
885 "schedule",
886 "add",
887 "--at",
888 "3pm",
889 "--collection",
890 "docs",
891 ]);
892 assert_eq!(parsed.space.as_deref(), Some("uiux"));
893 assert!(matches!(
894 parsed.command,
895 Command::Schedule(schedule)
896 if schedule.command
897 == ScheduleCommand::Add(ScheduleAddArgs {
898 every: None,
899 at: Some("3pm".to_string()),
900 on: vec![],
901 space: None,
902 collections: vec!["docs".to_string()],
903 })
904 ));
905
906 let parsed = parse([
907 "kbolt",
908 "schedule",
909 "add",
910 "--at",
911 "3pm",
912 "--on",
913 "mon,fri",
914 "--space",
915 "work",
916 "--collection",
917 "api",
918 "--collection",
919 "docs",
920 ]);
921 assert!(matches!(
922 parsed.command,
923 Command::Schedule(schedule)
924 if schedule.command
925 == ScheduleCommand::Add(ScheduleAddArgs {
926 every: None,
927 at: Some("3pm".to_string()),
928 on: vec![ScheduleDayArg::Mon, ScheduleDayArg::Fri],
929 space: Some("work".to_string()),
930 collections: vec!["api".to_string(), "docs".to_string()],
931 })
932 ));
933 }
934
935 #[test]
936 fn parses_schedule_remove_selectors() {
937 let parsed = parse(["kbolt", "schedule", "remove", "s2"]);
938 assert!(matches!(
939 parsed.command,
940 Command::Schedule(schedule)
941 if schedule.command
942 == ScheduleCommand::Remove(ScheduleRemoveArgs {
943 id: Some("s2".to_string()),
944 all: false,
945 space: None,
946 collections: vec![],
947 })
948 ));
949
950 let parsed = parse([
951 "kbolt",
952 "schedule",
953 "remove",
954 "--space",
955 "work",
956 "--collection",
957 "api",
958 ]);
959 assert!(matches!(
960 parsed.command,
961 Command::Schedule(schedule)
962 if schedule.command
963 == ScheduleCommand::Remove(ScheduleRemoveArgs {
964 id: None,
965 all: false,
966 space: Some("work".to_string()),
967 collections: vec!["api".to_string()],
968 })
969 ));
970
971 let parsed = parse([
972 "kbolt",
973 "--space",
974 "uiux",
975 "schedule",
976 "remove",
977 "--collection",
978 "docs",
979 ]);
980 assert_eq!(parsed.space.as_deref(), Some("uiux"));
981 assert!(matches!(
982 parsed.command,
983 Command::Schedule(schedule)
984 if schedule.command
985 == ScheduleCommand::Remove(ScheduleRemoveArgs {
986 id: None,
987 all: false,
988 space: None,
989 collections: vec!["docs".to_string()],
990 })
991 ));
992 }
993
994 #[test]
995 fn parses_watch_commands_and_foreground_flag() {
996 let parsed = parse(["kbolt", "watch"]);
997 assert!(matches!(
998 parsed.command,
999 Command::Watch(WatchArgs {
1000 foreground: false,
1001 command: None,
1002 })
1003 ));
1004
1005 let parsed = parse(["kbolt", "watch", "enable"]);
1006 assert!(matches!(
1007 parsed.command,
1008 Command::Watch(WatchArgs {
1009 foreground: false,
1010 command: Some(WatchCommand::Enable),
1011 })
1012 ));
1013
1014 let parsed = parse(["kbolt", "watch", "--foreground"]);
1015 assert!(matches!(
1016 parsed.command,
1017 Command::Watch(WatchArgs {
1018 foreground: true,
1019 command: None,
1020 })
1021 ));
1022
1023 let parsed = parse(["kbolt", "watch", "logs", "--lines", "20"]);
1024 assert!(matches!(
1025 parsed.command,
1026 Command::Watch(WatchArgs {
1027 foreground: false,
1028 command: Some(WatchCommand::Logs { lines: 20 }),
1029 })
1030 ));
1031 }
1032
1033 #[test]
1034 fn parses_eval_run_with_optional_manifest_path() {
1035 let parsed = parse(["kbolt", "eval", "run"]);
1036 assert!(matches!(
1037 parsed.command,
1038 Command::Eval(eval) if eval.command == EvalCommand::Run(EvalRunArgs { file: None })
1039 ));
1040
1041 let parsed = parse(["kbolt", "eval", "run", "--file", "/tmp/scifact.toml"]);
1042 assert!(matches!(
1043 parsed.command,
1044 Command::Eval(eval)
1045 if eval.command
1046 == EvalCommand::Run(EvalRunArgs {
1047 file: Some(PathBuf::from("/tmp/scifact.toml"))
1048 })
1049 ));
1050 }
1051
1052 #[test]
1053 fn parses_eval_import_beir_with_required_paths() {
1054 let parsed = parse([
1055 "kbolt",
1056 "eval",
1057 "import",
1058 "beir",
1059 "--dataset",
1060 "fiqa",
1061 "--source",
1062 "/tmp/fiqa-source",
1063 "--output",
1064 "/tmp/fiqa-bench",
1065 ]);
1066
1067 let Command::Eval(eval) = parsed.command else {
1068 panic!("expected eval command");
1069 };
1070 assert_eq!(
1071 eval.command,
1072 EvalCommand::Import(EvalImportArgs {
1073 dataset: EvalImportCommand::Beir(EvalImportBeirArgs {
1074 dataset: "fiqa".to_string(),
1075 source: PathBuf::from("/tmp/fiqa-source"),
1076 output: PathBuf::from("/tmp/fiqa-bench"),
1077 collection: None,
1078 })
1079 })
1080 );
1081 }
1082
1083 #[test]
1084 fn parses_eval_import_beir_with_collection_override() {
1085 let parsed = parse([
1086 "kbolt",
1087 "eval",
1088 "import",
1089 "beir",
1090 "--dataset",
1091 "fiqa",
1092 "--source",
1093 "/tmp/fiqa-source",
1094 "--output",
1095 "/tmp/fiqa-bench",
1096 "--collection",
1097 "finance",
1098 ]);
1099
1100 let Command::Eval(eval) = parsed.command else {
1101 panic!("expected eval command");
1102 };
1103 assert_eq!(
1104 eval.command,
1105 EvalCommand::Import(EvalImportArgs {
1106 dataset: EvalImportCommand::Beir(EvalImportBeirArgs {
1107 dataset: "fiqa".to_string(),
1108 source: PathBuf::from("/tmp/fiqa-source"),
1109 output: PathBuf::from("/tmp/fiqa-bench"),
1110 collection: Some("finance".to_string()),
1111 })
1112 })
1113 );
1114 }
1115}