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::{ConfigLayer, 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_catalog::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]
208#[must_use]
209pub struct UiState {
210    /// Render settings derived from the current config snapshot.
211    pub render_settings: RenderSettings,
212    /// Default message verbosity derived from the current runtime config.
213    pub message_verbosity: MessageLevel,
214    /// Numeric debug verbosity used for trace-style host output.
215    pub debug_verbosity: u8,
216}
217
218impl UiState {
219    /// Derives UI state from a resolved config snapshot and runtime context.
220    ///
221    /// # Examples
222    ///
223    /// ```
224    /// use osp_cli::app::{RuntimeContext, TerminalKind, UiState};
225    /// use osp_cli::config::{ConfigLayer, ConfigResolver, ResolveOptions};
226    ///
227    /// let mut defaults = ConfigLayer::default();
228    /// defaults.set("profile.default", "default");
229    /// defaults.set("ui.message.verbosity", "info");
230    ///
231    /// let mut resolver = ConfigResolver::default();
232    /// resolver.set_defaults(defaults);
233    /// let config = resolver.resolve(ResolveOptions::new().with_terminal("cli")).unwrap();
234    ///
235    /// let ui = UiState::from_resolved_config(
236    ///     &RuntimeContext::new(None, TerminalKind::Cli, Some("xterm-256color".to_string())),
237    ///     &config,
238    /// )
239    /// .unwrap();
240    ///
241    /// assert_eq!(ui.message_verbosity.as_env_str(), "info");
242    /// assert_eq!(ui.render_settings.runtime.terminal.as_deref(), Some("xterm-256color"));
243    /// ```
244    pub fn from_resolved_config(
245        context: &RuntimeContext,
246        config: &ResolvedConfig,
247    ) -> miette::Result<Self> {
248        let themes = crate::ui::theme_catalog::load_theme_catalog(config);
249        crate::app::assembly::derive_ui_state(
250            context,
251            config,
252            &themes,
253            crate::app::assembly::RenderSettingsSeed::DefaultAuto,
254            None,
255        )
256    }
257
258    /// Creates the UI state snapshot used for one resolved config revision.
259    ///
260    /// # Examples
261    ///
262    /// ```
263    /// use osp_cli::app::UiState;
264    /// use osp_cli::ui::RenderSettings;
265    /// use osp_cli::ui::messages::MessageLevel;
266    /// use osp_cli::core::output::OutputFormat;
267    ///
268    /// let ui = UiState::new(
269    ///     RenderSettings::test_plain(OutputFormat::Json),
270    ///     MessageLevel::Success,
271    ///     2,
272    /// );
273    ///
274    /// assert_eq!(ui.message_verbosity, MessageLevel::Success);
275    /// assert_eq!(ui.debug_verbosity, 2);
276    /// ```
277    pub fn new(
278        render_settings: RenderSettings,
279        message_verbosity: MessageLevel,
280        debug_verbosity: u8,
281    ) -> Self {
282        Self {
283            render_settings,
284            message_verbosity,
285            debug_verbosity,
286        }
287    }
288
289    /// Replaces the render-settings baseline used by this UI state.
290    pub fn with_render_settings(mut self, render_settings: RenderSettings) -> Self {
291        self.render_settings = render_settings;
292        self
293    }
294
295    /// Replaces the message verbosity used for buffered UI messages.
296    pub fn with_message_verbosity(mut self, message_verbosity: MessageLevel) -> Self {
297        self.message_verbosity = message_verbosity;
298        self
299    }
300
301    /// Replaces the numeric debug verbosity.
302    pub fn with_debug_verbosity(mut self, debug_verbosity: u8) -> Self {
303        self.debug_verbosity = debug_verbosity;
304        self
305    }
306}
307
308/// Startup inputs used to assemble runtime services and locate on-disk state.
309///
310/// This is launch-time provenance for the running host. It is kept separate
311/// from [`RuntimeContext`] because callers may need to rebuild caches or plugin
312/// services from the same startup inputs after config changes.
313#[derive(Debug, Clone)]
314#[non_exhaustive]
315#[must_use]
316pub struct LaunchContext {
317    /// Explicit plugin directories requested by the caller at launch time.
318    pub plugin_dirs: Vec<PathBuf>,
319    /// Optional config-root override for runtime config discovery.
320    pub config_root: Option<PathBuf>,
321    /// Optional cache-root override for runtime state and caches.
322    pub cache_root: Option<PathBuf>,
323    /// Flags controlling which runtime config sources are consulted.
324    pub runtime_load: RuntimeLoadOptions,
325    /// Timestamp captured before startup work begins.
326    pub startup_started_at: Instant,
327}
328
329impl LaunchContext {
330    /// Creates launch-time provenance for one host bootstrap attempt.
331    pub fn new(
332        plugin_dirs: Vec<PathBuf>,
333        config_root: Option<PathBuf>,
334        cache_root: Option<PathBuf>,
335        runtime_load: RuntimeLoadOptions,
336    ) -> Self {
337        Self {
338            plugin_dirs,
339            config_root,
340            cache_root,
341            runtime_load,
342            startup_started_at: Instant::now(),
343        }
344    }
345
346    /// Appends one explicit plugin directory.
347    pub fn with_plugin_dir(mut self, plugin_dir: impl Into<PathBuf>) -> Self {
348        self.plugin_dirs.push(plugin_dir.into());
349        self
350    }
351
352    /// Replaces the explicit plugin directory list.
353    pub fn with_plugin_dirs(mut self, plugin_dirs: impl IntoIterator<Item = PathBuf>) -> Self {
354        self.plugin_dirs = plugin_dirs.into_iter().collect();
355        self
356    }
357
358    /// Sets the config root override.
359    pub fn with_config_root(mut self, config_root: Option<PathBuf>) -> Self {
360        self.config_root = config_root;
361        self
362    }
363
364    /// Sets the cache root override.
365    pub fn with_cache_root(mut self, cache_root: Option<PathBuf>) -> Self {
366        self.cache_root = cache_root;
367        self
368    }
369
370    /// Replaces the runtime-load flags carried by the launch context.
371    pub fn with_runtime_load(mut self, runtime_load: RuntimeLoadOptions) -> Self {
372        self.runtime_load = runtime_load;
373        self
374    }
375
376    /// Replaces the captured startup timestamp.
377    pub fn with_startup_started_at(mut self, startup_started_at: Instant) -> Self {
378        self.startup_started_at = startup_started_at;
379        self
380    }
381}
382
383impl Default for LaunchContext {
384    fn default() -> Self {
385        Self::new(Vec::new(), None, None, RuntimeLoadOptions::default())
386    }
387}
388
389/// Long-lived client registries shared across command execution.
390///
391/// This bundles expensive or stateful clients so they do not have to be
392/// recreated on every command dispatch.
393///
394/// Public API note: this is intentionally constructor/accessor driven. The
395/// internal registries stay private so the host can grow additional cached
396/// machinery without breaking callers.
397#[non_exhaustive]
398#[must_use]
399pub struct AppClients {
400    /// Plugin manager used for discovery, dispatch, and provider metadata.
401    plugins: PluginManager,
402    /// In-process registry of native commands.
403    native_commands: NativeCommandRegistry,
404    plugin_config_env: PluginConfigEnvCache,
405}
406
407impl AppClients {
408    /// Creates the shared client registry used by the application.
409    pub fn new(plugins: PluginManager, native_commands: NativeCommandRegistry) -> Self {
410        Self {
411            plugins,
412            native_commands,
413            plugin_config_env: PluginConfigEnvCache::default(),
414        }
415    }
416
417    /// Returns the shared plugin manager.
418    pub fn plugins(&self) -> &PluginManager {
419        &self.plugins
420    }
421
422    /// Returns the shared registry of native commands.
423    pub fn native_commands(&self) -> &NativeCommandRegistry {
424        &self.native_commands
425    }
426
427    pub(crate) fn plugin_config_env(&self, config: &ConfigState) -> PluginConfigEnv {
428        self.plugin_config_env.collect(config)
429    }
430
431    pub(crate) fn plugin_config_entries(
432        &self,
433        config: &ConfigState,
434        plugin_id: &str,
435    ) -> Vec<PluginConfigEntry> {
436        self.plugin_config_env(config).effective_entries(plugin_id)
437    }
438}
439
440impl Default for AppClients {
441    fn default() -> Self {
442        Self::new(
443            PluginManager::new(Vec::new()),
444            NativeCommandRegistry::default(),
445        )
446    }
447}
448
449/// Runtime-scoped application state shared across commands.
450///
451/// This is the assembled host snapshot that command and REPL code read while
452/// the process is running. The fields here are intended to move together: when
453/// config changes, callers should rebuild the derived UI/auth/theme state
454/// rather than mixing old and new snapshots.
455///
456/// Public API note: this is a host snapshot you usually receive from app
457/// bootstrap, not a semantic DTO meant for arbitrary external construction.
458#[non_exhaustive]
459pub struct AppRuntime {
460    /// Startup-time runtime identity used for config selection and rebuilds.
461    pub context: RuntimeContext,
462    /// Authoritative resolved config snapshot and its in-memory revision.
463    pub config: ConfigState,
464    /// UI-facing state derived from the current resolved config.
465    pub ui: UiState,
466    /// Authorization and command-visibility policy state derived from config.
467    pub auth: AuthState,
468    pub(crate) themes: ThemeCatalog,
469    /// Launch-time inputs used to assemble caches and external services.
470    pub launch: LaunchContext,
471    product_defaults: ConfigLayer,
472}
473
474impl AppRuntime {
475    /// Creates the runtime snapshot shared across CLI and REPL execution.
476    pub(crate) fn new(
477        context: RuntimeContext,
478        config: ConfigState,
479        ui: UiState,
480        auth: AuthState,
481        themes: ThemeCatalog,
482        launch: LaunchContext,
483    ) -> Self {
484        Self {
485            context,
486            config,
487            ui,
488            auth,
489            themes,
490            launch,
491            product_defaults: ConfigLayer::default(),
492        }
493    }
494
495    /// Returns the runtime context used for config selection and rebuilds.
496    pub fn context(&self) -> &RuntimeContext {
497        &self.context
498    }
499
500    /// Returns the authoritative resolved-config state.
501    pub fn config_state(&self) -> &ConfigState {
502        &self.config
503    }
504
505    /// Returns mutable resolved-config state.
506    pub fn config_state_mut(&mut self) -> &mut ConfigState {
507        &mut self.config
508    }
509
510    /// Returns the UI state derived from the current config snapshot.
511    pub fn ui(&self) -> &UiState {
512        &self.ui
513    }
514
515    /// Returns mutable UI state for in-process adjustments.
516    pub fn ui_mut(&mut self) -> &mut UiState {
517        &mut self.ui
518    }
519
520    /// Returns the command-visibility/auth state.
521    pub fn auth(&self) -> &AuthState {
522        &self.auth
523    }
524
525    /// Returns mutable command-visibility/auth state.
526    pub fn auth_mut(&mut self) -> &mut AuthState {
527        &mut self.auth
528    }
529
530    /// Returns the launch-time provenance used to assemble the runtime.
531    pub fn launch(&self) -> &LaunchContext {
532        &self.launch
533    }
534
535    pub(crate) fn product_defaults(&self) -> &ConfigLayer {
536        &self.product_defaults
537    }
538
539    pub(crate) fn set_product_defaults(&mut self, product_defaults: ConfigLayer) {
540        self.product_defaults = product_defaults;
541    }
542}
543
544/// Authorization and command-visibility state derived from configuration.
545pub struct AuthState {
546    builtins_allowlist: Option<HashSet<String>>,
547    external_allowlist: Option<HashSet<String>>,
548    policy_context: CommandPolicyContext,
549    builtin_policy: CommandPolicyRegistry,
550    external_policy: CommandPolicyRegistry,
551}
552
553impl AuthState {
554    /// Builds authorization state from the resolved configuration.
555    pub fn from_resolved(config: &ResolvedConfig) -> Self {
556        Self {
557            builtins_allowlist: parse_allowlist(config.get_string("auth.visible.builtins")),
558            // Non-builtin top-level commands currently still use the historical
559            // `auth.visible.plugins` key. That surface now covers both external
560            // plugins and native registered integrations dispatched via the
561            // generic external command path.
562            external_allowlist: parse_allowlist(config.get_string("auth.visible.plugins")),
563            policy_context: CommandPolicyContext::default(),
564            builtin_policy: CommandPolicyRegistry::default(),
565            external_policy: CommandPolicyRegistry::default(),
566        }
567    }
568
569    /// Builds authorization state and external policy from the current config
570    /// and active command registries.
571    pub(crate) fn from_resolved_with_external_policies(
572        config: &ResolvedConfig,
573        plugins: &PluginManager,
574        native_commands: &NativeCommandRegistry,
575    ) -> Self {
576        let mut auth = Self::from_resolved(config);
577        let plugin_policy = plugins.command_policy_registry();
578        let external_policy =
579            merge_policy_registries(plugin_policy, native_commands.command_policy_registry());
580        auth.replace_external_policy(external_policy);
581        auth
582    }
583
584    /// Returns the context used when evaluating command policies.
585    pub fn policy_context(&self) -> &CommandPolicyContext {
586        &self.policy_context
587    }
588
589    /// Replaces the context used when evaluating command policies.
590    pub fn set_policy_context(&mut self, context: CommandPolicyContext) {
591        self.policy_context = context;
592    }
593
594    /// Returns the policy registry for built-in commands.
595    pub fn builtin_policy(&self) -> &CommandPolicyRegistry {
596        &self.builtin_policy
597    }
598
599    /// Returns the mutable policy registry for built-in commands.
600    pub fn builtin_policy_mut(&mut self) -> &mut CommandPolicyRegistry {
601        &mut self.builtin_policy
602    }
603
604    /// Returns the policy registry for externally dispatched commands.
605    pub fn external_policy(&self) -> &CommandPolicyRegistry {
606        &self.external_policy
607    }
608
609    /// Returns the mutable policy registry for externally dispatched commands.
610    pub fn external_policy_mut(&mut self) -> &mut CommandPolicyRegistry {
611        &mut self.external_policy
612    }
613
614    /// Replaces the policy registry for externally dispatched commands.
615    pub fn replace_external_policy(&mut self, registry: CommandPolicyRegistry) {
616        self.external_policy = registry;
617    }
618
619    /// Evaluates access for a built-in command.
620    pub fn builtin_access(&self, command: &str) -> CommandAccess {
621        command_access_for(
622            command,
623            &self.builtins_allowlist,
624            &self.builtin_policy,
625            &self.policy_context,
626        )
627    }
628
629    /// Evaluates access for an external command.
630    pub fn external_command_access(&self, command: &str) -> CommandAccess {
631        command_access_for(
632            command,
633            &self.external_allowlist,
634            &self.external_policy,
635            &self.policy_context,
636        )
637    }
638
639    /// Returns whether a built-in command should be shown to the user.
640    pub fn is_builtin_visible(&self, command: &str) -> bool {
641        self.builtin_access(command).is_visible()
642    }
643
644    /// Returns whether an external command should be shown to the user.
645    pub fn is_external_command_visible(&self, command: &str) -> bool {
646        self.external_command_access(command).is_visible()
647    }
648}
649
650fn parse_allowlist(raw: Option<&str>) -> Option<HashSet<String>> {
651    let raw = raw.map(str::trim).filter(|value| !value.is_empty())?;
652
653    if raw == "*" {
654        return None;
655    }
656
657    let values = raw
658        .split([',', ' '])
659        .map(str::trim)
660        .filter(|value| !value.is_empty())
661        .map(|value| value.to_ascii_lowercase())
662        .collect::<HashSet<String>>();
663    if values.is_empty() {
664        None
665    } else {
666        Some(values)
667    }
668}
669
670fn is_visible_in_allowlist(allowlist: &Option<HashSet<String>>, command: &str) -> bool {
671    match allowlist {
672        None => true,
673        Some(values) => values.contains(&command.to_ascii_lowercase()),
674    }
675}
676
677fn command_access_for(
678    command: &str,
679    allowlist: &Option<HashSet<String>>,
680    registry: &CommandPolicyRegistry,
681    context: &CommandPolicyContext,
682) -> CommandAccess {
683    let normalized = command.trim().to_ascii_lowercase();
684    let default_policy = CommandPolicy::new(crate::core::command_policy::CommandPath::new([
685        normalized.clone(),
686    ]))
687    .visibility(VisibilityMode::Public);
688    let mut access = registry
689        .evaluate(&default_policy.path, context)
690        .unwrap_or_else(|| crate::core::command_policy::evaluate_policy(&default_policy, context));
691
692    if !is_visible_in_allowlist(allowlist, &normalized) {
693        access = CommandAccess::hidden(AccessReason::HiddenByPolicy);
694    }
695
696    access
697}
698
699fn merge_policy_registries(
700    mut left: CommandPolicyRegistry,
701    right: CommandPolicyRegistry,
702) -> CommandPolicyRegistry {
703    for policy in right.entries() {
704        left.register(policy.clone());
705    }
706    left
707}
708
709#[cfg(test)]
710mod tests {
711    use std::collections::HashSet;
712
713    use crate::config::{ConfigLayer, ConfigResolver, LoadedLayers, ResolveOptions};
714    use crate::core::command_policy::{
715        AccessReason, CommandPath, CommandPolicy, CommandPolicyContext, CommandPolicyRegistry,
716        VisibilityMode,
717    };
718
719    use super::{
720        AuthState, ConfigState, RuntimeContext, TerminalKind, command_access_for,
721        is_visible_in_allowlist, parse_allowlist,
722    };
723
724    fn resolved_with(entries: &[(&str, &str)]) -> crate::config::ResolvedConfig {
725        let mut file = ConfigLayer::default();
726        for (key, value) in entries {
727            file.set(*key, (*value).to_string());
728        }
729        ConfigResolver::from_loaded_layers(LoadedLayers {
730            file,
731            ..LoadedLayers::default()
732        })
733        .resolve(ResolveOptions::default())
734        .expect("config should resolve")
735    }
736
737    #[test]
738    fn runtime_context_and_allowlists_normalize_inputs() {
739        let context = RuntimeContext::new(
740            Some("  Dev  ".to_string()),
741            TerminalKind::Repl,
742            Some("xterm-256color".to_string()),
743        );
744        assert_eq!(context.profile_override(), Some("dev"));
745        assert_eq!(context.terminal_kind(), TerminalKind::Repl);
746        assert_eq!(context.terminal_env(), Some("xterm-256color"));
747
748        assert_eq!(parse_allowlist(None), None);
749        assert_eq!(parse_allowlist(Some("   ")), None);
750        assert_eq!(parse_allowlist(Some("*")), None);
751        assert_eq!(
752            parse_allowlist(Some(" LDAP, mreg ldap ")),
753            Some(HashSet::from(["ldap".to_string(), "mreg".to_string()]))
754        );
755
756        let allowlist = Some(HashSet::from(["ldap".to_string()]));
757        assert!(is_visible_in_allowlist(&allowlist, "LDAP"));
758        assert!(!is_visible_in_allowlist(&allowlist, "orch"));
759    }
760
761    #[test]
762    fn config_state_tracks_noops_changes_and_transaction_errors() {
763        let resolved = resolved_with(&[]);
764        let mut state = ConfigState::new(resolved.clone());
765        assert_eq!(state.revision(), 1);
766        assert!(!state.replace_resolved(resolved.clone()));
767        assert_eq!(state.revision(), 1);
768
769        let changed = resolved_with(&[("ui.format", "json")]);
770        assert!(state.replace_resolved(changed));
771        assert_eq!(state.revision(), 2);
772
773        let changed = state
774            .transaction(|current| {
775                let _ = current;
776                Ok::<_, &'static str>(resolved_with(&[("ui.format", "mreg")]))
777            })
778            .expect("transaction should succeed");
779        assert!(changed);
780        assert_eq!(state.revision(), 3);
781
782        let err = state
783            .transaction(|_| Err::<crate::config::ResolvedConfig, _>("boom"))
784            .expect_err("transaction error should propagate");
785        assert_eq!(err, "boom");
786        assert_eq!(state.revision(), 3);
787    }
788
789    #[test]
790    fn auth_state_and_command_access_layer_policy_overrides_on_allowlists() {
791        let resolved = resolved_with(&[
792            ("auth.visible.builtins", "config"),
793            ("auth.visible.plugins", "ldap"),
794        ]);
795        let mut auth = AuthState::from_resolved(&resolved);
796        auth.set_policy_context(
797            CommandPolicyContext::default()
798                .authenticated(true)
799                .with_capabilities(["orch.approval.decide"]),
800        );
801        assert!(auth.policy_context().authenticated);
802
803        auth.builtin_policy_mut().register(
804            CommandPolicy::new(CommandPath::new(["config"]))
805                .visibility(VisibilityMode::Authenticated),
806        );
807        assert!(auth.builtin_access("config").is_runnable());
808        assert!(auth.is_builtin_visible("config"));
809        assert!(!auth.is_builtin_visible("theme"));
810
811        let mut plugin_registry = CommandPolicyRegistry::new();
812        plugin_registry.register(
813            CommandPolicy::new(CommandPath::new(["ldap"]))
814                .visibility(VisibilityMode::CapabilityGated)
815                .require_capability("orch.approval.decide"),
816        );
817        plugin_registry.register(
818            CommandPolicy::new(CommandPath::new(["orch"]))
819                .visibility(VisibilityMode::Authenticated),
820        );
821        auth.replace_external_policy(plugin_registry);
822
823        assert!(auth.external_policy().contains(&CommandPath::new(["ldap"])));
824        assert!(
825            auth.external_policy_mut()
826                .contains(&CommandPath::new(["ldap"]))
827        );
828        assert!(auth.external_command_access("ldap").is_runnable());
829        assert!(auth.is_external_command_visible("ldap"));
830
831        let hidden = auth.external_command_access("orch");
832        assert_eq!(hidden.reasons, vec![AccessReason::HiddenByPolicy]);
833        assert!(!hidden.is_visible());
834    }
835
836    #[test]
837    fn command_access_for_uses_registry_when_present_and_public_default_otherwise() {
838        let context = CommandPolicyContext::default();
839        let allowlist = Some(HashSet::from(["config".to_string()]));
840        let mut registry = CommandPolicyRegistry::new();
841        registry.register(
842            CommandPolicy::new(CommandPath::new(["config"]))
843                .visibility(VisibilityMode::Authenticated),
844        );
845
846        let denied = command_access_for("config", &allowlist, &registry, &context);
847        assert_eq!(denied.reasons, vec![AccessReason::Unauthenticated]);
848        assert!(denied.is_visible());
849        assert!(!denied.is_runnable());
850
851        let hidden = command_access_for("theme", &allowlist, &registry, &context);
852        assert_eq!(hidden.reasons, vec![AccessReason::HiddenByPolicy]);
853        assert!(!hidden.is_visible());
854
855        let fallback =
856            command_access_for("config", &None, &CommandPolicyRegistry::default(), &context);
857        assert!(fallback.is_visible());
858        assert!(fallback.is_runnable());
859    }
860}