1pub(crate) mod commands;
2pub(crate) mod invocation;
3pub mod pipeline;
4pub(crate) mod rows;
5use crate::config::{ConfigLayer, ConfigValue, ResolvedConfig, RuntimeLoadOptions};
6use crate::core::output::{ColorMode, OutputFormat, RenderMode, UnicodeMode};
7use crate::ui::chrome::SectionFrameStyle;
8use crate::ui::theme::DEFAULT_THEME_NAME;
9use crate::ui::{RenderRuntime, RenderSettings, StyleOverrides, TableBorderStyle, TableOverflow};
10use clap::{Args, Parser, Subcommand, ValueEnum};
11use std::path::PathBuf;
12
13use crate::ui::presentation::UiPresentation;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
16enum PresentationArg {
17 Expressive,
18 Compact,
19 #[value(alias = "gammel-og-bitter")]
20 Austere,
21}
22
23impl From<PresentationArg> for UiPresentation {
24 fn from(value: PresentationArg) -> Self {
25 match value {
26 PresentationArg::Expressive => UiPresentation::Expressive,
27 PresentationArg::Compact => UiPresentation::Compact,
28 PresentationArg::Austere => UiPresentation::Austere,
29 }
30 }
31}
32
33#[derive(Debug, Parser)]
34#[command(
35 name = "osp",
36 version = env!("CARGO_PKG_VERSION"),
37 about = "OSP CLI",
38 after_help = "Use `osp plugins commands` to list plugin-provided commands."
39)]
40pub struct Cli {
41 #[arg(short = 'u', long = "user")]
42 pub user: Option<String>,
43
44 #[arg(short = 'i', long = "incognito", global = true)]
45 pub incognito: bool,
46
47 #[arg(long = "profile", global = true)]
48 pub profile: Option<String>,
49
50 #[arg(long = "no-env", global = true)]
51 pub no_env: bool,
52
53 #[arg(long = "no-config-file", alias = "no-config", global = true)]
54 pub no_config_file: bool,
55
56 #[arg(long = "plugin-dir", global = true)]
57 pub plugin_dirs: Vec<PathBuf>,
58
59 #[arg(long = "theme", global = true)]
60 pub theme: Option<String>,
61
62 #[arg(long = "presentation", alias = "app-style", global = true)]
63 presentation: Option<PresentationArg>,
64
65 #[arg(
66 long = "gammel-og-bitter",
67 conflicts_with = "presentation",
68 global = true
69 )]
70 gammel_og_bitter: bool,
71
72 #[command(subcommand)]
73 pub command: Option<Commands>,
74}
75
76impl Cli {
77 pub fn runtime_load_options(&self) -> RuntimeLoadOptions {
78 RuntimeLoadOptions {
79 include_env: !self.no_env,
80 include_config_file: !self.no_config_file,
81 }
82 }
83}
84
85#[derive(Debug, Subcommand)]
86pub enum Commands {
87 Plugins(PluginsArgs),
88 Doctor(DoctorArgs),
89 Theme(ThemeArgs),
90 Config(ConfigArgs),
91 History(HistoryArgs),
92 #[command(hide = true)]
93 Repl(ReplArgs),
94 #[command(external_subcommand)]
95 External(Vec<String>),
96}
97
98#[derive(Debug, Parser)]
99#[command(name = "osp", no_binary_name = true)]
100pub struct InlineCommandCli {
101 #[command(subcommand)]
102 pub command: Option<Commands>,
103}
104
105#[derive(Debug, Args)]
106pub struct ReplArgs {
107 #[command(subcommand)]
108 pub command: ReplCommands,
109}
110
111#[derive(Debug, Subcommand)]
112pub enum ReplCommands {
113 #[command(name = "debug-complete", hide = true)]
114 DebugComplete(DebugCompleteArgs),
115 #[command(name = "debug-highlight", hide = true)]
116 DebugHighlight(DebugHighlightArgs),
117}
118
119#[derive(Debug, Args)]
120pub struct DebugCompleteArgs {
121 #[arg(long)]
122 pub line: String,
123
124 #[arg(long)]
125 pub cursor: Option<usize>,
126
127 #[arg(long, default_value_t = 80)]
128 pub width: u16,
129
130 #[arg(long, default_value_t = 24)]
131 pub height: u16,
132
133 #[arg(long = "step")]
134 pub steps: Vec<String>,
135
136 #[arg(long = "menu-ansi", default_value_t = false)]
137 pub menu_ansi: bool,
138
139 #[arg(long = "menu-unicode", default_value_t = false)]
140 pub menu_unicode: bool,
141}
142
143#[derive(Debug, Args)]
144pub struct DebugHighlightArgs {
145 #[arg(long)]
146 pub line: String,
147}
148
149#[derive(Debug, Args)]
150pub struct PluginsArgs {
151 #[command(subcommand)]
152 pub command: PluginsCommands,
153}
154
155#[derive(Debug, Args)]
156pub struct DoctorArgs {
157 #[command(subcommand)]
158 pub command: Option<DoctorCommands>,
159}
160
161#[derive(Debug, Subcommand)]
162pub enum DoctorCommands {
163 All,
164 Config,
165 Last,
166 Plugins,
167 Theme,
168}
169
170#[derive(Debug, Subcommand)]
171pub enum PluginsCommands {
172 List,
173 Commands,
174 Config(PluginConfigArgs),
175 Refresh,
176 Enable(PluginToggleArgs),
177 Disable(PluginToggleArgs),
178 SelectProvider(PluginProviderSelectArgs),
179 ClearProvider(PluginProviderClearArgs),
180 Doctor,
181}
182
183#[derive(Debug, Args)]
184pub struct ThemeArgs {
185 #[command(subcommand)]
186 pub command: ThemeCommands,
187}
188
189#[derive(Debug, Subcommand)]
190pub enum ThemeCommands {
191 List,
192 Show(ThemeShowArgs),
193 Use(ThemeUseArgs),
194}
195
196#[derive(Debug, Args)]
197pub struct ThemeShowArgs {
198 pub name: Option<String>,
199}
200
201#[derive(Debug, Args)]
202pub struct ThemeUseArgs {
203 pub name: String,
204}
205
206#[derive(Debug, Args)]
207pub struct PluginToggleArgs {
208 pub plugin_id: String,
209}
210
211#[derive(Debug, Args)]
212pub struct PluginProviderSelectArgs {
213 pub command: String,
214 pub plugin_id: String,
215}
216
217#[derive(Debug, Args)]
218pub struct PluginProviderClearArgs {
219 pub command: String,
220}
221
222#[derive(Debug, Args)]
223pub struct PluginConfigArgs {
224 pub plugin_id: String,
225}
226
227#[derive(Debug, Args)]
228pub struct ConfigArgs {
229 #[command(subcommand)]
230 pub command: ConfigCommands,
231}
232
233#[derive(Debug, Args)]
234pub struct HistoryArgs {
235 #[command(subcommand)]
236 pub command: HistoryCommands,
237}
238
239#[derive(Debug, Subcommand)]
240pub enum HistoryCommands {
241 List,
242 Prune(HistoryPruneArgs),
243 Clear,
244}
245
246#[derive(Debug, Args)]
247pub struct HistoryPruneArgs {
248 pub keep: usize,
249}
250
251#[derive(Debug, Subcommand)]
252pub enum ConfigCommands {
253 Show(ConfigShowArgs),
254 Get(ConfigGetArgs),
255 Explain(ConfigExplainArgs),
256 Set(ConfigSetArgs),
257 Unset(ConfigUnsetArgs),
258 #[command(alias = "diagnostics")]
259 Doctor,
260}
261
262#[derive(Debug, Args)]
263pub struct ConfigShowArgs {
264 #[arg(long = "sources")]
265 pub sources: bool,
266
267 #[arg(long = "raw")]
268 pub raw: bool,
269}
270
271#[derive(Debug, Args)]
272pub struct ConfigGetArgs {
273 pub key: String,
274
275 #[arg(long = "sources")]
276 pub sources: bool,
277
278 #[arg(long = "raw")]
279 pub raw: bool,
280}
281
282#[derive(Debug, Args)]
283pub struct ConfigExplainArgs {
284 pub key: String,
285
286 #[arg(long = "show-secrets")]
287 pub show_secrets: bool,
288}
289
290#[derive(Debug, Args)]
291pub struct ConfigSetArgs {
292 pub key: String,
293 pub value: String,
294
295 #[arg(long = "global", conflicts_with_all = ["profile", "profile_all"])]
296 pub global: bool,
297
298 #[arg(long = "profile", conflicts_with = "profile_all")]
299 pub profile: Option<String>,
300
301 #[arg(long = "profile-all", conflicts_with = "profile")]
302 pub profile_all: bool,
303
304 #[arg(
305 long = "terminal",
306 num_args = 0..=1,
307 default_missing_value = "__current__"
308 )]
309 pub terminal: Option<String>,
310
311 #[arg(long = "session", conflicts_with_all = ["config_store", "secrets", "save"])]
312 pub session: bool,
313
314 #[arg(long = "config", conflicts_with_all = ["session", "secrets"])]
315 pub config_store: bool,
316
317 #[arg(long = "secrets", conflicts_with_all = ["session", "config_store"])]
318 pub secrets: bool,
319
320 #[arg(long = "save", conflicts_with_all = ["session", "config_store", "secrets"])]
321 pub save: bool,
322
323 #[arg(long = "dry-run")]
324 pub dry_run: bool,
325
326 #[arg(long = "yes")]
327 pub yes: bool,
328
329 #[arg(long = "explain")]
330 pub explain: bool,
331}
332
333#[derive(Debug, Args)]
334pub struct ConfigUnsetArgs {
335 pub key: String,
336
337 #[arg(long = "global", conflicts_with_all = ["profile", "profile_all"])]
338 pub global: bool,
339
340 #[arg(long = "profile", conflicts_with = "profile_all")]
341 pub profile: Option<String>,
342
343 #[arg(long = "profile-all", conflicts_with = "profile")]
344 pub profile_all: bool,
345
346 #[arg(
347 long = "terminal",
348 num_args = 0..=1,
349 default_missing_value = "__current__"
350 )]
351 pub terminal: Option<String>,
352
353 #[arg(long = "session", conflicts_with_all = ["config_store", "secrets", "save"])]
354 pub session: bool,
355
356 #[arg(long = "config", conflicts_with_all = ["session", "secrets"])]
357 pub config_store: bool,
358
359 #[arg(long = "secrets", conflicts_with_all = ["session", "config_store"])]
360 pub secrets: bool,
361
362 #[arg(long = "save", conflicts_with_all = ["session", "config_store", "secrets"])]
363 pub save: bool,
364
365 #[arg(long = "dry-run")]
366 pub dry_run: bool,
367}
368
369impl Cli {
370 pub fn render_settings(&self) -> RenderSettings {
371 default_render_settings()
372 }
373
374 pub fn seed_render_settings_from_config(
375 &self,
376 settings: &mut RenderSettings,
377 config: &ResolvedConfig,
378 ) {
379 apply_render_settings_from_config(settings, config);
380 }
381
382 pub fn selected_theme_name(&self, config: &ResolvedConfig) -> String {
383 self.theme
384 .as_deref()
385 .or_else(|| config.get_string("theme.name"))
386 .unwrap_or(DEFAULT_THEME_NAME)
387 .to_string()
388 }
389
390 pub(crate) fn append_static_session_overrides(&self, layer: &mut ConfigLayer) {
391 if let Some(user) = self
392 .user
393 .as_deref()
394 .map(str::trim)
395 .filter(|value| !value.is_empty())
396 {
397 layer.set("user.name", user);
398 }
399 if self.incognito {
400 layer.set("repl.history.enabled", false);
401 }
402 if let Some(theme) = self
403 .theme
404 .as_deref()
405 .map(str::trim)
406 .filter(|value| !value.is_empty())
407 {
408 layer.set("theme.name", theme);
409 }
410 if self.gammel_og_bitter {
411 layer.set("ui.presentation", UiPresentation::Austere.as_config_value());
412 } else if let Some(presentation) = self.presentation {
413 layer.set(
414 "ui.presentation",
415 UiPresentation::from(presentation).as_config_value(),
416 );
417 }
418 }
419}
420
421pub(crate) fn default_render_settings() -> RenderSettings {
422 RenderSettings {
423 format: OutputFormat::Auto,
424 mode: RenderMode::Auto,
425 color: ColorMode::Auto,
426 unicode: UnicodeMode::Auto,
427 width: None,
428 margin: 0,
429 indent_size: 2,
430 short_list_max: 1,
431 medium_list_max: 5,
432 grid_padding: 4,
433 grid_columns: None,
434 column_weight: 3,
435 table_overflow: TableOverflow::Clip,
436 table_border: TableBorderStyle::Square,
437 mreg_stack_min_col_width: 10,
438 mreg_stack_overflow_ratio: 200,
439 theme_name: DEFAULT_THEME_NAME.to_string(),
440 theme: None,
441 style_overrides: StyleOverrides::default(),
442 chrome_frame: SectionFrameStyle::Top,
443 runtime: RenderRuntime::default(),
444 }
445}
446
447pub(crate) fn apply_render_settings_from_config(
448 settings: &mut RenderSettings,
449 config: &ResolvedConfig,
450) {
451 if let Some(value) = config.get_string("ui.format")
452 && let Some(parsed) = parse_output_format(value)
453 {
454 settings.format = parsed;
455 }
456
457 if let Some(value) = config.get_string("ui.mode")
458 && let Some(parsed) = parse_render_mode(value)
459 {
460 settings.mode = parsed;
461 }
462
463 if let Some(value) = config.get_string("ui.unicode.mode")
464 && let Some(parsed) = parse_unicode_mode(value)
465 {
466 settings.unicode = parsed;
467 }
468
469 if let Some(value) = config.get_string("ui.color.mode")
470 && let Some(parsed) = parse_color_mode(value)
471 {
472 settings.color = parsed;
473 }
474
475 if let Some(value) = config.get_string("ui.chrome.frame")
476 && let Some(parsed) = SectionFrameStyle::parse(value)
477 {
478 settings.chrome_frame = parsed;
479 }
480
481 if settings.width.is_none() {
482 match config.get("ui.width").map(ConfigValue::reveal) {
483 Some(ConfigValue::Integer(width)) if *width > 0 => {
484 settings.width = Some(*width as usize);
485 }
486 Some(ConfigValue::String(raw)) => {
487 if let Ok(width) = raw.trim().parse::<usize>()
488 && width > 0
489 {
490 settings.width = Some(width);
491 }
492 }
493 _ => {}
494 }
495 }
496
497 sync_render_settings_from_config(settings, config);
498}
499
500pub(crate) fn sync_render_settings_from_config(
501 settings: &mut RenderSettings,
502 config: &ResolvedConfig,
503) {
504 if let Some(value) = config_int(config, "ui.margin")
505 && value >= 0
506 {
507 settings.margin = value as usize;
508 }
509
510 if let Some(value) = config_int(config, "ui.indent")
511 && value > 0
512 {
513 settings.indent_size = value as usize;
514 }
515
516 if let Some(value) = config_int(config, "ui.short_list_max")
517 && value > 0
518 {
519 settings.short_list_max = value as usize;
520 }
521
522 if let Some(value) = config_int(config, "ui.medium_list_max")
523 && value > 0
524 {
525 settings.medium_list_max = value as usize;
526 }
527
528 if let Some(value) = config_int(config, "ui.grid_padding")
529 && value > 0
530 {
531 settings.grid_padding = value as usize;
532 }
533
534 if let Some(value) = config_int(config, "ui.grid_columns") {
535 settings.grid_columns = if value > 0 {
536 Some(value as usize)
537 } else {
538 None
539 };
540 }
541
542 if let Some(value) = config_int(config, "ui.column_weight")
543 && value > 0
544 {
545 settings.column_weight = value as usize;
546 }
547
548 if let Some(value) = config_int(config, "ui.mreg.stack_min_col_width")
549 && value > 0
550 {
551 settings.mreg_stack_min_col_width = value as usize;
552 }
553
554 if let Some(value) = config_int(config, "ui.mreg.stack_overflow_ratio")
555 && value >= 100
556 {
557 settings.mreg_stack_overflow_ratio = value as usize;
558 }
559
560 if let Some(value) = config.get_string("ui.table.overflow")
561 && let Some(parsed) = TableOverflow::parse(value)
562 {
563 settings.table_overflow = parsed;
564 }
565
566 if let Some(value) = config.get_string("ui.table.border")
567 && let Some(parsed) = TableBorderStyle::parse(value)
568 {
569 settings.table_border = parsed;
570 }
571
572 settings.style_overrides = StyleOverrides {
573 text: config_non_empty_string(config, "color.text"),
574 key: config_non_empty_string(config, "color.key"),
575 muted: config_non_empty_string(config, "color.text.muted"),
576 table_header: config_non_empty_string(config, "color.table.header"),
577 mreg_key: config_non_empty_string(config, "color.mreg.key"),
578 value: config_non_empty_string(config, "color.value"),
579 number: config_non_empty_string(config, "color.value.number"),
580 bool_true: config_non_empty_string(config, "color.value.bool_true"),
581 bool_false: config_non_empty_string(config, "color.value.bool_false"),
582 null_value: config_non_empty_string(config, "color.value.null"),
583 ipv4: config_non_empty_string(config, "color.value.ipv4"),
584 ipv6: config_non_empty_string(config, "color.value.ipv6"),
585 panel_border: config_non_empty_string(config, "color.panel.border")
586 .or_else(|| config_non_empty_string(config, "color.border")),
587 panel_title: config_non_empty_string(config, "color.panel.title"),
588 code: config_non_empty_string(config, "color.code"),
589 json_key: config_non_empty_string(config, "color.json.key"),
590 message_error: config_non_empty_string(config, "color.message.error"),
591 message_warning: config_non_empty_string(config, "color.message.warning"),
592 message_success: config_non_empty_string(config, "color.message.success"),
593 message_info: config_non_empty_string(config, "color.message.info"),
594 message_trace: config_non_empty_string(config, "color.message.trace"),
595 };
596}
597
598fn parse_output_format(value: &str) -> Option<OutputFormat> {
599 match value.trim().to_ascii_lowercase().as_str() {
600 "auto" => Some(OutputFormat::Auto),
601 "json" => Some(OutputFormat::Json),
602 "table" => Some(OutputFormat::Table),
603 "md" | "markdown" => Some(OutputFormat::Markdown),
604 "mreg" => Some(OutputFormat::Mreg),
605 "value" => Some(OutputFormat::Value),
606 _ => None,
607 }
608}
609
610fn parse_render_mode(value: &str) -> Option<RenderMode> {
611 match value.trim().to_ascii_lowercase().as_str() {
612 "auto" => Some(RenderMode::Auto),
613 "plain" => Some(RenderMode::Plain),
614 "rich" => Some(RenderMode::Rich),
615 _ => None,
616 }
617}
618
619fn parse_color_mode(value: &str) -> Option<ColorMode> {
620 match value.trim().to_ascii_lowercase().as_str() {
621 "auto" => Some(ColorMode::Auto),
622 "always" => Some(ColorMode::Always),
623 "never" => Some(ColorMode::Never),
624 _ => None,
625 }
626}
627
628fn parse_unicode_mode(value: &str) -> Option<UnicodeMode> {
629 match value.trim().to_ascii_lowercase().as_str() {
630 "auto" => Some(UnicodeMode::Auto),
631 "always" => Some(UnicodeMode::Always),
632 "never" => Some(UnicodeMode::Never),
633 _ => None,
634 }
635}
636
637fn config_int(config: &ResolvedConfig, key: &str) -> Option<i64> {
638 match config.get(key).map(ConfigValue::reveal) {
639 Some(ConfigValue::Integer(value)) => Some(*value),
640 Some(ConfigValue::String(raw)) => raw.trim().parse::<i64>().ok(),
641 _ => None,
642 }
643}
644
645fn config_non_empty_string(config: &ResolvedConfig, key: &str) -> Option<String> {
646 config
647 .get_string(key)
648 .map(str::trim)
649 .filter(|value| !value.is_empty())
650 .map(ToOwned::to_owned)
651}
652
653pub fn parse_inline_command_tokens(tokens: &[String]) -> Result<Option<Commands>, clap::Error> {
654 InlineCommandCli::try_parse_from(tokens.iter().map(String::as_str)).map(|parsed| parsed.command)
655}
656
657#[cfg(test)]
658mod tests {
659 use super::{
660 Cli, ColorMode, Commands, ConfigCommands, InlineCommandCli, OutputFormat, RenderMode,
661 RuntimeLoadOptions, SectionFrameStyle, TableBorderStyle, TableOverflow, UnicodeMode,
662 apply_render_settings_from_config, config_int, config_non_empty_string, parse_color_mode,
663 parse_inline_command_tokens, parse_output_format, parse_render_mode, parse_unicode_mode,
664 };
665 use crate::config::{ConfigLayer, ConfigResolver, ConfigValue, ResolveOptions};
666 use crate::ui::RenderSettings;
667 use crate::ui::presentation::build_presentation_defaults_layer;
668 use clap::Parser;
669
670 fn resolved(entries: &[(&str, &str)]) -> crate::config::ResolvedConfig {
671 let mut defaults = ConfigLayer::default();
672 defaults.set("profile.default", "default");
673 for (key, value) in entries {
674 defaults.set(*key, *value);
675 }
676 let mut resolver = ConfigResolver::default();
677 resolver.set_defaults(defaults);
678 let options = ResolveOptions::default().with_terminal("cli");
679 let base = resolver
680 .resolve(options.clone())
681 .expect("base test config should resolve");
682 resolver.set_presentation(build_presentation_defaults_layer(&base));
683 resolver
684 .resolve(options)
685 .expect("test config should resolve")
686 }
687
688 fn resolved_with_session(
689 defaults_entries: &[(&str, &str)],
690 session_entries: &[(&str, &str)],
691 ) -> crate::config::ResolvedConfig {
692 let mut defaults = ConfigLayer::default();
693 defaults.set("profile.default", "default");
694 for (key, value) in defaults_entries {
695 defaults.set(*key, *value);
696 }
697
698 let mut resolver = ConfigResolver::default();
699 resolver.set_defaults(defaults);
700
701 let mut session = ConfigLayer::default();
702 for (key, value) in session_entries {
703 session.set(*key, *value);
704 }
705 resolver.set_session(session);
706
707 let options = ResolveOptions::default().with_terminal("cli");
708 let base = resolver
709 .resolve(options.clone())
710 .expect("base test config should resolve");
711 resolver.set_presentation(build_presentation_defaults_layer(&base));
712 resolver
713 .resolve(options)
714 .expect("test config should resolve")
715 }
716
717 #[test]
718 fn parse_mode_helpers_accept_aliases_and_trim_input_unit() {
719 assert_eq!(
720 parse_output_format(" markdown "),
721 Some(OutputFormat::Markdown)
722 );
723 assert_eq!(parse_render_mode(" Rich "), Some(RenderMode::Rich));
724 assert_eq!(parse_color_mode(" NEVER "), Some(ColorMode::Never));
725 assert_eq!(parse_unicode_mode(" always "), Some(UnicodeMode::Always));
726 assert_eq!(parse_output_format("yaml"), None);
727 }
728
729 #[test]
730 fn config_helpers_ignore_blank_strings_and_parse_integers_unit() {
731 let config = resolved(&[
732 ("ui.width", "120"),
733 ("color.text", " "),
734 ("ui.margin", "3"),
735 ]);
736
737 assert_eq!(config_int(&config, "ui.width"), Some(120));
738 assert_eq!(config_int(&config, "ui.margin"), Some(3));
739 assert_eq!(config_non_empty_string(&config, "color.text"), None);
740 }
741
742 #[test]
743 fn render_settings_apply_low_level_ui_overrides_unit() {
744 let config = resolved_with_session(
745 &[("ui.width", "88")],
746 &[
747 ("ui.chrome.frame", "round"),
748 ("ui.table.border", "square"),
749 ("ui.table.overflow", "wrap"),
750 ],
751 );
752 let mut settings = RenderSettings::test_plain(OutputFormat::Table);
753
754 apply_render_settings_from_config(&mut settings, &config);
755
756 assert_eq!(settings.width, Some(88));
757 assert_eq!(settings.chrome_frame, SectionFrameStyle::Round);
758 assert_eq!(settings.table_border, TableBorderStyle::Square);
759 assert_eq!(settings.table_overflow, TableOverflow::Wrap);
760 }
761
762 #[test]
763 fn presentation_seeds_runtime_chrome_and_table_defaults_unit() {
764 let config = resolved(&[("ui.presentation", "expressive")]);
765 let mut settings = RenderSettings::test_plain(OutputFormat::Table);
766
767 apply_render_settings_from_config(&mut settings, &config);
768
769 assert_eq!(settings.chrome_frame, SectionFrameStyle::TopBottom);
770 assert_eq!(settings.table_border, TableBorderStyle::Round);
771 }
772
773 #[test]
774 fn explicit_low_level_overrides_beat_presentation_defaults_unit() {
775 let config = resolved_with_session(
776 &[("ui.presentation", "expressive")],
777 &[("ui.chrome.frame", "square"), ("ui.table.border", "none")],
778 );
779 let mut settings = RenderSettings::test_plain(OutputFormat::Table);
780
781 apply_render_settings_from_config(&mut settings, &config);
782
783 assert_eq!(settings.chrome_frame, SectionFrameStyle::Square);
784 assert_eq!(settings.table_border, TableBorderStyle::None);
785 }
786
787 #[test]
788 fn parse_inline_command_tokens_accepts_builtin_and_external_commands_unit() {
789 let builtin = parse_inline_command_tokens(&["config".to_string(), "doctor".to_string()])
790 .expect("builtin command should parse");
791 assert!(matches!(
792 builtin,
793 Some(Commands::Config(args)) if matches!(args.command, ConfigCommands::Doctor)
794 ));
795
796 let external = parse_inline_command_tokens(&["ldap".to_string(), "user".to_string()])
797 .expect("external command should parse");
798 assert!(
799 matches!(external, Some(Commands::External(tokens)) if tokens == vec!["ldap", "user"])
800 );
801 }
802
803 #[test]
804 fn cli_runtime_load_options_follow_disable_flags_unit() {
805 let cli = Cli::parse_from(["osp", "--no-env", "--no-config-file", "theme", "list"]);
806 assert_eq!(
807 cli.runtime_load_options(),
808 RuntimeLoadOptions {
809 include_env: false,
810 include_config_file: false,
811 }
812 );
813
814 let inline = InlineCommandCli::try_parse_from(["theme", "list"])
815 .expect("inline command should parse");
816 assert!(matches!(inline.command, Some(Commands::Theme(_))));
817 }
818
819 #[test]
820 fn app_style_alias_maps_to_presentation_unit() {
821 let cli = Cli::parse_from(["osp", "--app-style", "austere"]);
822 let mut layer = ConfigLayer::default();
823 cli.append_static_session_overrides(&mut layer);
824 assert_eq!(
825 layer
826 .entries()
827 .iter()
828 .find(|entry| entry.key == "ui.presentation")
829 .map(|entry| &entry.value),
830 Some(&ConfigValue::from("austere"))
831 );
832 }
833}