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)]
148#[must_use]
149pub struct RuntimeHints {
150    /// Effective UI message verbosity.
151    pub ui_verbosity: UiVerbosity,
152    /// Effective debug level capped to the supported range.
153    pub debug_level: u8,
154    /// Effective output format preference.
155    pub format: OutputFormat,
156    /// Effective color-mode preference.
157    pub color: ColorMode,
158    /// Effective Unicode-mode preference.
159    pub unicode: UnicodeMode,
160    /// Active profile identifier, when set.
161    pub profile: Option<String>,
162    /// Active terminal identifier, when set.
163    pub terminal: Option<String>,
164    /// Effective terminal kind hint.
165    pub terminal_kind: RuntimeTerminalKind,
166}
167
168impl Default for RuntimeHints {
169    fn default() -> Self {
170        Self {
171            ui_verbosity: UiVerbosity::Success,
172            debug_level: 0,
173            format: OutputFormat::Auto,
174            color: ColorMode::Auto,
175            unicode: UnicodeMode::Auto,
176            profile: None,
177            terminal: None,
178            terminal_kind: RuntimeTerminalKind::Unknown,
179        }
180    }
181}
182
183impl RuntimeHints {
184    /// Creates a fully specified runtime-hint payload with no profile or
185    /// terminal metadata attached yet.
186    ///
187    /// `debug_level` is clamped to the supported `0..=3` range.
188    ///
189    /// # Examples
190    ///
191    /// ```
192    /// use osp_cli::core::output::{ColorMode, OutputFormat, UnicodeMode};
193    /// use osp_cli::core::runtime::{RuntimeHints, UiVerbosity};
194    ///
195    /// let hints = RuntimeHints::new(
196    ///     UiVerbosity::Info,
197    ///     7,
198    ///     OutputFormat::Json,
199    ///     ColorMode::Always,
200    ///     UnicodeMode::Never,
201    /// );
202    ///
203    /// assert_eq!(hints.ui_verbosity, UiVerbosity::Info);
204    /// assert_eq!(hints.debug_level, 3);
205    /// assert_eq!(hints.profile, None);
206    /// ```
207    pub fn new(
208        ui_verbosity: UiVerbosity,
209        debug_level: u8,
210        format: OutputFormat,
211        color: ColorMode,
212        unicode: UnicodeMode,
213    ) -> Self {
214        Self {
215            ui_verbosity,
216            debug_level: debug_level.min(3),
217            format,
218            color,
219            unicode,
220            profile: None,
221            terminal: None,
222            terminal_kind: RuntimeTerminalKind::Unknown,
223        }
224    }
225
226    /// Reads runtime hints from the current process environment.
227    pub fn from_env() -> Self {
228        Self::from_env_iter(std::env::vars())
229    }
230
231    /// Replaces the optional active-profile label.
232    pub fn with_profile(mut self, profile: Option<String>) -> Self {
233        self.profile = profile
234            .map(|value| value.trim().to_string())
235            .filter(|value| !value.is_empty());
236        self
237    }
238
239    /// Replaces the optional terminal identifier.
240    pub fn with_terminal(mut self, terminal: Option<String>) -> Self {
241        self.terminal = terminal
242            .map(|value| value.trim().to_string())
243            .filter(|value| !value.is_empty());
244        self
245    }
246
247    /// Replaces the terminal-kind hint.
248    pub fn with_terminal_kind(mut self, terminal_kind: RuntimeTerminalKind) -> Self {
249        self.terminal_kind = terminal_kind;
250        self
251    }
252
253    /// Builds runtime hints from arbitrary key-value environment pairs.
254    ///
255    /// # Examples
256    ///
257    /// ```
258    /// use osp_cli::core::output::{ColorMode, OutputFormat, UnicodeMode};
259    /// use osp_cli::core::runtime::{
260    ///     ENV_OSP_COLOR, ENV_OSP_DEBUG_LEVEL, ENV_OSP_FORMAT, ENV_OSP_PROFILE,
261    ///     ENV_OSP_TERMINAL_KIND, ENV_OSP_UI_VERBOSITY, RuntimeHints,
262    ///     RuntimeTerminalKind, UiVerbosity,
263    /// };
264    ///
265    /// let hints = RuntimeHints::from_env_iter([
266    ///     (ENV_OSP_UI_VERBOSITY, "trace"),
267    ///     (ENV_OSP_DEBUG_LEVEL, "7"),
268    ///     (ENV_OSP_FORMAT, "json"),
269    ///     (ENV_OSP_COLOR, "never"),
270    ///     (ENV_OSP_PROFILE, "uio"),
271    ///     (ENV_OSP_TERMINAL_KIND, "repl"),
272    /// ]);
273    ///
274    /// assert_eq!(hints.ui_verbosity, UiVerbosity::Trace);
275    /// assert_eq!(hints.debug_level, 3);
276    /// assert_eq!(hints.format, OutputFormat::Json);
277    /// assert_eq!(hints.color, ColorMode::Never);
278    /// assert_eq!(hints.unicode, UnicodeMode::Auto);
279    /// assert_eq!(hints.profile.as_deref(), Some("uio"));
280    /// assert_eq!(hints.terminal_kind, RuntimeTerminalKind::Repl);
281    /// ```
282    pub fn from_env_iter<I, K, V>(vars: I) -> Self
283    where
284        I: IntoIterator<Item = (K, V)>,
285        K: AsRef<str>,
286        V: AsRef<str>,
287    {
288        let values = vars
289            .into_iter()
290            .map(|(k, v)| (k.as_ref().to_string(), v.as_ref().to_string()))
291            .collect::<HashMap<String, String>>();
292
293        let ui_verbosity = values
294            .get(ENV_OSP_UI_VERBOSITY)
295            .and_then(|value| UiVerbosity::parse(value))
296            .unwrap_or(UiVerbosity::Success);
297        let debug_level = values
298            .get(ENV_OSP_DEBUG_LEVEL)
299            .and_then(|value| value.trim().parse::<u8>().ok())
300            .unwrap_or(0)
301            .min(3);
302        let format = values
303            .get(ENV_OSP_FORMAT)
304            .and_then(|value| OutputFormat::parse(value))
305            .unwrap_or(OutputFormat::Auto);
306        let color = values
307            .get(ENV_OSP_COLOR)
308            .and_then(|value| ColorMode::parse(value))
309            .unwrap_or(ColorMode::Auto);
310        let unicode = values
311            .get(ENV_OSP_UNICODE)
312            .and_then(|value| UnicodeMode::parse(value))
313            .unwrap_or(UnicodeMode::Auto);
314        let profile = values
315            .get(ENV_OSP_PROFILE)
316            .map(String::as_str)
317            .map(str::trim)
318            .filter(|value| !value.is_empty())
319            .map(ToOwned::to_owned);
320        let terminal = values
321            .get(ENV_OSP_TERMINAL)
322            .map(String::as_str)
323            .map(str::trim)
324            .filter(|value| !value.is_empty())
325            .map(ToOwned::to_owned);
326        let terminal_kind = values
327            .get(ENV_OSP_TERMINAL_KIND)
328            .and_then(|value| RuntimeTerminalKind::parse(value))
329            .or_else(|| {
330                values
331                    .get(ENV_OSP_TERMINAL)
332                    .and_then(|value| RuntimeTerminalKind::parse(value))
333            })
334            .unwrap_or(RuntimeTerminalKind::Unknown);
335
336        Self::new(ui_verbosity, debug_level, format, color, unicode)
337            .with_profile(profile)
338            .with_terminal(terminal)
339            .with_terminal_kind(terminal_kind)
340    }
341
342    /// Returns this hint set as environment variable pairs suitable for export.
343    ///
344    /// # Examples
345    ///
346    /// ```
347    /// use osp_cli::core::output::{ColorMode, OutputFormat, UnicodeMode};
348    /// use osp_cli::core::runtime::{RuntimeHints, RuntimeTerminalKind, UiVerbosity};
349    ///
350    /// let hints = RuntimeHints::new(
351    ///     UiVerbosity::Info,
352    ///     2,
353    ///     OutputFormat::Json,
354    ///     ColorMode::Always,
355    ///     UnicodeMode::Never,
356    /// )
357    /// .with_profile(Some("uio".to_string()))
358    /// .with_terminal(Some("xterm-256color".to_string()))
359    /// .with_terminal_kind(RuntimeTerminalKind::Cli);
360    /// let pairs = hints.env_pairs();
361    ///
362    /// assert!(pairs.iter().any(|(key, value)| *key == "OSP_FORMAT" && value == "json"));
363    /// assert!(pairs.iter().any(|(key, value)| *key == "OSP_PROFILE" && value == "uio"));
364    /// ```
365    pub fn env_pairs(&self) -> Vec<(&'static str, String)> {
366        let mut out = vec![
367            (ENV_OSP_UI_VERBOSITY, self.ui_verbosity.as_str().to_string()),
368            (ENV_OSP_DEBUG_LEVEL, self.debug_level.min(3).to_string()),
369            (ENV_OSP_FORMAT, self.format.as_str().to_string()),
370            (ENV_OSP_COLOR, self.color.as_str().to_string()),
371            (ENV_OSP_UNICODE, self.unicode.as_str().to_string()),
372            (
373                ENV_OSP_TERMINAL_KIND,
374                self.terminal_kind.as_str().to_string(),
375            ),
376        ];
377
378        if let Some(profile) = &self.profile {
379            out.push((ENV_OSP_PROFILE, profile.clone()));
380        }
381        if let Some(terminal) = &self.terminal {
382            out.push((ENV_OSP_TERMINAL, terminal.clone()));
383        }
384
385        out
386    }
387}
388
389#[cfg(test)]
390mod tests {
391    use super::{
392        ENV_OSP_COLOR, ENV_OSP_DEBUG_LEVEL, ENV_OSP_FORMAT, ENV_OSP_PROFILE, ENV_OSP_TERMINAL,
393        ENV_OSP_UI_VERBOSITY, ENV_OSP_UNICODE, RuntimeHints, RuntimeTerminalKind, UiVerbosity,
394    };
395    use crate::core::output::{ColorMode, OutputFormat, UnicodeMode};
396
397    #[test]
398    fn env_roundtrip_keeps_runtime_hints() {
399        let hints = RuntimeHints::new(
400            UiVerbosity::Trace,
401            7,
402            OutputFormat::Json,
403            ColorMode::Never,
404            UnicodeMode::Always,
405        )
406        .with_profile(Some("uio".to_string()))
407        .with_terminal(Some("xterm-256color".to_string()))
408        .with_terminal_kind(RuntimeTerminalKind::Repl);
409
410        let parsed = RuntimeHints::from_env_iter(hints.env_pairs());
411        assert_eq!(parsed.ui_verbosity, UiVerbosity::Trace);
412        assert_eq!(parsed.debug_level, 3);
413        assert_eq!(parsed.format, OutputFormat::Json);
414        assert_eq!(parsed.color, ColorMode::Never);
415        assert_eq!(parsed.unicode, UnicodeMode::Always);
416        assert_eq!(parsed.profile.as_deref(), Some("uio"));
417        assert_eq!(parsed.terminal.as_deref(), Some("xterm-256color"));
418        assert_eq!(parsed.terminal_kind, RuntimeTerminalKind::Repl);
419    }
420
421    #[test]
422    fn new_and_with_helpers_build_runtime_hints_unit() {
423        let hints = RuntimeHints::new(
424            UiVerbosity::Info,
425            9,
426            OutputFormat::Table,
427            ColorMode::Always,
428            UnicodeMode::Never,
429        )
430        .with_profile(Some("  dev  ".to_string()))
431        .with_terminal(Some(" xterm-256color ".to_string()))
432        .with_terminal_kind(RuntimeTerminalKind::Cli);
433
434        assert_eq!(hints.ui_verbosity, UiVerbosity::Info);
435        assert_eq!(hints.debug_level, 3);
436        assert_eq!(hints.profile.as_deref(), Some("dev"));
437        assert_eq!(hints.terminal.as_deref(), Some("xterm-256color"));
438        assert_eq!(hints.terminal_kind, RuntimeTerminalKind::Cli);
439    }
440
441    #[test]
442    fn from_env_defaults_when_vars_missing_or_invalid() {
443        let parsed = RuntimeHints::from_env_iter(vec![
444            (ENV_OSP_UI_VERBOSITY, "loud"),
445            (ENV_OSP_DEBUG_LEVEL, "NaN"),
446            (ENV_OSP_FORMAT, "???"),
447            (ENV_OSP_COLOR, "blue"),
448            (ENV_OSP_UNICODE, "emoji"),
449        ]);
450
451        assert_eq!(parsed.ui_verbosity, UiVerbosity::Success);
452        assert_eq!(parsed.debug_level, 0);
453        assert_eq!(parsed.format, OutputFormat::Auto);
454        assert_eq!(parsed.color, ColorMode::Auto);
455        assert_eq!(parsed.unicode, UnicodeMode::Auto);
456        assert_eq!(parsed.profile, None);
457        assert_eq!(parsed.terminal, None);
458        assert_eq!(parsed.terminal_kind, RuntimeTerminalKind::Unknown);
459    }
460
461    #[test]
462    fn terminal_kind_falls_back_to_terminal_env() {
463        let parsed =
464            RuntimeHints::from_env_iter(vec![(ENV_OSP_TERMINAL, "repl"), (ENV_OSP_PROFILE, "tsd")]);
465
466        assert_eq!(parsed.profile.as_deref(), Some("tsd"));
467        assert_eq!(parsed.terminal.as_deref(), Some("repl"));
468        assert_eq!(parsed.terminal_kind, RuntimeTerminalKind::Repl);
469    }
470}