1pub(crate) mod commands;
16pub(crate) mod invocation;
17pub(crate) mod pipeline;
18pub(crate) mod rows;
19use crate::config::{ConfigLayer, ConfigValue, ResolvedConfig, RuntimeLoadOptions};
20use crate::core::output::{ColorMode, OutputFormat, RenderMode, UnicodeMode};
21use crate::ui::chrome::{RuledSectionPolicy, SectionFrameStyle};
22use crate::ui::theme::DEFAULT_THEME_NAME;
23use crate::ui::{
24 GuideDefaultFormat, HelpTableChrome, RenderSettings, StyleOverrides, TableBorderStyle,
25 TableOverflow,
26};
27use clap::{Args, Parser, Subcommand, ValueEnum};
28use std::path::PathBuf;
29
30use crate::ui::presentation::UiPresentation;
31
32pub use pipeline::{
33 ParsedCommandLine, is_cli_help_stage, parse_command_text_with_aliases,
34 parse_command_tokens_with_aliases, validate_cli_dsl_stages,
35};
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
38enum PresentationArg {
39 Expressive,
40 Compact,
41 #[value(alias = "gammel-og-bitter")]
42 Austere,
43}
44
45impl From<PresentationArg> for UiPresentation {
46 fn from(value: PresentationArg) -> Self {
47 match value {
48 PresentationArg::Expressive => UiPresentation::Expressive,
49 PresentationArg::Compact => UiPresentation::Compact,
50 PresentationArg::Austere => UiPresentation::Austere,
51 }
52 }
53}
54
55#[derive(Debug, Parser)]
57#[command(
58 name = "osp",
59 version = env!("CARGO_PKG_VERSION"),
60 about = "OSP CLI",
61 after_help = "Use `osp plugins commands` to list plugin-provided commands."
62)]
63pub struct Cli {
64 #[arg(short = 'u', long = "user")]
66 pub user: Option<String>,
67
68 #[arg(short = 'i', long = "incognito", global = true)]
70 pub incognito: bool,
71
72 #[arg(long = "profile", global = true)]
74 pub profile: Option<String>,
75
76 #[arg(long = "no-env", global = true)]
78 pub no_env: bool,
79
80 #[arg(long = "no-config-file", alias = "no-config", global = true)]
82 pub no_config_file: bool,
83
84 #[arg(long = "plugin-dir", global = true)]
86 pub plugin_dirs: Vec<PathBuf>,
87
88 #[arg(long = "theme", global = true)]
90 pub theme: Option<String>,
91
92 #[arg(long = "presentation", alias = "app-style", global = true)]
93 presentation: Option<PresentationArg>,
94
95 #[arg(
96 long = "gammel-og-bitter",
97 conflicts_with = "presentation",
98 global = true
99 )]
100 gammel_og_bitter: bool,
101
102 #[command(subcommand)]
104 pub command: Option<Commands>,
105}
106
107impl Cli {
108 pub fn runtime_load_options(&self) -> RuntimeLoadOptions {
110 RuntimeLoadOptions::new()
111 .with_env(!self.no_env)
112 .with_config_file(!self.no_config_file)
113 }
114}
115
116#[derive(Debug, Subcommand)]
118pub enum Commands {
119 Plugins(PluginsArgs),
121 Doctor(DoctorArgs),
123 Theme(ThemeArgs),
125 Config(ConfigArgs),
127 History(HistoryArgs),
129 #[command(hide = true)]
130 Intro(IntroArgs),
132 #[command(hide = true)]
133 Repl(ReplArgs),
135 #[command(external_subcommand)]
136 External(Vec<String>),
138}
139
140#[derive(Debug, Parser)]
142#[command(name = "osp", no_binary_name = true)]
143pub struct InlineCommandCli {
144 #[command(subcommand)]
146 pub command: Option<Commands>,
147}
148
149#[derive(Debug, Args)]
151pub struct ReplArgs {
152 #[command(subcommand)]
154 pub command: ReplCommands,
155}
156
157#[derive(Debug, Subcommand)]
159pub enum ReplCommands {
160 #[command(name = "debug-complete", hide = true)]
161 DebugComplete(DebugCompleteArgs),
163 #[command(name = "debug-highlight", hide = true)]
164 DebugHighlight(DebugHighlightArgs),
166}
167
168#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
170pub enum DebugMenuArg {
171 Completion,
173 History,
175}
176
177#[derive(Debug, Args)]
179pub struct DebugCompleteArgs {
180 #[arg(long)]
182 pub line: String,
183
184 #[arg(long = "menu", value_enum, default_value_t = DebugMenuArg::Completion)]
186 pub menu: DebugMenuArg,
187
188 #[arg(long)]
190 pub cursor: Option<usize>,
191
192 #[arg(long, default_value_t = 80)]
194 pub width: u16,
195
196 #[arg(long, default_value_t = 24)]
198 pub height: u16,
199
200 #[arg(long = "step")]
202 pub steps: Vec<String>,
203
204 #[arg(long = "menu-ansi", default_value_t = false)]
206 pub menu_ansi: bool,
207
208 #[arg(long = "menu-unicode", default_value_t = false)]
210 pub menu_unicode: bool,
211}
212
213#[derive(Debug, Args)]
215pub struct DebugHighlightArgs {
216 #[arg(long)]
218 pub line: String,
219}
220
221#[derive(Debug, Args)]
223pub struct PluginsArgs {
224 #[command(subcommand)]
226 pub command: PluginsCommands,
227}
228
229#[derive(Debug, Args)]
231pub struct DoctorArgs {
232 #[command(subcommand)]
234 pub command: Option<DoctorCommands>,
235}
236
237#[derive(Debug, Subcommand)]
239pub enum DoctorCommands {
240 All,
242 Config,
244 Last,
246 Plugins,
248 Theme,
250}
251
252#[derive(Debug, Subcommand)]
254pub enum PluginsCommands {
255 List,
257 Commands,
259 Config(PluginConfigArgs),
261 Refresh,
263 Enable(PluginCommandStateArgs),
265 Disable(PluginCommandStateArgs),
267 ClearState(PluginCommandClearArgs),
269 SelectProvider(PluginProviderSelectArgs),
271 ClearProvider(PluginProviderClearArgs),
273 Doctor,
275}
276
277#[derive(Debug, Args)]
279pub struct ThemeArgs {
280 #[command(subcommand)]
282 pub command: ThemeCommands,
283}
284
285#[derive(Debug, Subcommand)]
287pub enum ThemeCommands {
288 List,
290 Show(ThemeShowArgs),
292 Use(ThemeUseArgs),
294}
295
296#[derive(Debug, Args)]
298pub struct ThemeShowArgs {
299 pub name: Option<String>,
301}
302
303#[derive(Debug, Args)]
305pub struct ThemeUseArgs {
306 pub name: String,
308}
309
310#[derive(Debug, Args)]
312pub struct PluginCommandStateArgs {
313 pub command: String,
315
316 #[arg(long = "global", conflicts_with = "profile")]
318 pub global: bool,
319
320 #[arg(long = "profile")]
322 pub profile: Option<String>,
323
324 #[arg(
326 long = "terminal",
327 num_args = 0..=1,
328 default_missing_value = "__current__"
329 )]
330 pub terminal: Option<String>,
331}
332
333#[derive(Debug, Args)]
335pub struct PluginCommandClearArgs {
336 pub command: String,
338
339 #[arg(long = "global", conflicts_with = "profile")]
341 pub global: bool,
342
343 #[arg(long = "profile")]
345 pub profile: Option<String>,
346
347 #[arg(
349 long = "terminal",
350 num_args = 0..=1,
351 default_missing_value = "__current__"
352 )]
353 pub terminal: Option<String>,
354}
355
356#[derive(Debug, Args)]
358pub struct PluginProviderSelectArgs {
359 pub command: String,
361 pub plugin_id: 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 PluginProviderClearArgs {
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 PluginConfigArgs {
407 pub plugin_id: String,
409}
410
411#[derive(Debug, Args)]
413pub struct ConfigArgs {
414 #[command(subcommand)]
416 pub command: ConfigCommands,
417}
418
419#[derive(Debug, Args)]
421pub struct HistoryArgs {
422 #[command(subcommand)]
424 pub command: HistoryCommands,
425}
426
427#[derive(Debug, Args, Clone, Default)]
429pub struct IntroArgs {}
430
431#[derive(Debug, Subcommand)]
433pub enum HistoryCommands {
434 List,
436 Prune(HistoryPruneArgs),
438 Clear,
440}
441
442#[derive(Debug, Args)]
444pub struct HistoryPruneArgs {
445 pub keep: usize,
447}
448
449#[derive(Debug, Subcommand)]
451pub enum ConfigCommands {
452 Show(ConfigShowArgs),
454 Get(ConfigGetArgs),
456 Explain(ConfigExplainArgs),
458 Set(ConfigSetArgs),
460 Unset(ConfigUnsetArgs),
462 #[command(alias = "diagnostics")]
463 Doctor,
465}
466
467#[derive(Debug, Args)]
469pub struct ConfigShowArgs {
470 #[arg(long = "sources")]
472 pub sources: bool,
473
474 #[arg(long = "raw")]
476 pub raw: bool,
477}
478
479#[derive(Debug, Args)]
481pub struct ConfigGetArgs {
482 pub key: String,
484
485 #[arg(long = "sources")]
487 pub sources: bool,
488
489 #[arg(long = "raw")]
491 pub raw: bool,
492}
493
494#[derive(Debug, Args)]
496pub struct ConfigExplainArgs {
497 pub key: String,
499
500 #[arg(long = "show-secrets")]
502 pub show_secrets: bool,
503}
504
505#[derive(Debug, Args)]
507pub struct ConfigSetArgs {
508 pub key: String,
510 pub value: String,
512
513 #[arg(long = "global", conflicts_with_all = ["profile", "profile_all"])]
515 pub global: bool,
516
517 #[arg(long = "profile", conflicts_with = "profile_all")]
519 pub profile: Option<String>,
520
521 #[arg(long = "profile-all", conflicts_with = "profile")]
523 pub profile_all: bool,
524
525 #[arg(
527 long = "terminal",
528 num_args = 0..=1,
529 default_missing_value = "__current__"
530 )]
531 pub terminal: Option<String>,
532
533 #[arg(long = "session", conflicts_with_all = ["config_store", "secrets", "save"])]
535 pub session: bool,
536
537 #[arg(long = "config", conflicts_with_all = ["session", "secrets"])]
539 pub config_store: bool,
540
541 #[arg(long = "secrets", conflicts_with_all = ["session", "config_store"])]
543 pub secrets: bool,
544
545 #[arg(long = "save", conflicts_with_all = ["session", "config_store", "secrets"])]
547 pub save: bool,
548
549 #[arg(long = "dry-run")]
551 pub dry_run: bool,
552
553 #[arg(long = "yes")]
555 pub yes: bool,
556
557 #[arg(long = "explain")]
559 pub explain: bool,
560}
561
562#[derive(Debug, Args)]
564pub struct ConfigUnsetArgs {
565 pub key: String,
567
568 #[arg(long = "global", conflicts_with_all = ["profile", "profile_all"])]
570 pub global: bool,
571
572 #[arg(long = "profile", conflicts_with = "profile_all")]
574 pub profile: Option<String>,
575
576 #[arg(long = "profile-all", conflicts_with = "profile")]
578 pub profile_all: bool,
579
580 #[arg(
582 long = "terminal",
583 num_args = 0..=1,
584 default_missing_value = "__current__"
585 )]
586 pub terminal: Option<String>,
587
588 #[arg(long = "session", conflicts_with_all = ["config_store", "secrets", "save"])]
590 pub session: bool,
591
592 #[arg(long = "config", conflicts_with_all = ["session", "secrets"])]
594 pub config_store: bool,
595
596 #[arg(long = "secrets", conflicts_with_all = ["session", "config_store"])]
598 pub secrets: bool,
599
600 #[arg(long = "save", conflicts_with_all = ["session", "config_store", "secrets"])]
602 pub save: bool,
603
604 #[arg(long = "dry-run")]
606 pub dry_run: bool,
607}
608
609impl Cli {
610 pub fn render_settings(&self) -> RenderSettings {
612 default_render_settings()
613 }
614
615 pub fn seed_render_settings_from_config(
617 &self,
618 settings: &mut RenderSettings,
619 config: &ResolvedConfig,
620 ) {
621 apply_render_settings_from_config(settings, config);
622 }
623
624 pub fn selected_theme_name(&self, config: &ResolvedConfig) -> String {
626 self.theme
627 .as_deref()
628 .or_else(|| config.get_string("theme.name"))
629 .unwrap_or(DEFAULT_THEME_NAME)
630 .to_string()
631 }
632
633 pub(crate) fn append_static_session_overrides(&self, layer: &mut ConfigLayer) {
634 if let Some(user) = self
635 .user
636 .as_deref()
637 .map(str::trim)
638 .filter(|value| !value.is_empty())
639 {
640 layer.set("user.name", user);
641 }
642 if self.incognito {
643 layer.set("repl.history.enabled", false);
644 }
645 if let Some(theme) = self
646 .theme
647 .as_deref()
648 .map(str::trim)
649 .filter(|value| !value.is_empty())
650 {
651 layer.set("theme.name", theme);
652 }
653 if self.gammel_og_bitter {
654 layer.set("ui.presentation", UiPresentation::Austere.as_config_value());
655 } else if let Some(presentation) = self.presentation {
656 layer.set(
657 "ui.presentation",
658 UiPresentation::from(presentation).as_config_value(),
659 );
660 }
661 }
662}
663
664pub(crate) fn default_render_settings() -> RenderSettings {
665 RenderSettings::default()
666}
667
668pub(crate) fn apply_render_settings_from_config(
669 settings: &mut RenderSettings,
670 config: &ResolvedConfig,
671) {
672 if let Some(value) = config.get_string("ui.format")
673 && let Some(parsed) = parse_output_format(value)
674 {
675 settings.format = parsed;
676 }
677
678 if let Some(value) = config.get_string("ui.mode")
679 && let Some(parsed) = parse_render_mode(value)
680 {
681 settings.mode = parsed;
682 }
683
684 if let Some(value) = config.get_string("ui.unicode.mode")
685 && let Some(parsed) = parse_unicode_mode(value)
686 {
687 settings.unicode = parsed;
688 }
689
690 if let Some(value) = config.get_string("ui.color.mode")
691 && let Some(parsed) = parse_color_mode(value)
692 {
693 settings.color = parsed;
694 }
695
696 if let Some(value) = config.get_string("ui.chrome.frame")
697 && let Some(parsed) = SectionFrameStyle::parse(value)
698 {
699 settings.chrome_frame = parsed;
700 }
701
702 if let Some(value) = config.get_string("ui.chrome.rule_policy")
703 && let Some(parsed) = RuledSectionPolicy::parse(value)
704 {
705 settings.ruled_section_policy = parsed;
706 }
707
708 if let Some(value) = config.get_string("ui.guide.default_format")
709 && let Some(parsed) = GuideDefaultFormat::parse(value)
710 {
711 settings.guide_default_format = parsed;
712 }
713
714 if settings.width.is_none() {
715 match config.get("ui.width").map(ConfigValue::reveal) {
716 Some(ConfigValue::Integer(width)) if *width > 0 => {
717 settings.width = Some(*width as usize);
718 }
719 Some(ConfigValue::String(raw)) => {
720 if let Ok(width) = raw.trim().parse::<usize>()
721 && width > 0
722 {
723 settings.width = Some(width);
724 }
725 }
726 _ => {}
727 }
728 }
729
730 sync_render_settings_from_config(settings, config);
731}
732
733pub(crate) fn sync_render_settings_from_config(
734 settings: &mut RenderSettings,
735 config: &ResolvedConfig,
736) {
737 if let Some(value) = config_int(config, "ui.margin")
738 && value >= 0
739 {
740 settings.margin = value as usize;
741 }
742
743 if let Some(value) = config_int(config, "ui.indent")
744 && value > 0
745 {
746 settings.indent_size = value as usize;
747 }
748
749 if let Some(value) = config_int(config, "ui.short_list_max")
750 && value > 0
751 {
752 settings.short_list_max = value as usize;
753 }
754
755 if let Some(value) = config_int(config, "ui.medium_list_max")
756 && value > 0
757 {
758 settings.medium_list_max = value as usize;
759 }
760
761 if let Some(value) = config_int(config, "ui.grid_padding")
762 && value > 0
763 {
764 settings.grid_padding = value as usize;
765 }
766
767 if let Some(value) = config_int(config, "ui.grid_columns") {
768 settings.grid_columns = if value > 0 {
769 Some(value as usize)
770 } else {
771 None
772 };
773 }
774
775 if let Some(value) = config_int(config, "ui.column_weight")
776 && value > 0
777 {
778 settings.column_weight = value as usize;
779 }
780
781 if let Some(value) = config_int(config, "ui.mreg.stack_min_col_width")
782 && value > 0
783 {
784 settings.mreg_stack_min_col_width = value as usize;
785 }
786
787 if let Some(value) = config_int(config, "ui.mreg.stack_overflow_ratio")
788 && value >= 100
789 {
790 settings.mreg_stack_overflow_ratio = value as usize;
791 }
792
793 if let Some(value) = config.get_string("ui.table.overflow")
794 && let Some(parsed) = TableOverflow::parse(value)
795 {
796 settings.table_overflow = parsed;
797 }
798
799 if let Some(value) = config.get_string("ui.table.border")
800 && let Some(parsed) = TableBorderStyle::parse(value)
801 {
802 settings.table_border = parsed;
803 }
804
805 if let Some(value) = config.get_string("ui.help.table_chrome")
806 && let Some(parsed) = HelpTableChrome::parse(value)
807 {
808 settings.help_chrome.table_chrome = parsed;
809 }
810
811 settings.help_chrome.entry_indent = config_usize_override(config, "ui.help.entry_indent");
812 settings.help_chrome.entry_gap = config_usize_override(config, "ui.help.entry_gap");
813 settings.help_chrome.section_spacing = config_usize_override(config, "ui.help.section_spacing");
814
815 settings.style_overrides = StyleOverrides {
816 text: config_non_empty_string(config, "color.text"),
817 key: config_non_empty_string(config, "color.key"),
818 muted: config_non_empty_string(config, "color.text.muted"),
819 table_header: config_non_empty_string(config, "color.table.header"),
820 mreg_key: config_non_empty_string(config, "color.mreg.key"),
821 value: config_non_empty_string(config, "color.value"),
822 number: config_non_empty_string(config, "color.value.number"),
823 bool_true: config_non_empty_string(config, "color.value.bool_true"),
824 bool_false: config_non_empty_string(config, "color.value.bool_false"),
825 null_value: config_non_empty_string(config, "color.value.null"),
826 ipv4: config_non_empty_string(config, "color.value.ipv4"),
827 ipv6: config_non_empty_string(config, "color.value.ipv6"),
828 panel_border: config_non_empty_string(config, "color.panel.border")
829 .or_else(|| config_non_empty_string(config, "color.border")),
830 panel_title: config_non_empty_string(config, "color.panel.title"),
831 code: config_non_empty_string(config, "color.code"),
832 json_key: config_non_empty_string(config, "color.json.key"),
833 message_error: config_non_empty_string(config, "color.message.error"),
834 message_warning: config_non_empty_string(config, "color.message.warning"),
835 message_success: config_non_empty_string(config, "color.message.success"),
836 message_info: config_non_empty_string(config, "color.message.info"),
837 message_trace: config_non_empty_string(config, "color.message.trace"),
838 };
839}
840
841fn parse_output_format(value: &str) -> Option<OutputFormat> {
842 match value.trim().to_ascii_lowercase().as_str() {
843 "auto" => Some(OutputFormat::Auto),
844 "guide" => Some(OutputFormat::Guide),
845 "json" => Some(OutputFormat::Json),
846 "table" => Some(OutputFormat::Table),
847 "md" | "markdown" => Some(OutputFormat::Markdown),
848 "mreg" => Some(OutputFormat::Mreg),
849 "value" => Some(OutputFormat::Value),
850 _ => None,
851 }
852}
853
854fn parse_render_mode(value: &str) -> Option<RenderMode> {
855 match value.trim().to_ascii_lowercase().as_str() {
856 "auto" => Some(RenderMode::Auto),
857 "plain" => Some(RenderMode::Plain),
858 "rich" => Some(RenderMode::Rich),
859 _ => None,
860 }
861}
862
863fn parse_color_mode(value: &str) -> Option<ColorMode> {
864 match value.trim().to_ascii_lowercase().as_str() {
865 "auto" => Some(ColorMode::Auto),
866 "always" => Some(ColorMode::Always),
867 "never" => Some(ColorMode::Never),
868 _ => None,
869 }
870}
871
872fn parse_unicode_mode(value: &str) -> Option<UnicodeMode> {
873 match value.trim().to_ascii_lowercase().as_str() {
874 "auto" => Some(UnicodeMode::Auto),
875 "always" => Some(UnicodeMode::Always),
876 "never" => Some(UnicodeMode::Never),
877 _ => None,
878 }
879}
880
881fn config_int(config: &ResolvedConfig, key: &str) -> Option<i64> {
882 match config.get(key).map(ConfigValue::reveal) {
883 Some(ConfigValue::Integer(value)) => Some(*value),
884 Some(ConfigValue::String(raw)) => raw.trim().parse::<i64>().ok(),
885 _ => None,
886 }
887}
888
889fn config_non_empty_string(config: &ResolvedConfig, key: &str) -> Option<String> {
890 config
891 .get_string(key)
892 .map(str::trim)
893 .filter(|value| !value.is_empty())
894 .map(ToOwned::to_owned)
895}
896
897fn config_usize_override(config: &ResolvedConfig, key: &str) -> Option<usize> {
898 match config.get(key).map(ConfigValue::reveal) {
899 Some(ConfigValue::Integer(value)) if *value >= 0 => Some(*value as usize),
900 Some(ConfigValue::String(raw)) => {
901 let trimmed = raw.trim();
902 if trimmed.eq_ignore_ascii_case("inherit") || trimmed.is_empty() {
903 None
904 } else {
905 trimmed.parse::<usize>().ok()
906 }
907 }
908 _ => None,
909 }
910}
911
912pub fn parse_inline_command_tokens(tokens: &[String]) -> Result<Option<Commands>, clap::Error> {
941 InlineCommandCli::try_parse_from(tokens.iter().map(String::as_str)).map(|parsed| parsed.command)
942}
943
944#[cfg(test)]
945mod tests {
946 use super::{
947 Cli, ColorMode, Commands, ConfigCommands, InlineCommandCli, OutputFormat, RenderMode,
948 RuntimeLoadOptions, SectionFrameStyle, TableBorderStyle, TableOverflow, UnicodeMode,
949 apply_render_settings_from_config, config_int, config_non_empty_string,
950 config_usize_override, parse_color_mode, parse_inline_command_tokens, parse_output_format,
951 parse_render_mode, parse_unicode_mode,
952 };
953 use crate::config::{ConfigLayer, ConfigResolver, ConfigValue, ResolveOptions};
954 use crate::ui::presentation::build_presentation_defaults_layer;
955 use crate::ui::{GuideDefaultFormat, RenderSettings};
956 use clap::Parser;
957
958 fn resolved(entries: &[(&str, &str)]) -> crate::config::ResolvedConfig {
959 let mut defaults = ConfigLayer::default();
960 defaults.set("profile.default", "default");
961 for (key, value) in entries {
962 defaults.set(*key, *value);
963 }
964 let mut resolver = ConfigResolver::default();
965 resolver.set_defaults(defaults);
966 let options = ResolveOptions::default().with_terminal("cli");
967 let base = resolver
968 .resolve(options.clone())
969 .expect("base test config should resolve");
970 resolver.set_presentation(build_presentation_defaults_layer(&base));
971 resolver
972 .resolve(options)
973 .expect("test config should resolve")
974 }
975
976 fn resolved_with_session(
977 defaults_entries: &[(&str, &str)],
978 session_entries: &[(&str, &str)],
979 ) -> crate::config::ResolvedConfig {
980 let mut defaults = ConfigLayer::default();
981 defaults.set("profile.default", "default");
982 for (key, value) in defaults_entries {
983 defaults.set(*key, *value);
984 }
985
986 let mut resolver = ConfigResolver::default();
987 resolver.set_defaults(defaults);
988
989 let mut session = ConfigLayer::default();
990 for (key, value) in session_entries {
991 session.set(*key, *value);
992 }
993 resolver.set_session(session);
994
995 let options = ResolveOptions::default().with_terminal("cli");
996 let base = resolver
997 .resolve(options.clone())
998 .expect("base test config should resolve");
999 resolver.set_presentation(build_presentation_defaults_layer(&base));
1000 resolver
1001 .resolve(options)
1002 .expect("test config should resolve")
1003 }
1004
1005 #[test]
1006 fn parse_mode_helpers_accept_aliases_and_trim_input_unit() {
1007 assert_eq!(parse_output_format(" guide "), Some(OutputFormat::Guide));
1008 assert_eq!(
1009 parse_output_format(" markdown "),
1010 Some(OutputFormat::Markdown)
1011 );
1012 assert_eq!(parse_render_mode(" Rich "), Some(RenderMode::Rich));
1013 assert_eq!(parse_color_mode(" NEVER "), Some(ColorMode::Never));
1014 assert_eq!(parse_unicode_mode(" always "), Some(UnicodeMode::Always));
1015 assert_eq!(parse_output_format("yaml"), None);
1016 }
1017
1018 #[test]
1019 fn config_helpers_ignore_blank_strings_and_parse_integers_unit() {
1020 let config = resolved(&[
1021 ("ui.width", "120"),
1022 ("color.text", " "),
1023 ("ui.margin", "3"),
1024 ]);
1025
1026 assert_eq!(config_int(&config, "ui.width"), Some(120));
1027 assert_eq!(config_int(&config, "ui.margin"), Some(3));
1028 assert_eq!(config_non_empty_string(&config, "color.text"), None);
1029 }
1030
1031 #[test]
1032 fn render_settings_apply_low_level_ui_overrides_unit() {
1033 let config = resolved_with_session(
1034 &[("ui.width", "88")],
1035 &[
1036 ("ui.chrome.frame", "round"),
1037 ("ui.chrome.rule_policy", "stacked"),
1038 ("ui.table.border", "square"),
1039 ("ui.table.overflow", "wrap"),
1040 ],
1041 );
1042 let mut settings = RenderSettings::test_plain(OutputFormat::Table);
1043
1044 apply_render_settings_from_config(&mut settings, &config);
1045
1046 assert_eq!(settings.width, Some(88));
1047 assert_eq!(settings.chrome_frame, SectionFrameStyle::Round);
1048 assert_eq!(
1049 settings.ruled_section_policy,
1050 crate::ui::RuledSectionPolicy::Shared
1051 );
1052 assert_eq!(settings.table_border, TableBorderStyle::Square);
1053 assert_eq!(settings.table_overflow, TableOverflow::Wrap);
1054 }
1055
1056 #[test]
1057 fn presentation_seeds_runtime_chrome_and_table_defaults_unit() {
1058 let config = resolved(&[("ui.presentation", "expressive")]);
1059 let mut settings = RenderSettings::test_plain(OutputFormat::Table);
1060
1061 apply_render_settings_from_config(&mut settings, &config);
1062
1063 assert_eq!(settings.chrome_frame, SectionFrameStyle::TopBottom);
1064 assert_eq!(settings.table_border, TableBorderStyle::Round);
1065 }
1066
1067 #[test]
1068 fn explicit_low_level_overrides_beat_presentation_defaults_unit() {
1069 let config = resolved_with_session(
1070 &[("ui.presentation", "expressive")],
1071 &[("ui.chrome.frame", "square"), ("ui.table.border", "none")],
1072 );
1073 let mut settings = RenderSettings::test_plain(OutputFormat::Table);
1074
1075 apply_render_settings_from_config(&mut settings, &config);
1076
1077 assert_eq!(settings.chrome_frame, SectionFrameStyle::Square);
1078 assert_eq!(settings.table_border, TableBorderStyle::None);
1079 }
1080
1081 #[test]
1082 fn guide_default_format_reads_from_config_unit() {
1083 let config = resolved(&[("ui.guide.default_format", "inherit")]);
1084 let mut settings = RenderSettings::test_plain(OutputFormat::Json);
1085
1086 apply_render_settings_from_config(&mut settings, &config);
1087
1088 assert_eq!(settings.guide_default_format, GuideDefaultFormat::Inherit);
1089 }
1090
1091 #[test]
1092 fn help_spacing_overrides_support_inherit_and_numeric_values_unit() {
1093 let config = resolved(&[
1094 ("ui.help.entry_indent", "4"),
1095 ("ui.help.entry_gap", "3"),
1096 ("ui.help.section_spacing", "inherit"),
1097 ]);
1098 let mut settings = RenderSettings::test_plain(OutputFormat::Guide);
1099
1100 apply_render_settings_from_config(&mut settings, &config);
1101
1102 assert_eq!(
1103 config_usize_override(&config, "ui.help.entry_indent"),
1104 Some(4)
1105 );
1106 assert_eq!(settings.help_chrome.entry_indent, Some(4));
1107 assert_eq!(settings.help_chrome.entry_gap, Some(3));
1108 assert_eq!(settings.help_chrome.section_spacing, None);
1109 }
1110
1111 #[test]
1112 fn parse_inline_command_tokens_accepts_builtin_and_external_commands_unit() {
1113 let builtin = parse_inline_command_tokens(&["config".to_string(), "doctor".to_string()])
1114 .expect("builtin command should parse");
1115 assert!(matches!(
1116 builtin,
1117 Some(Commands::Config(args)) if matches!(args.command, ConfigCommands::Doctor)
1118 ));
1119
1120 let external = parse_inline_command_tokens(&["ldap".to_string(), "user".to_string()])
1121 .expect("external command should parse");
1122 assert!(
1123 matches!(external, Some(Commands::External(tokens)) if tokens == vec!["ldap", "user"])
1124 );
1125 }
1126
1127 #[test]
1128 fn cli_runtime_load_options_follow_disable_flags_unit() {
1129 let cli = Cli::parse_from(["osp", "--no-env", "--no-config-file", "theme", "list"]);
1130 assert_eq!(
1131 cli.runtime_load_options(),
1132 RuntimeLoadOptions::new()
1133 .with_env(false)
1134 .with_config_file(false)
1135 );
1136
1137 let inline = InlineCommandCli::try_parse_from(["theme", "list"])
1138 .expect("inline command should parse");
1139 assert!(matches!(inline.command, Some(Commands::Theme(_))));
1140 }
1141
1142 #[test]
1143 fn app_style_alias_maps_to_presentation_unit() {
1144 let cli = Cli::parse_from(["osp", "--app-style", "austere"]);
1145 let mut layer = ConfigLayer::default();
1146 cli.append_static_session_overrides(&mut layer);
1147 assert_eq!(
1148 layer
1149 .entries()
1150 .iter()
1151 .find(|entry| entry.key == "ui.presentation")
1152 .map(|entry| &entry.value),
1153 Some(&ConfigValue::from("austere"))
1154 );
1155 }
1156}