Skip to main content

osp_cli/app/
runtime.rs

1//! Runtime-scoped host state.
2
3use std::collections::HashSet;
4use std::path::PathBuf;
5
6use crate::config::{ResolvedConfig, RuntimeLoadOptions};
7use crate::plugin::PluginManager;
8use crate::plugin::config::{PluginConfigEntry, PluginConfigEnv, PluginConfigEnvCache};
9use crate::ui::RenderSettings;
10use crate::ui::messages::MessageLevel;
11use crate::ui::theme_loader::ThemeCatalog;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum TerminalKind {
15    Cli,
16    Repl,
17}
18
19impl TerminalKind {
20    pub fn as_config_terminal(self) -> &'static str {
21        match self {
22            TerminalKind::Cli => "cli",
23            TerminalKind::Repl => "repl",
24        }
25    }
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct RuntimeContext {
30    profile_override: Option<String>,
31    terminal_kind: TerminalKind,
32    terminal_env: Option<String>,
33}
34
35impl RuntimeContext {
36    pub fn new(
37        profile_override: Option<String>,
38        terminal_kind: TerminalKind,
39        terminal_env: Option<String>,
40    ) -> Self {
41        Self {
42            profile_override: profile_override
43                .map(|value| value.trim().to_ascii_lowercase())
44                .filter(|value| !value.is_empty()),
45            terminal_kind,
46            terminal_env,
47        }
48    }
49
50    pub fn profile_override(&self) -> Option<&str> {
51        self.profile_override.as_deref()
52    }
53
54    pub fn terminal_kind(&self) -> TerminalKind {
55        self.terminal_kind
56    }
57
58    pub fn terminal_env(&self) -> Option<&str> {
59        self.terminal_env.as_deref()
60    }
61}
62
63pub struct ConfigState {
64    resolved: ResolvedConfig,
65    revision: u64,
66}
67
68impl ConfigState {
69    pub fn new(resolved: ResolvedConfig) -> Self {
70        Self {
71            resolved,
72            revision: 1,
73        }
74    }
75
76    pub fn resolved(&self) -> &ResolvedConfig {
77        &self.resolved
78    }
79
80    pub fn revision(&self) -> u64 {
81        self.revision
82    }
83
84    pub fn replace_resolved(&mut self, next: ResolvedConfig) -> bool {
85        if self.resolved == next {
86            return false;
87        }
88
89        self.resolved = next;
90        self.revision += 1;
91        true
92    }
93
94    pub fn transaction<F, E>(&mut self, mutator: F) -> Result<bool, E>
95    where
96        F: FnOnce(&ResolvedConfig) -> Result<ResolvedConfig, E>,
97    {
98        let current = self.resolved.clone();
99        let candidate = mutator(&current)?;
100        Ok(self.replace_resolved(candidate))
101    }
102}
103
104#[derive(Debug, Clone)]
105pub struct UiState {
106    pub render_settings: RenderSettings,
107    pub message_verbosity: MessageLevel,
108    pub debug_verbosity: u8,
109}
110
111#[derive(Debug, Clone, Default)]
112pub struct LaunchContext {
113    pub plugin_dirs: Vec<PathBuf>,
114    pub config_root: Option<PathBuf>,
115    pub cache_root: Option<PathBuf>,
116    pub runtime_load: RuntimeLoadOptions,
117}
118
119pub struct AppClients {
120    pub plugins: PluginManager,
121    plugin_config_env: PluginConfigEnvCache,
122}
123
124impl AppClients {
125    pub fn new(plugins: PluginManager) -> Self {
126        Self {
127            plugins,
128            plugin_config_env: PluginConfigEnvCache::default(),
129        }
130    }
131
132    pub(crate) fn plugin_config_env(&self, config: &ConfigState) -> PluginConfigEnv {
133        self.plugin_config_env.collect(config)
134    }
135
136    pub(crate) fn plugin_config_entries(
137        &self,
138        config: &ConfigState,
139        plugin_id: &str,
140    ) -> Vec<PluginConfigEntry> {
141        let config_env = self.plugin_config_env(config);
142        let mut merged = std::collections::BTreeMap::new();
143        for entry in config_env.shared {
144            merged.insert(entry.env_key.clone(), entry);
145        }
146        if let Some(entries) = config_env.by_plugin_id.get(plugin_id) {
147            for entry in entries {
148                merged.insert(entry.env_key.clone(), entry.clone());
149            }
150        }
151        merged.into_values().collect()
152    }
153}
154
155pub struct AppRuntime {
156    pub context: RuntimeContext,
157    pub config: ConfigState,
158    pub ui: UiState,
159    pub auth: AuthState,
160    pub(crate) themes: ThemeCatalog,
161    pub launch: LaunchContext,
162}
163
164pub struct AuthState {
165    builtins_allowlist: Option<HashSet<String>>,
166    plugins_allowlist: Option<HashSet<String>>,
167}
168
169impl AuthState {
170    pub fn from_resolved(config: &ResolvedConfig) -> Self {
171        Self {
172            builtins_allowlist: parse_allowlist(config.get_string("auth.visible.builtins")),
173            plugins_allowlist: parse_allowlist(config.get_string("auth.visible.plugins")),
174        }
175    }
176
177    pub fn is_builtin_visible(&self, command: &str) -> bool {
178        is_visible_in_allowlist(&self.builtins_allowlist, command)
179    }
180
181    pub fn is_plugin_command_visible(&self, command: &str) -> bool {
182        is_visible_in_allowlist(&self.plugins_allowlist, command)
183    }
184}
185
186fn parse_allowlist(raw: Option<&str>) -> Option<HashSet<String>> {
187    let raw = raw.map(str::trim).filter(|value| !value.is_empty())?;
188
189    if raw == "*" {
190        return None;
191    }
192
193    let values = raw
194        .split([',', ' '])
195        .map(str::trim)
196        .filter(|value| !value.is_empty())
197        .map(|value| value.to_ascii_lowercase())
198        .collect::<HashSet<String>>();
199    if values.is_empty() {
200        None
201    } else {
202        Some(values)
203    }
204}
205
206fn is_visible_in_allowlist(allowlist: &Option<HashSet<String>>, command: &str) -> bool {
207    match allowlist {
208        None => true,
209        Some(values) => values.contains(&command.to_ascii_lowercase()),
210    }
211}