1pub(crate) mod commands;
52pub(crate) mod invocation;
53pub(crate) mod pipeline;
54pub(crate) mod rows;
55use crate::config::{ConfigLayer, ConfigValue, ResolvedConfig, RuntimeLoadOptions};
56use crate::core::output::{ColorMode, OutputFormat, RenderMode, UnicodeMode};
57use crate::ui::chrome::{RuledSectionPolicy, SectionFrameStyle};
58use crate::ui::theme::DEFAULT_THEME_NAME;
59use crate::ui::{
60 GuideDefaultFormat, HelpTableChrome, RenderSettings, StyleOverrides, TableBorderStyle,
61 TableOverflow,
62};
63use clap::{Args, Parser, Subcommand, ValueEnum};
64use std::path::PathBuf;
65
66use crate::ui::presentation::UiPresentation;
67
68pub use pipeline::{
69 ParsedCommandLine, is_cli_help_stage, parse_command_text_with_aliases,
70 parse_command_tokens_with_aliases, validate_cli_dsl_stages,
71};
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
74enum PresentationArg {
75 Expressive,
76 Compact,
77 #[value(alias = "gammel-og-bitter")]
78 Austere,
79}
80
81impl From<PresentationArg> for UiPresentation {
82 fn from(value: PresentationArg) -> Self {
83 match value {
84 PresentationArg::Expressive => UiPresentation::Expressive,
85 PresentationArg::Compact => UiPresentation::Compact,
86 PresentationArg::Austere => UiPresentation::Austere,
87 }
88 }
89}
90
91#[derive(Debug, Parser)]
93#[command(
94 name = "osp",
95 version = env!("CARGO_PKG_VERSION"),
96 about = "OSP CLI",
97 after_help = "Use `osp plugins commands` to list plugin-provided commands."
98)]
99pub struct Cli {
100 #[arg(short = 'u', long = "user")]
102 pub user: Option<String>,
103
104 #[arg(short = 'i', long = "incognito", global = true)]
106 pub incognito: bool,
107
108 #[arg(long = "profile", global = true)]
110 pub profile: Option<String>,
111
112 #[arg(long = "no-env", global = true)]
114 pub no_env: bool,
115
116 #[arg(long = "no-config-file", alias = "no-config", global = true)]
118 pub no_config_file: bool,
119
120 #[arg(long = "defaults-only", global = true)]
126 pub defaults_only: bool,
127
128 #[arg(long = "plugin-dir", global = true)]
130 pub plugin_dirs: Vec<PathBuf>,
131
132 #[arg(long = "theme", global = true)]
134 pub theme: Option<String>,
135
136 #[arg(long = "presentation", alias = "app-style", global = true)]
137 presentation: Option<PresentationArg>,
138
139 #[arg(
140 long = "gammel-og-bitter",
141 conflicts_with = "presentation",
142 global = true
143 )]
144 gammel_og_bitter: bool,
145
146 #[command(subcommand)]
148 pub command: Option<Commands>,
149}
150
151impl Cli {
152 pub fn runtime_load_options(&self) -> RuntimeLoadOptions {
154 if self.defaults_only {
155 RuntimeLoadOptions::defaults_only()
156 } else {
157 RuntimeLoadOptions::new()
158 .with_env(!self.no_env)
159 .with_config_file(!self.no_config_file)
160 }
161 }
162}
163
164#[derive(Debug, Subcommand)]
166pub enum Commands {
167 Plugins(PluginsArgs),
169 Doctor(DoctorArgs),
171 Theme(ThemeArgs),
173 Config(ConfigArgs),
175 History(HistoryArgs),
177 #[command(hide = true)]
178 Intro(IntroArgs),
180 #[command(hide = true)]
181 Repl(ReplArgs),
183 #[command(external_subcommand)]
184 External(Vec<String>),
186}
187
188#[derive(Debug, Parser)]
190#[command(name = "osp", no_binary_name = true)]
191pub struct InlineCommandCli {
192 #[command(subcommand)]
194 pub command: Option<Commands>,
195}
196
197#[derive(Debug, Args)]
199pub struct ReplArgs {
200 #[command(subcommand)]
202 pub command: ReplCommands,
203}
204
205#[derive(Debug, Subcommand)]
207pub enum ReplCommands {
208 #[command(name = "debug-complete", hide = true)]
209 DebugComplete(DebugCompleteArgs),
211 #[command(name = "debug-highlight", hide = true)]
212 DebugHighlight(DebugHighlightArgs),
214}
215
216#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
218pub enum DebugMenuArg {
219 Completion,
221 History,
223}
224
225#[derive(Debug, Args)]
227pub struct DebugCompleteArgs {
228 #[arg(long)]
230 pub line: String,
231
232 #[arg(long = "menu", value_enum, default_value_t = DebugMenuArg::Completion)]
234 pub menu: DebugMenuArg,
235
236 #[arg(long)]
238 pub cursor: Option<usize>,
239
240 #[arg(long, default_value_t = 80)]
242 pub width: u16,
243
244 #[arg(long, default_value_t = 24)]
246 pub height: u16,
247
248 #[arg(long = "step")]
250 pub steps: Vec<String>,
251
252 #[arg(long = "menu-ansi", default_value_t = false)]
254 pub menu_ansi: bool,
255
256 #[arg(long = "menu-unicode", default_value_t = false)]
258 pub menu_unicode: bool,
259}
260
261#[derive(Debug, Args)]
263pub struct DebugHighlightArgs {
264 #[arg(long)]
266 pub line: String,
267}
268
269#[derive(Debug, Args)]
271pub struct PluginsArgs {
272 #[command(subcommand)]
274 pub command: PluginsCommands,
275}
276
277#[derive(Debug, Args)]
279pub struct DoctorArgs {
280 #[command(subcommand)]
282 pub command: Option<DoctorCommands>,
283}
284
285#[derive(Debug, Subcommand)]
287pub enum DoctorCommands {
288 All,
290 Config,
292 Last,
294 Plugins,
296 Theme,
298}
299
300#[derive(Debug, Subcommand)]
302pub enum PluginsCommands {
303 List,
305 Commands,
307 Config(PluginConfigArgs),
309 Refresh,
311 Enable(PluginCommandStateArgs),
313 Disable(PluginCommandStateArgs),
315 ClearState(PluginCommandClearArgs),
317 SelectProvider(PluginProviderSelectArgs),
319 ClearProvider(PluginProviderClearArgs),
321 Doctor,
323}
324
325#[derive(Debug, Args)]
327pub struct ThemeArgs {
328 #[command(subcommand)]
330 pub command: ThemeCommands,
331}
332
333#[derive(Debug, Subcommand)]
335pub enum ThemeCommands {
336 List,
338 Show(ThemeShowArgs),
340 Use(ThemeUseArgs),
342}
343
344#[derive(Debug, Args)]
346pub struct ThemeShowArgs {
347 pub name: Option<String>,
349}
350
351#[derive(Debug, Args)]
353pub struct ThemeUseArgs {
354 pub name: String,
356}
357
358#[derive(Debug, Args)]
360pub struct PluginCommandStateArgs {
361 pub command: String,
363
364 #[arg(long = "global", conflicts_with = "profile")]
366 pub global: bool,
367
368 #[arg(long = "profile")]
370 pub profile: Option<String>,
371
372 #[arg(
374 long = "terminal",
375 num_args = 0..=1,
376 default_missing_value = "__current__"
377 )]
378 pub terminal: Option<String>,
379}
380
381#[derive(Debug, Args)]
383pub struct PluginCommandClearArgs {
384 pub command: String,
386
387 #[arg(long = "global", conflicts_with = "profile")]
389 pub global: bool,
390
391 #[arg(long = "profile")]
393 pub profile: Option<String>,
394
395 #[arg(
397 long = "terminal",
398 num_args = 0..=1,
399 default_missing_value = "__current__"
400 )]
401 pub terminal: Option<String>,
402}
403
404#[derive(Debug, Args)]
406pub struct PluginProviderSelectArgs {
407 pub command: String,
409 pub plugin_id: String,
411
412 #[arg(long = "global", conflicts_with = "profile")]
414 pub global: bool,
415
416 #[arg(long = "profile")]
418 pub profile: Option<String>,
419
420 #[arg(
422 long = "terminal",
423 num_args = 0..=1,
424 default_missing_value = "__current__"
425 )]
426 pub terminal: Option<String>,
427}
428
429#[derive(Debug, Args)]
431pub struct PluginProviderClearArgs {
432 pub command: String,
434
435 #[arg(long = "global", conflicts_with = "profile")]
437 pub global: bool,
438
439 #[arg(long = "profile")]
441 pub profile: Option<String>,
442
443 #[arg(
445 long = "terminal",
446 num_args = 0..=1,
447 default_missing_value = "__current__"
448 )]
449 pub terminal: Option<String>,
450}
451
452#[derive(Debug, Args)]
454pub struct PluginConfigArgs {
455 pub plugin_id: String,
457}
458
459#[derive(Debug, Args)]
461pub struct ConfigArgs {
462 #[command(subcommand)]
464 pub command: ConfigCommands,
465}
466
467#[derive(Debug, Args)]
469pub struct HistoryArgs {
470 #[command(subcommand)]
472 pub command: HistoryCommands,
473}
474
475#[derive(Debug, Args, Clone, Default)]
477pub struct IntroArgs {}
478
479#[derive(Debug, Subcommand)]
481pub enum HistoryCommands {
482 List,
484 Prune(HistoryPruneArgs),
486 Clear,
488}
489
490#[derive(Debug, Args)]
492pub struct HistoryPruneArgs {
493 pub keep: usize,
495}
496
497#[derive(Debug, Subcommand)]
499pub enum ConfigCommands {
500 Show(ConfigShowArgs),
502 Get(ConfigGetArgs),
504 Explain(ConfigExplainArgs),
506 Set(ConfigSetArgs),
508 Unset(ConfigUnsetArgs),
510 #[command(alias = "diagnostics")]
511 Doctor,
513}
514
515#[derive(Debug, Args)]
517pub struct ConfigShowArgs {
518 #[arg(long = "sources")]
520 pub sources: bool,
521
522 #[arg(long = "raw")]
524 pub raw: bool,
525}
526
527#[derive(Debug, Args)]
529pub struct ConfigGetArgs {
530 pub key: String,
532
533 #[arg(long = "sources")]
535 pub sources: bool,
536
537 #[arg(long = "raw")]
539 pub raw: bool,
540}
541
542#[derive(Debug, Args)]
544pub struct ConfigExplainArgs {
545 pub key: String,
547
548 #[arg(long = "show-secrets")]
550 pub show_secrets: bool,
551}
552
553#[derive(Debug, Args)]
555pub struct ConfigSetArgs {
556 pub key: String,
558 pub value: String,
560
561 #[arg(long = "global", conflicts_with_all = ["profile", "profile_all"])]
563 pub global: bool,
564
565 #[arg(long = "profile", conflicts_with = "profile_all")]
567 pub profile: Option<String>,
568
569 #[arg(long = "profile-all", conflicts_with = "profile")]
571 pub profile_all: bool,
572
573 #[arg(
575 long = "terminal",
576 num_args = 0..=1,
577 default_missing_value = "__current__"
578 )]
579 pub terminal: Option<String>,
580
581 #[arg(long = "session", conflicts_with_all = ["config_store", "secrets", "save"])]
583 pub session: bool,
584
585 #[arg(long = "config", conflicts_with_all = ["session", "secrets"])]
587 pub config_store: bool,
588
589 #[arg(long = "secrets", conflicts_with_all = ["session", "config_store"])]
591 pub secrets: bool,
592
593 #[arg(long = "save", conflicts_with_all = ["session", "config_store", "secrets"])]
595 pub save: bool,
596
597 #[arg(long = "dry-run")]
599 pub dry_run: bool,
600
601 #[arg(long = "yes")]
603 pub yes: bool,
604
605 #[arg(long = "explain")]
607 pub explain: bool,
608}
609
610#[derive(Debug, Args)]
612pub struct ConfigUnsetArgs {
613 pub key: String,
615
616 #[arg(long = "global", conflicts_with_all = ["profile", "profile_all"])]
618 pub global: bool,
619
620 #[arg(long = "profile", conflicts_with = "profile_all")]
622 pub profile: Option<String>,
623
624 #[arg(long = "profile-all", conflicts_with = "profile")]
626 pub profile_all: bool,
627
628 #[arg(
630 long = "terminal",
631 num_args = 0..=1,
632 default_missing_value = "__current__"
633 )]
634 pub terminal: Option<String>,
635
636 #[arg(long = "session", conflicts_with_all = ["config_store", "secrets", "save"])]
638 pub session: bool,
639
640 #[arg(long = "config", conflicts_with_all = ["session", "secrets"])]
642 pub config_store: bool,
643
644 #[arg(long = "secrets", conflicts_with_all = ["session", "config_store"])]
646 pub secrets: bool,
647
648 #[arg(long = "save", conflicts_with_all = ["session", "config_store", "secrets"])]
650 pub save: bool,
651
652 #[arg(long = "dry-run")]
654 pub dry_run: bool,
655}
656
657impl Cli {
658 pub(crate) fn default_invocation() -> Self {
659 Self {
660 user: None,
661 incognito: false,
662 profile: None,
663 no_env: false,
664 no_config_file: false,
665 defaults_only: false,
666 plugin_dirs: Vec::new(),
667 theme: None,
668 presentation: None,
669 gammel_og_bitter: false,
670 command: None,
671 }
672 }
673
674 pub fn render_settings(&self) -> RenderSettings {
676 default_render_settings()
677 }
678
679 pub fn seed_render_settings_from_config(
681 &self,
682 settings: &mut RenderSettings,
683 config: &ResolvedConfig,
684 ) {
685 apply_render_settings_from_config(settings, config);
686 }
687
688 pub fn selected_theme_name(&self, config: &ResolvedConfig) -> String {
690 self.theme
691 .as_deref()
692 .or_else(|| config.get_string("theme.name"))
693 .unwrap_or(DEFAULT_THEME_NAME)
694 .to_string()
695 }
696
697 pub(crate) fn append_static_session_overrides(&self, layer: &mut ConfigLayer) {
698 if let Some(user) = self
699 .user
700 .as_deref()
701 .map(str::trim)
702 .filter(|value| !value.is_empty())
703 {
704 layer.set("user.name", user);
705 }
706 if self.incognito {
707 layer.set("repl.history.enabled", false);
708 }
709 if let Some(theme) = self
710 .theme
711 .as_deref()
712 .map(str::trim)
713 .filter(|value| !value.is_empty())
714 {
715 layer.set("theme.name", theme);
716 }
717 if self.gammel_og_bitter {
718 layer.set("ui.presentation", UiPresentation::Austere.as_config_value());
719 } else if let Some(presentation) = self.presentation {
720 layer.set(
721 "ui.presentation",
722 UiPresentation::from(presentation).as_config_value(),
723 );
724 }
725 }
726}
727
728pub(crate) fn default_render_settings() -> RenderSettings {
729 RenderSettings::default()
730}
731
732pub(crate) fn apply_render_settings_from_config(
733 settings: &mut RenderSettings,
734 config: &ResolvedConfig,
735) {
736 if let Some(value) = config.get_string("ui.format")
737 && let Some(parsed) = parse_output_format(value)
738 {
739 settings.format = parsed;
740 }
741
742 if let Some(value) = config.get_string("ui.mode")
743 && let Some(parsed) = parse_render_mode(value)
744 {
745 settings.mode = parsed;
746 }
747
748 if let Some(value) = config.get_string("ui.unicode.mode")
749 && let Some(parsed) = parse_unicode_mode(value)
750 {
751 settings.unicode = parsed;
752 }
753
754 if let Some(value) = config.get_string("ui.color.mode")
755 && let Some(parsed) = parse_color_mode(value)
756 {
757 settings.color = parsed;
758 }
759
760 if let Some(value) = config.get_string("ui.chrome.frame")
761 && let Some(parsed) = SectionFrameStyle::parse(value)
762 {
763 settings.chrome_frame = parsed;
764 }
765
766 if let Some(value) = config.get_string("ui.chrome.rule_policy")
767 && let Some(parsed) = RuledSectionPolicy::parse(value)
768 {
769 settings.ruled_section_policy = parsed;
770 }
771
772 if let Some(value) = config.get_string("ui.guide.default_format")
773 && let Some(parsed) = GuideDefaultFormat::parse(value)
774 {
775 settings.guide_default_format = parsed;
776 }
777
778 if settings.width.is_none() {
779 match config.get("ui.width").map(ConfigValue::reveal) {
780 Some(ConfigValue::Integer(width)) if *width > 0 => {
781 settings.width = Some(*width as usize);
782 }
783 Some(ConfigValue::String(raw)) => {
784 if let Ok(width) = raw.trim().parse::<usize>()
785 && width > 0
786 {
787 settings.width = Some(width);
788 }
789 }
790 _ => {}
791 }
792 }
793
794 sync_render_settings_from_config(settings, config);
795}
796
797pub(crate) fn sync_render_settings_from_config(
798 settings: &mut RenderSettings,
799 config: &ResolvedConfig,
800) {
801 if let Some(value) = config_int(config, "ui.margin")
802 && value >= 0
803 {
804 settings.margin = value as usize;
805 }
806
807 if let Some(value) = config_int(config, "ui.indent")
808 && value > 0
809 {
810 settings.indent_size = value as usize;
811 }
812
813 if let Some(value) = config_int(config, "ui.short_list_max")
814 && value > 0
815 {
816 settings.short_list_max = value as usize;
817 }
818
819 if let Some(value) = config_int(config, "ui.medium_list_max")
820 && value > 0
821 {
822 settings.medium_list_max = value as usize;
823 }
824
825 if let Some(value) = config_int(config, "ui.grid_padding")
826 && value > 0
827 {
828 settings.grid_padding = value as usize;
829 }
830
831 if let Some(value) = config_int(config, "ui.grid_columns") {
832 settings.grid_columns = if value > 0 {
833 Some(value as usize)
834 } else {
835 None
836 };
837 }
838
839 if let Some(value) = config_int(config, "ui.column_weight")
840 && value > 0
841 {
842 settings.column_weight = value as usize;
843 }
844
845 if let Some(value) = config_int(config, "ui.mreg.stack_min_col_width")
846 && value > 0
847 {
848 settings.mreg_stack_min_col_width = value as usize;
849 }
850
851 if let Some(value) = config_int(config, "ui.mreg.stack_overflow_ratio")
852 && value >= 100
853 {
854 settings.mreg_stack_overflow_ratio = value as usize;
855 }
856
857 if let Some(value) = config.get_string("ui.table.overflow")
858 && let Some(parsed) = TableOverflow::parse(value)
859 {
860 settings.table_overflow = parsed;
861 }
862
863 if let Some(value) = config.get_string("ui.table.border")
864 && let Some(parsed) = TableBorderStyle::parse(value)
865 {
866 settings.table_border = parsed;
867 }
868
869 if let Some(value) = config.get_string("ui.help.table_chrome")
870 && let Some(parsed) = HelpTableChrome::parse(value)
871 {
872 settings.help_chrome.table_chrome = parsed;
873 }
874
875 settings.help_chrome.entry_indent = config_usize_override(config, "ui.help.entry_indent");
876 settings.help_chrome.entry_gap = config_usize_override(config, "ui.help.entry_gap");
877 settings.help_chrome.section_spacing = config_usize_override(config, "ui.help.section_spacing");
878
879 settings.style_overrides = StyleOverrides {
880 text: config_non_empty_string(config, "color.text"),
881 key: config_non_empty_string(config, "color.key"),
882 muted: config_non_empty_string(config, "color.text.muted"),
883 table_header: config_non_empty_string(config, "color.table.header"),
884 mreg_key: config_non_empty_string(config, "color.mreg.key"),
885 value: config_non_empty_string(config, "color.value"),
886 number: config_non_empty_string(config, "color.value.number"),
887 bool_true: config_non_empty_string(config, "color.value.bool_true"),
888 bool_false: config_non_empty_string(config, "color.value.bool_false"),
889 null_value: config_non_empty_string(config, "color.value.null"),
890 ipv4: config_non_empty_string(config, "color.value.ipv4"),
891 ipv6: config_non_empty_string(config, "color.value.ipv6"),
892 panel_border: config_non_empty_string(config, "color.panel.border")
893 .or_else(|| config_non_empty_string(config, "color.border")),
894 panel_title: config_non_empty_string(config, "color.panel.title"),
895 code: config_non_empty_string(config, "color.code"),
896 json_key: config_non_empty_string(config, "color.json.key"),
897 message_error: config_non_empty_string(config, "color.message.error"),
898 message_warning: config_non_empty_string(config, "color.message.warning"),
899 message_success: config_non_empty_string(config, "color.message.success"),
900 message_info: config_non_empty_string(config, "color.message.info"),
901 message_trace: config_non_empty_string(config, "color.message.trace"),
902 };
903}
904
905fn parse_output_format(value: &str) -> Option<OutputFormat> {
906 match value.trim().to_ascii_lowercase().as_str() {
907 "auto" => Some(OutputFormat::Auto),
908 "guide" => Some(OutputFormat::Guide),
909 "json" => Some(OutputFormat::Json),
910 "table" => Some(OutputFormat::Table),
911 "md" | "markdown" => Some(OutputFormat::Markdown),
912 "mreg" => Some(OutputFormat::Mreg),
913 "value" => Some(OutputFormat::Value),
914 _ => None,
915 }
916}
917
918fn parse_render_mode(value: &str) -> Option<RenderMode> {
919 match value.trim().to_ascii_lowercase().as_str() {
920 "auto" => Some(RenderMode::Auto),
921 "plain" => Some(RenderMode::Plain),
922 "rich" => Some(RenderMode::Rich),
923 _ => None,
924 }
925}
926
927fn parse_color_mode(value: &str) -> Option<ColorMode> {
928 match value.trim().to_ascii_lowercase().as_str() {
929 "auto" => Some(ColorMode::Auto),
930 "always" => Some(ColorMode::Always),
931 "never" => Some(ColorMode::Never),
932 _ => None,
933 }
934}
935
936fn parse_unicode_mode(value: &str) -> Option<UnicodeMode> {
937 match value.trim().to_ascii_lowercase().as_str() {
938 "auto" => Some(UnicodeMode::Auto),
939 "always" => Some(UnicodeMode::Always),
940 "never" => Some(UnicodeMode::Never),
941 _ => None,
942 }
943}
944
945fn config_int(config: &ResolvedConfig, key: &str) -> Option<i64> {
946 match config.get(key).map(ConfigValue::reveal) {
947 Some(ConfigValue::Integer(value)) => Some(*value),
948 Some(ConfigValue::String(raw)) => raw.trim().parse::<i64>().ok(),
949 _ => None,
950 }
951}
952
953fn config_non_empty_string(config: &ResolvedConfig, key: &str) -> Option<String> {
954 config
955 .get_string(key)
956 .map(str::trim)
957 .filter(|value| !value.is_empty())
958 .map(ToOwned::to_owned)
959}
960
961fn config_usize_override(config: &ResolvedConfig, key: &str) -> Option<usize> {
962 match config.get(key).map(ConfigValue::reveal) {
963 Some(ConfigValue::Integer(value)) if *value >= 0 => Some(*value as usize),
964 Some(ConfigValue::String(raw)) => {
965 let trimmed = raw.trim();
966 if trimmed.eq_ignore_ascii_case("inherit") || trimmed.is_empty() {
967 None
968 } else {
969 trimmed.parse::<usize>().ok()
970 }
971 }
972 _ => None,
973 }
974}
975
976pub fn parse_inline_command_tokens(tokens: &[String]) -> Result<Option<Commands>, clap::Error> {
1005 InlineCommandCli::try_parse_from(tokens.iter().map(String::as_str)).map(|parsed| parsed.command)
1006}
1007
1008#[cfg(test)]
1009mod tests {
1010 use super::{
1011 Cli, ColorMode, Commands, ConfigCommands, InlineCommandCli, OutputFormat, RenderMode,
1012 RuntimeLoadOptions, SectionFrameStyle, TableBorderStyle, TableOverflow, UnicodeMode,
1013 apply_render_settings_from_config, config_int, config_non_empty_string,
1014 config_usize_override, parse_color_mode, parse_inline_command_tokens, parse_output_format,
1015 parse_render_mode, parse_unicode_mode,
1016 };
1017 use crate::config::{ConfigLayer, ConfigResolver, ConfigValue, ResolveOptions};
1018 use crate::ui::presentation::build_presentation_defaults_layer;
1019 use crate::ui::{GuideDefaultFormat, RenderSettings};
1020 use clap::Parser;
1021
1022 fn resolved(entries: &[(&str, &str)]) -> crate::config::ResolvedConfig {
1023 let mut defaults = ConfigLayer::default();
1024 defaults.set("profile.default", "default");
1025 for (key, value) in entries {
1026 defaults.set(*key, *value);
1027 }
1028 let mut resolver = ConfigResolver::default();
1029 resolver.set_defaults(defaults);
1030 let options = ResolveOptions::default().with_terminal("cli");
1031 let base = resolver
1032 .resolve(options.clone())
1033 .expect("base test config should resolve");
1034 resolver.set_presentation(build_presentation_defaults_layer(&base));
1035 resolver
1036 .resolve(options)
1037 .expect("test config should resolve")
1038 }
1039
1040 fn resolved_with_session(
1041 defaults_entries: &[(&str, &str)],
1042 session_entries: &[(&str, &str)],
1043 ) -> crate::config::ResolvedConfig {
1044 let mut defaults = ConfigLayer::default();
1045 defaults.set("profile.default", "default");
1046 for (key, value) in defaults_entries {
1047 defaults.set(*key, *value);
1048 }
1049
1050 let mut resolver = ConfigResolver::default();
1051 resolver.set_defaults(defaults);
1052
1053 let mut session = ConfigLayer::default();
1054 for (key, value) in session_entries {
1055 session.set(*key, *value);
1056 }
1057 resolver.set_session(session);
1058
1059 let options = ResolveOptions::default().with_terminal("cli");
1060 let base = resolver
1061 .resolve(options.clone())
1062 .expect("base test config should resolve");
1063 resolver.set_presentation(build_presentation_defaults_layer(&base));
1064 resolver
1065 .resolve(options)
1066 .expect("test config should resolve")
1067 }
1068
1069 #[test]
1070 fn parse_mode_and_config_helpers_normalize_strings_blanks_and_integers_unit() {
1071 assert_eq!(parse_output_format(" guide "), Some(OutputFormat::Guide));
1072 assert_eq!(
1073 parse_output_format(" markdown "),
1074 Some(OutputFormat::Markdown)
1075 );
1076 assert_eq!(parse_render_mode(" Rich "), Some(RenderMode::Rich));
1077 assert_eq!(parse_color_mode(" NEVER "), Some(ColorMode::Never));
1078 assert_eq!(parse_unicode_mode(" always "), Some(UnicodeMode::Always));
1079 assert_eq!(parse_output_format("yaml"), None);
1080
1081 let config = resolved(&[
1082 ("ui.width", "120"),
1083 ("color.text", " "),
1084 ("ui.margin", "3"),
1085 ]);
1086
1087 assert_eq!(config_int(&config, "ui.width"), Some(120));
1088 assert_eq!(config_int(&config, "ui.margin"), Some(3));
1089 assert_eq!(config_non_empty_string(&config, "color.text"), None);
1090 }
1091
1092 #[test]
1093 fn render_settings_apply_presentation_defaults_explicit_overrides_and_help_spacing_unit() {
1094 let config = resolved_with_session(
1095 &[("ui.width", "88")],
1096 &[
1097 ("ui.chrome.frame", "round"),
1098 ("ui.chrome.rule_policy", "stacked"),
1099 ("ui.table.border", "square"),
1100 ("ui.table.overflow", "wrap"),
1101 ],
1102 );
1103 let mut settings = RenderSettings::test_plain(OutputFormat::Table);
1104
1105 apply_render_settings_from_config(&mut settings, &config);
1106
1107 assert_eq!(settings.width, Some(88));
1108 assert_eq!(settings.chrome_frame, SectionFrameStyle::Round);
1109 assert_eq!(
1110 settings.ruled_section_policy,
1111 crate::ui::RuledSectionPolicy::Shared
1112 );
1113 assert_eq!(settings.table_border, TableBorderStyle::Square);
1114 assert_eq!(settings.table_overflow, TableOverflow::Wrap);
1115
1116 let config = resolved(&[("ui.presentation", "expressive")]);
1117 let mut settings = RenderSettings::test_plain(OutputFormat::Table);
1118
1119 apply_render_settings_from_config(&mut settings, &config);
1120
1121 assert_eq!(settings.chrome_frame, SectionFrameStyle::TopBottom);
1122 assert_eq!(settings.table_border, TableBorderStyle::Round);
1123
1124 let config = resolved_with_session(
1125 &[("ui.presentation", "expressive")],
1126 &[("ui.chrome.frame", "square"), ("ui.table.border", "none")],
1127 );
1128 let mut settings = RenderSettings::test_plain(OutputFormat::Table);
1129
1130 apply_render_settings_from_config(&mut settings, &config);
1131
1132 assert_eq!(settings.chrome_frame, SectionFrameStyle::Square);
1133 assert_eq!(settings.table_border, TableBorderStyle::None);
1134
1135 let config = resolved(&[("ui.guide.default_format", "inherit")]);
1136 let mut settings = RenderSettings::test_plain(OutputFormat::Json);
1137
1138 apply_render_settings_from_config(&mut settings, &config);
1139
1140 assert_eq!(settings.guide_default_format, GuideDefaultFormat::Inherit);
1141
1142 let config = resolved(&[
1143 ("ui.help.entry_indent", "4"),
1144 ("ui.help.entry_gap", "3"),
1145 ("ui.help.section_spacing", "inherit"),
1146 ]);
1147 let mut settings = RenderSettings::test_plain(OutputFormat::Guide);
1148
1149 apply_render_settings_from_config(&mut settings, &config);
1150
1151 assert_eq!(
1152 config_usize_override(&config, "ui.help.entry_indent"),
1153 Some(4)
1154 );
1155 assert_eq!(settings.help_chrome.entry_indent, Some(4));
1156 assert_eq!(settings.help_chrome.entry_gap, Some(3));
1157 assert_eq!(settings.help_chrome.section_spacing, None);
1158 }
1159
1160 #[test]
1161 fn parse_inline_command_tokens_accepts_builtin_and_external_commands_unit() {
1162 let builtin = parse_inline_command_tokens(&["config".to_string(), "doctor".to_string()])
1163 .expect("builtin command should parse");
1164 assert!(matches!(
1165 builtin,
1166 Some(Commands::Config(args)) if matches!(args.command, ConfigCommands::Doctor)
1167 ));
1168
1169 let external = parse_inline_command_tokens(&["ldap".to_string(), "user".to_string()])
1170 .expect("external command should parse");
1171 assert!(
1172 matches!(external, Some(Commands::External(tokens)) if tokens == vec!["ldap", "user"])
1173 );
1174 }
1175
1176 #[test]
1177 fn cli_runtime_load_options_and_inline_parser_follow_disable_flags_unit() {
1178 let cli = Cli::parse_from(["osp", "--no-env", "--no-config-file", "theme", "list"]);
1179 assert_eq!(
1180 cli.runtime_load_options(),
1181 RuntimeLoadOptions::new()
1182 .with_env(false)
1183 .with_config_file(false)
1184 );
1185
1186 let cli = Cli::parse_from(["osp", "--defaults-only", "theme", "list"]);
1187 assert_eq!(
1188 cli.runtime_load_options(),
1189 RuntimeLoadOptions::defaults_only()
1190 );
1191
1192 let inline = InlineCommandCli::try_parse_from(["theme", "list"])
1193 .expect("inline command should parse");
1194 assert!(matches!(inline.command, Some(Commands::Theme(_))));
1195 }
1196
1197 #[test]
1198 fn app_style_alias_maps_to_presentation_unit() {
1199 let cli = Cli::parse_from(["osp", "--app-style", "austere"]);
1200 let mut layer = ConfigLayer::default();
1201 cli.append_static_session_overrides(&mut layer);
1202 assert_eq!(
1203 layer
1204 .entries()
1205 .iter()
1206 .find(|entry| entry.key == "ui.presentation")
1207 .map(|entry| &entry.value),
1208 Some(&ConfigValue::from("austere"))
1209 );
1210 }
1211}