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