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, ConfigValue, ResolvedConfig, RuntimeLoadOptions};
56use crate::core::output::{ColorMode, OutputFormat, RenderMode, UnicodeMode};
57use crate::ui::chrome::{RuledSectionPolicy, SectionFrameStyle};
58use crate::ui::theme::DEFAULT_THEME_NAME;
59use crate::ui::{
60    GuideDefaultFormat, HelpTableChrome, RenderSettings, StyleOverrides, TableBorderStyle,
61    TableOverflow,
62};
63use clap::{Args, Parser, Subcommand, ValueEnum};
64use std::path::PathBuf;
65
66use crate::ui::presentation::UiPresentation;
67
68pub use pipeline::{
69    ParsedCommandLine, is_cli_help_stage, parse_command_text_with_aliases,
70    parse_command_tokens_with_aliases, validate_cli_dsl_stages,
71};
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
74enum PresentationArg {
75    Expressive,
76    Compact,
77    #[value(alias = "gammel-og-bitter")]
78    Austere,
79}
80
81impl From<PresentationArg> for UiPresentation {
82    fn from(value: PresentationArg) -> Self {
83        match value {
84            PresentationArg::Expressive => UiPresentation::Expressive,
85            PresentationArg::Compact => UiPresentation::Compact,
86            PresentationArg::Austere => UiPresentation::Austere,
87        }
88    }
89}
90
91/// Top-level CLI parser for the `osp` command.
92#[derive(Debug, Parser)]
93#[command(
94    name = "osp",
95    version = env!("CARGO_PKG_VERSION"),
96    about = "OSP CLI",
97    after_help = "Use `osp plugins commands` to list plugin-provided commands."
98)]
99pub struct Cli {
100    /// Override the effective user name for this invocation.
101    #[arg(short = 'u', long = "user")]
102    pub user: Option<String>,
103
104    /// Disable persistent REPL history and other identity-linked behavior.
105    #[arg(short = 'i', long = "incognito", global = true)]
106    pub incognito: bool,
107
108    /// Select the active config profile for the invocation.
109    #[arg(long = "profile", global = true)]
110    pub profile: Option<String>,
111
112    /// Skip environment-derived config sources.
113    #[arg(long = "no-env", global = true)]
114    pub no_env: bool,
115
116    /// Skip config-file-derived sources.
117    #[arg(long = "no-config-file", alias = "no-config", global = true)]
118    pub no_config_file: bool,
119
120    /// Use only built-in defaults and explicit in-memory overrides.
121    ///
122    /// This is stricter than combining `--no-env` and `--no-config-file`: it
123    /// also disables env/path bootstrap discovery through `HOME`, `XDG_*`,
124    /// `OSP_CONFIG_FILE`, and `OSP_SECRETS_FILE`.
125    #[arg(long = "defaults-only", global = true)]
126    pub defaults_only: bool,
127
128    /// Add one or more plugin discovery directories.
129    #[arg(long = "plugin-dir", global = true)]
130    pub plugin_dirs: Vec<PathBuf>,
131
132    /// Override the selected output theme.
133    #[arg(long = "theme", global = true)]
134    pub theme: Option<String>,
135
136    #[arg(long = "presentation", alias = "app-style", global = true)]
137    presentation: Option<PresentationArg>,
138
139    #[arg(
140        long = "gammel-og-bitter",
141        conflicts_with = "presentation",
142        global = true
143    )]
144    gammel_og_bitter: bool,
145
146    /// Top-level built-in or plugin command selection.
147    #[command(subcommand)]
148    pub command: Option<Commands>,
149}
150
151impl Cli {
152    /// Returns the runtime source-loading options implied by global CLI flags.
153    pub fn runtime_load_options(&self) -> RuntimeLoadOptions {
154        if self.defaults_only {
155            RuntimeLoadOptions::defaults_only()
156        } else {
157            RuntimeLoadOptions::new()
158                .with_env(!self.no_env)
159                .with_config_file(!self.no_config_file)
160        }
161    }
162}
163
164/// Top-level commands accepted by `osp`.
165#[derive(Debug, Subcommand)]
166pub enum Commands {
167    /// Inspect and manage discovered plugins.
168    Plugins(PluginsArgs),
169    /// Run local diagnostics and health checks.
170    Doctor(DoctorArgs),
171    /// Inspect and change output themes.
172    Theme(ThemeArgs),
173    /// Inspect and mutate CLI configuration.
174    Config(ConfigArgs),
175    /// Manage persisted REPL history.
176    History(HistoryArgs),
177    #[command(hide = true)]
178    /// Render the legacy intro/help experience.
179    Intro(IntroArgs),
180    #[command(hide = true)]
181    /// Access hidden REPL debugging and support commands.
182    Repl(ReplArgs),
183    #[command(external_subcommand)]
184    /// Dispatch an external or plugin-provided command line.
185    External(Vec<String>),
186}
187
188/// Parser used for inline command execution without the binary name prefix.
189#[derive(Debug, Parser)]
190#[command(name = "osp", no_binary_name = true)]
191pub struct InlineCommandCli {
192    /// Parsed command payload, if any.
193    #[command(subcommand)]
194    pub command: Option<Commands>,
195}
196
197/// Hidden REPL-only command namespace.
198#[derive(Debug, Args)]
199pub struct ReplArgs {
200    /// Hidden REPL subcommand to run.
201    #[command(subcommand)]
202    pub command: ReplCommands,
203}
204
205/// Hidden REPL debugging commands.
206#[derive(Debug, Subcommand)]
207pub enum ReplCommands {
208    #[command(name = "debug-complete", hide = true)]
209    /// Trace completion candidates for a partially typed line.
210    DebugComplete(DebugCompleteArgs),
211    #[command(name = "debug-highlight", hide = true)]
212    /// Trace syntax-highlighting output for a line.
213    DebugHighlight(DebugHighlightArgs),
214}
215
216/// Popup menu target to inspect through the hidden REPL debug surface.
217#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
218pub enum DebugMenuArg {
219    /// Trace the normal completion popup.
220    Completion,
221    /// Trace the history-search popup used by `Ctrl-R`.
222    History,
223}
224
225/// Arguments for REPL completion debugging.
226#[derive(Debug, Args)]
227pub struct DebugCompleteArgs {
228    /// Input line to complete.
229    #[arg(long)]
230    pub line: String,
231
232    /// Selects which REPL popup menu to debug.
233    #[arg(long = "menu", value_enum, default_value_t = DebugMenuArg::Completion)]
234    pub menu: DebugMenuArg,
235
236    /// Cursor position within `line`; defaults to the end of the line.
237    #[arg(long)]
238    pub cursor: Option<usize>,
239
240    /// Virtual menu width to use when rendering completion output.
241    #[arg(long, default_value_t = 80)]
242    pub width: u16,
243
244    /// Virtual menu height to use when rendering completion output.
245    #[arg(long, default_value_t = 24)]
246    pub height: u16,
247
248    /// Optional completion trace steps to enable.
249    #[arg(long = "step")]
250    pub steps: Vec<String>,
251
252    /// Enable ANSI styling in the rendered completion menu.
253    #[arg(long = "menu-ansi", default_value_t = false)]
254    pub menu_ansi: bool,
255
256    /// Enable Unicode box-drawing in the rendered completion menu.
257    #[arg(long = "menu-unicode", default_value_t = false)]
258    pub menu_unicode: bool,
259}
260
261/// Arguments for REPL highlighting debugging.
262#[derive(Debug, Args)]
263pub struct DebugHighlightArgs {
264    /// Input line to highlight.
265    #[arg(long)]
266    pub line: String,
267}
268
269/// Top-level plugin command arguments.
270#[derive(Debug, Args)]
271pub struct PluginsArgs {
272    /// Plugin management action to perform.
273    #[command(subcommand)]
274    pub command: PluginsCommands,
275}
276
277/// Top-level doctor command arguments.
278#[derive(Debug, Args)]
279pub struct DoctorArgs {
280    /// Optional narrowed diagnostic target.
281    #[command(subcommand)]
282    pub command: Option<DoctorCommands>,
283}
284
285/// Built-in diagnostic groups exposed through `osp doctor`.
286#[derive(Debug, Subcommand)]
287pub enum DoctorCommands {
288    /// Run every available built-in diagnostic.
289    All,
290    /// Validate resolved configuration state.
291    Config,
292    /// Show the last run metadata when available.
293    Last,
294    /// Validate plugin discovery and state.
295    Plugins,
296    /// Validate theme resolution and rendering support.
297    Theme,
298}
299
300/// Built-in plugin management subcommands.
301#[derive(Debug, Subcommand)]
302pub enum PluginsCommands {
303    /// List discovered plugins.
304    List,
305    /// List commands exported by plugins.
306    Commands,
307    /// Show plugin-declared configuration metadata.
308    Config(PluginConfigArgs),
309    /// Force a fresh plugin discovery pass.
310    Refresh,
311    /// Enable a plugin-backed command.
312    Enable(PluginCommandStateArgs),
313    /// Disable a plugin-backed command.
314    Disable(PluginCommandStateArgs),
315    /// Clear persisted state for a command.
316    ClearState(PluginCommandClearArgs),
317    /// Select the provider implementation used for a command.
318    SelectProvider(PluginProviderSelectArgs),
319    /// Clear an explicit provider selection for a command.
320    ClearProvider(PluginProviderClearArgs),
321    /// Run plugin-specific diagnostics.
322    Doctor,
323}
324
325/// Top-level theme command arguments.
326#[derive(Debug, Args)]
327pub struct ThemeArgs {
328    /// Theme action to perform.
329    #[command(subcommand)]
330    pub command: ThemeCommands,
331}
332
333/// Theme inspection and selection commands.
334#[derive(Debug, Subcommand)]
335pub enum ThemeCommands {
336    /// List available themes.
337    List,
338    /// Show details for a specific theme.
339    Show(ThemeShowArgs),
340    /// Persist or apply a selected theme.
341    Use(ThemeUseArgs),
342}
343
344/// Arguments for `theme show`.
345#[derive(Debug, Args)]
346pub struct ThemeShowArgs {
347    /// Theme name to inspect; defaults to the active theme.
348    pub name: Option<String>,
349}
350
351/// Arguments for `theme use`.
352#[derive(Debug, Args)]
353pub struct ThemeUseArgs {
354    /// Theme name to activate.
355    pub name: String,
356}
357
358/// Shared arguments for enabling or disabling a plugin command.
359#[derive(Debug, Args)]
360pub struct PluginCommandStateArgs {
361    /// Command name to enable or disable.
362    pub command: String,
363
364    /// Apply the change globally instead of to a profile.
365    #[arg(long = "global", conflicts_with = "profile")]
366    pub global: bool,
367
368    /// Apply the change to a named profile.
369    #[arg(long = "profile")]
370    pub profile: Option<String>,
371
372    /// Target a specific terminal context, or the current one when omitted.
373    #[arg(
374        long = "terminal",
375        num_args = 0..=1,
376        default_missing_value = "__current__"
377    )]
378    pub terminal: Option<String>,
379}
380
381/// Arguments for clearing persisted command state.
382#[derive(Debug, Args)]
383pub struct PluginCommandClearArgs {
384    /// Command name whose state should be cleared.
385    pub command: String,
386
387    /// Clear global state instead of profile-scoped state.
388    #[arg(long = "global", conflicts_with = "profile")]
389    pub global: bool,
390
391    /// Clear state for a named profile.
392    #[arg(long = "profile")]
393    pub profile: Option<String>,
394
395    /// Target a specific terminal context, or the current one when omitted.
396    #[arg(
397        long = "terminal",
398        num_args = 0..=1,
399        default_missing_value = "__current__"
400    )]
401    pub terminal: Option<String>,
402}
403
404/// Arguments for selecting a provider implementation for a command.
405#[derive(Debug, Args)]
406pub struct PluginProviderSelectArgs {
407    /// Command name whose provider should be selected.
408    pub command: String,
409    /// Plugin identifier to bind to the command.
410    pub plugin_id: String,
411
412    /// Apply the change globally instead of to a profile.
413    #[arg(long = "global", conflicts_with = "profile")]
414    pub global: bool,
415
416    /// Apply the change to a named profile.
417    #[arg(long = "profile")]
418    pub profile: Option<String>,
419
420    /// Target a specific terminal context, or the current one when omitted.
421    #[arg(
422        long = "terminal",
423        num_args = 0..=1,
424        default_missing_value = "__current__"
425    )]
426    pub terminal: Option<String>,
427}
428
429/// Arguments for clearing a provider selection.
430#[derive(Debug, Args)]
431pub struct PluginProviderClearArgs {
432    /// Command name whose provider binding should be removed.
433    pub command: String,
434
435    /// Clear the global binding instead of a profile-scoped binding.
436    #[arg(long = "global", conflicts_with = "profile")]
437    pub global: bool,
438
439    /// Clear the binding for a named profile.
440    #[arg(long = "profile")]
441    pub profile: Option<String>,
442
443    /// Target a specific terminal context, or the current one when omitted.
444    #[arg(
445        long = "terminal",
446        num_args = 0..=1,
447        default_missing_value = "__current__"
448    )]
449    pub terminal: Option<String>,
450}
451
452/// Arguments for `plugins config`.
453#[derive(Debug, Args)]
454pub struct PluginConfigArgs {
455    /// Plugin identifier whose config schema should be shown.
456    pub plugin_id: String,
457}
458
459/// Top-level config command arguments.
460#[derive(Debug, Args)]
461pub struct ConfigArgs {
462    /// Config action to perform.
463    #[command(subcommand)]
464    pub command: ConfigCommands,
465}
466
467/// Top-level history command arguments.
468#[derive(Debug, Args)]
469pub struct HistoryArgs {
470    /// History action to perform.
471    #[command(subcommand)]
472    pub command: HistoryCommands,
473}
474
475/// Hidden intro command arguments.
476#[derive(Debug, Args, Clone, Default)]
477pub struct IntroArgs {}
478
479/// History management commands.
480#[derive(Debug, Subcommand)]
481pub enum HistoryCommands {
482    /// List persisted history entries.
483    List,
484    /// Retain only the newest `keep` entries.
485    Prune(HistoryPruneArgs),
486    /// Remove all persisted history entries.
487    Clear,
488}
489
490/// Arguments for `history prune`.
491#[derive(Debug, Args)]
492pub struct HistoryPruneArgs {
493    /// Number of recent entries to keep.
494    pub keep: usize,
495}
496
497/// Configuration inspection and mutation commands.
498#[derive(Debug, Subcommand)]
499pub enum ConfigCommands {
500    /// Show the resolved configuration view.
501    Show(ConfigShowArgs),
502    /// Read a single resolved config key.
503    Get(ConfigGetArgs),
504    /// Explain how a config key was resolved.
505    Explain(ConfigExplainArgs),
506    /// Set a config key in one or more writable stores.
507    Set(ConfigSetArgs),
508    /// Remove a config key from one or more writable stores.
509    Unset(ConfigUnsetArgs),
510    #[command(alias = "diagnostics")]
511    /// Run config-specific diagnostics.
512    Doctor,
513}
514
515/// Arguments for `config show`.
516#[derive(Debug, Args)]
517pub struct ConfigShowArgs {
518    /// Include source provenance for each returned key.
519    #[arg(long = "sources")]
520    pub sources: bool,
521
522    /// Emit raw stored values without presentation formatting.
523    #[arg(long = "raw")]
524    pub raw: bool,
525}
526
527/// Arguments for `config get`.
528#[derive(Debug, Args)]
529pub struct ConfigGetArgs {
530    /// Config key to read.
531    pub key: String,
532
533    /// Include source provenance for the resolved key.
534    #[arg(long = "sources")]
535    pub sources: bool,
536
537    /// Emit the raw stored value without presentation formatting.
538    #[arg(long = "raw")]
539    pub raw: bool,
540}
541
542/// Arguments for `config explain`.
543#[derive(Debug, Args)]
544pub struct ConfigExplainArgs {
545    /// Config key to explain.
546    pub key: String,
547
548    /// Reveal secret values in the explanation output.
549    #[arg(long = "show-secrets")]
550    pub show_secrets: bool,
551}
552
553/// Arguments for `config set`.
554#[derive(Debug, Args)]
555pub struct ConfigSetArgs {
556    /// Config key to write.
557    pub key: String,
558    /// Config value to write.
559    pub value: String,
560
561    /// Write to the global store instead of a profile-scoped store.
562    #[arg(long = "global", conflicts_with_all = ["profile", "profile_all"])]
563    pub global: bool,
564
565    /// Write to a single named profile.
566    #[arg(long = "profile", conflicts_with = "profile_all")]
567    pub profile: Option<String>,
568
569    /// Write to every known profile store.
570    #[arg(long = "profile-all", conflicts_with = "profile")]
571    pub profile_all: bool,
572
573    /// Write to a terminal-scoped store, or the current terminal when omitted.
574    #[arg(
575        long = "terminal",
576        num_args = 0..=1,
577        default_missing_value = "__current__"
578    )]
579    pub terminal: Option<String>,
580
581    /// Apply the change only to the current in-memory session.
582    #[arg(long = "session", conflicts_with_all = ["config_store", "secrets", "save"])]
583    pub session: bool,
584
585    /// Force the regular config store as the destination.
586    #[arg(long = "config", conflicts_with_all = ["session", "secrets"])]
587    pub config_store: bool,
588
589    /// Force the secrets store as the destination.
590    #[arg(long = "secrets", conflicts_with_all = ["session", "config_store"])]
591    pub secrets: bool,
592
593    /// Persist the change immediately after validation.
594    #[arg(long = "save", conflicts_with_all = ["session", "config_store", "secrets"])]
595    pub save: bool,
596
597    /// Show the resolved write plan without applying it.
598    #[arg(long = "dry-run")]
599    pub dry_run: bool,
600
601    /// Skip interactive confirmation prompts.
602    #[arg(long = "yes")]
603    pub yes: bool,
604
605    /// Show an explanation of the resolved write targets.
606    #[arg(long = "explain")]
607    pub explain: bool,
608}
609
610/// Arguments for `config unset`.
611#[derive(Debug, Args)]
612pub struct ConfigUnsetArgs {
613    /// Config key to remove.
614    pub key: String,
615
616    /// Remove the key from the global store instead of a profile-scoped store.
617    #[arg(long = "global", conflicts_with_all = ["profile", "profile_all"])]
618    pub global: bool,
619
620    /// Remove the key from a single named profile.
621    #[arg(long = "profile", conflicts_with = "profile_all")]
622    pub profile: Option<String>,
623
624    /// Remove the key from every known profile store.
625    #[arg(long = "profile-all", conflicts_with = "profile")]
626    pub profile_all: bool,
627
628    /// Remove the key from a terminal-scoped store, or the current terminal when omitted.
629    #[arg(
630        long = "terminal",
631        num_args = 0..=1,
632        default_missing_value = "__current__"
633    )]
634    pub terminal: Option<String>,
635
636    /// Remove the key only from the current in-memory session.
637    #[arg(long = "session", conflicts_with_all = ["config_store", "secrets", "save"])]
638    pub session: bool,
639
640    /// Force the regular config store as the source to edit.
641    #[arg(long = "config", conflicts_with_all = ["session", "secrets"])]
642    pub config_store: bool,
643
644    /// Force the secrets store as the source to edit.
645    #[arg(long = "secrets", conflicts_with_all = ["session", "config_store"])]
646    pub secrets: bool,
647
648    /// Persist the change immediately after validation.
649    #[arg(long = "save", conflicts_with_all = ["session", "config_store", "secrets"])]
650    pub save: bool,
651
652    /// Show the resolved removal plan without applying it.
653    #[arg(long = "dry-run")]
654    pub dry_run: bool,
655}
656
657impl Cli {
658    pub(crate) fn default_invocation() -> Self {
659        Self {
660            user: None,
661            incognito: false,
662            profile: None,
663            no_env: false,
664            no_config_file: false,
665            defaults_only: false,
666            plugin_dirs: Vec::new(),
667            theme: None,
668            presentation: None,
669            gammel_og_bitter: false,
670            command: None,
671        }
672    }
673
674    /// Returns the default render settings for this CLI invocation.
675    pub fn render_settings(&self) -> RenderSettings {
676        default_render_settings()
677    }
678
679    /// Applies config-backed render settings to an existing settings struct.
680    pub fn seed_render_settings_from_config(
681        &self,
682        settings: &mut RenderSettings,
683        config: &ResolvedConfig,
684    ) {
685        apply_render_settings_from_config(settings, config);
686    }
687
688    /// Returns the theme name selected by CLI override or resolved config.
689    pub fn selected_theme_name(&self, config: &ResolvedConfig) -> String {
690        self.theme
691            .as_deref()
692            .or_else(|| config.get_string("theme.name"))
693            .unwrap_or(DEFAULT_THEME_NAME)
694            .to_string()
695    }
696
697    pub(crate) fn append_static_session_overrides(&self, layer: &mut ConfigLayer) {
698        if let Some(user) = self
699            .user
700            .as_deref()
701            .map(str::trim)
702            .filter(|value| !value.is_empty())
703        {
704            layer.set("user.name", user);
705        }
706        if self.incognito {
707            layer.set("repl.history.enabled", false);
708        }
709        if let Some(theme) = self
710            .theme
711            .as_deref()
712            .map(str::trim)
713            .filter(|value| !value.is_empty())
714        {
715            layer.set("theme.name", theme);
716        }
717        if self.gammel_og_bitter {
718            layer.set("ui.presentation", UiPresentation::Austere.as_config_value());
719        } else if let Some(presentation) = self.presentation {
720            layer.set(
721                "ui.presentation",
722                UiPresentation::from(presentation).as_config_value(),
723            );
724        }
725    }
726}
727
728pub(crate) fn default_render_settings() -> RenderSettings {
729    RenderSettings::default()
730}
731
732pub(crate) fn apply_render_settings_from_config(
733    settings: &mut RenderSettings,
734    config: &ResolvedConfig,
735) {
736    if let Some(value) = config.get_string("ui.format")
737        && let Some(parsed) = parse_output_format(value)
738    {
739        settings.format = parsed;
740    }
741
742    if let Some(value) = config.get_string("ui.mode")
743        && let Some(parsed) = parse_render_mode(value)
744    {
745        settings.mode = parsed;
746    }
747
748    if let Some(value) = config.get_string("ui.unicode.mode")
749        && let Some(parsed) = parse_unicode_mode(value)
750    {
751        settings.unicode = parsed;
752    }
753
754    if let Some(value) = config.get_string("ui.color.mode")
755        && let Some(parsed) = parse_color_mode(value)
756    {
757        settings.color = parsed;
758    }
759
760    if let Some(value) = config.get_string("ui.chrome.frame")
761        && let Some(parsed) = SectionFrameStyle::parse(value)
762    {
763        settings.chrome_frame = parsed;
764    }
765
766    if let Some(value) = config.get_string("ui.chrome.rule_policy")
767        && let Some(parsed) = RuledSectionPolicy::parse(value)
768    {
769        settings.ruled_section_policy = parsed;
770    }
771
772    if let Some(value) = config.get_string("ui.guide.default_format")
773        && let Some(parsed) = GuideDefaultFormat::parse(value)
774    {
775        settings.guide_default_format = parsed;
776    }
777
778    if settings.width.is_none() {
779        match config.get("ui.width").map(ConfigValue::reveal) {
780            Some(ConfigValue::Integer(width)) if *width > 0 => {
781                settings.width = Some(*width as usize);
782            }
783            Some(ConfigValue::String(raw)) => {
784                if let Ok(width) = raw.trim().parse::<usize>()
785                    && width > 0
786                {
787                    settings.width = Some(width);
788                }
789            }
790            _ => {}
791        }
792    }
793
794    sync_render_settings_from_config(settings, config);
795}
796
797pub(crate) fn sync_render_settings_from_config(
798    settings: &mut RenderSettings,
799    config: &ResolvedConfig,
800) {
801    if let Some(value) = config_int(config, "ui.margin")
802        && value >= 0
803    {
804        settings.margin = value as usize;
805    }
806
807    if let Some(value) = config_int(config, "ui.indent")
808        && value > 0
809    {
810        settings.indent_size = value as usize;
811    }
812
813    if let Some(value) = config_int(config, "ui.short_list_max")
814        && value > 0
815    {
816        settings.short_list_max = value as usize;
817    }
818
819    if let Some(value) = config_int(config, "ui.medium_list_max")
820        && value > 0
821    {
822        settings.medium_list_max = value as usize;
823    }
824
825    if let Some(value) = config_int(config, "ui.grid_padding")
826        && value > 0
827    {
828        settings.grid_padding = value as usize;
829    }
830
831    if let Some(value) = config_int(config, "ui.grid_columns") {
832        settings.grid_columns = if value > 0 {
833            Some(value as usize)
834        } else {
835            None
836        };
837    }
838
839    if let Some(value) = config_int(config, "ui.column_weight")
840        && value > 0
841    {
842        settings.column_weight = value as usize;
843    }
844
845    if let Some(value) = config_int(config, "ui.mreg.stack_min_col_width")
846        && value > 0
847    {
848        settings.mreg_stack_min_col_width = value as usize;
849    }
850
851    if let Some(value) = config_int(config, "ui.mreg.stack_overflow_ratio")
852        && value >= 100
853    {
854        settings.mreg_stack_overflow_ratio = value as usize;
855    }
856
857    if let Some(value) = config.get_string("ui.table.overflow")
858        && let Some(parsed) = TableOverflow::parse(value)
859    {
860        settings.table_overflow = parsed;
861    }
862
863    if let Some(value) = config.get_string("ui.table.border")
864        && let Some(parsed) = TableBorderStyle::parse(value)
865    {
866        settings.table_border = parsed;
867    }
868
869    if let Some(value) = config.get_string("ui.help.table_chrome")
870        && let Some(parsed) = HelpTableChrome::parse(value)
871    {
872        settings.help_chrome.table_chrome = parsed;
873    }
874
875    settings.help_chrome.entry_indent = config_usize_override(config, "ui.help.entry_indent");
876    settings.help_chrome.entry_gap = config_usize_override(config, "ui.help.entry_gap");
877    settings.help_chrome.section_spacing = config_usize_override(config, "ui.help.section_spacing");
878
879    settings.style_overrides = StyleOverrides {
880        text: config_non_empty_string(config, "color.text"),
881        key: config_non_empty_string(config, "color.key"),
882        muted: config_non_empty_string(config, "color.text.muted"),
883        table_header: config_non_empty_string(config, "color.table.header"),
884        mreg_key: config_non_empty_string(config, "color.mreg.key"),
885        value: config_non_empty_string(config, "color.value"),
886        number: config_non_empty_string(config, "color.value.number"),
887        bool_true: config_non_empty_string(config, "color.value.bool_true"),
888        bool_false: config_non_empty_string(config, "color.value.bool_false"),
889        null_value: config_non_empty_string(config, "color.value.null"),
890        ipv4: config_non_empty_string(config, "color.value.ipv4"),
891        ipv6: config_non_empty_string(config, "color.value.ipv6"),
892        panel_border: config_non_empty_string(config, "color.panel.border")
893            .or_else(|| config_non_empty_string(config, "color.border")),
894        panel_title: config_non_empty_string(config, "color.panel.title"),
895        code: config_non_empty_string(config, "color.code"),
896        json_key: config_non_empty_string(config, "color.json.key"),
897        message_error: config_non_empty_string(config, "color.message.error"),
898        message_warning: config_non_empty_string(config, "color.message.warning"),
899        message_success: config_non_empty_string(config, "color.message.success"),
900        message_info: config_non_empty_string(config, "color.message.info"),
901        message_trace: config_non_empty_string(config, "color.message.trace"),
902    };
903}
904
905fn parse_output_format(value: &str) -> Option<OutputFormat> {
906    match value.trim().to_ascii_lowercase().as_str() {
907        "auto" => Some(OutputFormat::Auto),
908        "guide" => Some(OutputFormat::Guide),
909        "json" => Some(OutputFormat::Json),
910        "table" => Some(OutputFormat::Table),
911        "md" | "markdown" => Some(OutputFormat::Markdown),
912        "mreg" => Some(OutputFormat::Mreg),
913        "value" => Some(OutputFormat::Value),
914        _ => None,
915    }
916}
917
918fn parse_render_mode(value: &str) -> Option<RenderMode> {
919    match value.trim().to_ascii_lowercase().as_str() {
920        "auto" => Some(RenderMode::Auto),
921        "plain" => Some(RenderMode::Plain),
922        "rich" => Some(RenderMode::Rich),
923        _ => None,
924    }
925}
926
927fn parse_color_mode(value: &str) -> Option<ColorMode> {
928    match value.trim().to_ascii_lowercase().as_str() {
929        "auto" => Some(ColorMode::Auto),
930        "always" => Some(ColorMode::Always),
931        "never" => Some(ColorMode::Never),
932        _ => None,
933    }
934}
935
936fn parse_unicode_mode(value: &str) -> Option<UnicodeMode> {
937    match value.trim().to_ascii_lowercase().as_str() {
938        "auto" => Some(UnicodeMode::Auto),
939        "always" => Some(UnicodeMode::Always),
940        "never" => Some(UnicodeMode::Never),
941        _ => None,
942    }
943}
944
945fn config_int(config: &ResolvedConfig, key: &str) -> Option<i64> {
946    match config.get(key).map(ConfigValue::reveal) {
947        Some(ConfigValue::Integer(value)) => Some(*value),
948        Some(ConfigValue::String(raw)) => raw.trim().parse::<i64>().ok(),
949        _ => None,
950    }
951}
952
953fn config_non_empty_string(config: &ResolvedConfig, key: &str) -> Option<String> {
954    config
955        .get_string(key)
956        .map(str::trim)
957        .filter(|value| !value.is_empty())
958        .map(ToOwned::to_owned)
959}
960
961fn config_usize_override(config: &ResolvedConfig, key: &str) -> Option<usize> {
962    match config.get(key).map(ConfigValue::reveal) {
963        Some(ConfigValue::Integer(value)) if *value >= 0 => Some(*value as usize),
964        Some(ConfigValue::String(raw)) => {
965            let trimmed = raw.trim();
966            if trimmed.eq_ignore_ascii_case("inherit") || trimmed.is_empty() {
967                None
968            } else {
969                trimmed.parse::<usize>().ok()
970            }
971        }
972        _ => None,
973    }
974}
975
976/// Parses inline command tokens with the same clap model as the top-level CLI.
977///
978/// This is the REPL-facing path for turning already-tokenized input into a
979/// concrete builtin command, and it returns `Ok(None)` when no subcommand has
980/// been selected yet.
981///
982/// # Examples
983///
984/// ```
985/// use osp_cli::cli::{Commands, ThemeCommands, parse_inline_command_tokens};
986///
987/// let tokens = vec![
988///     "theme".to_string(),
989///     "show".to_string(),
990///     "dracula".to_string(),
991/// ];
992///
993/// let command = parse_inline_command_tokens(&tokens).unwrap().unwrap();
994/// match command {
995///     Commands::Theme(args) => match args.command {
996///         ThemeCommands::Show(show) => {
997///             assert_eq!(show.name.as_deref(), Some("dracula"));
998///         }
999///         other => panic!("unexpected theme command: {other:?}"),
1000///     },
1001///     other => panic!("unexpected command: {other:?}"),
1002/// }
1003/// ```
1004pub fn parse_inline_command_tokens(tokens: &[String]) -> Result<Option<Commands>, clap::Error> {
1005    InlineCommandCli::try_parse_from(tokens.iter().map(String::as_str)).map(|parsed| parsed.command)
1006}
1007
1008#[cfg(test)]
1009mod tests {
1010    use super::{
1011        Cli, ColorMode, Commands, ConfigCommands, InlineCommandCli, OutputFormat, RenderMode,
1012        RuntimeLoadOptions, SectionFrameStyle, TableBorderStyle, TableOverflow, UnicodeMode,
1013        apply_render_settings_from_config, config_int, config_non_empty_string,
1014        config_usize_override, parse_color_mode, parse_inline_command_tokens, parse_output_format,
1015        parse_render_mode, parse_unicode_mode,
1016    };
1017    use crate::config::{ConfigLayer, ConfigResolver, ConfigValue, ResolveOptions};
1018    use crate::ui::presentation::build_presentation_defaults_layer;
1019    use crate::ui::{GuideDefaultFormat, RenderSettings};
1020    use clap::Parser;
1021
1022    fn resolved(entries: &[(&str, &str)]) -> crate::config::ResolvedConfig {
1023        let mut defaults = ConfigLayer::default();
1024        defaults.set("profile.default", "default");
1025        for (key, value) in entries {
1026            defaults.set(*key, *value);
1027        }
1028        let mut resolver = ConfigResolver::default();
1029        resolver.set_defaults(defaults);
1030        let options = ResolveOptions::default().with_terminal("cli");
1031        let base = resolver
1032            .resolve(options.clone())
1033            .expect("base test config should resolve");
1034        resolver.set_presentation(build_presentation_defaults_layer(&base));
1035        resolver
1036            .resolve(options)
1037            .expect("test config should resolve")
1038    }
1039
1040    fn resolved_with_session(
1041        defaults_entries: &[(&str, &str)],
1042        session_entries: &[(&str, &str)],
1043    ) -> crate::config::ResolvedConfig {
1044        let mut defaults = ConfigLayer::default();
1045        defaults.set("profile.default", "default");
1046        for (key, value) in defaults_entries {
1047            defaults.set(*key, *value);
1048        }
1049
1050        let mut resolver = ConfigResolver::default();
1051        resolver.set_defaults(defaults);
1052
1053        let mut session = ConfigLayer::default();
1054        for (key, value) in session_entries {
1055            session.set(*key, *value);
1056        }
1057        resolver.set_session(session);
1058
1059        let options = ResolveOptions::default().with_terminal("cli");
1060        let base = resolver
1061            .resolve(options.clone())
1062            .expect("base test config should resolve");
1063        resolver.set_presentation(build_presentation_defaults_layer(&base));
1064        resolver
1065            .resolve(options)
1066            .expect("test config should resolve")
1067    }
1068
1069    #[test]
1070    fn parse_mode_and_config_helpers_normalize_strings_blanks_and_integers_unit() {
1071        assert_eq!(parse_output_format(" guide "), Some(OutputFormat::Guide));
1072        assert_eq!(
1073            parse_output_format(" markdown "),
1074            Some(OutputFormat::Markdown)
1075        );
1076        assert_eq!(parse_render_mode(" Rich "), Some(RenderMode::Rich));
1077        assert_eq!(parse_color_mode(" NEVER "), Some(ColorMode::Never));
1078        assert_eq!(parse_unicode_mode(" always "), Some(UnicodeMode::Always));
1079        assert_eq!(parse_output_format("yaml"), None);
1080
1081        let config = resolved(&[
1082            ("ui.width", "120"),
1083            ("color.text", "  "),
1084            ("ui.margin", "3"),
1085        ]);
1086
1087        assert_eq!(config_int(&config, "ui.width"), Some(120));
1088        assert_eq!(config_int(&config, "ui.margin"), Some(3));
1089        assert_eq!(config_non_empty_string(&config, "color.text"), None);
1090    }
1091
1092    #[test]
1093    fn render_settings_apply_presentation_defaults_explicit_overrides_and_help_spacing_unit() {
1094        let config = resolved_with_session(
1095            &[("ui.width", "88")],
1096            &[
1097                ("ui.chrome.frame", "round"),
1098                ("ui.chrome.rule_policy", "stacked"),
1099                ("ui.table.border", "square"),
1100                ("ui.table.overflow", "wrap"),
1101            ],
1102        );
1103        let mut settings = RenderSettings::test_plain(OutputFormat::Table);
1104
1105        apply_render_settings_from_config(&mut settings, &config);
1106
1107        assert_eq!(settings.width, Some(88));
1108        assert_eq!(settings.chrome_frame, SectionFrameStyle::Round);
1109        assert_eq!(
1110            settings.ruled_section_policy,
1111            crate::ui::RuledSectionPolicy::Shared
1112        );
1113        assert_eq!(settings.table_border, TableBorderStyle::Square);
1114        assert_eq!(settings.table_overflow, TableOverflow::Wrap);
1115
1116        let config = resolved(&[("ui.presentation", "expressive")]);
1117        let mut settings = RenderSettings::test_plain(OutputFormat::Table);
1118
1119        apply_render_settings_from_config(&mut settings, &config);
1120
1121        assert_eq!(settings.chrome_frame, SectionFrameStyle::TopBottom);
1122        assert_eq!(settings.table_border, TableBorderStyle::Round);
1123
1124        let config = resolved_with_session(
1125            &[("ui.presentation", "expressive")],
1126            &[("ui.chrome.frame", "square"), ("ui.table.border", "none")],
1127        );
1128        let mut settings = RenderSettings::test_plain(OutputFormat::Table);
1129
1130        apply_render_settings_from_config(&mut settings, &config);
1131
1132        assert_eq!(settings.chrome_frame, SectionFrameStyle::Square);
1133        assert_eq!(settings.table_border, TableBorderStyle::None);
1134
1135        let config = resolved(&[("ui.guide.default_format", "inherit")]);
1136        let mut settings = RenderSettings::test_plain(OutputFormat::Json);
1137
1138        apply_render_settings_from_config(&mut settings, &config);
1139
1140        assert_eq!(settings.guide_default_format, GuideDefaultFormat::Inherit);
1141
1142        let config = resolved(&[
1143            ("ui.help.entry_indent", "4"),
1144            ("ui.help.entry_gap", "3"),
1145            ("ui.help.section_spacing", "inherit"),
1146        ]);
1147        let mut settings = RenderSettings::test_plain(OutputFormat::Guide);
1148
1149        apply_render_settings_from_config(&mut settings, &config);
1150
1151        assert_eq!(
1152            config_usize_override(&config, "ui.help.entry_indent"),
1153            Some(4)
1154        );
1155        assert_eq!(settings.help_chrome.entry_indent, Some(4));
1156        assert_eq!(settings.help_chrome.entry_gap, Some(3));
1157        assert_eq!(settings.help_chrome.section_spacing, None);
1158    }
1159
1160    #[test]
1161    fn parse_inline_command_tokens_accepts_builtin_and_external_commands_unit() {
1162        let builtin = parse_inline_command_tokens(&["config".to_string(), "doctor".to_string()])
1163            .expect("builtin command should parse");
1164        assert!(matches!(
1165            builtin,
1166            Some(Commands::Config(args)) if matches!(args.command, ConfigCommands::Doctor)
1167        ));
1168
1169        let external = parse_inline_command_tokens(&["ldap".to_string(), "user".to_string()])
1170            .expect("external command should parse");
1171        assert!(
1172            matches!(external, Some(Commands::External(tokens)) if tokens == vec!["ldap", "user"])
1173        );
1174    }
1175
1176    #[test]
1177    fn cli_runtime_load_options_and_inline_parser_follow_disable_flags_unit() {
1178        let cli = Cli::parse_from(["osp", "--no-env", "--no-config-file", "theme", "list"]);
1179        assert_eq!(
1180            cli.runtime_load_options(),
1181            RuntimeLoadOptions::new()
1182                .with_env(false)
1183                .with_config_file(false)
1184        );
1185
1186        let cli = Cli::parse_from(["osp", "--defaults-only", "theme", "list"]);
1187        assert_eq!(
1188            cli.runtime_load_options(),
1189            RuntimeLoadOptions::defaults_only()
1190        );
1191
1192        let inline = InlineCommandCli::try_parse_from(["theme", "list"])
1193            .expect("inline command should parse");
1194        assert!(matches!(inline.command, Some(Commands::Theme(_))));
1195    }
1196
1197    #[test]
1198    fn app_style_alias_maps_to_presentation_unit() {
1199        let cli = Cli::parse_from(["osp", "--app-style", "austere"]);
1200        let mut layer = ConfigLayer::default();
1201        cli.append_static_session_overrides(&mut layer);
1202        assert_eq!(
1203            layer
1204                .entries()
1205                .iter()
1206                .find(|entry| entry.key == "ui.presentation")
1207                .map(|entry| &entry.value),
1208            Some(&ConfigValue::from("austere"))
1209        );
1210    }
1211}