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