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