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_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]
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_loader::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        let config_env = self.plugin_config_env(config);
437        let mut merged = std::collections::BTreeMap::new();
438        for entry in config_env.shared {
439            merged.insert(entry.env_key.clone(), entry);
440        }
441        if let Some(entries) = config_env.by_plugin_id.get(plugin_id) {
442            for entry in entries {
443                merged.insert(entry.env_key.clone(), entry.clone());
444            }
445        }
446        merged.into_values().collect()
447    }
448}
449
450impl Default for AppClients {
451    fn default() -> Self {
452        Self::new(
453            PluginManager::new(Vec::new()),
454            NativeCommandRegistry::default(),
455        )
456    }
457}
458
459/// Runtime-scoped application state shared across commands.
460///
461/// This is the assembled host snapshot that command and REPL code read while
462/// the process is running. The fields here are intended to move together: when
463/// config changes, callers should rebuild the derived UI/auth/theme state
464/// rather than mixing old and new snapshots.
465///
466/// Public API note: this is a host snapshot you usually receive from app
467/// bootstrap, not a semantic DTO meant for arbitrary external construction.
468#[non_exhaustive]
469pub struct AppRuntime {
470    /// Startup-time runtime identity used for config selection and rebuilds.
471    pub context: RuntimeContext,
472    /// Authoritative resolved config snapshot and its in-memory revision.
473    pub config: ConfigState,
474    /// UI-facing state derived from the current resolved config.
475    pub ui: UiState,
476    /// Authorization and command-visibility policy state derived from config.
477    pub auth: AuthState,
478    pub(crate) themes: ThemeCatalog,
479    /// Launch-time inputs used to assemble caches and external services.
480    pub launch: LaunchContext,
481    product_defaults: ConfigLayer,
482}
483
484impl AppRuntime {
485    /// Creates the runtime snapshot shared across CLI and REPL execution.
486    pub(crate) fn new(
487        context: RuntimeContext,
488        config: ConfigState,
489        ui: UiState,
490        auth: AuthState,
491        themes: ThemeCatalog,
492        launch: LaunchContext,
493    ) -> Self {
494        Self {
495            context,
496            config,
497            ui,
498            auth,
499            themes,
500            launch,
501            product_defaults: ConfigLayer::default(),
502        }
503    }
504
505    /// Returns the runtime context used for config selection and rebuilds.
506    pub fn context(&self) -> &RuntimeContext {
507        &self.context
508    }
509
510    /// Returns the authoritative resolved-config state.
511    pub fn config_state(&self) -> &ConfigState {
512        &self.config
513    }
514
515    /// Returns mutable resolved-config state.
516    pub fn config_state_mut(&mut self) -> &mut ConfigState {
517        &mut self.config
518    }
519
520    /// Returns the UI state derived from the current config snapshot.
521    pub fn ui(&self) -> &UiState {
522        &self.ui
523    }
524
525    /// Returns mutable UI state for in-process adjustments.
526    pub fn ui_mut(&mut self) -> &mut UiState {
527        &mut self.ui
528    }
529
530    /// Returns the command-visibility/auth state.
531    pub fn auth(&self) -> &AuthState {
532        &self.auth
533    }
534
535    /// Returns mutable command-visibility/auth state.
536    pub fn auth_mut(&mut self) -> &mut AuthState {
537        &mut self.auth
538    }
539
540    /// Returns the launch-time provenance used to assemble the runtime.
541    pub fn launch(&self) -> &LaunchContext {
542        &self.launch
543    }
544
545    pub(crate) fn product_defaults(&self) -> &ConfigLayer {
546        &self.product_defaults
547    }
548
549    pub(crate) fn set_product_defaults(&mut self, product_defaults: ConfigLayer) {
550        self.product_defaults = product_defaults;
551    }
552}
553
554/// Authorization and command-visibility state derived from configuration.
555pub struct AuthState {
556    builtins_allowlist: Option<HashSet<String>>,
557    external_allowlist: Option<HashSet<String>>,
558    policy_context: CommandPolicyContext,
559    builtin_policy: CommandPolicyRegistry,
560    external_policy: CommandPolicyRegistry,
561}
562
563impl AuthState {
564    /// Builds authorization state from the resolved configuration.
565    pub fn from_resolved(config: &ResolvedConfig) -> Self {
566        Self {
567            builtins_allowlist: parse_allowlist(config.get_string("auth.visible.builtins")),
568            // Non-builtin top-level commands currently still use the historical
569            // `auth.visible.plugins` key. That surface now covers both external
570            // plugins and native registered integrations dispatched via the
571            // generic external command path.
572            external_allowlist: parse_allowlist(config.get_string("auth.visible.plugins")),
573            policy_context: CommandPolicyContext::default(),
574            builtin_policy: CommandPolicyRegistry::default(),
575            external_policy: CommandPolicyRegistry::default(),
576        }
577    }
578
579    /// Builds authorization state and external policy from the current config
580    /// and active command registries.
581    pub(crate) fn from_resolved_with_external_policies(
582        config: &ResolvedConfig,
583        plugins: &PluginManager,
584        native_commands: &NativeCommandRegistry,
585    ) -> Self {
586        let mut auth = Self::from_resolved(config);
587        let plugin_policy = plugins.command_policy_registry();
588        let external_policy =
589            merge_policy_registries(plugin_policy, native_commands.command_policy_registry());
590        auth.replace_external_policy(external_policy);
591        auth
592    }
593
594    /// Returns the context used when evaluating command policies.
595    pub fn policy_context(&self) -> &CommandPolicyContext {
596        &self.policy_context
597    }
598
599    /// Replaces the context used when evaluating command policies.
600    pub fn set_policy_context(&mut self, context: CommandPolicyContext) {
601        self.policy_context = context;
602    }
603
604    /// Returns the policy registry for built-in commands.
605    pub fn builtin_policy(&self) -> &CommandPolicyRegistry {
606        &self.builtin_policy
607    }
608
609    /// Returns the mutable policy registry for built-in commands.
610    pub fn builtin_policy_mut(&mut self) -> &mut CommandPolicyRegistry {
611        &mut self.builtin_policy
612    }
613
614    /// Returns the policy registry for externally dispatched commands.
615    pub fn external_policy(&self) -> &CommandPolicyRegistry {
616        &self.external_policy
617    }
618
619    /// Returns the mutable policy registry for externally dispatched commands.
620    pub fn external_policy_mut(&mut self) -> &mut CommandPolicyRegistry {
621        &mut self.external_policy
622    }
623
624    /// Replaces the policy registry for externally dispatched commands.
625    pub fn replace_external_policy(&mut self, registry: CommandPolicyRegistry) {
626        self.external_policy = registry;
627    }
628
629    /// Evaluates access for a built-in command.
630    pub fn builtin_access(&self, command: &str) -> CommandAccess {
631        command_access_for(
632            command,
633            &self.builtins_allowlist,
634            &self.builtin_policy,
635            &self.policy_context,
636        )
637    }
638
639    /// Evaluates access for an external command.
640    pub fn external_command_access(&self, command: &str) -> CommandAccess {
641        command_access_for(
642            command,
643            &self.external_allowlist,
644            &self.external_policy,
645            &self.policy_context,
646        )
647    }
648
649    /// Returns whether a built-in command should be shown to the user.
650    pub fn is_builtin_visible(&self, command: &str) -> bool {
651        self.builtin_access(command).is_visible()
652    }
653
654    /// Returns whether an external command should be shown to the user.
655    pub fn is_external_command_visible(&self, command: &str) -> bool {
656        self.external_command_access(command).is_visible()
657    }
658}
659
660fn parse_allowlist(raw: Option<&str>) -> Option<HashSet<String>> {
661    let raw = raw.map(str::trim).filter(|value| !value.is_empty())?;
662
663    if raw == "*" {
664        return None;
665    }
666
667    let values = raw
668        .split([',', ' '])
669        .map(str::trim)
670        .filter(|value| !value.is_empty())
671        .map(|value| value.to_ascii_lowercase())
672        .collect::<HashSet<String>>();
673    if values.is_empty() {
674        None
675    } else {
676        Some(values)
677    }
678}
679
680fn is_visible_in_allowlist(allowlist: &Option<HashSet<String>>, command: &str) -> bool {
681    match allowlist {
682        None => true,
683        Some(values) => values.contains(&command.to_ascii_lowercase()),
684    }
685}
686
687fn command_access_for(
688    command: &str,
689    allowlist: &Option<HashSet<String>>,
690    registry: &CommandPolicyRegistry,
691    context: &CommandPolicyContext,
692) -> CommandAccess {
693    let normalized = command.trim().to_ascii_lowercase();
694    let default_policy = CommandPolicy::new(crate::core::command_policy::CommandPath::new([
695        normalized.clone(),
696    ]))
697    .visibility(VisibilityMode::Public);
698    let mut access = registry
699        .evaluate(&default_policy.path, context)
700        .unwrap_or_else(|| crate::core::command_policy::evaluate_policy(&default_policy, context));
701
702    if !is_visible_in_allowlist(allowlist, &normalized) {
703        access = CommandAccess::hidden(AccessReason::HiddenByPolicy);
704    }
705
706    access
707}
708
709fn merge_policy_registries(
710    mut left: CommandPolicyRegistry,
711    right: CommandPolicyRegistry,
712) -> CommandPolicyRegistry {
713    for policy in right.entries() {
714        left.register(policy.clone());
715    }
716    left
717}
718
719#[cfg(test)]
720mod tests {
721    use std::collections::HashSet;
722
723    use crate::config::{ConfigLayer, ConfigResolver, LoadedLayers, ResolveOptions};
724    use crate::core::command_policy::{
725        AccessReason, CommandPath, CommandPolicy, CommandPolicyContext, CommandPolicyRegistry,
726        VisibilityMode,
727    };
728
729    use super::{
730        AuthState, ConfigState, RuntimeContext, TerminalKind, command_access_for,
731        is_visible_in_allowlist, parse_allowlist,
732    };
733
734    fn resolved_with(entries: &[(&str, &str)]) -> crate::config::ResolvedConfig {
735        let mut file = ConfigLayer::default();
736        for (key, value) in entries {
737            file.set(*key, (*value).to_string());
738        }
739        ConfigResolver::from_loaded_layers(LoadedLayers {
740            file,
741            ..LoadedLayers::default()
742        })
743        .resolve(ResolveOptions::default())
744        .expect("config should resolve")
745    }
746
747    #[test]
748    fn runtime_context_and_allowlists_normalize_inputs() {
749        let context = RuntimeContext::new(
750            Some("  Dev  ".to_string()),
751            TerminalKind::Repl,
752            Some("xterm-256color".to_string()),
753        );
754        assert_eq!(context.profile_override(), Some("dev"));
755        assert_eq!(context.terminal_kind(), TerminalKind::Repl);
756        assert_eq!(context.terminal_env(), Some("xterm-256color"));
757
758        assert_eq!(parse_allowlist(None), None);
759        assert_eq!(parse_allowlist(Some("   ")), None);
760        assert_eq!(parse_allowlist(Some("*")), None);
761        assert_eq!(
762            parse_allowlist(Some(" LDAP, mreg ldap ")),
763            Some(HashSet::from(["ldap".to_string(), "mreg".to_string()]))
764        );
765
766        let allowlist = Some(HashSet::from(["ldap".to_string()]));
767        assert!(is_visible_in_allowlist(&allowlist, "LDAP"));
768        assert!(!is_visible_in_allowlist(&allowlist, "orch"));
769    }
770
771    #[test]
772    fn config_state_tracks_noops_changes_and_transaction_errors() {
773        let resolved = resolved_with(&[]);
774        let mut state = ConfigState::new(resolved.clone());
775        assert_eq!(state.revision(), 1);
776        assert!(!state.replace_resolved(resolved.clone()));
777        assert_eq!(state.revision(), 1);
778
779        let changed = resolved_with(&[("ui.format", "json")]);
780        assert!(state.replace_resolved(changed));
781        assert_eq!(state.revision(), 2);
782
783        let changed = state
784            .transaction(|current| {
785                let _ = current;
786                Ok::<_, &'static str>(resolved_with(&[("ui.format", "mreg")]))
787            })
788            .expect("transaction should succeed");
789        assert!(changed);
790        assert_eq!(state.revision(), 3);
791
792        let err = state
793            .transaction(|_| Err::<crate::config::ResolvedConfig, _>("boom"))
794            .expect_err("transaction error should propagate");
795        assert_eq!(err, "boom");
796        assert_eq!(state.revision(), 3);
797    }
798
799    #[test]
800    fn auth_state_and_command_access_layer_policy_overrides_on_allowlists() {
801        let resolved = resolved_with(&[
802            ("auth.visible.builtins", "config"),
803            ("auth.visible.plugins", "ldap"),
804        ]);
805        let mut auth = AuthState::from_resolved(&resolved);
806        auth.set_policy_context(
807            CommandPolicyContext::default()
808                .authenticated(true)
809                .with_capabilities(["orch.approval.decide"]),
810        );
811        assert!(auth.policy_context().authenticated);
812
813        auth.builtin_policy_mut().register(
814            CommandPolicy::new(CommandPath::new(["config"]))
815                .visibility(VisibilityMode::Authenticated),
816        );
817        assert!(auth.builtin_access("config").is_runnable());
818        assert!(auth.is_builtin_visible("config"));
819        assert!(!auth.is_builtin_visible("theme"));
820
821        let mut plugin_registry = CommandPolicyRegistry::new();
822        plugin_registry.register(
823            CommandPolicy::new(CommandPath::new(["ldap"]))
824                .visibility(VisibilityMode::CapabilityGated)
825                .require_capability("orch.approval.decide"),
826        );
827        plugin_registry.register(
828            CommandPolicy::new(CommandPath::new(["orch"]))
829                .visibility(VisibilityMode::Authenticated),
830        );
831        auth.replace_external_policy(plugin_registry);
832
833        assert!(auth.external_policy().contains(&CommandPath::new(["ldap"])));
834        assert!(
835            auth.external_policy_mut()
836                .contains(&CommandPath::new(["ldap"]))
837        );
838        assert!(auth.external_command_access("ldap").is_runnable());
839        assert!(auth.is_external_command_visible("ldap"));
840
841        let hidden = auth.external_command_access("orch");
842        assert_eq!(hidden.reasons, vec![AccessReason::HiddenByPolicy]);
843        assert!(!hidden.is_visible());
844    }
845
846    #[test]
847    fn command_access_for_uses_registry_when_present_and_public_default_otherwise() {
848        let context = CommandPolicyContext::default();
849        let allowlist = Some(HashSet::from(["config".to_string()]));
850        let mut registry = CommandPolicyRegistry::new();
851        registry.register(
852            CommandPolicy::new(CommandPath::new(["config"]))
853                .visibility(VisibilityMode::Authenticated),
854        );
855
856        let denied = command_access_for("config", &allowlist, &registry, &context);
857        assert_eq!(denied.reasons, vec![AccessReason::Unauthenticated]);
858        assert!(denied.is_visible());
859        assert!(!denied.is_runnable());
860
861        let hidden = command_access_for("theme", &allowlist, &registry, &context);
862        assert_eq!(hidden.reasons, vec![AccessReason::HiddenByPolicy]);
863        assert!(!hidden.is_visible());
864
865        let fallback =
866            command_access_for("config", &None, &CommandPolicyRegistry::default(), &context);
867        assert!(fallback.is_visible());
868        assert!(fallback.is_runnable());
869    }
870}