Skip to main content

osp_cli/app/
host.rs

1use crate::config::{ConfigValue, DEFAULT_UI_WIDTH, ResolvedConfig};
2use crate::core::output::OutputFormat;
3use crate::core::runtime::{RuntimeHints, RuntimeTerminalKind, UiVerbosity};
4use crate::native::{NativeCommandCatalogEntry, NativeCommandRegistry};
5use crate::repl::{self, SharedHistory};
6use clap::Parser;
7use miette::{Result, WrapErr, miette};
8
9use crate::guide::{GuideSection, GuideSectionKind, GuideView};
10use crate::ui::messages::MessageLevel;
11use crate::ui::presentation::{HelpLevel, help_level};
12use crate::ui::theme::normalize_theme_name;
13use crate::ui::{RenderRuntime, RenderSettings, render_guide_payload_with_layout};
14use std::borrow::Cow;
15use std::ffi::OsString;
16use std::io::IsTerminal;
17use std::time::Instant;
18use terminal_size::{Width, terminal_size};
19
20use super::help;
21use crate::app::logging::{bootstrap_logging_config, init_developer_logging};
22use crate::app::sink::{StdIoUiSink, UiSink};
23use crate::app::{
24    AppClients, AppRuntime, AppSession, AuthState, LaunchContext, TerminalKind, UiState,
25};
26use crate::cli::commands::{
27    config as config_cmd, doctor as doctor_cmd, history as history_cmd, intro as intro_cmd,
28    plugins as plugins_cmd, theme as theme_cmd,
29};
30use crate::cli::invocation::{InvocationOptions, extend_with_invocation_help, scan_cli_argv};
31use crate::cli::{Cli, Commands};
32use crate::plugin::{
33    CommandCatalogEntry, DEFAULT_PLUGIN_PROCESS_TIMEOUT_MS, PluginDispatchContext,
34    PluginDispatchError,
35};
36
37pub(crate) use super::bootstrap::{
38    RuntimeConfigRequest, build_cli_session_layer, build_runtime_context, resolve_runtime_config,
39};
40pub(crate) use super::command_output::{CliCommandResult, CommandRenderRuntime, run_cli_command};
41pub(crate) use super::config_explain::{
42    ConfigExplainContext, config_explain_json, config_explain_result, config_value_to_json,
43    explain_runtime_config, format_scope, is_sensitive_key, render_config_explain_text,
44};
45pub(crate) use super::dispatch::{
46    RunAction, build_dispatch_plan, ensure_builtin_visible_for, ensure_dispatch_visibility,
47    ensure_plugin_visible_for, normalize_cli_profile, normalize_profile_override,
48};
49pub(crate) use super::external::run_external_command_with_help_renderer;
50use super::external::{ExternalCommandRuntime, run_external_command};
51#[cfg(test)]
52pub(crate) use super::repl_lifecycle::rebuild_repl_state;
53pub(crate) use super::timing::{TimingSummary, format_timing_badge, right_align_timing_line};
54pub(crate) use crate::plugin::config::{
55    PluginConfigEntry, PluginConfigScope, plugin_config_entries,
56};
57#[cfg(test)]
58pub(crate) use crate::plugin::config::{
59    collect_plugin_config_env, config_value_to_plugin_env, plugin_config_env_name,
60};
61use crate::ui::theme_loader;
62
63pub(crate) const CMD_PLUGINS: &str = "plugins";
64pub(crate) const CMD_DOCTOR: &str = "doctor";
65pub(crate) const CMD_CONFIG: &str = "config";
66pub(crate) const CMD_THEME: &str = "theme";
67pub(crate) const CMD_HISTORY: &str = "history";
68pub(crate) const CMD_INTRO: &str = "intro";
69pub(crate) const CMD_HELP: &str = "help";
70pub(crate) const CMD_LIST: &str = "list";
71pub(crate) const CMD_SHOW: &str = "show";
72pub(crate) const CMD_USE: &str = "use";
73pub const EXIT_CODE_ERROR: i32 = 1;
74pub const EXIT_CODE_USAGE: i32 = 2;
75pub const EXIT_CODE_CONFIG: i32 = 3;
76pub const EXIT_CODE_PLUGIN: i32 = 4;
77pub(crate) const DEFAULT_REPL_PROMPT: &str = "╭─{user}@{domain} {indicator}\n╰─{profile}> ";
78pub(crate) const CURRENT_TERMINAL_SENTINEL: &str = "__current__";
79pub(crate) const REPL_SHELLABLE_COMMANDS: [&str; 5] = ["nh", "mreg", "ldap", "vm", "orch"];
80
81#[derive(Debug, Clone)]
82pub(crate) struct ReplCommandSpec {
83    pub(crate) name: Cow<'static, str>,
84    pub(crate) supports_dsl: bool,
85}
86
87#[derive(Debug, Clone)]
88pub(crate) struct ResolvedInvocation {
89    pub(crate) ui: UiState,
90    pub(crate) plugin_provider: Option<String>,
91    pub(crate) help_level: HelpLevel,
92}
93
94#[derive(Debug)]
95struct ContextError<E> {
96    context: &'static str,
97    source: E,
98}
99
100#[derive(Clone, Copy)]
101struct KnownErrorChain<'a> {
102    clap: Option<&'a clap::Error>,
103    config: Option<&'a crate::config::ConfigError>,
104    plugin: Option<&'a PluginDispatchError>,
105}
106
107impl<'a> KnownErrorChain<'a> {
108    fn inspect(err: &'a miette::Report) -> Self {
109        Self {
110            clap: find_error_in_chain::<clap::Error>(err),
111            config: find_error_in_chain::<crate::config::ConfigError>(err),
112            plugin: find_error_in_chain::<PluginDispatchError>(err),
113        }
114    }
115}
116
117impl<E> std::fmt::Display for ContextError<E>
118where
119    E: std::error::Error + Send + Sync + 'static,
120{
121    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
122        write!(f, "{}", self.context)
123    }
124}
125
126impl<E> std::error::Error for ContextError<E>
127where
128    E: std::error::Error + Send + Sync + 'static,
129{
130    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
131        Some(&self.source)
132    }
133}
134
135impl<E> miette::Diagnostic for ContextError<E> where E: std::error::Error + Send + Sync + 'static {}
136
137/// Runs the top-level CLI entrypoint from an argv-like iterator.
138///
139/// This is the library-friendly wrapper around the binary entrypoint and
140/// returns the process exit code that should be reported to the caller.
141pub fn run_from<I, T>(args: I) -> Result<i32>
142where
143    I: IntoIterator<Item = T>,
144    T: Into<std::ffi::OsString> + Clone,
145{
146    let mut sink = StdIoUiSink;
147    run_from_with_sink(args, &mut sink)
148}
149
150pub(crate) fn run_from_with_sink<I, T>(args: I, sink: &mut dyn UiSink) -> Result<i32>
151where
152    I: IntoIterator<Item = T>,
153    T: Into<std::ffi::OsString> + Clone,
154{
155    run_from_with_sink_and_app(args, sink, &super::AppDefinition::default())
156}
157
158pub(crate) fn run_from_with_sink_and_app<I, T>(
159    args: I,
160    sink: &mut dyn UiSink,
161    app: &super::AppDefinition,
162) -> Result<i32>
163where
164    I: IntoIterator<Item = T>,
165    T: Into<std::ffi::OsString> + Clone,
166{
167    let argv = args.into_iter().map(Into::into).collect::<Vec<OsString>>();
168    init_developer_logging(bootstrap_logging_config(&argv));
169    let scanned = scan_cli_argv(&argv)?;
170    match Cli::try_parse_from(scanned.argv.iter().cloned()) {
171        Ok(cli) => run(cli, scanned.invocation, sink, app),
172        Err(err) => handle_clap_parse_error(&argv, err, sink, app),
173    }
174}
175
176fn handle_clap_parse_error(
177    args: &[OsString],
178    err: clap::Error,
179    sink: &mut dyn UiSink,
180    app: &super::AppDefinition,
181) -> Result<i32> {
182    match err.kind() {
183        clap::error::ErrorKind::DisplayHelp => {
184            let help_context = help::render_settings_for_help(args, &app.product_defaults);
185            let mut body = GuideView::from_text(&err.to_string());
186            extend_with_invocation_help(&mut body, help_context.help_level);
187            add_native_command_help(&mut body, &app.native_commands);
188            let rendered = render_guide_payload_with_layout(
189                &body.filtered_for_help_level(help_context.help_level),
190                &help_context.settings,
191                help_context.layout,
192            );
193            sink.write_stdout(&rendered);
194            Ok(0)
195        }
196        clap::error::ErrorKind::DisplayVersion => {
197            sink.write_stdout(&err.to_string());
198            Ok(0)
199        }
200        _ => Err(report_std_error_with_context(
201            err,
202            "failed to parse CLI arguments",
203        )),
204    }
205}
206
207// Keep the top-level CLI entrypoint readable as a table of contents:
208// normalize input -> bootstrap runtime state -> hand off to the selected mode.
209fn run(
210    mut cli: Cli,
211    invocation: InvocationOptions,
212    sink: &mut dyn UiSink,
213    app: &super::AppDefinition,
214) -> Result<i32> {
215    let run_started = Instant::now();
216    if invocation.cache {
217        return Err(miette!(
218            "`--cache` is only available inside the interactive REPL"
219        ));
220    }
221
222    let normalized_profile = normalize_cli_profile(&mut cli);
223    let runtime_load = cli.runtime_load_options();
224    // Startup resolves config in three phases:
225    // 1. bootstrap once to discover known profiles
226    // 2. build the session layer, including derived overrides
227    // 3. resolve again with the full session layer applied
228    let initial_config = resolve_runtime_config(
229        RuntimeConfigRequest::new(normalized_profile.clone(), Some("cli"))
230            .with_runtime_load(runtime_load)
231            .with_product_defaults(app.product_defaults.clone()),
232    )
233    .wrap_err("failed to resolve initial config for startup")?;
234    let known_profiles = initial_config.known_profiles().clone();
235    let dispatch = build_dispatch_plan(&mut cli, &known_profiles)?;
236    tracing::debug!(
237        action = ?dispatch.action,
238        profile_override = ?dispatch.profile_override,
239        known_profiles = known_profiles.len(),
240        "built dispatch plan"
241    );
242
243    let terminal_kind = dispatch.action.terminal_kind();
244    let runtime_context = build_runtime_context(dispatch.profile_override.clone(), terminal_kind);
245    let session_layer = build_cli_session_layer(
246        &cli,
247        runtime_context.profile_override().map(ToOwned::to_owned),
248        runtime_context.terminal_kind(),
249        runtime_load,
250        &app.product_defaults,
251    )?;
252    let launch_context = LaunchContext::default()
253        .with_plugin_dirs(cli.plugin_dirs.clone())
254        .with_runtime_load(runtime_load)
255        .with_startup_started_at(run_started);
256
257    let config = resolve_runtime_config(
258        RuntimeConfigRequest::new(
259            runtime_context.profile_override().map(ToOwned::to_owned),
260            Some(runtime_context.terminal_kind().as_config_terminal()),
261        )
262        .with_runtime_load(launch_context.runtime_load)
263        .with_product_defaults(app.product_defaults.clone())
264        .with_session_layer(session_layer.clone()),
265    )
266    .wrap_err("failed to resolve config with session layer")?;
267    let host_inputs = super::assembly::ResolvedHostInputs::derive(
268        &runtime_context,
269        &config,
270        &launch_context,
271        super::assembly::RenderSettingsSeed::DefaultAuto,
272        None,
273        None,
274        session_layer.clone(),
275    )
276    .wrap_err("failed to derive host runtime inputs for startup")?;
277    let mut state = crate::app::AppStateBuilder::new(runtime_context, config, host_inputs.ui)
278        .with_launch(launch_context)
279        .with_plugins(host_inputs.plugins)
280        .with_native_commands(app.native_commands.clone())
281        .with_session(host_inputs.default_session)
282        .with_themes(host_inputs.themes)
283        .build();
284    state
285        .runtime
286        .set_product_defaults(app.product_defaults.clone());
287    ensure_dispatch_visibility(&state.runtime.auth, &dispatch.action)?;
288    let invocation_ui = resolve_invocation_ui(
289        state.runtime.config.resolved(),
290        &state.runtime.ui,
291        &invocation,
292    );
293    super::assembly::apply_runtime_side_effects(
294        state.runtime.config.resolved(),
295        invocation_ui.ui.debug_verbosity,
296        &state.runtime.themes,
297    );
298    tracing::debug!(
299        debug_count = invocation_ui.ui.debug_verbosity,
300        "developer logging initialized"
301    );
302
303    tracing::info!(
304        profile = %state.runtime.config.resolved().active_profile(),
305        terminal = %state.runtime.context.terminal_kind().as_config_terminal(),
306        action = ?dispatch.action,
307        plugin_timeout_ms = plugin_process_timeout(state.runtime.config.resolved()).as_millis(),
308        "osp session initialized"
309    );
310
311    let action_started = Instant::now();
312    let is_repl = matches!(dispatch.action, RunAction::Repl);
313    let action = dispatch.action;
314    let result = match action {
315        RunAction::Repl => {
316            state.runtime.ui = invocation_ui.ui.clone();
317            repl::run_plugin_repl(&mut state)
318        }
319        RunAction::ReplCommand(args) => run_builtin_cli_command_parts(
320            &mut state.runtime,
321            &mut state.session,
322            &state.clients,
323            &invocation_ui,
324            Commands::Repl(args),
325            sink,
326        ),
327        RunAction::Plugins(args) => run_builtin_cli_command_parts(
328            &mut state.runtime,
329            &mut state.session,
330            &state.clients,
331            &invocation_ui,
332            Commands::Plugins(args),
333            sink,
334        ),
335        RunAction::Doctor(args) => run_builtin_cli_command_parts(
336            &mut state.runtime,
337            &mut state.session,
338            &state.clients,
339            &invocation_ui,
340            Commands::Doctor(args),
341            sink,
342        ),
343        RunAction::Theme(args) => run_builtin_cli_command_parts(
344            &mut state.runtime,
345            &mut state.session,
346            &state.clients,
347            &invocation_ui,
348            Commands::Theme(args),
349            sink,
350        ),
351        RunAction::Config(args) => run_builtin_cli_command_parts(
352            &mut state.runtime,
353            &mut state.session,
354            &state.clients,
355            &invocation_ui,
356            Commands::Config(args),
357            sink,
358        ),
359        RunAction::History(args) => run_builtin_cli_command_parts(
360            &mut state.runtime,
361            &mut state.session,
362            &state.clients,
363            &invocation_ui,
364            Commands::History(args),
365            sink,
366        ),
367        RunAction::Intro(args) => run_builtin_cli_command_parts(
368            &mut state.runtime,
369            &mut state.session,
370            &state.clients,
371            &invocation_ui,
372            Commands::Intro(args),
373            sink,
374        ),
375        RunAction::External(ref tokens) => run_external_command(
376            &mut state.runtime,
377            &mut state.session,
378            &state.clients,
379            tokens,
380            &invocation_ui,
381        )
382        .and_then(|result| {
383            run_cli_command(
384                &CommandRenderRuntime::new(state.runtime.config.resolved(), &invocation_ui.ui),
385                result,
386                sink,
387            )
388        }),
389    };
390
391    if !is_repl && invocation_ui.ui.debug_verbosity > 0 {
392        let total = run_started.elapsed();
393        let startup = action_started.saturating_duration_since(run_started);
394        let command = total.saturating_sub(startup);
395        let footer = right_align_timing_line(
396            TimingSummary {
397                total,
398                parse: if invocation_ui.ui.debug_verbosity >= 3 {
399                    Some(startup)
400                } else {
401                    None
402                },
403                execute: if invocation_ui.ui.debug_verbosity >= 3 {
404                    Some(command)
405                } else {
406                    None
407                },
408                render: None,
409            },
410            invocation_ui.ui.debug_verbosity,
411            &invocation_ui.ui.render_settings.resolve_render_settings(),
412        );
413        if !footer.is_empty() {
414            sink.write_stderr(&footer);
415        }
416    }
417
418    result
419}
420
421pub(crate) fn authorized_command_catalog_for(
422    auth: &AuthState,
423    clients: &AppClients,
424) -> Result<Vec<CommandCatalogEntry>> {
425    let mut all = clients.plugins().command_catalog();
426    all.extend(
427        clients
428            .native_commands()
429            .catalog()
430            .into_iter()
431            .map(native_catalog_entry_to_command_catalog_entry),
432    );
433    all.sort_by(|left, right| left.name.cmp(&right.name));
434    Ok(all
435        .into_iter()
436        .filter(|entry| auth.is_external_command_visible(&entry.name))
437        .collect())
438}
439
440fn run_builtin_cli_command_parts(
441    runtime: &mut AppRuntime,
442    session: &mut AppSession,
443    clients: &AppClients,
444    invocation: &ResolvedInvocation,
445    command: Commands,
446    sink: &mut dyn UiSink,
447) -> Result<i32> {
448    let result =
449        dispatch_builtin_command_parts(runtime, session, clients, None, Some(invocation), command)?
450            .ok_or_else(|| miette!("expected builtin command"))?;
451    run_cli_command(
452        &CommandRenderRuntime::new(runtime.config.resolved(), &invocation.ui),
453        result,
454        sink,
455    )
456}
457
458pub(crate) fn run_inline_builtin_command(
459    runtime: &mut AppRuntime,
460    session: &mut AppSession,
461    clients: &AppClients,
462    invocation: Option<&ResolvedInvocation>,
463    command: Commands,
464    stages: &[String],
465) -> Result<Option<CliCommandResult>> {
466    if matches!(command, Commands::External(_)) {
467        return Ok(None);
468    }
469
470    let spec = repl::repl_command_spec(&command);
471    ensure_command_supports_dsl(&spec, stages)?;
472    dispatch_builtin_command_parts(runtime, session, clients, None, invocation, command)
473}
474
475pub(crate) fn dispatch_builtin_command_parts(
476    runtime: &mut AppRuntime,
477    session: &mut AppSession,
478    clients: &AppClients,
479    repl_history: Option<&SharedHistory>,
480    invocation: Option<&ResolvedInvocation>,
481    command: Commands,
482) -> Result<Option<CliCommandResult>> {
483    let invocation_ui = ui_state_for_invocation(&runtime.ui, invocation);
484    match command {
485        Commands::Plugins(args) => {
486            ensure_builtin_visible_for(&runtime.auth, CMD_PLUGINS)?;
487            plugins_cmd::run_plugins_command(plugins_command_context(runtime, clients), args)
488                .map(Some)
489        }
490        Commands::Doctor(args) => {
491            ensure_builtin_visible_for(&runtime.auth, CMD_DOCTOR)?;
492            doctor_cmd::run_doctor_command(
493                doctor_command_context(runtime, session, clients, &invocation_ui),
494                args,
495            )
496            .map(Some)
497        }
498        Commands::Theme(args) => {
499            ensure_builtin_visible_for(&runtime.auth, CMD_THEME)?;
500            let ui = &invocation_ui;
501            let themes = &runtime.themes;
502            theme_cmd::run_theme_command(
503                &mut session.config_overrides,
504                theme_cmd::ThemeCommandContext { ui, themes },
505                args,
506            )
507            .map(Some)
508        }
509        Commands::Config(args) => {
510            ensure_builtin_visible_for(&runtime.auth, CMD_CONFIG)?;
511            config_cmd::run_config_command(
512                config_command_context(runtime, session, &invocation_ui),
513                args,
514            )
515            .map(Some)
516        }
517        Commands::History(args) => {
518            ensure_builtin_visible_for(&runtime.auth, CMD_HISTORY)?;
519            match repl_history {
520                Some(history) => {
521                    history_cmd::run_history_repl_command(session, args, history).map(Some)
522                }
523                None => history_cmd::run_history_command(args).map(Some),
524            }
525        }
526        Commands::Intro(args) => intro_cmd::run_intro_command(
527            intro_command_context(runtime, session, clients, &invocation_ui),
528            args,
529        )
530        .map(Some),
531        Commands::Repl(args) => {
532            if repl_history.is_some() {
533                Err(miette!("`repl` debug commands are not available in REPL"))
534            } else {
535                repl::run_repl_debug_command_for(runtime, session, clients, args).map(Some)
536            }
537        }
538        Commands::External(_) => Ok(None),
539    }
540}
541
542fn plugins_command_context<'a>(
543    runtime: &'a AppRuntime,
544    clients: &'a AppClients,
545) -> plugins_cmd::PluginsCommandContext<'a> {
546    plugins_cmd::PluginsCommandContext {
547        context: &runtime.context,
548        config: runtime.config.resolved(),
549        config_state: Some(&runtime.config),
550        auth: &runtime.auth,
551        clients: Some(clients),
552        plugin_manager: clients.plugins(),
553        product_defaults: runtime.product_defaults(),
554        runtime_load: runtime.launch.runtime_load,
555    }
556}
557
558fn config_read_context<'a>(
559    runtime: &'a AppRuntime,
560    session: &'a AppSession,
561    ui: &'a UiState,
562) -> config_cmd::ConfigReadContext<'a> {
563    config_cmd::ConfigReadContext {
564        context: &runtime.context,
565        config: runtime.config.resolved(),
566        ui,
567        themes: &runtime.themes,
568        config_overrides: &session.config_overrides,
569        product_defaults: runtime.product_defaults(),
570        runtime_load: runtime.launch.runtime_load,
571    }
572}
573
574fn config_command_context<'a>(
575    runtime: &'a AppRuntime,
576    session: &'a mut AppSession,
577    ui: &'a UiState,
578) -> config_cmd::ConfigCommandContext<'a> {
579    config_cmd::ConfigCommandContext {
580        context: &runtime.context,
581        config: runtime.config.resolved(),
582        ui,
583        themes: &runtime.themes,
584        config_overrides: &mut session.config_overrides,
585        product_defaults: runtime.product_defaults(),
586        runtime_load: runtime.launch.runtime_load,
587    }
588}
589
590fn doctor_command_context<'a>(
591    runtime: &'a AppRuntime,
592    session: &'a AppSession,
593    clients: &'a AppClients,
594    ui: &'a UiState,
595) -> doctor_cmd::DoctorCommandContext<'a> {
596    doctor_cmd::DoctorCommandContext {
597        config: config_read_context(runtime, session, ui),
598        plugins: plugins_command_context(runtime, clients),
599        ui,
600        auth: &runtime.auth,
601        themes: &runtime.themes,
602        last_failure: session.last_failure.as_ref(),
603    }
604}
605
606fn intro_command_context<'a>(
607    runtime: &'a AppRuntime,
608    session: &'a AppSession,
609    clients: &'a AppClients,
610    ui: &'a UiState,
611) -> intro_cmd::IntroCommandContext<'a> {
612    let view = repl::ReplViewContext {
613        config: runtime.config.resolved(),
614        ui,
615        auth: &runtime.auth,
616        themes: &runtime.themes,
617        scope: &session.scope,
618    };
619    let surface = authorized_command_catalog_for(&runtime.auth, clients)
620        .ok()
621        .map(|catalog| repl::surface::build_repl_surface(view, &catalog))
622        .unwrap_or_else(|| repl::surface::ReplSurface {
623            root_words: Vec::new(),
624            intro_commands: Vec::new(),
625            specs: Vec::new(),
626            aliases: Vec::new(),
627            overview_entries: Vec::new(),
628        });
629
630    intro_cmd::IntroCommandContext { view, surface }
631}
632
633fn ui_state_for_invocation(ui: &UiState, invocation: Option<&ResolvedInvocation>) -> UiState {
634    let Some(invocation) = invocation else {
635        return ui.clone();
636    };
637    invocation.ui.clone()
638}
639
640pub(crate) fn resolve_invocation_ui(
641    config: &ResolvedConfig,
642    ui: &UiState,
643    invocation: &InvocationOptions,
644) -> ResolvedInvocation {
645    let mut render_settings = ui.render_settings.clone();
646    render_settings.format_explicit = invocation.format.is_some();
647    if let Some(format) = invocation.format {
648        render_settings.format = format;
649    }
650    if let Some(mode) = invocation.mode {
651        render_settings.mode = mode;
652    }
653    if let Some(color) = invocation.color {
654        render_settings.color = color;
655    }
656    if let Some(unicode) = invocation.unicode {
657        render_settings.unicode = unicode;
658    }
659
660    ResolvedInvocation {
661        ui: UiState::new(
662            render_settings,
663            crate::ui::messages::adjust_verbosity(
664                ui.message_verbosity,
665                invocation.verbose,
666                invocation.quiet,
667            ),
668            if invocation.debug > 0 {
669                invocation.debug.min(3)
670            } else {
671                ui.debug_verbosity
672            },
673        ),
674        plugin_provider: invocation.plugin_provider.clone(),
675        help_level: help_level(config, invocation.verbose, invocation.quiet),
676    }
677}
678
679pub(crate) fn ensure_command_supports_dsl(spec: &ReplCommandSpec, stages: &[String]) -> Result<()> {
680    if stages.is_empty() || spec.supports_dsl {
681        return Ok(());
682    }
683
684    Err(miette!(
685        "`{}` does not support DSL pipeline stages",
686        spec.name
687    ))
688}
689
690pub(crate) fn resolve_known_theme_name(
691    value: &str,
692    catalog: &theme_loader::ThemeCatalog,
693) -> Result<String> {
694    let normalized = normalize_theme_name(value);
695    if catalog.resolve(&normalized).is_some() {
696        return Ok(normalized);
697    }
698
699    let known = catalog.ids().join(", ");
700    Err(miette!("unknown theme: {value}. available themes: {known}"))
701}
702
703pub(crate) fn enrich_dispatch_error(err: PluginDispatchError) -> miette::Report {
704    report_std_error_with_context(err, "plugin command failed")
705}
706
707/// Maps a reported error to the CLI exit code family used by OSP.
708pub fn classify_exit_code(err: &miette::Report) -> i32 {
709    let known = KnownErrorChain::inspect(err);
710    if known.clap.is_some() {
711        EXIT_CODE_USAGE
712    } else if known.config.is_some() {
713        EXIT_CODE_CONFIG
714    } else if known.plugin.is_some() {
715        EXIT_CODE_PLUGIN
716    } else {
717        EXIT_CODE_ERROR
718    }
719}
720
721/// Renders a user-facing error message for the requested message verbosity.
722///
723/// Higher verbosity levels include more source-chain detail and may append a hint.
724pub fn render_report_message(err: &miette::Report, verbosity: MessageLevel) -> String {
725    if verbosity >= MessageLevel::Trace {
726        return format!("{err:?}");
727    }
728
729    let known = KnownErrorChain::inspect(err);
730    let mut message = base_error_message(err, &known);
731
732    if verbosity >= MessageLevel::Info {
733        let mut next: Option<&(dyn std::error::Error + 'static)> = Some(err.as_ref());
734        while let Some(source) = next {
735            let source_text = source.to_string();
736            if !source_text.is_empty() && !message.contains(&source_text) {
737                message.push_str(": ");
738                message.push_str(&source_text);
739            }
740            next = source.source();
741        }
742    }
743
744    if verbosity >= MessageLevel::Success
745        && let Some(hint) = known_error_hint(&known)
746        && !message.contains(hint)
747    {
748        message.push_str("\nHint: ");
749        message.push_str(hint);
750    }
751
752    message
753}
754
755pub(crate) fn config_usize(config: &ResolvedConfig, key: &str, fallback: usize) -> usize {
756    match config.get(key).map(ConfigValue::reveal) {
757        Some(ConfigValue::Integer(value)) if *value > 0 => *value as usize,
758        Some(ConfigValue::String(raw)) => raw
759            .trim()
760            .parse::<usize>()
761            .ok()
762            .filter(|value| *value > 0)
763            .unwrap_or(fallback),
764        _ => fallback,
765    }
766}
767
768pub(crate) fn plugin_process_timeout(config: &ResolvedConfig) -> std::time::Duration {
769    std::time::Duration::from_millis(config_usize(
770        config,
771        "extensions.plugins.timeout_ms",
772        DEFAULT_PLUGIN_PROCESS_TIMEOUT_MS,
773    ) as u64)
774}
775
776pub(crate) fn plugin_path_discovery_enabled(config: &ResolvedConfig) -> bool {
777    config
778        .get_bool("extensions.plugins.discovery.path")
779        .unwrap_or(false)
780}
781
782fn known_error_hint(known: &KnownErrorChain<'_>) -> Option<&'static str> {
783    if let Some(plugin_err) = known.plugin {
784        return Some(match plugin_err {
785            PluginDispatchError::CommandNotFound { .. } => {
786                "run `osp plugins list` and set --plugin-dir or OSP_PLUGIN_PATH"
787            }
788            PluginDispatchError::CommandAmbiguous { .. } => {
789                "rerun with --plugin-provider <plugin-id> or persist a default with `osp plugins select-provider <command> <plugin-id>`"
790            }
791            PluginDispatchError::ProviderNotFound { .. } => {
792                "pick one of the available providers from `osp plugins commands` or `osp plugins doctor`"
793            }
794            PluginDispatchError::ExecuteFailed { .. } => {
795                "verify the plugin executable exists and is executable"
796            }
797            PluginDispatchError::TimedOut { .. } => {
798                "increase extensions.plugins.timeout_ms or inspect the plugin executable"
799            }
800            PluginDispatchError::NonZeroExit { .. } => {
801                "inspect the plugin stderr output or rerun with -v/-vv for more context"
802            }
803            PluginDispatchError::InvalidJsonResponse { .. }
804            | PluginDispatchError::InvalidResponsePayload { .. } => {
805                "inspect the plugin response contract and stderr output"
806            }
807        });
808    }
809
810    if let Some(config_err) = known.config {
811        return Some(match config_err {
812            crate::config::ConfigError::UnknownProfile { .. } => {
813                "run `osp config explain profile.default` or choose a known profile"
814            }
815            crate::config::ConfigError::InsecureSecretsPermissions { .. } => {
816                "restrict the secrets file permissions to 0600"
817            }
818            _ => "run `osp config explain <key>` to inspect config provenance",
819        });
820    }
821
822    if known.clap.is_some() {
823        return Some("use --help to inspect accepted flags and subcommands");
824    }
825
826    None
827}
828
829fn base_error_message(err: &miette::Report, known: &KnownErrorChain<'_>) -> String {
830    if let Some(plugin_err) = known.plugin {
831        return plugin_err.to_string();
832    }
833
834    if let Some(config_err) = known.config {
835        return config_err.to_string();
836    }
837
838    if let Some(clap_err) = known.clap {
839        return clap_err.to_string();
840    }
841
842    let outer = err.to_string();
843    let mut deepest_source = None;
844    let mut current = err.source();
845    while let Some(source) = current {
846        let text = source.to_string();
847        if !text.is_empty() {
848            deepest_source = Some(text);
849        }
850        current = source.source();
851    }
852
853    match deepest_source {
854        // Context wrappers are still useful for verbose output, but at default
855        // message levels users usually need the concrete actionable cause.
856        Some(source) if outer.starts_with("failed to ") || outer.starts_with("unable to ") => {
857            source
858        }
859        _ => outer,
860    }
861}
862
863pub(crate) fn report_std_error_with_context<E>(err: E, context: &'static str) -> miette::Report
864where
865    E: std::error::Error + Send + Sync + 'static,
866{
867    miette::Report::new(ContextError {
868        context,
869        source: err,
870    })
871}
872
873fn find_error_in_chain<E>(err: &miette::Report) -> Option<&E>
874where
875    E: std::error::Error + 'static,
876{
877    let mut current: Option<&(dyn std::error::Error + 'static)> = Some(err.as_ref());
878    while let Some(source) = current {
879        if let Some(found) = source.downcast_ref::<E>() {
880            return Some(found);
881        }
882        current = source.source();
883    }
884    None
885}
886
887pub(crate) fn resolve_default_render_width(config: &ResolvedConfig) -> usize {
888    let configured = config_usize(config, "ui.width", DEFAULT_UI_WIDTH as usize);
889    if configured != DEFAULT_UI_WIDTH as usize {
890        return configured;
891    }
892
893    detect_terminal_width()
894        .or_else(|| {
895            std::env::var("COLUMNS")
896                .ok()
897                .and_then(|value| value.trim().parse::<usize>().ok())
898                .filter(|value| *value > 0)
899        })
900        .unwrap_or(configured)
901}
902
903fn detect_terminal_width() -> Option<usize> {
904    if !std::io::stdout().is_terminal() {
905        return None;
906    }
907    terminal_size()
908        .map(|(Width(columns), _)| columns as usize)
909        .filter(|value| *value > 0)
910}
911
912fn detect_columns_env() -> Option<usize> {
913    std::env::var("COLUMNS")
914        .ok()
915        .and_then(|value| value.trim().parse::<usize>().ok())
916        .filter(|value| *value > 0)
917}
918
919fn locale_utf8_hint_from_env() -> Option<bool> {
920    for key in ["LC_ALL", "LC_CTYPE", "LANG"] {
921        if let Ok(value) = std::env::var(key) {
922            let lower = value.to_ascii_lowercase();
923            if lower.contains("utf-8") || lower.contains("utf8") {
924                return Some(true);
925            }
926            return Some(false);
927        }
928    }
929    None
930}
931
932pub(crate) fn build_render_runtime(terminal_env: Option<&str>) -> RenderRuntime {
933    RenderRuntime {
934        stdout_is_tty: std::io::stdout().is_terminal(),
935        terminal: terminal_env.map(ToOwned::to_owned),
936        no_color: std::env::var("NO_COLOR").is_ok(),
937        width: detect_terminal_width().or_else(detect_columns_env),
938        locale_utf8: locale_utf8_hint_from_env(),
939    }
940}
941
942fn to_ui_verbosity(level: MessageLevel) -> UiVerbosity {
943    match level {
944        MessageLevel::Error => UiVerbosity::Error,
945        MessageLevel::Warning => UiVerbosity::Warning,
946        MessageLevel::Success => UiVerbosity::Success,
947        MessageLevel::Info => UiVerbosity::Info,
948        MessageLevel::Trace => UiVerbosity::Trace,
949    }
950}
951
952#[cfg(test)]
953pub(crate) fn plugin_dispatch_context_for_runtime(
954    runtime: &crate::app::AppRuntime,
955    clients: &AppClients,
956    invocation: Option<&ResolvedInvocation>,
957) -> PluginDispatchContext {
958    build_plugin_dispatch_context(
959        &runtime.context,
960        &runtime.config,
961        clients,
962        invocation.map(|value| &value.ui).unwrap_or(&runtime.ui),
963    )
964}
965
966pub(in crate::app) fn plugin_dispatch_context_for(
967    runtime: &ExternalCommandRuntime<'_>,
968    invocation: Option<&ResolvedInvocation>,
969) -> PluginDispatchContext {
970    build_plugin_dispatch_context(
971        runtime.context,
972        runtime.config_state,
973        runtime.clients,
974        invocation.map(|value| &value.ui).unwrap_or(runtime.ui),
975    )
976}
977
978fn build_plugin_dispatch_context(
979    context: &crate::app::RuntimeContext,
980    config: &crate::app::ConfigState,
981    clients: &AppClients,
982    ui: &crate::app::UiState,
983) -> PluginDispatchContext {
984    let config_env = clients.plugin_config_env(config);
985    PluginDispatchContext::new(
986        RuntimeHints::new(
987            to_ui_verbosity(ui.message_verbosity),
988            ui.debug_verbosity.min(3),
989            ui.render_settings.format,
990            ui.render_settings.color,
991            ui.render_settings.unicode,
992        )
993        .with_profile(Some(config.resolved().active_profile().to_string()))
994        .with_terminal(context.terminal_env().map(ToOwned::to_owned))
995        .with_terminal_kind(match context.terminal_kind() {
996            TerminalKind::Cli => RuntimeTerminalKind::Cli,
997            TerminalKind::Repl => RuntimeTerminalKind::Repl,
998        }),
999    )
1000    .with_shared_env(
1001        config_env
1002            .shared
1003            .iter()
1004            .map(|entry| (entry.env_key.clone(), entry.value.clone()))
1005            .collect::<Vec<_>>(),
1006    )
1007    .with_plugin_env(
1008        config_env
1009            .by_plugin_id
1010            .into_iter()
1011            .map(|(plugin_id, entries)| {
1012                (
1013                    plugin_id,
1014                    entries
1015                        .into_iter()
1016                        .map(|entry| (entry.env_key, entry.value))
1017                        .collect(),
1018                )
1019            })
1020            .collect(),
1021    )
1022}
1023
1024pub(crate) fn runtime_hints_for_runtime(runtime: &crate::app::AppRuntime) -> RuntimeHints {
1025    RuntimeHints::new(
1026        to_ui_verbosity(runtime.ui.message_verbosity),
1027        runtime.ui.debug_verbosity.min(3),
1028        runtime.ui.render_settings.format,
1029        runtime.ui.render_settings.color,
1030        runtime.ui.render_settings.unicode,
1031    )
1032    .with_profile(Some(runtime.config.resolved().active_profile().to_string()))
1033    .with_terminal(runtime.context.terminal_env().map(ToOwned::to_owned))
1034    .with_terminal_kind(match runtime.context.terminal_kind() {
1035        TerminalKind::Cli => RuntimeTerminalKind::Cli,
1036        TerminalKind::Repl => RuntimeTerminalKind::Repl,
1037    })
1038}
1039
1040fn native_catalog_entry_to_command_catalog_entry(
1041    entry: NativeCommandCatalogEntry,
1042) -> CommandCatalogEntry {
1043    CommandCatalogEntry {
1044        name: entry.name,
1045        about: entry.about,
1046        auth: entry.auth,
1047        subcommands: entry.subcommands,
1048        completion: entry.completion,
1049        provider: None,
1050        providers: Vec::new(),
1051        conflicted: false,
1052        requires_selection: false,
1053        selected_explicitly: false,
1054        source: None,
1055    }
1056}
1057
1058fn add_native_command_help(view: &mut GuideView, native_commands: &NativeCommandRegistry) {
1059    let catalog = native_commands.catalog();
1060    if catalog.is_empty() {
1061        return;
1062    }
1063
1064    let mut section = GuideSection::new("Native integrations", GuideSectionKind::Custom);
1065    for entry in catalog {
1066        section = section.entry(entry.name, entry.about.trim());
1067    }
1068    view.sections.push(section);
1069}
1070
1071pub(crate) fn resolve_render_settings_with_hint(
1072    settings: &RenderSettings,
1073    format_hint: Option<OutputFormat>,
1074) -> RenderSettings {
1075    if matches!(settings.format, OutputFormat::Auto)
1076        && let Some(format) = format_hint
1077    {
1078        let mut effective = settings.clone();
1079        effective.format = format;
1080        return effective;
1081    }
1082    settings.clone()
1083}