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