Skip to main content

osp_cli/cli/
mod.rs

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}