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