Skip to main content

osp_cli/cli/
mod.rs

1//! The CLI module exists to define the public command-line grammar of `osp`.
2//!
3//! This module owns the public command-line grammar for `osp`: top-level
4//! commands, shared flags, inline parsing helpers, and the bridge from CLI
5//! arguments into render/config runtime settings. It does not execute commands;
6//! that handoff happens in [`crate::app`].
7//!
8//! Read this module when you need to answer:
9//!
10//! - what is a valid `osp ...` command line?
11//! - which flags are persistent config versus one-shot invocation settings?
12//! - how does the REPL reuse the same grammar without reusing process argv?
13//!
14//! Broad-strokes flow:
15//!
16//! ```text
17//! process argv or REPL line
18//!      │
19//!      ▼
20//! [ Cli / InlineCommandCli ]
21//! clap grammar for builtins and shared flags
22//!      │
23//!      ├── [ invocation ] one-shot execution/render flags (`--format`, `-v`,
24//!      │                  `--cache`, `--plugin-provider`, ...)
25//!      ├── [ pipeline ]   alias-aware command token parsing plus DSL stages
26//!      └── [ commands ]   built-in command handlers once parsing is complete
27//!      │
28//!      ▼
29//! [ app ] host orchestration and final dispatch
30//! ```
31//!
32//! Most callers only need a few entry points:
33//!
34//! - [`crate::cli::Cli`] for the binary-facing grammar
35//! - [`crate::cli::InlineCommandCli`] for command text that omits the binary name
36//! - [`crate::cli::parse_command_text_with_aliases`] when you need alias-aware command plus
37//!   DSL parsing
38//!
39//! The split here is deliberate. One-shot flags that affect rendering or
40//! dispatch should be modeled here so CLI, REPL, tests, and embedders all see
41//! the same contract. Do not let individual command handlers invent their own
42//! side-channel parsing rules or hidden output flags.
43//!
44//! Contract:
45//!
46//! - this module defines what users are allowed to type
47//! - it may translate flags into config/render settings
48//! - it should not dispatch commands, query external systems, or own REPL
49//!   editor behavior
50
51pub(crate) mod commands;
52pub(crate) mod invocation;
53pub(crate) mod pipeline;
54pub(crate) mod rows;
55use crate::config::{ConfigLayer, RuntimeLoadOptions};
56use clap::{Args, Parser, Subcommand, ValueEnum};
57use std::path::PathBuf;
58
59use crate::ui::UiPresentation;
60
61pub use pipeline::{
62    ParsedCommandLine, is_cli_help_stage, parse_command_text_with_aliases,
63    parse_command_tokens_with_aliases, validate_cli_dsl_stages,
64};
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
67enum PresentationArg {
68    Expressive,
69    Compact,
70    #[value(alias = "gammel-og-bitter")]
71    Austere,
72}
73
74impl From<PresentationArg> for UiPresentation {
75    fn from(value: PresentationArg) -> Self {
76        match value {
77            PresentationArg::Expressive => UiPresentation::Expressive,
78            PresentationArg::Compact => UiPresentation::Compact,
79            PresentationArg::Austere => UiPresentation::Austere,
80        }
81    }
82}
83
84/// Top-level CLI parser for the `osp` command.
85#[derive(Debug, Parser)]
86#[command(
87    name = "osp",
88    version = env!("CARGO_PKG_VERSION"),
89    about = "OSP CLI",
90    after_help = "Use `osp plugins commands` to list plugin-provided commands."
91)]
92pub struct Cli {
93    /// Override the effective user name for this invocation.
94    #[arg(short = 'u', long = "user")]
95    pub user: Option<String>,
96
97    /// Disable persistent REPL history and other identity-linked behavior.
98    #[arg(short = 'i', long = "incognito", global = true)]
99    pub incognito: bool,
100
101    /// Select the active config profile for the invocation.
102    #[arg(long = "profile", global = true)]
103    pub profile: Option<String>,
104
105    /// Skip environment-derived config sources.
106    #[arg(long = "no-env", global = true)]
107    pub no_env: bool,
108
109    /// Skip config-file-derived sources.
110    #[arg(long = "no-config-file", alias = "no-config", global = true)]
111    pub no_config_file: bool,
112
113    /// Use only built-in defaults and explicit in-memory overrides.
114    ///
115    /// This is stricter than combining `--no-env` and `--no-config-file`: it
116    /// also disables env/path bootstrap discovery through `HOME`, `XDG_*`,
117    /// `OSP_CONFIG_FILE`, and `OSP_SECRETS_FILE`.
118    #[arg(long = "defaults-only", global = true)]
119    pub defaults_only: bool,
120
121    /// Add one or more plugin discovery directories.
122    #[arg(long = "plugin-dir", global = true)]
123    pub plugin_dirs: Vec<PathBuf>,
124
125    /// Override the selected output theme.
126    #[arg(long = "theme", global = true)]
127    pub theme: Option<String>,
128
129    #[arg(long = "presentation", alias = "app-style", global = true)]
130    presentation: Option<PresentationArg>,
131
132    #[arg(
133        long = "gammel-og-bitter",
134        conflicts_with = "presentation",
135        global = true
136    )]
137    gammel_og_bitter: bool,
138
139    /// Top-level built-in or plugin command selection.
140    #[command(subcommand)]
141    pub command: Option<Commands>,
142}
143
144impl Cli {
145    /// Returns the runtime source-loading options implied by global CLI flags.
146    pub fn runtime_load_options(&self) -> RuntimeLoadOptions {
147        runtime_load_options_from_flags(self.no_env, self.no_config_file, self.defaults_only)
148    }
149}
150
151/// Top-level commands accepted by `osp`.
152#[derive(Debug, Subcommand)]
153pub enum Commands {
154    /// Inspect and manage discovered plugins.
155    Plugins(PluginsArgs),
156    /// Run local diagnostics and health checks.
157    Doctor(DoctorArgs),
158    /// Inspect and change output themes.
159    Theme(ThemeArgs),
160    /// Inspect and mutate CLI configuration.
161    Config(ConfigArgs),
162    /// Manage persisted REPL history.
163    History(HistoryArgs),
164    #[command(hide = true)]
165    /// Render the legacy intro/help experience.
166    Intro(IntroArgs),
167    #[command(hide = true)]
168    /// Access hidden REPL debugging and support commands.
169    Repl(ReplArgs),
170    #[command(external_subcommand)]
171    /// Dispatch an external or plugin-provided command line.
172    External(Vec<String>),
173}
174
175/// Parser used for inline command execution without the binary name prefix.
176#[derive(Debug, Parser)]
177#[command(name = "osp", no_binary_name = true)]
178pub struct InlineCommandCli {
179    /// Parsed command payload, if any.
180    #[command(subcommand)]
181    pub command: Option<Commands>,
182}
183
184/// Hidden REPL-only command namespace.
185#[derive(Debug, Args)]
186pub struct ReplArgs {
187    /// Hidden REPL subcommand to run.
188    #[command(subcommand)]
189    pub command: ReplCommands,
190}
191
192/// Hidden REPL debugging commands.
193#[derive(Debug, Subcommand)]
194pub enum ReplCommands {
195    #[command(name = "debug-complete", hide = true)]
196    /// Trace completion candidates for a partially typed line.
197    DebugComplete(DebugCompleteArgs),
198    #[command(name = "debug-highlight", hide = true)]
199    /// Trace syntax-highlighting output for a line.
200    DebugHighlight(DebugHighlightArgs),
201}
202
203/// Popup menu target to inspect through the hidden REPL debug surface.
204#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
205pub enum DebugMenuArg {
206    /// Trace the normal completion popup.
207    Completion,
208    /// Trace the history-search popup used by `Ctrl-R`.
209    History,
210}
211
212/// Arguments for REPL completion debugging.
213#[derive(Debug, Args)]
214pub struct DebugCompleteArgs {
215    /// Input line to complete.
216    #[arg(long)]
217    pub line: String,
218
219    /// Selects which REPL popup menu to debug.
220    #[arg(long = "menu", value_enum, default_value_t = DebugMenuArg::Completion)]
221    pub menu: DebugMenuArg,
222
223    /// Cursor position within `line`; defaults to the end of the line.
224    #[arg(long)]
225    pub cursor: Option<usize>,
226
227    /// Virtual menu width to use when rendering completion output.
228    #[arg(long, default_value_t = 80)]
229    pub width: u16,
230
231    /// Virtual menu height to use when rendering completion output.
232    #[arg(long, default_value_t = 24)]
233    pub height: u16,
234
235    /// Optional completion trace steps to enable.
236    #[arg(long = "step")]
237    pub steps: Vec<String>,
238
239    /// Enable ANSI styling in the rendered completion menu.
240    #[arg(long = "menu-ansi", default_value_t = false)]
241    pub menu_ansi: bool,
242
243    /// Enable Unicode box-drawing in the rendered completion menu.
244    #[arg(long = "menu-unicode", default_value_t = false)]
245    pub menu_unicode: bool,
246}
247
248/// Arguments for REPL highlighting debugging.
249#[derive(Debug, Args)]
250pub struct DebugHighlightArgs {
251    /// Input line to highlight.
252    #[arg(long)]
253    pub line: String,
254}
255
256/// Top-level plugin command arguments.
257#[derive(Debug, Args)]
258pub struct PluginsArgs {
259    /// Plugin management action to perform.
260    #[command(subcommand)]
261    pub command: PluginsCommands,
262}
263
264/// Top-level doctor command arguments.
265#[derive(Debug, Args)]
266pub struct DoctorArgs {
267    /// Optional narrowed diagnostic target.
268    #[command(subcommand)]
269    pub command: Option<DoctorCommands>,
270}
271
272/// Built-in diagnostic groups exposed through `osp doctor`.
273#[derive(Debug, Subcommand)]
274pub enum DoctorCommands {
275    /// Run every available built-in diagnostic.
276    All,
277    /// Validate resolved configuration state.
278    Config,
279    /// Show the last run metadata when available.
280    Last,
281    /// Validate plugin discovery and state.
282    Plugins,
283    /// Validate theme resolution and rendering support.
284    Theme,
285}
286
287/// Built-in plugin management subcommands.
288#[derive(Debug, Subcommand)]
289pub enum PluginsCommands {
290    /// List discovered plugins.
291    List,
292    /// List commands exported by plugins.
293    Commands,
294    /// Show plugin-declared configuration metadata.
295    Config(PluginConfigArgs),
296    /// Force a fresh plugin discovery pass.
297    Refresh,
298    /// Enable a plugin-backed command.
299    Enable(PluginCommandStateArgs),
300    /// Disable a plugin-backed command.
301    Disable(PluginCommandStateArgs),
302    /// Clear persisted state for a command.
303    ClearState(PluginCommandClearArgs),
304    /// Select the provider implementation used for a command.
305    SelectProvider(PluginProviderSelectArgs),
306    /// Clear an explicit provider selection for a command.
307    ClearProvider(PluginProviderClearArgs),
308    /// Run plugin-specific diagnostics.
309    Doctor,
310}
311
312/// Top-level theme command arguments.
313#[derive(Debug, Args)]
314pub struct ThemeArgs {
315    /// Theme action to perform.
316    #[command(subcommand)]
317    pub command: ThemeCommands,
318}
319
320/// Theme inspection and selection commands.
321#[derive(Debug, Subcommand)]
322pub enum ThemeCommands {
323    /// List available themes.
324    List,
325    /// Show details for a specific theme.
326    Show(ThemeShowArgs),
327    /// Persist or apply a selected theme.
328    Use(ThemeUseArgs),
329}
330
331/// Arguments for `theme show`.
332#[derive(Debug, Args)]
333pub struct ThemeShowArgs {
334    /// Theme name to inspect; defaults to the active theme.
335    pub name: Option<String>,
336}
337
338/// Arguments for `theme use`.
339#[derive(Debug, Args)]
340pub struct ThemeUseArgs {
341    /// Theme name to activate.
342    pub name: String,
343}
344
345/// Shared arguments for enabling or disabling a plugin command.
346#[derive(Debug, Args, Clone, Default)]
347pub struct PluginScopeArgs {
348    /// Apply the change globally instead of to a profile.
349    #[arg(long = "global", conflicts_with = "profile")]
350    pub global: bool,
351
352    /// Apply the change to a named profile.
353    #[arg(long = "profile")]
354    pub profile: Option<String>,
355
356    /// Target a specific terminal context, or the current one when omitted.
357    #[arg(
358        long = "terminal",
359        num_args = 0..=1,
360        default_missing_value = "__current__"
361    )]
362    pub terminal: Option<String>,
363}
364
365/// Shared config write scope arguments.
366#[derive(Debug, Args, Clone, Default)]
367pub struct ConfigScopeArgs {
368    /// Write to the global store instead of a profile-scoped store.
369    #[arg(long = "global", conflicts_with_all = ["profile", "profile_all"])]
370    pub global: bool,
371
372    /// Write to a single named profile.
373    #[arg(long = "profile", conflicts_with = "profile_all")]
374    pub profile: Option<String>,
375
376    /// Write to every known profile store.
377    #[arg(long = "profile-all", conflicts_with = "profile")]
378    pub profile_all: bool,
379
380    /// Write to a terminal-scoped store, or the current terminal when omitted.
381    #[arg(
382        long = "terminal",
383        num_args = 0..=1,
384        default_missing_value = "__current__"
385    )]
386    pub terminal: Option<String>,
387}
388
389/// Shared config store-selection arguments.
390#[derive(Debug, Args, Clone, Default)]
391pub struct ConfigStoreArgs {
392    /// Apply the change only to the current in-memory session.
393    #[arg(long = "session", conflicts_with_all = ["config_store", "secrets", "save"])]
394    pub session: bool,
395
396    /// Force the regular config store as the destination.
397    #[arg(long = "config", conflicts_with_all = ["session", "secrets"])]
398    pub config_store: bool,
399
400    /// Force the secrets store as the destination.
401    #[arg(long = "secrets", conflicts_with_all = ["session", "config_store"])]
402    pub secrets: bool,
403
404    /// Persist the change immediately after validation.
405    #[arg(long = "save", conflicts_with_all = ["session", "config_store", "secrets"])]
406    pub save: bool,
407}
408
409/// Shared plugin command target arguments.
410#[derive(Debug, Args, Clone)]
411pub struct PluginCommandTargetArgs {
412    /// Command name to mutate.
413    pub command: String,
414
415    /// Shared global/profile/terminal targeting flags.
416    #[command(flatten)]
417    pub scope: PluginScopeArgs,
418}
419
420/// Shared config read-output arguments.
421#[derive(Debug, Args, Clone, Default)]
422pub struct ConfigReadOutputArgs {
423    /// Include source provenance for returned keys.
424    #[arg(long = "sources")]
425    pub sources: bool,
426
427    /// Emit raw stored values without presentation formatting.
428    #[arg(long = "raw")]
429    pub raw: bool,
430}
431
432/// Shared arguments for enabling or disabling a plugin command.
433#[derive(Debug, Args)]
434pub struct PluginCommandStateArgs {
435    /// Shared command name plus global/profile/terminal targeting flags.
436    #[command(flatten)]
437    pub target: PluginCommandTargetArgs,
438}
439
440/// Arguments for clearing persisted command state.
441#[derive(Debug, Args)]
442pub struct PluginCommandClearArgs {
443    /// Shared command name plus global/profile/terminal targeting flags.
444    #[command(flatten)]
445    pub target: PluginCommandTargetArgs,
446}
447
448/// Arguments for selecting a provider implementation for a command.
449#[derive(Debug, Args)]
450pub struct PluginProviderSelectArgs {
451    /// Shared command name plus global/profile/terminal targeting flags.
452    #[command(flatten)]
453    pub target: PluginCommandTargetArgs,
454
455    /// Plugin identifier to bind to the command.
456    pub plugin_id: String,
457}
458
459/// Arguments for clearing a provider selection.
460#[derive(Debug, Args)]
461pub struct PluginProviderClearArgs {
462    /// Shared command name plus global/profile/terminal targeting flags.
463    #[command(flatten)]
464    pub target: PluginCommandTargetArgs,
465}
466
467/// Arguments for `plugins config`.
468#[derive(Debug, Args)]
469pub struct PluginConfigArgs {
470    /// Plugin identifier whose config schema should be shown.
471    pub plugin_id: String,
472}
473
474/// Top-level config command arguments.
475#[derive(Debug, Args)]
476pub struct ConfigArgs {
477    /// Config action to perform.
478    #[command(subcommand)]
479    pub command: ConfigCommands,
480}
481
482/// Top-level history command arguments.
483#[derive(Debug, Args)]
484pub struct HistoryArgs {
485    /// History action to perform.
486    #[command(subcommand)]
487    pub command: HistoryCommands,
488}
489
490/// Hidden intro command arguments.
491#[derive(Debug, Args, Clone, Default)]
492pub struct IntroArgs {}
493
494/// History management commands.
495#[derive(Debug, Subcommand)]
496pub enum HistoryCommands {
497    /// List persisted history entries.
498    List,
499    /// Retain only the newest `keep` entries.
500    Prune(HistoryPruneArgs),
501    /// Remove all persisted history entries.
502    Clear,
503}
504
505/// Arguments for `history prune`.
506#[derive(Debug, Args)]
507pub struct HistoryPruneArgs {
508    /// Number of recent entries to keep.
509    pub keep: usize,
510}
511
512/// Configuration inspection and mutation commands.
513#[derive(Debug, Subcommand)]
514pub enum ConfigCommands {
515    /// Show the resolved configuration view.
516    Show(ConfigShowArgs),
517    /// Read a single resolved config key.
518    Get(ConfigGetArgs),
519    /// Explain how a config key was resolved.
520    Explain(ConfigExplainArgs),
521    /// Set a config key in one or more writable stores.
522    Set(ConfigSetArgs),
523    /// Remove a config key from one or more writable stores.
524    Unset(ConfigUnsetArgs),
525    #[command(alias = "diagnostics")]
526    /// Run config-specific diagnostics.
527    Doctor,
528}
529
530/// Arguments for `config show`.
531#[derive(Debug, Args)]
532pub struct ConfigShowArgs {
533    /// Shared source/raw output flags for config reads.
534    #[command(flatten)]
535    pub output: ConfigReadOutputArgs,
536}
537
538/// Arguments for `config get`.
539#[derive(Debug, Args)]
540pub struct ConfigGetArgs {
541    /// Config key to read.
542    pub key: String,
543
544    /// Shared source/raw output flags for config reads.
545    #[command(flatten)]
546    pub output: ConfigReadOutputArgs,
547}
548
549/// Arguments for `config explain`.
550#[derive(Debug, Args)]
551pub struct ConfigExplainArgs {
552    /// Config key to explain.
553    pub key: String,
554
555    /// Reveal secret values in the explanation output.
556    #[arg(long = "show-secrets")]
557    pub show_secrets: bool,
558}
559
560/// Arguments for `config set`.
561#[derive(Debug, Args)]
562pub struct ConfigSetArgs {
563    /// Config key to write.
564    pub key: String,
565    /// Config value to write.
566    pub value: String,
567
568    /// Shared global/profile/terminal targeting flags.
569    #[command(flatten)]
570    pub scope: ConfigScopeArgs,
571
572    /// Shared config/session/secrets store-selection flags.
573    #[command(flatten)]
574    pub store: ConfigStoreArgs,
575
576    /// Show the resolved write plan without applying it.
577    #[arg(long = "dry-run")]
578    pub dry_run: bool,
579
580    /// Skip interactive confirmation prompts.
581    #[arg(long = "yes")]
582    pub yes: bool,
583
584    /// Show an explanation of the resolved write targets.
585    #[arg(long = "explain")]
586    pub explain: bool,
587}
588
589/// Arguments for `config unset`.
590#[derive(Debug, Args)]
591pub struct ConfigUnsetArgs {
592    /// Config key to remove.
593    pub key: String,
594
595    /// Shared global/profile/terminal targeting flags.
596    #[command(flatten)]
597    pub scope: ConfigScopeArgs,
598
599    /// Shared config/session/secrets store-selection flags.
600    #[command(flatten)]
601    pub store: ConfigStoreArgs,
602
603    /// Show the resolved removal plan without applying it.
604    #[arg(long = "dry-run")]
605    pub dry_run: bool,
606}
607
608impl Cli {
609    pub(crate) fn append_static_session_overrides(&self, layer: &mut ConfigLayer) {
610        if let Some(user) = self
611            .user
612            .as_deref()
613            .map(str::trim)
614            .filter(|value| !value.is_empty())
615        {
616            layer.set("user.name", user);
617        }
618        if self.incognito {
619            layer.set("repl.history.enabled", false);
620        }
621        append_appearance_overrides(
622            layer,
623            self.theme.as_deref(),
624            if self.gammel_og_bitter {
625                Some(UiPresentation::Austere)
626            } else {
627                self.presentation.map(UiPresentation::from)
628            },
629        );
630    }
631}
632
633pub(crate) fn append_appearance_overrides(
634    layer: &mut ConfigLayer,
635    theme: Option<&str>,
636    presentation: Option<UiPresentation>,
637) {
638    if let Some(theme) = theme.map(str::trim).filter(|value| !value.is_empty()) {
639        layer.set("theme.name", theme);
640    }
641    if let Some(presentation) = presentation {
642        layer.set("ui.presentation", presentation.as_config_value());
643    }
644}
645
646pub(crate) fn runtime_load_options_from_flags(
647    no_env: bool,
648    no_config_file: bool,
649    defaults_only: bool,
650) -> RuntimeLoadOptions {
651    if defaults_only {
652        RuntimeLoadOptions::defaults_only()
653    } else {
654        RuntimeLoadOptions::new()
655            .with_env(!no_env)
656            .with_config_file(!no_config_file)
657    }
658}
659
660/// Parses inline command tokens with the same clap model as the top-level CLI.
661///
662/// This is the REPL-facing path for turning already-tokenized input into a
663/// concrete builtin command, and it returns `Ok(None)` when no subcommand has
664/// been selected yet.
665///
666/// # Examples
667///
668/// ```
669/// use osp_cli::cli::{Commands, ThemeCommands, parse_inline_command_tokens};
670///
671/// let tokens = vec![
672///     "theme".to_string(),
673///     "show".to_string(),
674///     "dracula".to_string(),
675/// ];
676///
677/// let command = parse_inline_command_tokens(&tokens).unwrap().unwrap();
678/// match command {
679///     Commands::Theme(args) => match args.command {
680///         ThemeCommands::Show(show) => {
681///             assert_eq!(show.name.as_deref(), Some("dracula"));
682///         }
683///         other => panic!("unexpected theme command: {other:?}"),
684///     },
685///     other => panic!("unexpected command: {other:?}"),
686/// }
687/// ```
688pub fn parse_inline_command_tokens(tokens: &[String]) -> Result<Option<Commands>, clap::Error> {
689    InlineCommandCli::try_parse_from(tokens.iter().map(String::as_str)).map(|parsed| parsed.command)
690}
691
692#[cfg(test)]
693mod tests {
694    use super::{
695        Cli, Commands, ConfigCommands, InlineCommandCli, RuntimeLoadOptions,
696        append_appearance_overrides, parse_inline_command_tokens,
697    };
698    use crate::config::{ConfigLayer, ConfigValue};
699    use clap::Parser;
700
701    #[test]
702    fn parse_inline_command_tokens_accepts_builtin_and_external_commands_unit() {
703        let builtin = parse_inline_command_tokens(&["config".to_string(), "doctor".to_string()])
704            .expect("builtin command should parse");
705        assert!(matches!(
706            builtin,
707            Some(Commands::Config(args)) if matches!(args.command, ConfigCommands::Doctor)
708        ));
709
710        let external = parse_inline_command_tokens(&["ldap".to_string(), "user".to_string()])
711            .expect("external command should parse");
712        assert!(
713            matches!(external, Some(Commands::External(tokens)) if tokens == vec!["ldap", "user"])
714        );
715    }
716
717    #[test]
718    fn cli_runtime_load_options_and_inline_parser_follow_disable_flags_unit() {
719        let cli = Cli::parse_from(["osp", "--no-env", "--no-config-file", "theme", "list"]);
720        assert_eq!(
721            cli.runtime_load_options(),
722            RuntimeLoadOptions::new()
723                .with_env(false)
724                .with_config_file(false)
725        );
726
727        let cli = Cli::parse_from(["osp", "--defaults-only", "theme", "list"]);
728        assert_eq!(
729            cli.runtime_load_options(),
730            RuntimeLoadOptions::defaults_only()
731        );
732
733        let inline = InlineCommandCli::try_parse_from(["theme", "list"])
734            .expect("inline command should parse");
735        assert!(matches!(inline.command, Some(Commands::Theme(_))));
736    }
737
738    #[test]
739    fn app_style_alias_maps_to_presentation_unit() {
740        let cli = Cli::parse_from(["osp", "--app-style", "austere"]);
741        let mut layer = ConfigLayer::default();
742        cli.append_static_session_overrides(&mut layer);
743        assert_eq!(
744            layer
745                .entries()
746                .iter()
747                .find(|entry| entry.key == "ui.presentation")
748                .map(|entry| &entry.value),
749            Some(&ConfigValue::from("austere"))
750        );
751    }
752
753    #[test]
754    fn appearance_overrides_trim_theme_and_apply_presentation_unit() {
755        let mut layer = ConfigLayer::default();
756        append_appearance_overrides(
757            &mut layer,
758            Some(" nord "),
759            Some(crate::ui::UiPresentation::Compact),
760        );
761
762        assert_eq!(
763            layer
764                .entries()
765                .iter()
766                .find(|entry| entry.key == "theme.name")
767                .map(|entry| &entry.value),
768            Some(&ConfigValue::from("nord"))
769        );
770        assert_eq!(
771            layer
772                .entries()
773                .iter()
774                .find(|entry| entry.key == "ui.presentation")
775                .map(|entry| &entry.value),
776            Some(&ConfigValue::from("compact"))
777        );
778    }
779}