1use std::collections::HashSet;
27use std::path::PathBuf;
28use std::time::Instant;
29
30use crate::config::{ResolvedConfig, RuntimeLoadOptions};
31use crate::core::command_policy::{
32 AccessReason, CommandAccess, CommandPolicy, CommandPolicyContext, CommandPolicyRegistry,
33 VisibilityMode,
34};
35use crate::native::NativeCommandRegistry;
36use crate::plugin::PluginManager;
37use crate::plugin::config::{PluginConfigEntry, PluginConfigEnv, PluginConfigEnvCache};
38use crate::ui::RenderSettings;
39use crate::ui::messages::MessageLevel;
40use crate::ui::theme_loader::ThemeCatalog;
41
42#[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]
208pub struct UiState {
209 pub render_settings: RenderSettings,
211 pub message_verbosity: MessageLevel,
213 pub debug_verbosity: u8,
215}
216
217impl UiState {
218 pub fn builder(render_settings: RenderSettings) -> UiStateBuilder {
220 UiStateBuilder::new(render_settings)
221 }
222
223 pub fn from_resolved_config(
249 context: &RuntimeContext,
250 config: &ResolvedConfig,
251 ) -> miette::Result<Self> {
252 UiStateBuilder::from_resolved_config(context, config).map(UiStateBuilder::build)
253 }
254
255 pub fn new(
257 render_settings: RenderSettings,
258 message_verbosity: MessageLevel,
259 debug_verbosity: u8,
260 ) -> Self {
261 Self {
262 render_settings,
263 message_verbosity,
264 debug_verbosity,
265 }
266 }
267}
268
269pub struct UiStateBuilder {
273 render_settings: RenderSettings,
274 message_verbosity: MessageLevel,
275 debug_verbosity: u8,
276}
277
278impl UiStateBuilder {
279 pub fn new(render_settings: RenderSettings) -> Self {
281 Self {
282 render_settings,
283 message_verbosity: MessageLevel::Success,
284 debug_verbosity: 0,
285 }
286 }
287
288 pub fn from_resolved_config(
290 context: &RuntimeContext,
291 config: &ResolvedConfig,
292 ) -> miette::Result<Self> {
293 let themes = crate::ui::theme_loader::load_theme_catalog(config);
294 Self::from_resolved_config_with_themes(context, config, &themes)
295 }
296
297 pub(crate) fn from_resolved_config_with_themes(
298 context: &RuntimeContext,
299 config: &ResolvedConfig,
300 themes: &ThemeCatalog,
301 ) -> miette::Result<Self> {
302 let ui = crate::app::assembly::derive_ui_state(
305 context,
306 config,
307 themes,
308 crate::app::assembly::RenderSettingsSeed::DefaultAuto,
309 None,
310 )?;
311
312 Ok(Self {
313 render_settings: ui.render_settings,
314 message_verbosity: ui.message_verbosity,
315 debug_verbosity: ui.debug_verbosity,
316 })
317 }
318
319 pub fn with_render_settings(mut self, render_settings: RenderSettings) -> Self {
321 self.render_settings = render_settings;
322 self
323 }
324
325 pub fn with_message_verbosity(mut self, message_verbosity: MessageLevel) -> Self {
327 self.message_verbosity = message_verbosity;
328 self
329 }
330
331 pub fn with_debug_verbosity(mut self, debug_verbosity: u8) -> Self {
333 self.debug_verbosity = debug_verbosity;
334 self
335 }
336
337 pub fn build(self) -> UiState {
339 UiState::new(
340 self.render_settings,
341 self.message_verbosity,
342 self.debug_verbosity,
343 )
344 }
345}
346
347#[derive(Debug, Clone)]
353#[non_exhaustive]
354pub struct LaunchContext {
355 pub plugin_dirs: Vec<PathBuf>,
357 pub config_root: Option<PathBuf>,
359 pub cache_root: Option<PathBuf>,
361 pub runtime_load: RuntimeLoadOptions,
363 pub startup_started_at: Instant,
365}
366
367impl LaunchContext {
368 pub fn builder() -> LaunchContextBuilder {
370 LaunchContextBuilder::new()
371 }
372
373 pub fn new(
375 plugin_dirs: Vec<PathBuf>,
376 config_root: Option<PathBuf>,
377 cache_root: Option<PathBuf>,
378 runtime_load: RuntimeLoadOptions,
379 ) -> Self {
380 Self {
381 plugin_dirs,
382 config_root,
383 cache_root,
384 runtime_load,
385 startup_started_at: Instant::now(),
386 }
387 }
388
389 pub fn with_startup_started_at(mut self, startup_started_at: Instant) -> Self {
391 self.startup_started_at = startup_started_at;
392 self
393 }
394}
395
396impl Default for LaunchContext {
397 fn default() -> Self {
398 Self::new(Vec::new(), None, None, RuntimeLoadOptions::default())
399 }
400}
401
402pub struct LaunchContextBuilder {
406 plugin_dirs: Vec<PathBuf>,
407 config_root: Option<PathBuf>,
408 cache_root: Option<PathBuf>,
409 runtime_load: RuntimeLoadOptions,
410 startup_started_at: Instant,
411}
412
413impl Default for LaunchContextBuilder {
414 fn default() -> Self {
415 Self::new()
416 }
417}
418
419impl LaunchContextBuilder {
420 pub fn new() -> Self {
422 Self {
423 plugin_dirs: Vec::new(),
424 config_root: None,
425 cache_root: None,
426 runtime_load: RuntimeLoadOptions::default(),
427 startup_started_at: Instant::now(),
428 }
429 }
430
431 pub fn with_plugin_dir(mut self, plugin_dir: impl Into<PathBuf>) -> Self {
433 self.plugin_dirs.push(plugin_dir.into());
434 self
435 }
436
437 pub fn with_plugin_dirs(mut self, plugin_dirs: impl IntoIterator<Item = PathBuf>) -> Self {
439 self.plugin_dirs = plugin_dirs.into_iter().collect();
440 self
441 }
442
443 pub fn with_config_root(mut self, config_root: Option<PathBuf>) -> Self {
445 self.config_root = config_root;
446 self
447 }
448
449 pub fn with_cache_root(mut self, cache_root: Option<PathBuf>) -> Self {
451 self.cache_root = cache_root;
452 self
453 }
454
455 pub fn with_runtime_load(mut self, runtime_load: RuntimeLoadOptions) -> Self {
457 self.runtime_load = runtime_load;
458 self
459 }
460
461 pub fn with_startup_started_at(mut self, startup_started_at: Instant) -> Self {
463 self.startup_started_at = startup_started_at;
464 self
465 }
466
467 pub fn build(self) -> LaunchContext {
469 LaunchContext {
470 plugin_dirs: self.plugin_dirs,
471 config_root: self.config_root,
472 cache_root: self.cache_root,
473 runtime_load: self.runtime_load,
474 startup_started_at: self.startup_started_at,
475 }
476 }
477}
478
479#[non_exhaustive]
488pub struct AppClients {
489 plugins: PluginManager,
491 native_commands: NativeCommandRegistry,
493 plugin_config_env: PluginConfigEnvCache,
494}
495
496impl AppClients {
497 pub fn new(plugins: PluginManager, native_commands: NativeCommandRegistry) -> Self {
499 Self {
500 plugins,
501 native_commands,
502 plugin_config_env: PluginConfigEnvCache::default(),
503 }
504 }
505
506 pub fn plugins(&self) -> &PluginManager {
508 &self.plugins
509 }
510
511 pub fn native_commands(&self) -> &NativeCommandRegistry {
513 &self.native_commands
514 }
515
516 pub fn builder() -> AppClientsBuilder {
518 AppClientsBuilder::new()
519 }
520
521 pub(crate) fn plugin_config_env(&self, config: &ConfigState) -> PluginConfigEnv {
522 self.plugin_config_env.collect(config)
523 }
524
525 pub(crate) fn plugin_config_entries(
526 &self,
527 config: &ConfigState,
528 plugin_id: &str,
529 ) -> Vec<PluginConfigEntry> {
530 let config_env = self.plugin_config_env(config);
531 let mut merged = std::collections::BTreeMap::new();
532 for entry in config_env.shared {
533 merged.insert(entry.env_key.clone(), entry);
534 }
535 if let Some(entries) = config_env.by_plugin_id.get(plugin_id) {
536 for entry in entries {
537 merged.insert(entry.env_key.clone(), entry.clone());
538 }
539 }
540 merged.into_values().collect()
541 }
542}
543
544pub struct AppClientsBuilder {
548 plugins: PluginManager,
549 native_commands: NativeCommandRegistry,
550}
551
552impl Default for AppClientsBuilder {
553 fn default() -> Self {
554 Self {
555 plugins: PluginManager::new(Vec::new()),
556 native_commands: NativeCommandRegistry::default(),
557 }
558 }
559}
560
561impl AppClientsBuilder {
562 pub fn new() -> Self {
564 Self::default()
565 }
566
567 pub fn with_plugins(mut self, plugins: PluginManager) -> Self {
569 self.plugins = plugins;
570 self
571 }
572
573 pub fn with_native_commands(mut self, native_commands: NativeCommandRegistry) -> Self {
575 self.native_commands = native_commands;
576 self
577 }
578
579 pub fn build(self) -> AppClients {
581 AppClients::new(self.plugins, self.native_commands)
582 }
583}
584
585#[non_exhaustive]
595pub struct AppRuntime {
596 pub context: RuntimeContext,
598 pub config: ConfigState,
600 pub ui: UiState,
602 pub auth: AuthState,
604 pub(crate) themes: ThemeCatalog,
605 pub launch: LaunchContext,
607}
608
609impl AppRuntime {
610 pub(crate) fn new(
612 context: RuntimeContext,
613 config: ConfigState,
614 ui: UiState,
615 auth: AuthState,
616 themes: ThemeCatalog,
617 launch: LaunchContext,
618 ) -> Self {
619 Self {
620 context,
621 config,
622 ui,
623 auth,
624 themes,
625 launch,
626 }
627 }
628
629 pub fn context(&self) -> &RuntimeContext {
631 &self.context
632 }
633
634 pub fn config_state(&self) -> &ConfigState {
636 &self.config
637 }
638
639 pub fn config_state_mut(&mut self) -> &mut ConfigState {
641 &mut self.config
642 }
643
644 pub fn ui(&self) -> &UiState {
646 &self.ui
647 }
648
649 pub fn ui_mut(&mut self) -> &mut UiState {
651 &mut self.ui
652 }
653
654 pub fn auth(&self) -> &AuthState {
656 &self.auth
657 }
658
659 pub fn auth_mut(&mut self) -> &mut AuthState {
661 &mut self.auth
662 }
663
664 pub fn launch(&self) -> &LaunchContext {
666 &self.launch
667 }
668}
669
670pub struct AuthState {
672 builtins_allowlist: Option<HashSet<String>>,
673 external_allowlist: Option<HashSet<String>>,
674 policy_context: CommandPolicyContext,
675 builtin_policy: CommandPolicyRegistry,
676 external_policy: CommandPolicyRegistry,
677}
678
679impl AuthState {
680 pub fn from_resolved(config: &ResolvedConfig) -> Self {
682 Self {
683 builtins_allowlist: parse_allowlist(config.get_string("auth.visible.builtins")),
684 external_allowlist: parse_allowlist(config.get_string("auth.visible.plugins")),
689 policy_context: CommandPolicyContext::default(),
690 builtin_policy: CommandPolicyRegistry::default(),
691 external_policy: CommandPolicyRegistry::default(),
692 }
693 }
694
695 pub(crate) fn from_resolved_with_external_policies(
698 config: &ResolvedConfig,
699 plugins: &PluginManager,
700 native_commands: &NativeCommandRegistry,
701 ) -> Self {
702 let mut auth = Self::from_resolved(config);
703 let plugin_policy = plugins.command_policy_registry().unwrap_or_else(|err| {
704 tracing::warn!(error = %err, "failed to build plugin command policy registry");
705 CommandPolicyRegistry::default()
706 });
707 let external_policy =
708 merge_policy_registries(plugin_policy, native_commands.command_policy_registry());
709 auth.replace_external_policy(external_policy);
710 auth
711 }
712
713 pub fn policy_context(&self) -> &CommandPolicyContext {
715 &self.policy_context
716 }
717
718 pub fn set_policy_context(&mut self, context: CommandPolicyContext) {
720 self.policy_context = context;
721 }
722
723 pub fn builtin_policy(&self) -> &CommandPolicyRegistry {
725 &self.builtin_policy
726 }
727
728 pub fn builtin_policy_mut(&mut self) -> &mut CommandPolicyRegistry {
730 &mut self.builtin_policy
731 }
732
733 pub fn external_policy(&self) -> &CommandPolicyRegistry {
735 &self.external_policy
736 }
737
738 pub fn external_policy_mut(&mut self) -> &mut CommandPolicyRegistry {
740 &mut self.external_policy
741 }
742
743 pub fn replace_external_policy(&mut self, registry: CommandPolicyRegistry) {
745 self.external_policy = registry;
746 }
747
748 pub fn builtin_access(&self, command: &str) -> CommandAccess {
750 command_access_for(
751 command,
752 &self.builtins_allowlist,
753 &self.builtin_policy,
754 &self.policy_context,
755 )
756 }
757
758 pub fn external_command_access(&self, command: &str) -> CommandAccess {
760 command_access_for(
761 command,
762 &self.external_allowlist,
763 &self.external_policy,
764 &self.policy_context,
765 )
766 }
767
768 pub fn is_builtin_visible(&self, command: &str) -> bool {
770 self.builtin_access(command).is_visible()
771 }
772
773 pub fn is_external_command_visible(&self, command: &str) -> bool {
775 self.external_command_access(command).is_visible()
776 }
777}
778
779fn parse_allowlist(raw: Option<&str>) -> Option<HashSet<String>> {
780 let raw = raw.map(str::trim).filter(|value| !value.is_empty())?;
781
782 if raw == "*" {
783 return None;
784 }
785
786 let values = raw
787 .split([',', ' '])
788 .map(str::trim)
789 .filter(|value| !value.is_empty())
790 .map(|value| value.to_ascii_lowercase())
791 .collect::<HashSet<String>>();
792 if values.is_empty() {
793 None
794 } else {
795 Some(values)
796 }
797}
798
799fn is_visible_in_allowlist(allowlist: &Option<HashSet<String>>, command: &str) -> bool {
800 match allowlist {
801 None => true,
802 Some(values) => values.contains(&command.to_ascii_lowercase()),
803 }
804}
805
806fn command_access_for(
807 command: &str,
808 allowlist: &Option<HashSet<String>>,
809 registry: &CommandPolicyRegistry,
810 context: &CommandPolicyContext,
811) -> CommandAccess {
812 let normalized = command.trim().to_ascii_lowercase();
813 let default_policy = CommandPolicy::new(crate::core::command_policy::CommandPath::new([
814 normalized.clone(),
815 ]))
816 .visibility(VisibilityMode::Public);
817 let mut access = registry
818 .evaluate(&default_policy.path, context)
819 .unwrap_or_else(|| crate::core::command_policy::evaluate_policy(&default_policy, context));
820
821 if !is_visible_in_allowlist(allowlist, &normalized) {
822 access = CommandAccess::hidden(AccessReason::HiddenByPolicy);
823 }
824
825 access
826}
827
828fn merge_policy_registries(
829 mut left: CommandPolicyRegistry,
830 right: CommandPolicyRegistry,
831) -> CommandPolicyRegistry {
832 for policy in right.entries() {
833 left.register(policy.clone());
834 }
835 left
836}
837
838#[cfg(test)]
839mod tests {
840 use std::collections::HashSet;
841
842 use crate::config::{ConfigLayer, ConfigResolver, LoadedLayers, ResolveOptions};
843 use crate::core::command_policy::{
844 AccessReason, CommandPath, CommandPolicy, CommandPolicyContext, CommandPolicyRegistry,
845 VisibilityMode,
846 };
847
848 use super::{
849 AuthState, ConfigState, RuntimeContext, TerminalKind, command_access_for,
850 is_visible_in_allowlist, parse_allowlist,
851 };
852
853 fn resolved_with(entries: &[(&str, &str)]) -> crate::config::ResolvedConfig {
854 let mut file = ConfigLayer::default();
855 for (key, value) in entries {
856 file.set(*key, (*value).to_string());
857 }
858 ConfigResolver::from_loaded_layers(LoadedLayers {
859 file,
860 ..LoadedLayers::default()
861 })
862 .resolve(ResolveOptions::default())
863 .expect("config should resolve")
864 }
865
866 #[test]
867 fn runtime_context_and_allowlists_normalize_inputs() {
868 let context = RuntimeContext::new(
869 Some(" Dev ".to_string()),
870 TerminalKind::Repl,
871 Some("xterm-256color".to_string()),
872 );
873 assert_eq!(context.profile_override(), Some("dev"));
874 assert_eq!(context.terminal_kind(), TerminalKind::Repl);
875 assert_eq!(context.terminal_env(), Some("xterm-256color"));
876
877 assert_eq!(parse_allowlist(None), None);
878 assert_eq!(parse_allowlist(Some(" ")), None);
879 assert_eq!(parse_allowlist(Some("*")), None);
880 assert_eq!(
881 parse_allowlist(Some(" LDAP, mreg ldap ")),
882 Some(HashSet::from(["ldap".to_string(), "mreg".to_string()]))
883 );
884
885 let allowlist = Some(HashSet::from(["ldap".to_string()]));
886 assert!(is_visible_in_allowlist(&allowlist, "LDAP"));
887 assert!(!is_visible_in_allowlist(&allowlist, "orch"));
888 }
889
890 #[test]
891 fn config_state_tracks_noops_changes_and_transaction_errors() {
892 let resolved = resolved_with(&[]);
893 let mut state = ConfigState::new(resolved.clone());
894 assert_eq!(state.revision(), 1);
895 assert!(!state.replace_resolved(resolved.clone()));
896 assert_eq!(state.revision(), 1);
897
898 let changed = resolved_with(&[("ui.format", "json")]);
899 assert!(state.replace_resolved(changed));
900 assert_eq!(state.revision(), 2);
901
902 let changed = state
903 .transaction(|current| {
904 let _ = current;
905 Ok::<_, &'static str>(resolved_with(&[("ui.format", "mreg")]))
906 })
907 .expect("transaction should succeed");
908 assert!(changed);
909 assert_eq!(state.revision(), 3);
910
911 let err = state
912 .transaction(|_| Err::<crate::config::ResolvedConfig, _>("boom"))
913 .expect_err("transaction error should propagate");
914 assert_eq!(err, "boom");
915 assert_eq!(state.revision(), 3);
916 }
917
918 #[test]
919 fn auth_state_and_command_access_layer_policy_overrides_on_allowlists() {
920 let resolved = resolved_with(&[
921 ("auth.visible.builtins", "config"),
922 ("auth.visible.plugins", "ldap"),
923 ]);
924 let mut auth = AuthState::from_resolved(&resolved);
925 auth.set_policy_context(
926 CommandPolicyContext::default()
927 .authenticated(true)
928 .with_capabilities(["orch.approval.decide"]),
929 );
930 assert!(auth.policy_context().authenticated);
931
932 auth.builtin_policy_mut().register(
933 CommandPolicy::new(CommandPath::new(["config"]))
934 .visibility(VisibilityMode::Authenticated),
935 );
936 assert!(auth.builtin_access("config").is_runnable());
937 assert!(auth.is_builtin_visible("config"));
938 assert!(!auth.is_builtin_visible("theme"));
939
940 let mut plugin_registry = CommandPolicyRegistry::new();
941 plugin_registry.register(
942 CommandPolicy::new(CommandPath::new(["ldap"]))
943 .visibility(VisibilityMode::CapabilityGated)
944 .require_capability("orch.approval.decide"),
945 );
946 plugin_registry.register(
947 CommandPolicy::new(CommandPath::new(["orch"]))
948 .visibility(VisibilityMode::Authenticated),
949 );
950 auth.replace_external_policy(plugin_registry);
951
952 assert!(auth.external_policy().contains(&CommandPath::new(["ldap"])));
953 assert!(
954 auth.external_policy_mut()
955 .contains(&CommandPath::new(["ldap"]))
956 );
957 assert!(auth.external_command_access("ldap").is_runnable());
958 assert!(auth.is_external_command_visible("ldap"));
959
960 let hidden = auth.external_command_access("orch");
961 assert_eq!(hidden.reasons, vec![AccessReason::HiddenByPolicy]);
962 assert!(!hidden.is_visible());
963 }
964
965 #[test]
966 fn command_access_for_uses_registry_when_present_and_public_default_otherwise() {
967 let context = CommandPolicyContext::default();
968 let allowlist = Some(HashSet::from(["config".to_string()]));
969 let mut registry = CommandPolicyRegistry::new();
970 registry.register(
971 CommandPolicy::new(CommandPath::new(["config"]))
972 .visibility(VisibilityMode::Authenticated),
973 );
974
975 let denied = command_access_for("config", &allowlist, ®istry, &context);
976 assert_eq!(denied.reasons, vec![AccessReason::Unauthenticated]);
977 assert!(denied.is_visible());
978 assert!(!denied.is_runnable());
979
980 let hidden = command_access_for("theme", &allowlist, ®istry, &context);
981 assert_eq!(hidden.reasons, vec![AccessReason::HiddenByPolicy]);
982 assert!(!hidden.is_visible());
983
984 let fallback =
985 command_access_for("config", &None, &CommandPolicyRegistry::default(), &context);
986 assert!(fallback.is_visible());
987 assert!(fallback.is_runnable());
988 }
989}