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