Skip to main content

osp_cli/app/
runtime.rs

1//! Runtime-scoped host state shared across invocations.
2//!
3//! This module exists to hold the long-lived state that belongs to the running
4//! host rather than to any single command submission.
5//!
6//! High-level flow:
7//!
8//! - capture startup-time runtime context such as terminal kind and profile
9//!   override
10//! - keep the current resolved config and derived UI/plugin state together
11//! - expose one place where host code can read the active runtime snapshot
12//!
13//! Contract:
14//!
15//! - runtime state here is broader-lived than session/request state
16//! - per-command or per-REPL-line details should not accumulate here unless
17//!   they truly affect the whole running host
18//!
19//! Public API shape:
20//!
21//! - these types model host machinery, not lightweight semantic payloads
22//! - constructors/accessors are the preferred way to create and inspect them
23//! - callers usually receive [`AppRuntime`] and [`AppClients`] from host
24//!   bootstrap rather than assembling them field-by-field
25
26use std::collections::HashSet;
27use std::path::PathBuf;
28use std::time::Instant;
29
30use crate::config::{ResolvedConfig, RuntimeLoadOptions};
31use crate::core::command_policy::{
32    AccessReason, CommandAccess, CommandPolicy, CommandPolicyContext, CommandPolicyRegistry,
33    VisibilityMode,
34};
35use crate::native::NativeCommandRegistry;
36use crate::plugin::PluginManager;
37use crate::plugin::config::{PluginConfigEntry, PluginConfigEnv, PluginConfigEnvCache};
38use crate::ui::RenderSettings;
39use crate::ui::messages::MessageLevel;
40use crate::ui::theme_loader::ThemeCatalog;
41
42/// Identifies which top-level host surface is currently active.
43///
44/// This lets config selection and runtime behavior distinguish between
45/// one-shot CLI execution and the long-lived REPL host.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum TerminalKind {
48    /// One-shot command execution.
49    Cli,
50    /// Interactive REPL execution.
51    Repl,
52}
53
54impl TerminalKind {
55    /// Returns the config key fragment used for this terminal mode.
56    ///
57    /// # Examples
58    ///
59    /// ```
60    /// use osp_cli::app::TerminalKind;
61    ///
62    /// assert_eq!(TerminalKind::Cli.as_config_terminal(), "cli");
63    /// assert_eq!(TerminalKind::Repl.as_config_terminal(), "repl");
64    /// ```
65    pub fn as_config_terminal(self) -> &'static str {
66        match self {
67            TerminalKind::Cli => "cli",
68            TerminalKind::Repl => "repl",
69        }
70    }
71}
72
73/// Startup-time selection inputs that shape runtime config resolution.
74///
75/// This keeps the profile override and terminal identity together so later
76/// runtime rebuilds can resolve config against the same host context.
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct RuntimeContext {
79    profile_override: Option<String>,
80    terminal_kind: TerminalKind,
81    terminal_env: Option<String>,
82}
83
84impl RuntimeContext {
85    /// Creates a runtime context, normalizing the optional profile override.
86    ///
87    /// # Examples
88    ///
89    /// ```
90    /// use osp_cli::app::{RuntimeContext, TerminalKind};
91    ///
92    /// let ctx = RuntimeContext::new(
93    ///     Some("  Work  ".to_string()),
94    ///     TerminalKind::Repl,
95    ///     Some("xterm-256color".to_string()),
96    /// );
97    ///
98    /// assert_eq!(ctx.profile_override(), Some("work"));
99    /// assert_eq!(ctx.terminal_kind(), TerminalKind::Repl);
100    /// assert_eq!(ctx.terminal_env(), Some("xterm-256color"));
101    /// ```
102    pub fn new(
103        profile_override: Option<String>,
104        terminal_kind: TerminalKind,
105        terminal_env: Option<String>,
106    ) -> Self {
107        Self {
108            profile_override: profile_override
109                .map(|value| value.trim().to_ascii_lowercase())
110                .filter(|value| !value.is_empty()),
111            terminal_kind,
112            terminal_env,
113        }
114    }
115
116    /// Returns the normalized profile override, if one was supplied.
117    pub fn profile_override(&self) -> Option<&str> {
118        self.profile_override.as_deref()
119    }
120
121    /// Returns the active terminal mode.
122    pub fn terminal_kind(&self) -> TerminalKind {
123        self.terminal_kind
124    }
125
126    /// Returns the detected terminal environment string, if available.
127    pub fn terminal_env(&self) -> Option<&str> {
128        self.terminal_env.as_deref()
129    }
130}
131
132/// Holds the current resolved config plus a monotonic in-memory revision.
133///
134/// The revision gives caches and rebuild logic a cheap way to notice when the
135/// effective config actually changed.
136pub struct ConfigState {
137    resolved: ResolvedConfig,
138    revision: u64,
139}
140
141impl ConfigState {
142    /// Creates configuration state with an initial revision of `1`.
143    ///
144    /// # Examples
145    ///
146    /// ```
147    /// use osp_cli::app::ConfigState;
148    /// use osp_cli::config::{ConfigLayer, ConfigResolver, ResolveOptions};
149    ///
150    /// let mut defaults = ConfigLayer::default();
151    /// defaults.set("profile.default", "default");
152    ///
153    /// let mut resolver = ConfigResolver::default();
154    /// resolver.set_defaults(defaults);
155    /// let resolved = resolver.resolve(ResolveOptions::default()).unwrap();
156    ///
157    /// let mut state = ConfigState::new(resolved.clone());
158    /// assert_eq!(state.revision(), 1);
159    /// assert!(!state.replace_resolved(resolved));
160    /// assert_eq!(state.revision(), 1);
161    /// ```
162    pub fn new(resolved: ResolvedConfig) -> Self {
163        Self {
164            resolved,
165            revision: 1,
166        }
167    }
168
169    /// Returns the current resolved configuration snapshot.
170    pub fn resolved(&self) -> &ResolvedConfig {
171        &self.resolved
172    }
173
174    /// Returns the current configuration revision.
175    pub fn revision(&self) -> u64 {
176        self.revision
177    }
178
179    /// Replaces the resolved configuration and bumps the revision when it changes.
180    pub fn replace_resolved(&mut self, next: ResolvedConfig) -> bool {
181        if self.resolved == next {
182            return false;
183        }
184
185        self.resolved = next;
186        self.revision += 1;
187        true
188    }
189
190    /// Applies a configuration transform atomically against the current snapshot.
191    pub fn transaction<F, E>(&mut self, mutator: F) -> Result<bool, E>
192    where
193        F: FnOnce(&ResolvedConfig) -> Result<ResolvedConfig, E>,
194    {
195        let current = self.resolved.clone();
196        let candidate = mutator(&current)?;
197        Ok(self.replace_resolved(candidate))
198    }
199}
200
201/// Derived presentation/runtime state for the active config snapshot.
202///
203/// This is cached host state, not a second source of truth. Recompute it when
204/// the resolved config changes so renderers and message surfaces all read the
205/// same derived values.
206#[derive(Debug, Clone)]
207#[non_exhaustive]
208pub struct UiState {
209    /// Render settings derived from the current config snapshot.
210    pub render_settings: RenderSettings,
211    /// Default message verbosity derived from the current runtime config.
212    pub message_verbosity: MessageLevel,
213    /// Numeric debug verbosity used for trace-style host output.
214    pub debug_verbosity: u8,
215}
216
217impl UiState {
218    /// Starts a builder for UI state derived from a render-settings baseline.
219    pub fn builder(render_settings: RenderSettings) -> UiStateBuilder {
220        UiStateBuilder::new(render_settings)
221    }
222
223    /// Derives UI state from a resolved config snapshot and runtime context.
224    ///
225    /// # Examples
226    ///
227    /// ```
228    /// use osp_cli::app::{RuntimeContext, TerminalKind, UiState};
229    /// use osp_cli::config::{ConfigLayer, ConfigResolver, ResolveOptions};
230    ///
231    /// let mut defaults = ConfigLayer::default();
232    /// defaults.set("profile.default", "default");
233    /// defaults.set("ui.message.verbosity", "info");
234    ///
235    /// let mut resolver = ConfigResolver::default();
236    /// resolver.set_defaults(defaults);
237    /// let config = resolver.resolve(ResolveOptions::new().with_terminal("cli")).unwrap();
238    ///
239    /// let ui = UiState::from_resolved_config(
240    ///     &RuntimeContext::new(None, TerminalKind::Cli, Some("xterm-256color".to_string())),
241    ///     &config,
242    /// )
243    /// .unwrap();
244    ///
245    /// assert_eq!(ui.message_verbosity.as_env_str(), "info");
246    /// assert_eq!(ui.render_settings.runtime.terminal.as_deref(), Some("xterm-256color"));
247    /// ```
248    pub fn from_resolved_config(
249        context: &RuntimeContext,
250        config: &ResolvedConfig,
251    ) -> miette::Result<Self> {
252        UiStateBuilder::from_resolved_config(context, config).map(UiStateBuilder::build)
253    }
254
255    /// Creates the UI state snapshot used for one resolved config revision.
256    pub fn new(
257        render_settings: RenderSettings,
258        message_verbosity: MessageLevel,
259        debug_verbosity: u8,
260    ) -> Self {
261        Self {
262            render_settings,
263            message_verbosity,
264            debug_verbosity,
265        }
266    }
267}
268
269/// Builder for [`UiState`].
270///
271/// This is the guided construction path for host-facing UI state.
272pub struct UiStateBuilder {
273    render_settings: RenderSettings,
274    message_verbosity: MessageLevel,
275    debug_verbosity: u8,
276}
277
278impl UiStateBuilder {
279    /// Starts building UI state from the provided render settings.
280    pub fn new(render_settings: RenderSettings) -> Self {
281        Self {
282            render_settings,
283            message_verbosity: MessageLevel::Success,
284            debug_verbosity: 0,
285        }
286    }
287
288    /// Derives UI-state defaults from resolved config and runtime context.
289    pub fn from_resolved_config(
290        context: &RuntimeContext,
291        config: &ResolvedConfig,
292    ) -> miette::Result<Self> {
293        let themes = crate::ui::theme_loader::load_theme_catalog(config);
294        Self::from_resolved_config_with_themes(context, config, &themes)
295    }
296
297    pub(crate) fn from_resolved_config_with_themes(
298        context: &RuntimeContext,
299        config: &ResolvedConfig,
300        themes: &ThemeCatalog,
301    ) -> miette::Result<Self> {
302        // Rebuild/startup paths can preserve an existing render runtime, but
303        // fresh derivation should probe from the runtime context once here.
304        let ui = crate::app::assembly::derive_ui_state(
305            context,
306            config,
307            themes,
308            crate::app::assembly::RenderSettingsSeed::DefaultAuto,
309            None,
310        )?;
311
312        Ok(Self {
313            render_settings: ui.render_settings,
314            message_verbosity: ui.message_verbosity,
315            debug_verbosity: ui.debug_verbosity,
316        })
317    }
318
319    /// Replaces the render-settings baseline used by the built UI state.
320    pub fn with_render_settings(mut self, render_settings: RenderSettings) -> Self {
321        self.render_settings = render_settings;
322        self
323    }
324
325    /// Replaces the message verbosity used for buffered UI messages.
326    pub fn with_message_verbosity(mut self, message_verbosity: MessageLevel) -> Self {
327        self.message_verbosity = message_verbosity;
328        self
329    }
330
331    /// Replaces the numeric debug verbosity.
332    pub fn with_debug_verbosity(mut self, debug_verbosity: u8) -> Self {
333        self.debug_verbosity = debug_verbosity;
334        self
335    }
336
337    /// Builds the configured [`UiState`].
338    pub fn build(self) -> UiState {
339        UiState::new(
340            self.render_settings,
341            self.message_verbosity,
342            self.debug_verbosity,
343        )
344    }
345}
346
347/// Startup inputs used to assemble runtime services and locate on-disk state.
348///
349/// This is launch-time provenance for the running host. It is kept separate
350/// from [`RuntimeContext`] because callers may need to rebuild caches or plugin
351/// services from the same startup inputs after config changes.
352#[derive(Debug, Clone)]
353#[non_exhaustive]
354pub struct LaunchContext {
355    /// Explicit plugin directories requested by the caller at launch time.
356    pub plugin_dirs: Vec<PathBuf>,
357    /// Optional config-root override for runtime config discovery.
358    pub config_root: Option<PathBuf>,
359    /// Optional cache-root override for runtime state and caches.
360    pub cache_root: Option<PathBuf>,
361    /// Flags controlling which runtime config sources are consulted.
362    pub runtime_load: RuntimeLoadOptions,
363    /// Timestamp captured before startup work begins.
364    pub startup_started_at: Instant,
365}
366
367impl LaunchContext {
368    /// Starts a builder for launch-time host provenance.
369    pub fn builder() -> LaunchContextBuilder {
370        LaunchContextBuilder::new()
371    }
372
373    /// Creates launch-time provenance for one host bootstrap attempt.
374    pub fn new(
375        plugin_dirs: Vec<PathBuf>,
376        config_root: Option<PathBuf>,
377        cache_root: Option<PathBuf>,
378        runtime_load: RuntimeLoadOptions,
379    ) -> Self {
380        Self {
381            plugin_dirs,
382            config_root,
383            cache_root,
384            runtime_load,
385            startup_started_at: Instant::now(),
386        }
387    }
388
389    /// Replaces the captured startup timestamp.
390    pub fn with_startup_started_at(mut self, startup_started_at: Instant) -> Self {
391        self.startup_started_at = startup_started_at;
392        self
393    }
394}
395
396impl Default for LaunchContext {
397    fn default() -> Self {
398        Self::new(Vec::new(), None, None, RuntimeLoadOptions::default())
399    }
400}
401
402/// Builder for [`LaunchContext`].
403///
404/// This keeps launch-time bootstrap knobs grouped in one guided surface.
405pub struct LaunchContextBuilder {
406    plugin_dirs: Vec<PathBuf>,
407    config_root: Option<PathBuf>,
408    cache_root: Option<PathBuf>,
409    runtime_load: RuntimeLoadOptions,
410    startup_started_at: Instant,
411}
412
413impl Default for LaunchContextBuilder {
414    fn default() -> Self {
415        Self::new()
416    }
417}
418
419impl LaunchContextBuilder {
420    /// Starts a builder with empty plugin roots and default runtime loading.
421    pub fn new() -> Self {
422        Self {
423            plugin_dirs: Vec::new(),
424            config_root: None,
425            cache_root: None,
426            runtime_load: RuntimeLoadOptions::default(),
427            startup_started_at: Instant::now(),
428        }
429    }
430
431    /// Appends one explicit plugin directory.
432    pub fn with_plugin_dir(mut self, plugin_dir: impl Into<PathBuf>) -> Self {
433        self.plugin_dirs.push(plugin_dir.into());
434        self
435    }
436
437    /// Replaces the explicit plugin directory list.
438    pub fn with_plugin_dirs(mut self, plugin_dirs: impl IntoIterator<Item = PathBuf>) -> Self {
439        self.plugin_dirs = plugin_dirs.into_iter().collect();
440        self
441    }
442
443    /// Sets the config root override.
444    pub fn with_config_root(mut self, config_root: Option<PathBuf>) -> Self {
445        self.config_root = config_root;
446        self
447    }
448
449    /// Sets the cache root override.
450    pub fn with_cache_root(mut self, cache_root: Option<PathBuf>) -> Self {
451        self.cache_root = cache_root;
452        self
453    }
454
455    /// Replaces the runtime-load flags carried by the launch context.
456    pub fn with_runtime_load(mut self, runtime_load: RuntimeLoadOptions) -> Self {
457        self.runtime_load = runtime_load;
458        self
459    }
460
461    /// Replaces the captured startup timestamp.
462    pub fn with_startup_started_at(mut self, startup_started_at: Instant) -> Self {
463        self.startup_started_at = startup_started_at;
464        self
465    }
466
467    /// Builds the configured [`LaunchContext`].
468    pub fn build(self) -> LaunchContext {
469        LaunchContext {
470            plugin_dirs: self.plugin_dirs,
471            config_root: self.config_root,
472            cache_root: self.cache_root,
473            runtime_load: self.runtime_load,
474            startup_started_at: self.startup_started_at,
475        }
476    }
477}
478
479/// Long-lived client registries shared across command execution.
480///
481/// This bundles expensive or stateful clients so they do not have to be
482/// recreated on every command dispatch.
483///
484/// Public API note: this is intentionally constructor/accessor driven. The
485/// internal registries stay private so the host can grow additional cached
486/// machinery without breaking callers.
487#[non_exhaustive]
488pub struct AppClients {
489    /// Plugin manager used for discovery, dispatch, and provider metadata.
490    plugins: PluginManager,
491    /// In-process registry of native commands.
492    native_commands: NativeCommandRegistry,
493    plugin_config_env: PluginConfigEnvCache,
494}
495
496impl AppClients {
497    /// Creates the shared client registry used by the application.
498    pub fn new(plugins: PluginManager, native_commands: NativeCommandRegistry) -> Self {
499        Self {
500            plugins,
501            native_commands,
502            plugin_config_env: PluginConfigEnvCache::default(),
503        }
504    }
505
506    /// Returns the shared plugin manager.
507    pub fn plugins(&self) -> &PluginManager {
508        &self.plugins
509    }
510
511    /// Returns the shared registry of native commands.
512    pub fn native_commands(&self) -> &NativeCommandRegistry {
513        &self.native_commands
514    }
515
516    /// Starts a builder for shared client registries.
517    pub fn builder() -> AppClientsBuilder {
518        AppClientsBuilder::new()
519    }
520
521    pub(crate) fn plugin_config_env(&self, config: &ConfigState) -> PluginConfigEnv {
522        self.plugin_config_env.collect(config)
523    }
524
525    pub(crate) fn plugin_config_entries(
526        &self,
527        config: &ConfigState,
528        plugin_id: &str,
529    ) -> Vec<PluginConfigEntry> {
530        let config_env = self.plugin_config_env(config);
531        let mut merged = std::collections::BTreeMap::new();
532        for entry in config_env.shared {
533            merged.insert(entry.env_key.clone(), entry);
534        }
535        if let Some(entries) = config_env.by_plugin_id.get(plugin_id) {
536            for entry in entries {
537                merged.insert(entry.env_key.clone(), entry.clone());
538            }
539        }
540        merged.into_values().collect()
541    }
542}
543
544/// Builder for [`AppClients`].
545///
546/// This is the guided construction path for shared plugin/native registries.
547pub struct AppClientsBuilder {
548    plugins: PluginManager,
549    native_commands: NativeCommandRegistry,
550}
551
552impl Default for AppClientsBuilder {
553    fn default() -> Self {
554        Self {
555            plugins: PluginManager::new(Vec::new()),
556            native_commands: NativeCommandRegistry::default(),
557        }
558    }
559}
560
561impl AppClientsBuilder {
562    /// Starts a builder with empty plugin and native-command registries.
563    pub fn new() -> Self {
564        Self::default()
565    }
566
567    /// Replaces the plugin manager used by the built client registry.
568    pub fn with_plugins(mut self, plugins: PluginManager) -> Self {
569        self.plugins = plugins;
570        self
571    }
572
573    /// Replaces the native-command registry used by the built client registry.
574    pub fn with_native_commands(mut self, native_commands: NativeCommandRegistry) -> Self {
575        self.native_commands = native_commands;
576        self
577    }
578
579    /// Builds the configured [`AppClients`].
580    pub fn build(self) -> AppClients {
581        AppClients::new(self.plugins, self.native_commands)
582    }
583}
584
585/// Runtime-scoped application state shared across commands.
586///
587/// This is the assembled host snapshot that command and REPL code read while
588/// the process is running. The fields here are intended to move together: when
589/// config changes, callers should rebuild the derived UI/auth/theme state
590/// rather than mixing old and new snapshots.
591///
592/// Public API note: this is a host snapshot you usually receive from app
593/// bootstrap, not a semantic DTO meant for arbitrary external construction.
594#[non_exhaustive]
595pub struct AppRuntime {
596    /// Startup-time runtime identity used for config selection and rebuilds.
597    pub context: RuntimeContext,
598    /// Authoritative resolved config snapshot and its in-memory revision.
599    pub config: ConfigState,
600    /// UI-facing state derived from the current resolved config.
601    pub ui: UiState,
602    /// Authorization and command-visibility policy state derived from config.
603    pub auth: AuthState,
604    pub(crate) themes: ThemeCatalog,
605    /// Launch-time inputs used to assemble caches and external services.
606    pub launch: LaunchContext,
607}
608
609impl AppRuntime {
610    /// Creates the runtime snapshot shared across CLI and REPL execution.
611    pub(crate) fn new(
612        context: RuntimeContext,
613        config: ConfigState,
614        ui: UiState,
615        auth: AuthState,
616        themes: ThemeCatalog,
617        launch: LaunchContext,
618    ) -> Self {
619        Self {
620            context,
621            config,
622            ui,
623            auth,
624            themes,
625            launch,
626        }
627    }
628
629    /// Returns the runtime context used for config selection and rebuilds.
630    pub fn context(&self) -> &RuntimeContext {
631        &self.context
632    }
633
634    /// Returns the authoritative resolved-config state.
635    pub fn config_state(&self) -> &ConfigState {
636        &self.config
637    }
638
639    /// Returns mutable resolved-config state.
640    pub fn config_state_mut(&mut self) -> &mut ConfigState {
641        &mut self.config
642    }
643
644    /// Returns the UI state derived from the current config snapshot.
645    pub fn ui(&self) -> &UiState {
646        &self.ui
647    }
648
649    /// Returns mutable UI state for in-process adjustments.
650    pub fn ui_mut(&mut self) -> &mut UiState {
651        &mut self.ui
652    }
653
654    /// Returns the command-visibility/auth state.
655    pub fn auth(&self) -> &AuthState {
656        &self.auth
657    }
658
659    /// Returns mutable command-visibility/auth state.
660    pub fn auth_mut(&mut self) -> &mut AuthState {
661        &mut self.auth
662    }
663
664    /// Returns the launch-time provenance used to assemble the runtime.
665    pub fn launch(&self) -> &LaunchContext {
666        &self.launch
667    }
668}
669
670/// Authorization and command-visibility state derived from configuration.
671pub struct AuthState {
672    builtins_allowlist: Option<HashSet<String>>,
673    external_allowlist: Option<HashSet<String>>,
674    policy_context: CommandPolicyContext,
675    builtin_policy: CommandPolicyRegistry,
676    external_policy: CommandPolicyRegistry,
677}
678
679impl AuthState {
680    /// Builds authorization state from the resolved configuration.
681    pub fn from_resolved(config: &ResolvedConfig) -> Self {
682        Self {
683            builtins_allowlist: parse_allowlist(config.get_string("auth.visible.builtins")),
684            // Non-builtin top-level commands currently still use the historical
685            // `auth.visible.plugins` key. That surface now covers both external
686            // plugins and native registered integrations dispatched via the
687            // generic external command path.
688            external_allowlist: parse_allowlist(config.get_string("auth.visible.plugins")),
689            policy_context: CommandPolicyContext::default(),
690            builtin_policy: CommandPolicyRegistry::default(),
691            external_policy: CommandPolicyRegistry::default(),
692        }
693    }
694
695    /// Builds authorization state and external policy from the current config
696    /// and active command registries.
697    pub(crate) fn from_resolved_with_external_policies(
698        config: &ResolvedConfig,
699        plugins: &PluginManager,
700        native_commands: &NativeCommandRegistry,
701    ) -> Self {
702        let mut auth = Self::from_resolved(config);
703        let plugin_policy = plugins.command_policy_registry().unwrap_or_else(|err| {
704            tracing::warn!(error = %err, "failed to build plugin command policy registry");
705            CommandPolicyRegistry::default()
706        });
707        let external_policy =
708            merge_policy_registries(plugin_policy, native_commands.command_policy_registry());
709        auth.replace_external_policy(external_policy);
710        auth
711    }
712
713    /// Returns the context used when evaluating command policies.
714    pub fn policy_context(&self) -> &CommandPolicyContext {
715        &self.policy_context
716    }
717
718    /// Replaces the context used when evaluating command policies.
719    pub fn set_policy_context(&mut self, context: CommandPolicyContext) {
720        self.policy_context = context;
721    }
722
723    /// Returns the policy registry for built-in commands.
724    pub fn builtin_policy(&self) -> &CommandPolicyRegistry {
725        &self.builtin_policy
726    }
727
728    /// Returns the mutable policy registry for built-in commands.
729    pub fn builtin_policy_mut(&mut self) -> &mut CommandPolicyRegistry {
730        &mut self.builtin_policy
731    }
732
733    /// Returns the policy registry for externally dispatched commands.
734    pub fn external_policy(&self) -> &CommandPolicyRegistry {
735        &self.external_policy
736    }
737
738    /// Returns the mutable policy registry for externally dispatched commands.
739    pub fn external_policy_mut(&mut self) -> &mut CommandPolicyRegistry {
740        &mut self.external_policy
741    }
742
743    /// Replaces the policy registry for externally dispatched commands.
744    pub fn replace_external_policy(&mut self, registry: CommandPolicyRegistry) {
745        self.external_policy = registry;
746    }
747
748    /// Evaluates access for a built-in command.
749    pub fn builtin_access(&self, command: &str) -> CommandAccess {
750        command_access_for(
751            command,
752            &self.builtins_allowlist,
753            &self.builtin_policy,
754            &self.policy_context,
755        )
756    }
757
758    /// Evaluates access for an external command.
759    pub fn external_command_access(&self, command: &str) -> CommandAccess {
760        command_access_for(
761            command,
762            &self.external_allowlist,
763            &self.external_policy,
764            &self.policy_context,
765        )
766    }
767
768    /// Returns whether a built-in command should be shown to the user.
769    pub fn is_builtin_visible(&self, command: &str) -> bool {
770        self.builtin_access(command).is_visible()
771    }
772
773    /// Returns whether an external command should be shown to the user.
774    pub fn is_external_command_visible(&self, command: &str) -> bool {
775        self.external_command_access(command).is_visible()
776    }
777}
778
779fn parse_allowlist(raw: Option<&str>) -> Option<HashSet<String>> {
780    let raw = raw.map(str::trim).filter(|value| !value.is_empty())?;
781
782    if raw == "*" {
783        return None;
784    }
785
786    let values = raw
787        .split([',', ' '])
788        .map(str::trim)
789        .filter(|value| !value.is_empty())
790        .map(|value| value.to_ascii_lowercase())
791        .collect::<HashSet<String>>();
792    if values.is_empty() {
793        None
794    } else {
795        Some(values)
796    }
797}
798
799fn is_visible_in_allowlist(allowlist: &Option<HashSet<String>>, command: &str) -> bool {
800    match allowlist {
801        None => true,
802        Some(values) => values.contains(&command.to_ascii_lowercase()),
803    }
804}
805
806fn command_access_for(
807    command: &str,
808    allowlist: &Option<HashSet<String>>,
809    registry: &CommandPolicyRegistry,
810    context: &CommandPolicyContext,
811) -> CommandAccess {
812    let normalized = command.trim().to_ascii_lowercase();
813    let default_policy = CommandPolicy::new(crate::core::command_policy::CommandPath::new([
814        normalized.clone(),
815    ]))
816    .visibility(VisibilityMode::Public);
817    let mut access = registry
818        .evaluate(&default_policy.path, context)
819        .unwrap_or_else(|| crate::core::command_policy::evaluate_policy(&default_policy, context));
820
821    if !is_visible_in_allowlist(allowlist, &normalized) {
822        access = CommandAccess::hidden(AccessReason::HiddenByPolicy);
823    }
824
825    access
826}
827
828fn merge_policy_registries(
829    mut left: CommandPolicyRegistry,
830    right: CommandPolicyRegistry,
831) -> CommandPolicyRegistry {
832    for policy in right.entries() {
833        left.register(policy.clone());
834    }
835    left
836}
837
838#[cfg(test)]
839mod tests {
840    use std::collections::HashSet;
841
842    use crate::config::{ConfigLayer, ConfigResolver, LoadedLayers, ResolveOptions};
843    use crate::core::command_policy::{
844        AccessReason, CommandPath, CommandPolicy, CommandPolicyContext, CommandPolicyRegistry,
845        VisibilityMode,
846    };
847
848    use super::{
849        AuthState, ConfigState, RuntimeContext, TerminalKind, command_access_for,
850        is_visible_in_allowlist, parse_allowlist,
851    };
852
853    fn resolved_with(entries: &[(&str, &str)]) -> crate::config::ResolvedConfig {
854        let mut file = ConfigLayer::default();
855        for (key, value) in entries {
856            file.set(*key, (*value).to_string());
857        }
858        ConfigResolver::from_loaded_layers(LoadedLayers {
859            file,
860            ..LoadedLayers::default()
861        })
862        .resolve(ResolveOptions::default())
863        .expect("config should resolve")
864    }
865
866    #[test]
867    fn runtime_context_and_allowlists_normalize_inputs() {
868        let context = RuntimeContext::new(
869            Some("  Dev  ".to_string()),
870            TerminalKind::Repl,
871            Some("xterm-256color".to_string()),
872        );
873        assert_eq!(context.profile_override(), Some("dev"));
874        assert_eq!(context.terminal_kind(), TerminalKind::Repl);
875        assert_eq!(context.terminal_env(), Some("xterm-256color"));
876
877        assert_eq!(parse_allowlist(None), None);
878        assert_eq!(parse_allowlist(Some("   ")), None);
879        assert_eq!(parse_allowlist(Some("*")), None);
880        assert_eq!(
881            parse_allowlist(Some(" LDAP, mreg ldap ")),
882            Some(HashSet::from(["ldap".to_string(), "mreg".to_string()]))
883        );
884
885        let allowlist = Some(HashSet::from(["ldap".to_string()]));
886        assert!(is_visible_in_allowlist(&allowlist, "LDAP"));
887        assert!(!is_visible_in_allowlist(&allowlist, "orch"));
888    }
889
890    #[test]
891    fn config_state_tracks_noops_changes_and_transaction_errors() {
892        let resolved = resolved_with(&[]);
893        let mut state = ConfigState::new(resolved.clone());
894        assert_eq!(state.revision(), 1);
895        assert!(!state.replace_resolved(resolved.clone()));
896        assert_eq!(state.revision(), 1);
897
898        let changed = resolved_with(&[("ui.format", "json")]);
899        assert!(state.replace_resolved(changed));
900        assert_eq!(state.revision(), 2);
901
902        let changed = state
903            .transaction(|current| {
904                let _ = current;
905                Ok::<_, &'static str>(resolved_with(&[("ui.format", "mreg")]))
906            })
907            .expect("transaction should succeed");
908        assert!(changed);
909        assert_eq!(state.revision(), 3);
910
911        let err = state
912            .transaction(|_| Err::<crate::config::ResolvedConfig, _>("boom"))
913            .expect_err("transaction error should propagate");
914        assert_eq!(err, "boom");
915        assert_eq!(state.revision(), 3);
916    }
917
918    #[test]
919    fn auth_state_and_command_access_layer_policy_overrides_on_allowlists() {
920        let resolved = resolved_with(&[
921            ("auth.visible.builtins", "config"),
922            ("auth.visible.plugins", "ldap"),
923        ]);
924        let mut auth = AuthState::from_resolved(&resolved);
925        auth.set_policy_context(
926            CommandPolicyContext::default()
927                .authenticated(true)
928                .with_capabilities(["orch.approval.decide"]),
929        );
930        assert!(auth.policy_context().authenticated);
931
932        auth.builtin_policy_mut().register(
933            CommandPolicy::new(CommandPath::new(["config"]))
934                .visibility(VisibilityMode::Authenticated),
935        );
936        assert!(auth.builtin_access("config").is_runnable());
937        assert!(auth.is_builtin_visible("config"));
938        assert!(!auth.is_builtin_visible("theme"));
939
940        let mut plugin_registry = CommandPolicyRegistry::new();
941        plugin_registry.register(
942            CommandPolicy::new(CommandPath::new(["ldap"]))
943                .visibility(VisibilityMode::CapabilityGated)
944                .require_capability("orch.approval.decide"),
945        );
946        plugin_registry.register(
947            CommandPolicy::new(CommandPath::new(["orch"]))
948                .visibility(VisibilityMode::Authenticated),
949        );
950        auth.replace_external_policy(plugin_registry);
951
952        assert!(auth.external_policy().contains(&CommandPath::new(["ldap"])));
953        assert!(
954            auth.external_policy_mut()
955                .contains(&CommandPath::new(["ldap"]))
956        );
957        assert!(auth.external_command_access("ldap").is_runnable());
958        assert!(auth.is_external_command_visible("ldap"));
959
960        let hidden = auth.external_command_access("orch");
961        assert_eq!(hidden.reasons, vec![AccessReason::HiddenByPolicy]);
962        assert!(!hidden.is_visible());
963    }
964
965    #[test]
966    fn command_access_for_uses_registry_when_present_and_public_default_otherwise() {
967        let context = CommandPolicyContext::default();
968        let allowlist = Some(HashSet::from(["config".to_string()]));
969        let mut registry = CommandPolicyRegistry::new();
970        registry.register(
971            CommandPolicy::new(CommandPath::new(["config"]))
972                .visibility(VisibilityMode::Authenticated),
973        );
974
975        let denied = command_access_for("config", &allowlist, &registry, &context);
976        assert_eq!(denied.reasons, vec![AccessReason::Unauthenticated]);
977        assert!(denied.is_visible());
978        assert!(!denied.is_runnable());
979
980        let hidden = command_access_for("theme", &allowlist, &registry, &context);
981        assert_eq!(hidden.reasons, vec![AccessReason::HiddenByPolicy]);
982        assert!(!hidden.is_visible());
983
984        let fallback =
985            command_access_for("config", &None, &CommandPolicyRegistry::default(), &context);
986        assert!(fallback.is_visible());
987        assert!(fallback.is_runnable());
988    }
989}