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_loader::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_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 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 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#[non_exhaustive]
469pub struct AppRuntime {
470 pub context: RuntimeContext,
472 pub config: ConfigState,
474 pub ui: UiState,
476 pub auth: AuthState,
478 pub(crate) themes: ThemeCatalog,
479 pub launch: LaunchContext,
481 product_defaults: ConfigLayer,
482}
483
484impl AppRuntime {
485 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 pub fn context(&self) -> &RuntimeContext {
507 &self.context
508 }
509
510 pub fn config_state(&self) -> &ConfigState {
512 &self.config
513 }
514
515 pub fn config_state_mut(&mut self) -> &mut ConfigState {
517 &mut self.config
518 }
519
520 pub fn ui(&self) -> &UiState {
522 &self.ui
523 }
524
525 pub fn ui_mut(&mut self) -> &mut UiState {
527 &mut self.ui
528 }
529
530 pub fn auth(&self) -> &AuthState {
532 &self.auth
533 }
534
535 pub fn auth_mut(&mut self) -> &mut AuthState {
537 &mut self.auth
538 }
539
540 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
554pub 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 pub fn from_resolved(config: &ResolvedConfig) -> Self {
566 Self {
567 builtins_allowlist: parse_allowlist(config.get_string("auth.visible.builtins")),
568 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 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 pub fn policy_context(&self) -> &CommandPolicyContext {
596 &self.policy_context
597 }
598
599 pub fn set_policy_context(&mut self, context: CommandPolicyContext) {
601 self.policy_context = context;
602 }
603
604 pub fn builtin_policy(&self) -> &CommandPolicyRegistry {
606 &self.builtin_policy
607 }
608
609 pub fn builtin_policy_mut(&mut self) -> &mut CommandPolicyRegistry {
611 &mut self.builtin_policy
612 }
613
614 pub fn external_policy(&self) -> &CommandPolicyRegistry {
616 &self.external_policy
617 }
618
619 pub fn external_policy_mut(&mut self) -> &mut CommandPolicyRegistry {
621 &mut self.external_policy
622 }
623
624 pub fn replace_external_policy(&mut self, registry: CommandPolicyRegistry) {
626 self.external_policy = registry;
627 }
628
629 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 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 pub fn is_builtin_visible(&self, command: &str) -> bool {
651 self.builtin_access(command).is_visible()
652 }
653
654 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, ®istry, &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, ®istry, &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}