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