Skip to main content

osp_cli/core/
runtime.rs

1//! Runtime hint model shared across host, native commands, and plugin
2//! boundaries.
3//!
4//! This module exists so runtime-facing behavior like verbosity, output mode,
5//! and terminal identity can travel through the system as normalized data
6//! instead of ad hoc environment parsing in each caller.
7//!
8//! High-level flow:
9//!
10//! - parse runtime-oriented environment hints into
11//!   [`crate::core::runtime::RuntimeHints`]
12//! - normalize those values into enums and bounded scalar settings
13//! - export the same normalized hints back into environment pairs when needed
14//!
15//! Contract:
16//!
17//! - environment parsing rules for runtime hints live here
18//! - callers should consume [`crate::core::runtime::RuntimeHints`] instead of
19//!   reparsing raw env vars
20//!
21//! Public API shape:
22//!
23//! - [`crate::core::runtime::RuntimeHints::new`] is the exact constructor for
24//!   already-resolved runtime settings
25//! - [`crate::core::runtime::RuntimeHints::from_env`] and
26//!   [`crate::core::runtime::RuntimeHints::from_env_iter`] are the probing and
27//!   adaptation factories
28//! - optional metadata such as profile and terminal identity uses `with_*`
29//!   refinements instead of raw ad hoc assembly
30
31use std::collections::HashMap;
32
33use crate::core::output::{ColorMode, OutputFormat, UnicodeMode};
34
35/// Environment variable carrying the UI verbosity hint.
36pub const ENV_OSP_UI_VERBOSITY: &str = "OSP_UI_VERBOSITY";
37/// Environment variable carrying the debug level hint.
38pub const ENV_OSP_DEBUG_LEVEL: &str = "OSP_DEBUG_LEVEL";
39/// Environment variable carrying the preferred output format.
40pub const ENV_OSP_FORMAT: &str = "OSP_FORMAT";
41/// Environment variable carrying the color-mode hint.
42pub const ENV_OSP_COLOR: &str = "OSP_COLOR";
43/// Environment variable carrying the Unicode-mode hint.
44pub const ENV_OSP_UNICODE: &str = "OSP_UNICODE";
45/// Environment variable carrying the active profile name.
46pub const ENV_OSP_PROFILE: &str = "OSP_PROFILE";
47/// Environment variable carrying the active terminal identifier.
48pub const ENV_OSP_TERMINAL: &str = "OSP_TERMINAL";
49/// Environment variable carrying the terminal kind hint.
50pub const ENV_OSP_TERMINAL_KIND: &str = "OSP_TERMINAL_KIND";
51
52/// UI message verbosity derived from runtime hints and environment.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
54pub enum UiVerbosity {
55    /// Show only errors.
56    Error,
57    /// Show errors and warnings.
58    Warning,
59    /// Show success messages in addition to warnings and errors.
60    #[default]
61    Success,
62    /// Show normal informational output.
63    Info,
64    /// Show trace-level output.
65    Trace,
66}
67
68impl UiVerbosity {
69    /// Returns the canonical string representation for this verbosity level.
70    pub fn as_str(self) -> &'static str {
71        match self {
72            UiVerbosity::Error => "error",
73            UiVerbosity::Warning => "warning",
74            UiVerbosity::Success => "success",
75            UiVerbosity::Info => "info",
76            UiVerbosity::Trace => "trace",
77        }
78    }
79
80    /// Parses a case-insensitive verbosity level or supported alias.
81    ///
82    /// # Examples
83    ///
84    /// ```
85    /// use osp_cli::core::runtime::UiVerbosity;
86    ///
87    /// assert_eq!(UiVerbosity::parse("warn"), Some(UiVerbosity::Warning));
88    /// assert_eq!(UiVerbosity::parse("TRACE"), Some(UiVerbosity::Trace));
89    /// assert_eq!(UiVerbosity::parse("loud"), None);
90    /// ```
91    pub fn parse(value: &str) -> Option<Self> {
92        match value.trim().to_ascii_lowercase().as_str() {
93            "error" => Some(UiVerbosity::Error),
94            "warning" | "warn" => Some(UiVerbosity::Warning),
95            "success" => Some(UiVerbosity::Success),
96            "info" => Some(UiVerbosity::Info),
97            "trace" => Some(UiVerbosity::Trace),
98            _ => None,
99        }
100    }
101}
102
103/// Runtime terminal mode exposed through environment hints.
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
105pub enum RuntimeTerminalKind {
106    /// Invocation is running as a one-shot CLI command.
107    Cli,
108    /// Invocation is running inside the interactive REPL.
109    Repl,
110    /// Terminal kind is unknown or unspecified.
111    #[default]
112    Unknown,
113}
114
115impl RuntimeTerminalKind {
116    /// Returns the canonical string representation for this terminal kind.
117    pub fn as_str(self) -> &'static str {
118        match self {
119            RuntimeTerminalKind::Cli => "cli",
120            RuntimeTerminalKind::Repl => "repl",
121            RuntimeTerminalKind::Unknown => "unknown",
122        }
123    }
124
125    /// Parses a case-insensitive terminal kind name.
126    ///
127    /// # Examples
128    ///
129    /// ```
130    /// use osp_cli::core::runtime::RuntimeTerminalKind;
131    ///
132    /// assert_eq!(RuntimeTerminalKind::parse("cli"), Some(RuntimeTerminalKind::Cli));
133    /// assert_eq!(RuntimeTerminalKind::parse("REPL"), Some(RuntimeTerminalKind::Repl));
134    /// assert_eq!(RuntimeTerminalKind::parse("tty"), None);
135    /// ```
136    pub fn parse(value: &str) -> Option<Self> {
137        match value.trim().to_ascii_lowercase().as_str() {
138            "cli" => Some(RuntimeTerminalKind::Cli),
139            "repl" => Some(RuntimeTerminalKind::Repl),
140            "unknown" => Some(RuntimeTerminalKind::Unknown),
141            _ => None,
142        }
143    }
144}
145
146/// Normalized runtime settings loaded from environment variables.
147#[derive(Debug, Clone, PartialEq, Eq)]
148pub struct RuntimeHints {
149    /// Effective UI message verbosity.
150    pub ui_verbosity: UiVerbosity,
151    /// Effective debug level capped to the supported range.
152    pub debug_level: u8,
153    /// Effective output format preference.
154    pub format: OutputFormat,
155    /// Effective color-mode preference.
156    pub color: ColorMode,
157    /// Effective Unicode-mode preference.
158    pub unicode: UnicodeMode,
159    /// Active profile identifier, when set.
160    pub profile: Option<String>,
161    /// Active terminal identifier, when set.
162    pub terminal: Option<String>,
163    /// Effective terminal kind hint.
164    pub terminal_kind: RuntimeTerminalKind,
165}
166
167impl Default for RuntimeHints {
168    fn default() -> Self {
169        Self {
170            ui_verbosity: UiVerbosity::Success,
171            debug_level: 0,
172            format: OutputFormat::Auto,
173            color: ColorMode::Auto,
174            unicode: UnicodeMode::Auto,
175            profile: None,
176            terminal: None,
177            terminal_kind: RuntimeTerminalKind::Unknown,
178        }
179    }
180}
181
182impl RuntimeHints {
183    /// Creates a fully specified runtime-hint payload with no profile or
184    /// terminal metadata attached yet.
185    ///
186    /// `debug_level` is clamped to the supported `0..=3` range.
187    ///
188    /// # Examples
189    ///
190    /// ```
191    /// use osp_cli::core::output::{ColorMode, OutputFormat, UnicodeMode};
192    /// use osp_cli::core::runtime::{RuntimeHints, UiVerbosity};
193    ///
194    /// let hints = RuntimeHints::new(
195    ///     UiVerbosity::Info,
196    ///     7,
197    ///     OutputFormat::Json,
198    ///     ColorMode::Always,
199    ///     UnicodeMode::Never,
200    /// );
201    ///
202    /// assert_eq!(hints.ui_verbosity, UiVerbosity::Info);
203    /// assert_eq!(hints.debug_level, 3);
204    /// assert_eq!(hints.profile, None);
205    /// ```
206    pub fn new(
207        ui_verbosity: UiVerbosity,
208        debug_level: u8,
209        format: OutputFormat,
210        color: ColorMode,
211        unicode: UnicodeMode,
212    ) -> Self {
213        Self {
214            ui_verbosity,
215            debug_level: debug_level.min(3),
216            format,
217            color,
218            unicode,
219            profile: None,
220            terminal: None,
221            terminal_kind: RuntimeTerminalKind::Unknown,
222        }
223    }
224
225    /// Reads runtime hints from the current process environment.
226    pub fn from_env() -> Self {
227        Self::from_env_iter(std::env::vars())
228    }
229
230    /// Replaces the optional active-profile label.
231    pub fn with_profile(mut self, profile: Option<String>) -> Self {
232        self.profile = profile
233            .map(|value| value.trim().to_string())
234            .filter(|value| !value.is_empty());
235        self
236    }
237
238    /// Replaces the optional terminal identifier.
239    pub fn with_terminal(mut self, terminal: Option<String>) -> Self {
240        self.terminal = terminal
241            .map(|value| value.trim().to_string())
242            .filter(|value| !value.is_empty());
243        self
244    }
245
246    /// Replaces the terminal-kind hint.
247    pub fn with_terminal_kind(mut self, terminal_kind: RuntimeTerminalKind) -> Self {
248        self.terminal_kind = terminal_kind;
249        self
250    }
251
252    /// Builds runtime hints from arbitrary key-value environment pairs.
253    ///
254    /// # Examples
255    ///
256    /// ```
257    /// use osp_cli::core::output::{ColorMode, OutputFormat, UnicodeMode};
258    /// use osp_cli::core::runtime::{
259    ///     ENV_OSP_COLOR, ENV_OSP_DEBUG_LEVEL, ENV_OSP_FORMAT, ENV_OSP_PROFILE,
260    ///     ENV_OSP_TERMINAL_KIND, ENV_OSP_UI_VERBOSITY, RuntimeHints,
261    ///     RuntimeTerminalKind, UiVerbosity,
262    /// };
263    ///
264    /// let hints = RuntimeHints::from_env_iter([
265    ///     (ENV_OSP_UI_VERBOSITY, "trace"),
266    ///     (ENV_OSP_DEBUG_LEVEL, "7"),
267    ///     (ENV_OSP_FORMAT, "json"),
268    ///     (ENV_OSP_COLOR, "never"),
269    ///     (ENV_OSP_PROFILE, "uio"),
270    ///     (ENV_OSP_TERMINAL_KIND, "repl"),
271    /// ]);
272    ///
273    /// assert_eq!(hints.ui_verbosity, UiVerbosity::Trace);
274    /// assert_eq!(hints.debug_level, 3);
275    /// assert_eq!(hints.format, OutputFormat::Json);
276    /// assert_eq!(hints.color, ColorMode::Never);
277    /// assert_eq!(hints.unicode, UnicodeMode::Auto);
278    /// assert_eq!(hints.profile.as_deref(), Some("uio"));
279    /// assert_eq!(hints.terminal_kind, RuntimeTerminalKind::Repl);
280    /// ```
281    pub fn from_env_iter<I, K, V>(vars: I) -> Self
282    where
283        I: IntoIterator<Item = (K, V)>,
284        K: AsRef<str>,
285        V: AsRef<str>,
286    {
287        let values = vars
288            .into_iter()
289            .map(|(k, v)| (k.as_ref().to_string(), v.as_ref().to_string()))
290            .collect::<HashMap<String, String>>();
291
292        let ui_verbosity = values
293            .get(ENV_OSP_UI_VERBOSITY)
294            .and_then(|value| UiVerbosity::parse(value))
295            .unwrap_or(UiVerbosity::Success);
296        let debug_level = values
297            .get(ENV_OSP_DEBUG_LEVEL)
298            .and_then(|value| value.trim().parse::<u8>().ok())
299            .unwrap_or(0)
300            .min(3);
301        let format = values
302            .get(ENV_OSP_FORMAT)
303            .and_then(|value| OutputFormat::parse(value))
304            .unwrap_or(OutputFormat::Auto);
305        let color = values
306            .get(ENV_OSP_COLOR)
307            .and_then(|value| ColorMode::parse(value))
308            .unwrap_or(ColorMode::Auto);
309        let unicode = values
310            .get(ENV_OSP_UNICODE)
311            .and_then(|value| UnicodeMode::parse(value))
312            .unwrap_or(UnicodeMode::Auto);
313        let profile = values
314            .get(ENV_OSP_PROFILE)
315            .map(String::as_str)
316            .map(str::trim)
317            .filter(|value| !value.is_empty())
318            .map(ToOwned::to_owned);
319        let terminal = values
320            .get(ENV_OSP_TERMINAL)
321            .map(String::as_str)
322            .map(str::trim)
323            .filter(|value| !value.is_empty())
324            .map(ToOwned::to_owned);
325        let terminal_kind = values
326            .get(ENV_OSP_TERMINAL_KIND)
327            .and_then(|value| RuntimeTerminalKind::parse(value))
328            .or_else(|| {
329                values
330                    .get(ENV_OSP_TERMINAL)
331                    .and_then(|value| RuntimeTerminalKind::parse(value))
332            })
333            .unwrap_or(RuntimeTerminalKind::Unknown);
334
335        Self::new(ui_verbosity, debug_level, format, color, unicode)
336            .with_profile(profile)
337            .with_terminal(terminal)
338            .with_terminal_kind(terminal_kind)
339    }
340
341    /// Returns this hint set as environment variable pairs suitable for export.
342    ///
343    /// # Examples
344    ///
345    /// ```
346    /// use osp_cli::core::output::{ColorMode, OutputFormat, UnicodeMode};
347    /// use osp_cli::core::runtime::{RuntimeHints, RuntimeTerminalKind, UiVerbosity};
348    ///
349    /// let hints = RuntimeHints::new(
350    ///     UiVerbosity::Info,
351    ///     2,
352    ///     OutputFormat::Json,
353    ///     ColorMode::Always,
354    ///     UnicodeMode::Never,
355    /// )
356    /// .with_profile(Some("uio".to_string()))
357    /// .with_terminal(Some("xterm-256color".to_string()))
358    /// .with_terminal_kind(RuntimeTerminalKind::Cli);
359    /// let pairs = hints.env_pairs();
360    ///
361    /// assert!(pairs.iter().any(|(key, value)| *key == "OSP_FORMAT" && value == "json"));
362    /// assert!(pairs.iter().any(|(key, value)| *key == "OSP_PROFILE" && value == "uio"));
363    /// ```
364    pub fn env_pairs(&self) -> Vec<(&'static str, String)> {
365        let mut out = vec![
366            (ENV_OSP_UI_VERBOSITY, self.ui_verbosity.as_str().to_string()),
367            (ENV_OSP_DEBUG_LEVEL, self.debug_level.min(3).to_string()),
368            (ENV_OSP_FORMAT, self.format.as_str().to_string()),
369            (ENV_OSP_COLOR, self.color.as_str().to_string()),
370            (ENV_OSP_UNICODE, self.unicode.as_str().to_string()),
371            (
372                ENV_OSP_TERMINAL_KIND,
373                self.terminal_kind.as_str().to_string(),
374            ),
375        ];
376
377        if let Some(profile) = &self.profile {
378            out.push((ENV_OSP_PROFILE, profile.clone()));
379        }
380        if let Some(terminal) = &self.terminal {
381            out.push((ENV_OSP_TERMINAL, terminal.clone()));
382        }
383
384        out
385    }
386}
387
388#[cfg(test)]
389mod tests {
390    use super::{
391        ENV_OSP_COLOR, ENV_OSP_DEBUG_LEVEL, ENV_OSP_FORMAT, ENV_OSP_PROFILE, ENV_OSP_TERMINAL,
392        ENV_OSP_UI_VERBOSITY, ENV_OSP_UNICODE, RuntimeHints, RuntimeTerminalKind, UiVerbosity,
393    };
394    use crate::core::output::{ColorMode, OutputFormat, UnicodeMode};
395
396    #[test]
397    fn env_roundtrip_keeps_runtime_hints() {
398        let hints = RuntimeHints::new(
399            UiVerbosity::Trace,
400            7,
401            OutputFormat::Json,
402            ColorMode::Never,
403            UnicodeMode::Always,
404        )
405        .with_profile(Some("uio".to_string()))
406        .with_terminal(Some("xterm-256color".to_string()))
407        .with_terminal_kind(RuntimeTerminalKind::Repl);
408
409        let parsed = RuntimeHints::from_env_iter(hints.env_pairs());
410        assert_eq!(parsed.ui_verbosity, UiVerbosity::Trace);
411        assert_eq!(parsed.debug_level, 3);
412        assert_eq!(parsed.format, OutputFormat::Json);
413        assert_eq!(parsed.color, ColorMode::Never);
414        assert_eq!(parsed.unicode, UnicodeMode::Always);
415        assert_eq!(parsed.profile.as_deref(), Some("uio"));
416        assert_eq!(parsed.terminal.as_deref(), Some("xterm-256color"));
417        assert_eq!(parsed.terminal_kind, RuntimeTerminalKind::Repl);
418    }
419
420    #[test]
421    fn new_and_with_helpers_build_runtime_hints_unit() {
422        let hints = RuntimeHints::new(
423            UiVerbosity::Info,
424            9,
425            OutputFormat::Table,
426            ColorMode::Always,
427            UnicodeMode::Never,
428        )
429        .with_profile(Some("  dev  ".to_string()))
430        .with_terminal(Some(" xterm-256color ".to_string()))
431        .with_terminal_kind(RuntimeTerminalKind::Cli);
432
433        assert_eq!(hints.ui_verbosity, UiVerbosity::Info);
434        assert_eq!(hints.debug_level, 3);
435        assert_eq!(hints.profile.as_deref(), Some("dev"));
436        assert_eq!(hints.terminal.as_deref(), Some("xterm-256color"));
437        assert_eq!(hints.terminal_kind, RuntimeTerminalKind::Cli);
438    }
439
440    #[test]
441    fn from_env_defaults_when_vars_missing_or_invalid() {
442        let parsed = RuntimeHints::from_env_iter(vec![
443            (ENV_OSP_UI_VERBOSITY, "loud"),
444            (ENV_OSP_DEBUG_LEVEL, "NaN"),
445            (ENV_OSP_FORMAT, "???"),
446            (ENV_OSP_COLOR, "blue"),
447            (ENV_OSP_UNICODE, "emoji"),
448        ]);
449
450        assert_eq!(parsed.ui_verbosity, UiVerbosity::Success);
451        assert_eq!(parsed.debug_level, 0);
452        assert_eq!(parsed.format, OutputFormat::Auto);
453        assert_eq!(parsed.color, ColorMode::Auto);
454        assert_eq!(parsed.unicode, UnicodeMode::Auto);
455        assert_eq!(parsed.profile, None);
456        assert_eq!(parsed.terminal, None);
457        assert_eq!(parsed.terminal_kind, RuntimeTerminalKind::Unknown);
458    }
459
460    #[test]
461    fn terminal_kind_falls_back_to_terminal_env() {
462        let parsed =
463            RuntimeHints::from_env_iter(vec![(ENV_OSP_TERMINAL, "repl"), (ENV_OSP_PROFILE, "tsd")]);
464
465        assert_eq!(parsed.profile.as_deref(), Some("tsd"));
466        assert_eq!(parsed.terminal.as_deref(), Some("repl"));
467        assert_eq!(parsed.terminal_kind, RuntimeTerminalKind::Repl);
468    }
469}