1pub(crate) mod commands;
52pub(crate) mod invocation;
53pub(crate) mod pipeline;
54pub(crate) mod rows;
55use crate::config::{ConfigLayer, RuntimeLoadOptions};
56use clap::{Args, Parser, Subcommand, ValueEnum};
57use std::path::PathBuf;
58
59use crate::ui::UiPresentation;
60
61pub use pipeline::{
62 ParsedCommandLine, is_cli_help_stage, parse_command_text_with_aliases,
63 parse_command_tokens_with_aliases, validate_cli_dsl_stages,
64};
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
67enum PresentationArg {
68 Expressive,
69 Compact,
70 #[value(alias = "gammel-og-bitter")]
71 Austere,
72}
73
74impl From<PresentationArg> for UiPresentation {
75 fn from(value: PresentationArg) -> Self {
76 match value {
77 PresentationArg::Expressive => UiPresentation::Expressive,
78 PresentationArg::Compact => UiPresentation::Compact,
79 PresentationArg::Austere => UiPresentation::Austere,
80 }
81 }
82}
83
84#[derive(Debug, Parser)]
86#[command(
87 name = "osp",
88 version = env!("CARGO_PKG_VERSION"),
89 about = "OSP CLI",
90 after_help = "Use `osp plugins commands` to list plugin-provided commands."
91)]
92pub struct Cli {
93 #[arg(short = 'u', long = "user")]
95 pub user: Option<String>,
96
97 #[arg(short = 'i', long = "incognito", global = true)]
99 pub incognito: bool,
100
101 #[arg(long = "profile", global = true)]
103 pub profile: Option<String>,
104
105 #[arg(long = "no-env", global = true)]
107 pub no_env: bool,
108
109 #[arg(long = "no-config-file", alias = "no-config", global = true)]
111 pub no_config_file: bool,
112
113 #[arg(long = "defaults-only", global = true)]
119 pub defaults_only: bool,
120
121 #[arg(long = "plugin-dir", global = true)]
123 pub plugin_dirs: Vec<PathBuf>,
124
125 #[arg(long = "theme", global = true)]
127 pub theme: Option<String>,
128
129 #[arg(long = "presentation", alias = "app-style", global = true)]
130 presentation: Option<PresentationArg>,
131
132 #[arg(
133 long = "gammel-og-bitter",
134 conflicts_with = "presentation",
135 global = true
136 )]
137 gammel_og_bitter: bool,
138
139 #[command(subcommand)]
141 pub command: Option<Commands>,
142}
143
144impl Cli {
145 pub fn runtime_load_options(&self) -> RuntimeLoadOptions {
147 runtime_load_options_from_flags(self.no_env, self.no_config_file, self.defaults_only)
148 }
149}
150
151#[derive(Debug, Subcommand)]
153pub enum Commands {
154 Plugins(PluginsArgs),
156 Doctor(DoctorArgs),
158 Theme(ThemeArgs),
160 Config(ConfigArgs),
162 History(HistoryArgs),
164 #[command(hide = true)]
165 Intro(IntroArgs),
167 #[command(hide = true)]
168 Repl(ReplArgs),
170 #[command(external_subcommand)]
171 External(Vec<String>),
173}
174
175#[derive(Debug, Parser)]
177#[command(name = "osp", no_binary_name = true)]
178pub struct InlineCommandCli {
179 #[command(subcommand)]
181 pub command: Option<Commands>,
182}
183
184#[derive(Debug, Args)]
186pub struct ReplArgs {
187 #[command(subcommand)]
189 pub command: ReplCommands,
190}
191
192#[derive(Debug, Subcommand)]
194pub enum ReplCommands {
195 #[command(name = "debug-complete", hide = true)]
196 DebugComplete(DebugCompleteArgs),
198 #[command(name = "debug-highlight", hide = true)]
199 DebugHighlight(DebugHighlightArgs),
201}
202
203#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
205pub enum DebugMenuArg {
206 Completion,
208 History,
210}
211
212#[derive(Debug, Args)]
214pub struct DebugCompleteArgs {
215 #[arg(long)]
217 pub line: String,
218
219 #[arg(long = "menu", value_enum, default_value_t = DebugMenuArg::Completion)]
221 pub menu: DebugMenuArg,
222
223 #[arg(long)]
225 pub cursor: Option<usize>,
226
227 #[arg(long, default_value_t = 80)]
229 pub width: u16,
230
231 #[arg(long, default_value_t = 24)]
233 pub height: u16,
234
235 #[arg(long = "step")]
237 pub steps: Vec<String>,
238
239 #[arg(long = "menu-ansi", default_value_t = false)]
241 pub menu_ansi: bool,
242
243 #[arg(long = "menu-unicode", default_value_t = false)]
245 pub menu_unicode: bool,
246}
247
248#[derive(Debug, Args)]
250pub struct DebugHighlightArgs {
251 #[arg(long)]
253 pub line: String,
254}
255
256#[derive(Debug, Args)]
258pub struct PluginsArgs {
259 #[command(subcommand)]
261 pub command: PluginsCommands,
262}
263
264#[derive(Debug, Args)]
266pub struct DoctorArgs {
267 #[command(subcommand)]
269 pub command: Option<DoctorCommands>,
270}
271
272#[derive(Debug, Subcommand)]
274pub enum DoctorCommands {
275 All,
277 Config,
279 Last,
281 Plugins,
283 Theme,
285}
286
287#[derive(Debug, Subcommand)]
289pub enum PluginsCommands {
290 List,
292 Commands,
294 Config(PluginConfigArgs),
296 Refresh,
298 Enable(PluginCommandStateArgs),
300 Disable(PluginCommandStateArgs),
302 ClearState(PluginCommandClearArgs),
304 SelectProvider(PluginProviderSelectArgs),
306 ClearProvider(PluginProviderClearArgs),
308 Doctor,
310}
311
312#[derive(Debug, Args)]
314pub struct ThemeArgs {
315 #[command(subcommand)]
317 pub command: ThemeCommands,
318}
319
320#[derive(Debug, Subcommand)]
322pub enum ThemeCommands {
323 List,
325 Show(ThemeShowArgs),
327 Use(ThemeUseArgs),
329}
330
331#[derive(Debug, Args)]
333pub struct ThemeShowArgs {
334 pub name: Option<String>,
336}
337
338#[derive(Debug, Args)]
340pub struct ThemeUseArgs {
341 pub name: String,
343}
344
345#[derive(Debug, Args, Clone, Default)]
347pub struct PluginScopeArgs {
348 #[arg(long = "global", conflicts_with = "profile")]
350 pub global: bool,
351
352 #[arg(long = "profile")]
354 pub profile: Option<String>,
355
356 #[arg(
358 long = "terminal",
359 num_args = 0..=1,
360 default_missing_value = "__current__"
361 )]
362 pub terminal: Option<String>,
363}
364
365#[derive(Debug, Args, Clone, Default)]
367pub struct ConfigScopeArgs {
368 #[arg(long = "global", conflicts_with_all = ["profile", "profile_all"])]
370 pub global: bool,
371
372 #[arg(long = "profile", conflicts_with = "profile_all")]
374 pub profile: Option<String>,
375
376 #[arg(long = "profile-all", conflicts_with = "profile")]
378 pub profile_all: bool,
379
380 #[arg(
382 long = "terminal",
383 num_args = 0..=1,
384 default_missing_value = "__current__"
385 )]
386 pub terminal: Option<String>,
387}
388
389#[derive(Debug, Args, Clone, Default)]
391pub struct ConfigStoreArgs {
392 #[arg(long = "session", conflicts_with_all = ["config_store", "secrets", "save"])]
394 pub session: bool,
395
396 #[arg(long = "config", conflicts_with_all = ["session", "secrets"])]
398 pub config_store: bool,
399
400 #[arg(long = "secrets", conflicts_with_all = ["session", "config_store"])]
402 pub secrets: bool,
403
404 #[arg(long = "save", conflicts_with_all = ["session", "config_store", "secrets"])]
406 pub save: bool,
407}
408
409#[derive(Debug, Args, Clone)]
411pub struct PluginCommandTargetArgs {
412 pub command: String,
414
415 #[command(flatten)]
417 pub scope: PluginScopeArgs,
418}
419
420#[derive(Debug, Args, Clone, Default)]
422pub struct ConfigReadOutputArgs {
423 #[arg(long = "sources")]
425 pub sources: bool,
426
427 #[arg(long = "raw")]
429 pub raw: bool,
430}
431
432#[derive(Debug, Args)]
434pub struct PluginCommandStateArgs {
435 #[command(flatten)]
437 pub target: PluginCommandTargetArgs,
438}
439
440#[derive(Debug, Args)]
442pub struct PluginCommandClearArgs {
443 #[command(flatten)]
445 pub target: PluginCommandTargetArgs,
446}
447
448#[derive(Debug, Args)]
450pub struct PluginProviderSelectArgs {
451 #[command(flatten)]
453 pub target: PluginCommandTargetArgs,
454
455 pub plugin_id: String,
457}
458
459#[derive(Debug, Args)]
461pub struct PluginProviderClearArgs {
462 #[command(flatten)]
464 pub target: PluginCommandTargetArgs,
465}
466
467#[derive(Debug, Args)]
469pub struct PluginConfigArgs {
470 pub plugin_id: String,
472}
473
474#[derive(Debug, Args)]
476pub struct ConfigArgs {
477 #[command(subcommand)]
479 pub command: ConfigCommands,
480}
481
482#[derive(Debug, Args)]
484pub struct HistoryArgs {
485 #[command(subcommand)]
487 pub command: HistoryCommands,
488}
489
490#[derive(Debug, Args, Clone, Default)]
492pub struct IntroArgs {}
493
494#[derive(Debug, Subcommand)]
496pub enum HistoryCommands {
497 List,
499 Prune(HistoryPruneArgs),
501 Clear,
503}
504
505#[derive(Debug, Args)]
507pub struct HistoryPruneArgs {
508 pub keep: usize,
510}
511
512#[derive(Debug, Subcommand)]
514pub enum ConfigCommands {
515 Show(ConfigShowArgs),
517 Get(ConfigGetArgs),
519 Explain(ConfigExplainArgs),
521 Set(ConfigSetArgs),
523 Unset(ConfigUnsetArgs),
525 #[command(alias = "diagnostics")]
526 Doctor,
528}
529
530#[derive(Debug, Args)]
532pub struct ConfigShowArgs {
533 #[command(flatten)]
535 pub output: ConfigReadOutputArgs,
536}
537
538#[derive(Debug, Args)]
540pub struct ConfigGetArgs {
541 pub key: String,
543
544 #[command(flatten)]
546 pub output: ConfigReadOutputArgs,
547}
548
549#[derive(Debug, Args)]
551pub struct ConfigExplainArgs {
552 pub key: String,
554
555 #[arg(long = "show-secrets")]
557 pub show_secrets: bool,
558}
559
560#[derive(Debug, Args)]
562pub struct ConfigSetArgs {
563 pub key: String,
565 pub value: String,
567
568 #[command(flatten)]
570 pub scope: ConfigScopeArgs,
571
572 #[command(flatten)]
574 pub store: ConfigStoreArgs,
575
576 #[arg(long = "dry-run")]
578 pub dry_run: bool,
579
580 #[arg(long = "yes")]
582 pub yes: bool,
583
584 #[arg(long = "explain")]
586 pub explain: bool,
587}
588
589#[derive(Debug, Args)]
591pub struct ConfigUnsetArgs {
592 pub key: String,
594
595 #[command(flatten)]
597 pub scope: ConfigScopeArgs,
598
599 #[command(flatten)]
601 pub store: ConfigStoreArgs,
602
603 #[arg(long = "dry-run")]
605 pub dry_run: bool,
606}
607
608impl Cli {
609 pub(crate) fn append_static_session_overrides(&self, layer: &mut ConfigLayer) {
610 if let Some(user) = self
611 .user
612 .as_deref()
613 .map(str::trim)
614 .filter(|value| !value.is_empty())
615 {
616 layer.set("user.name", user);
617 }
618 if self.incognito {
619 layer.set("repl.history.enabled", false);
620 }
621 append_appearance_overrides(
622 layer,
623 self.theme.as_deref(),
624 if self.gammel_og_bitter {
625 Some(UiPresentation::Austere)
626 } else {
627 self.presentation.map(UiPresentation::from)
628 },
629 );
630 }
631}
632
633pub(crate) fn append_appearance_overrides(
634 layer: &mut ConfigLayer,
635 theme: Option<&str>,
636 presentation: Option<UiPresentation>,
637) {
638 if let Some(theme) = theme.map(str::trim).filter(|value| !value.is_empty()) {
639 layer.set("theme.name", theme);
640 }
641 if let Some(presentation) = presentation {
642 layer.set("ui.presentation", presentation.as_config_value());
643 }
644}
645
646pub(crate) fn runtime_load_options_from_flags(
647 no_env: bool,
648 no_config_file: bool,
649 defaults_only: bool,
650) -> RuntimeLoadOptions {
651 if defaults_only {
652 RuntimeLoadOptions::defaults_only()
653 } else {
654 RuntimeLoadOptions::new()
655 .with_env(!no_env)
656 .with_config_file(!no_config_file)
657 }
658}
659
660pub fn parse_inline_command_tokens(tokens: &[String]) -> Result<Option<Commands>, clap::Error> {
689 InlineCommandCli::try_parse_from(tokens.iter().map(String::as_str)).map(|parsed| parsed.command)
690}
691
692#[cfg(test)]
693mod tests {
694 use super::{
695 Cli, Commands, ConfigCommands, InlineCommandCli, RuntimeLoadOptions,
696 append_appearance_overrides, parse_inline_command_tokens,
697 };
698 use crate::config::{ConfigLayer, ConfigValue};
699 use clap::Parser;
700
701 #[test]
702 fn parse_inline_command_tokens_accepts_builtin_and_external_commands_unit() {
703 let builtin = parse_inline_command_tokens(&["config".to_string(), "doctor".to_string()])
704 .expect("builtin command should parse");
705 assert!(matches!(
706 builtin,
707 Some(Commands::Config(args)) if matches!(args.command, ConfigCommands::Doctor)
708 ));
709
710 let external = parse_inline_command_tokens(&["ldap".to_string(), "user".to_string()])
711 .expect("external command should parse");
712 assert!(
713 matches!(external, Some(Commands::External(tokens)) if tokens == vec!["ldap", "user"])
714 );
715 }
716
717 #[test]
718 fn cli_runtime_load_options_and_inline_parser_follow_disable_flags_unit() {
719 let cli = Cli::parse_from(["osp", "--no-env", "--no-config-file", "theme", "list"]);
720 assert_eq!(
721 cli.runtime_load_options(),
722 RuntimeLoadOptions::new()
723 .with_env(false)
724 .with_config_file(false)
725 );
726
727 let cli = Cli::parse_from(["osp", "--defaults-only", "theme", "list"]);
728 assert_eq!(
729 cli.runtime_load_options(),
730 RuntimeLoadOptions::defaults_only()
731 );
732
733 let inline = InlineCommandCli::try_parse_from(["theme", "list"])
734 .expect("inline command should parse");
735 assert!(matches!(inline.command, Some(Commands::Theme(_))));
736 }
737
738 #[test]
739 fn app_style_alias_maps_to_presentation_unit() {
740 let cli = Cli::parse_from(["osp", "--app-style", "austere"]);
741 let mut layer = ConfigLayer::default();
742 cli.append_static_session_overrides(&mut layer);
743 assert_eq!(
744 layer
745 .entries()
746 .iter()
747 .find(|entry| entry.key == "ui.presentation")
748 .map(|entry| &entry.value),
749 Some(&ConfigValue::from("austere"))
750 );
751 }
752
753 #[test]
754 fn appearance_overrides_trim_theme_and_apply_presentation_unit() {
755 let mut layer = ConfigLayer::default();
756 append_appearance_overrides(
757 &mut layer,
758 Some(" nord "),
759 Some(crate::ui::UiPresentation::Compact),
760 );
761
762 assert_eq!(
763 layer
764 .entries()
765 .iter()
766 .find(|entry| entry.key == "theme.name")
767 .map(|entry| &entry.value),
768 Some(&ConfigValue::from("nord"))
769 );
770 assert_eq!(
771 layer
772 .entries()
773 .iter()
774 .find(|entry| entry.key == "ui.presentation")
775 .map(|entry| &entry.value),
776 Some(&ConfigValue::from("compact"))
777 );
778 }
779}