1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum TerminalKind {
48 Cli,
50 Repl,
52}
53
54impl TerminalKind {
55 pub fn as_config_terminal(self) -> &'static str {
66 match self {
67 TerminalKind::Cli => "cli",
68 TerminalKind::Repl => "repl",
69 }
70 }
71}
72
73#[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 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 pub fn profile_override(&self) -> Option<&str> {
118 self.profile_override.as_deref()
119 }
120
121 pub fn terminal_kind(&self) -> TerminalKind {
123 self.terminal_kind
124 }
125
126 pub fn terminal_env(&self) -> Option<&str> {
128 self.terminal_env.as_deref()
129 }
130}
131
132pub struct ConfigState {
137 resolved: ResolvedConfig,
138 revision: u64,
139}
140
141impl ConfigState {
142 pub fn new(resolved: ResolvedConfig) -> Self {
163 Self {
164 resolved,
165 revision: 1,
166 }
167 }
168
169 pub fn resolved(&self) -> &ResolvedConfig {
171 &self.resolved
172 }
173
174 pub fn revision(&self) -> u64 {
176 self.revision
177 }
178
179 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 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(¤t)?;
197 Ok(self.replace_resolved(candidate))
198 }
199}
200
201#[derive(Debug, Clone)]
207#[non_exhaustive]
208#[must_use]
209pub struct UiState {
210 pub render_settings: RenderSettings,
212 pub message_verbosity: MessageLevel,
214 pub debug_verbosity: u8,
216}
217
218impl UiState {
219 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 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 pub fn with_render_settings(mut self, render_settings: RenderSettings) -> Self {
291 self.render_settings = render_settings;
292 self
293 }
294
295 pub fn with_message_verbosity(mut self, message_verbosity: MessageLevel) -> Self {
297 self.message_verbosity = message_verbosity;
298 self
299 }
300
301 pub fn with_debug_verbosity(mut self, debug_verbosity: u8) -> Self {
303 self.debug_verbosity = debug_verbosity;
304 self
305 }
306}
307
308#[derive(Debug, Clone)]
314#[non_exhaustive]
315#[must_use]
316pub struct LaunchContext {
317 pub plugin_dirs: Vec<PathBuf>,
319 pub config_root: Option<PathBuf>,
321 pub cache_root: Option<PathBuf>,
323 pub runtime_load: RuntimeLoadOptions,
325 pub startup_started_at: Instant,
327}
328
329impl LaunchContext {
330 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 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 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 pub fn with_config_root(mut self, config_root: Option<PathBuf>) -> Self {
360 self.config_root = config_root;
361 self
362 }
363
364 pub fn with_cache_root(mut self, cache_root: Option<PathBuf>) -> Self {
366 self.cache_root = cache_root;
367 self
368 }
369
370 pub fn with_runtime_load(mut self, runtime_load: RuntimeLoadOptions) -> Self {
372 self.runtime_load = runtime_load;
373 self
374 }
375
376 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#[non_exhaustive]
398#[must_use]
399pub struct AppClients {
400 plugins: PluginManager,
402 native_commands: NativeCommandRegistry,
404 plugin_config_env: PluginConfigEnvCache,
405}
406
407impl AppClients {
408 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 pub fn plugins(&self) -> &PluginManager {
419 &self.plugins
420 }
421
422 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#[non_exhaustive]
459pub struct AppRuntime {
460 pub context: RuntimeContext,
462 pub config: ConfigState,
464 pub ui: UiState,
466 pub auth: AuthState,
468 pub(crate) themes: ThemeCatalog,
469 pub launch: LaunchContext,
471 product_defaults: ConfigLayer,
472}
473
474impl AppRuntime {
475 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 pub fn context(&self) -> &RuntimeContext {
497 &self.context
498 }
499
500 pub fn config_state(&self) -> &ConfigState {
502 &self.config
503 }
504
505 pub fn config_state_mut(&mut self) -> &mut ConfigState {
507 &mut self.config
508 }
509
510 pub fn ui(&self) -> &UiState {
512 &self.ui
513 }
514
515 pub fn ui_mut(&mut self) -> &mut UiState {
517 &mut self.ui
518 }
519
520 pub fn auth(&self) -> &AuthState {
522 &self.auth
523 }
524
525 pub fn auth_mut(&mut self) -> &mut AuthState {
527 &mut self.auth
528 }
529
530 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
544pub 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 pub fn from_resolved(config: &ResolvedConfig) -> Self {
556 Self {
557 builtins_allowlist: parse_allowlist(config.get_string("auth.visible.builtins")),
558 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 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 pub fn policy_context(&self) -> &CommandPolicyContext {
586 &self.policy_context
587 }
588
589 pub fn set_policy_context(&mut self, context: CommandPolicyContext) {
591 self.policy_context = context;
592 }
593
594 pub fn builtin_policy(&self) -> &CommandPolicyRegistry {
596 &self.builtin_policy
597 }
598
599 pub fn builtin_policy_mut(&mut self) -> &mut CommandPolicyRegistry {
601 &mut self.builtin_policy
602 }
603
604 pub fn external_policy(&self) -> &CommandPolicyRegistry {
606 &self.external_policy
607 }
608
609 pub fn external_policy_mut(&mut self) -> &mut CommandPolicyRegistry {
611 &mut self.external_policy
612 }
613
614 pub fn replace_external_policy(&mut self, registry: CommandPolicyRegistry) {
616 self.external_policy = registry;
617 }
618
619 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 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 pub fn is_builtin_visible(&self, command: &str) -> bool {
641 self.builtin_access(command).is_visible()
642 }
643
644 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, ®istry, &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, ®istry, &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}