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::core::command_policy::{
8    AccessReason, CommandAccess, CommandPolicy, CommandPolicyContext, CommandPolicyRegistry,
9    VisibilityMode,
10};
11use crate::plugin::PluginManager;
12use crate::plugin::config::{PluginConfigEntry, PluginConfigEnv, PluginConfigEnvCache};
13use crate::ui::RenderSettings;
14use crate::ui::messages::MessageLevel;
15use crate::ui::theme_loader::ThemeCatalog;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum TerminalKind {
19    Cli,
20    Repl,
21}
22
23impl TerminalKind {
24    pub fn as_config_terminal(self) -> &'static str {
25        match self {
26            TerminalKind::Cli => "cli",
27            TerminalKind::Repl => "repl",
28        }
29    }
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct RuntimeContext {
34    profile_override: Option<String>,
35    terminal_kind: TerminalKind,
36    terminal_env: Option<String>,
37}
38
39impl RuntimeContext {
40    pub fn new(
41        profile_override: Option<String>,
42        terminal_kind: TerminalKind,
43        terminal_env: Option<String>,
44    ) -> Self {
45        Self {
46            profile_override: profile_override
47                .map(|value| value.trim().to_ascii_lowercase())
48                .filter(|value| !value.is_empty()),
49            terminal_kind,
50            terminal_env,
51        }
52    }
53
54    pub fn profile_override(&self) -> Option<&str> {
55        self.profile_override.as_deref()
56    }
57
58    pub fn terminal_kind(&self) -> TerminalKind {
59        self.terminal_kind
60    }
61
62    pub fn terminal_env(&self) -> Option<&str> {
63        self.terminal_env.as_deref()
64    }
65}
66
67pub struct ConfigState {
68    resolved: ResolvedConfig,
69    revision: u64,
70}
71
72impl ConfigState {
73    pub fn new(resolved: ResolvedConfig) -> Self {
74        Self {
75            resolved,
76            revision: 1,
77        }
78    }
79
80    pub fn resolved(&self) -> &ResolvedConfig {
81        &self.resolved
82    }
83
84    pub fn revision(&self) -> u64 {
85        self.revision
86    }
87
88    pub fn replace_resolved(&mut self, next: ResolvedConfig) -> bool {
89        if self.resolved == next {
90            return false;
91        }
92
93        self.resolved = next;
94        self.revision += 1;
95        true
96    }
97
98    pub fn transaction<F, E>(&mut self, mutator: F) -> Result<bool, E>
99    where
100        F: FnOnce(&ResolvedConfig) -> Result<ResolvedConfig, E>,
101    {
102        let current = self.resolved.clone();
103        let candidate = mutator(&current)?;
104        Ok(self.replace_resolved(candidate))
105    }
106}
107
108#[derive(Debug, Clone)]
109pub struct UiState {
110    pub render_settings: RenderSettings,
111    pub message_verbosity: MessageLevel,
112    pub debug_verbosity: u8,
113}
114
115#[derive(Debug, Clone, Default)]
116pub struct LaunchContext {
117    pub plugin_dirs: Vec<PathBuf>,
118    pub config_root: Option<PathBuf>,
119    pub cache_root: Option<PathBuf>,
120    pub runtime_load: RuntimeLoadOptions,
121}
122
123pub struct AppClients {
124    pub plugins: PluginManager,
125    plugin_config_env: PluginConfigEnvCache,
126}
127
128impl AppClients {
129    pub fn new(plugins: PluginManager) -> Self {
130        Self {
131            plugins,
132            plugin_config_env: PluginConfigEnvCache::default(),
133        }
134    }
135
136    pub(crate) fn plugin_config_env(&self, config: &ConfigState) -> PluginConfigEnv {
137        self.plugin_config_env.collect(config)
138    }
139
140    pub(crate) fn plugin_config_entries(
141        &self,
142        config: &ConfigState,
143        plugin_id: &str,
144    ) -> Vec<PluginConfigEntry> {
145        let config_env = self.plugin_config_env(config);
146        let mut merged = std::collections::BTreeMap::new();
147        for entry in config_env.shared {
148            merged.insert(entry.env_key.clone(), entry);
149        }
150        if let Some(entries) = config_env.by_plugin_id.get(plugin_id) {
151            for entry in entries {
152                merged.insert(entry.env_key.clone(), entry.clone());
153            }
154        }
155        merged.into_values().collect()
156    }
157}
158
159pub struct AppRuntime {
160    pub context: RuntimeContext,
161    pub config: ConfigState,
162    pub ui: UiState,
163    pub auth: AuthState,
164    pub(crate) themes: ThemeCatalog,
165    pub launch: LaunchContext,
166}
167
168pub struct AuthState {
169    builtins_allowlist: Option<HashSet<String>>,
170    plugins_allowlist: Option<HashSet<String>>,
171    policy_context: CommandPolicyContext,
172    builtin_policy: CommandPolicyRegistry,
173    plugin_policy: CommandPolicyRegistry,
174}
175
176impl AuthState {
177    pub fn from_resolved(config: &ResolvedConfig) -> Self {
178        Self {
179            builtins_allowlist: parse_allowlist(config.get_string("auth.visible.builtins")),
180            plugins_allowlist: parse_allowlist(config.get_string("auth.visible.plugins")),
181            policy_context: CommandPolicyContext::default(),
182            builtin_policy: CommandPolicyRegistry::default(),
183            plugin_policy: CommandPolicyRegistry::default(),
184        }
185    }
186
187    pub fn policy_context(&self) -> &CommandPolicyContext {
188        &self.policy_context
189    }
190
191    pub fn set_policy_context(&mut self, context: CommandPolicyContext) {
192        self.policy_context = context;
193    }
194
195    pub fn builtin_policy(&self) -> &CommandPolicyRegistry {
196        &self.builtin_policy
197    }
198
199    pub fn builtin_policy_mut(&mut self) -> &mut CommandPolicyRegistry {
200        &mut self.builtin_policy
201    }
202
203    pub fn plugin_policy(&self) -> &CommandPolicyRegistry {
204        &self.plugin_policy
205    }
206
207    pub fn plugin_policy_mut(&mut self) -> &mut CommandPolicyRegistry {
208        &mut self.plugin_policy
209    }
210
211    pub fn replace_plugin_policy(&mut self, registry: CommandPolicyRegistry) {
212        self.plugin_policy = registry;
213    }
214
215    pub fn builtin_access(&self, command: &str) -> CommandAccess {
216        command_access_for(
217            command,
218            &self.builtins_allowlist,
219            &self.builtin_policy,
220            &self.policy_context,
221        )
222    }
223
224    pub fn plugin_command_access(&self, command: &str) -> CommandAccess {
225        command_access_for(
226            command,
227            &self.plugins_allowlist,
228            &self.plugin_policy,
229            &self.policy_context,
230        )
231    }
232
233    pub fn is_builtin_visible(&self, command: &str) -> bool {
234        self.builtin_access(command).is_visible()
235    }
236
237    pub fn is_plugin_command_visible(&self, command: &str) -> bool {
238        self.plugin_command_access(command).is_visible()
239    }
240}
241
242fn parse_allowlist(raw: Option<&str>) -> Option<HashSet<String>> {
243    let raw = raw.map(str::trim).filter(|value| !value.is_empty())?;
244
245    if raw == "*" {
246        return None;
247    }
248
249    let values = raw
250        .split([',', ' '])
251        .map(str::trim)
252        .filter(|value| !value.is_empty())
253        .map(|value| value.to_ascii_lowercase())
254        .collect::<HashSet<String>>();
255    if values.is_empty() {
256        None
257    } else {
258        Some(values)
259    }
260}
261
262fn is_visible_in_allowlist(allowlist: &Option<HashSet<String>>, command: &str) -> bool {
263    match allowlist {
264        None => true,
265        Some(values) => values.contains(&command.to_ascii_lowercase()),
266    }
267}
268
269fn command_access_for(
270    command: &str,
271    allowlist: &Option<HashSet<String>>,
272    registry: &CommandPolicyRegistry,
273    context: &CommandPolicyContext,
274) -> CommandAccess {
275    let normalized = command.trim().to_ascii_lowercase();
276    let default_policy = CommandPolicy::new(crate::core::command_policy::CommandPath::new([
277        normalized.clone(),
278    ]))
279    .visibility(VisibilityMode::Public);
280    let mut access = registry
281        .evaluate(&default_policy.path, context)
282        .unwrap_or_else(|| crate::core::command_policy::evaluate_policy(&default_policy, context));
283
284    if !is_visible_in_allowlist(allowlist, &normalized) {
285        access = CommandAccess::hidden(AccessReason::HiddenByPolicy);
286    }
287
288    access
289}
290
291#[cfg(test)]
292mod tests {
293    use std::collections::HashSet;
294
295    use crate::config::{ConfigLayer, ConfigResolver, LoadedLayers, ResolveOptions};
296    use crate::core::command_policy::{
297        AccessReason, CommandPath, CommandPolicy, CommandPolicyContext, CommandPolicyRegistry,
298        VisibilityMode,
299    };
300
301    use super::{
302        AuthState, ConfigState, RuntimeContext, TerminalKind, command_access_for,
303        is_visible_in_allowlist, parse_allowlist,
304    };
305
306    fn resolved_with(entries: &[(&str, &str)]) -> crate::config::ResolvedConfig {
307        let mut file = ConfigLayer::default();
308        for (key, value) in entries {
309            file.set(*key, (*value).to_string());
310        }
311        ConfigResolver::from_loaded_layers(LoadedLayers {
312            file,
313            ..LoadedLayers::default()
314        })
315        .resolve(ResolveOptions::default())
316        .expect("config should resolve")
317    }
318
319    #[test]
320    fn runtime_context_and_allowlists_normalize_inputs() {
321        let context = RuntimeContext::new(
322            Some("  Dev  ".to_string()),
323            TerminalKind::Repl,
324            Some("xterm-256color".to_string()),
325        );
326        assert_eq!(context.profile_override(), Some("dev"));
327        assert_eq!(context.terminal_kind(), TerminalKind::Repl);
328        assert_eq!(context.terminal_env(), Some("xterm-256color"));
329
330        assert_eq!(parse_allowlist(None), None);
331        assert_eq!(parse_allowlist(Some("   ")), None);
332        assert_eq!(parse_allowlist(Some("*")), None);
333        assert_eq!(
334            parse_allowlist(Some(" LDAP, mreg ldap ")),
335            Some(HashSet::from(["ldap".to_string(), "mreg".to_string()]))
336        );
337
338        let allowlist = Some(HashSet::from(["ldap".to_string()]));
339        assert!(is_visible_in_allowlist(&allowlist, "LDAP"));
340        assert!(!is_visible_in_allowlist(&allowlist, "orch"));
341    }
342
343    #[test]
344    fn config_state_tracks_noops_changes_and_transaction_errors() {
345        let resolved = resolved_with(&[]);
346        let mut state = ConfigState::new(resolved.clone());
347        assert_eq!(state.revision(), 1);
348        assert!(!state.replace_resolved(resolved.clone()));
349        assert_eq!(state.revision(), 1);
350
351        let changed = resolved_with(&[("ui.format", "json")]);
352        assert!(state.replace_resolved(changed));
353        assert_eq!(state.revision(), 2);
354
355        let changed = state
356            .transaction(|current| {
357                let _ = current;
358                Ok::<_, &'static str>(resolved_with(&[("ui.format", "mreg")]))
359            })
360            .expect("transaction should succeed");
361        assert!(changed);
362        assert_eq!(state.revision(), 3);
363
364        let err = state
365            .transaction(|_| Err::<crate::config::ResolvedConfig, _>("boom"))
366            .expect_err("transaction error should propagate");
367        assert_eq!(err, "boom");
368        assert_eq!(state.revision(), 3);
369    }
370
371    #[test]
372    fn auth_state_and_command_access_layer_policy_overrides_on_allowlists() {
373        let resolved = resolved_with(&[
374            ("auth.visible.builtins", "config"),
375            ("auth.visible.plugins", "ldap"),
376        ]);
377        let mut auth = AuthState::from_resolved(&resolved);
378        auth.set_policy_context(
379            CommandPolicyContext::default()
380                .authenticated(true)
381                .with_capabilities(["orch.approval.decide"]),
382        );
383        assert!(auth.policy_context().authenticated);
384
385        auth.builtin_policy_mut().register(
386            CommandPolicy::new(CommandPath::new(["config"]))
387                .visibility(VisibilityMode::Authenticated),
388        );
389        assert!(auth.builtin_access("config").is_runnable());
390        assert!(auth.is_builtin_visible("config"));
391        assert!(!auth.is_builtin_visible("theme"));
392
393        let mut plugin_registry = CommandPolicyRegistry::new();
394        plugin_registry.register(
395            CommandPolicy::new(CommandPath::new(["ldap"]))
396                .visibility(VisibilityMode::CapabilityGated)
397                .require_capability("orch.approval.decide"),
398        );
399        plugin_registry.register(
400            CommandPolicy::new(CommandPath::new(["orch"]))
401                .visibility(VisibilityMode::Authenticated),
402        );
403        auth.replace_plugin_policy(plugin_registry);
404
405        assert!(auth.plugin_policy().contains(&CommandPath::new(["ldap"])));
406        assert!(
407            auth.plugin_policy_mut()
408                .contains(&CommandPath::new(["ldap"]))
409        );
410        assert!(auth.plugin_command_access("ldap").is_runnable());
411        assert!(auth.is_plugin_command_visible("ldap"));
412
413        let hidden = auth.plugin_command_access("orch");
414        assert_eq!(hidden.reasons, vec![AccessReason::HiddenByPolicy]);
415        assert!(!hidden.is_visible());
416    }
417
418    #[test]
419    fn command_access_for_uses_registry_when_present_and_public_default_otherwise() {
420        let context = CommandPolicyContext::default();
421        let allowlist = Some(HashSet::from(["config".to_string()]));
422        let mut registry = CommandPolicyRegistry::new();
423        registry.register(
424            CommandPolicy::new(CommandPath::new(["config"]))
425                .visibility(VisibilityMode::Authenticated),
426        );
427
428        let denied = command_access_for("config", &allowlist, &registry, &context);
429        assert_eq!(denied.reasons, vec![AccessReason::Unauthenticated]);
430        assert!(denied.is_visible());
431        assert!(!denied.is_runnable());
432
433        let hidden = command_access_for("theme", &allowlist, &registry, &context);
434        assert_eq!(hidden.reasons, vec![AccessReason::HiddenByPolicy]);
435        assert!(!hidden.is_visible());
436
437        let fallback =
438            command_access_for("config", &None, &CommandPolicyRegistry::default(), &context);
439        assert!(fallback.is_visible());
440        assert!(fallback.is_runnable());
441    }
442}