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