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
135pub 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
205fn 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 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
699pub 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
713pub 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 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}