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 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}