1use std::collections::HashSet;
4use std::path::PathBuf;
5
6use crate::config::{ResolvedConfig, RuntimeLoadOptions};
7use crate::core::command_policy::{
8 AccessReason, CommandAccess, CommandPolicy, CommandPolicyContext, CommandPolicyRegistry,
9 VisibilityMode,
10};
11use crate::native::NativeCommandRegistry;
12use crate::plugin::PluginManager;
13use crate::plugin::config::{PluginConfigEntry, PluginConfigEnv, PluginConfigEnvCache};
14use crate::ui::RenderSettings;
15use crate::ui::messages::MessageLevel;
16use crate::ui::theme_loader::ThemeCatalog;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum TerminalKind {
20 Cli,
21 Repl,
22}
23
24impl TerminalKind {
25 pub fn as_config_terminal(self) -> &'static str {
26 match self {
27 TerminalKind::Cli => "cli",
28 TerminalKind::Repl => "repl",
29 }
30 }
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct RuntimeContext {
35 profile_override: Option<String>,
36 terminal_kind: TerminalKind,
37 terminal_env: Option<String>,
38}
39
40impl RuntimeContext {
41 pub fn new(
42 profile_override: Option<String>,
43 terminal_kind: TerminalKind,
44 terminal_env: Option<String>,
45 ) -> Self {
46 Self {
47 profile_override: profile_override
48 .map(|value| value.trim().to_ascii_lowercase())
49 .filter(|value| !value.is_empty()),
50 terminal_kind,
51 terminal_env,
52 }
53 }
54
55 pub fn profile_override(&self) -> Option<&str> {
56 self.profile_override.as_deref()
57 }
58
59 pub fn terminal_kind(&self) -> TerminalKind {
60 self.terminal_kind
61 }
62
63 pub fn terminal_env(&self) -> Option<&str> {
64 self.terminal_env.as_deref()
65 }
66}
67
68pub struct ConfigState {
69 resolved: ResolvedConfig,
70 revision: u64,
71}
72
73impl ConfigState {
74 pub fn new(resolved: ResolvedConfig) -> Self {
75 Self {
76 resolved,
77 revision: 1,
78 }
79 }
80
81 pub fn resolved(&self) -> &ResolvedConfig {
82 &self.resolved
83 }
84
85 pub fn revision(&self) -> u64 {
86 self.revision
87 }
88
89 pub fn replace_resolved(&mut self, next: ResolvedConfig) -> bool {
90 if self.resolved == next {
91 return false;
92 }
93
94 self.resolved = next;
95 self.revision += 1;
96 true
97 }
98
99 pub fn transaction<F, E>(&mut self, mutator: F) -> Result<bool, E>
100 where
101 F: FnOnce(&ResolvedConfig) -> Result<ResolvedConfig, E>,
102 {
103 let current = self.resolved.clone();
104 let candidate = mutator(¤t)?;
105 Ok(self.replace_resolved(candidate))
106 }
107}
108
109#[derive(Debug, Clone)]
110pub struct UiState {
111 pub render_settings: RenderSettings,
112 pub message_verbosity: MessageLevel,
113 pub debug_verbosity: u8,
114}
115
116#[derive(Debug, Clone, Default)]
117pub struct LaunchContext {
118 pub plugin_dirs: Vec<PathBuf>,
119 pub config_root: Option<PathBuf>,
120 pub cache_root: Option<PathBuf>,
121 pub runtime_load: RuntimeLoadOptions,
122}
123
124pub struct AppClients {
125 pub plugins: PluginManager,
126 pub native_commands: NativeCommandRegistry,
127 plugin_config_env: PluginConfigEnvCache,
128}
129
130impl AppClients {
131 pub fn new(plugins: PluginManager, native_commands: NativeCommandRegistry) -> Self {
132 Self {
133 plugins,
134 native_commands,
135 plugin_config_env: PluginConfigEnvCache::default(),
136 }
137 }
138
139 pub(crate) fn plugin_config_env(&self, config: &ConfigState) -> PluginConfigEnv {
140 self.plugin_config_env.collect(config)
141 }
142
143 pub(crate) fn plugin_config_entries(
144 &self,
145 config: &ConfigState,
146 plugin_id: &str,
147 ) -> Vec<PluginConfigEntry> {
148 let config_env = self.plugin_config_env(config);
149 let mut merged = std::collections::BTreeMap::new();
150 for entry in config_env.shared {
151 merged.insert(entry.env_key.clone(), entry);
152 }
153 if let Some(entries) = config_env.by_plugin_id.get(plugin_id) {
154 for entry in entries {
155 merged.insert(entry.env_key.clone(), entry.clone());
156 }
157 }
158 merged.into_values().collect()
159 }
160}
161
162pub struct AppRuntime {
163 pub context: RuntimeContext,
164 pub config: ConfigState,
165 pub ui: UiState,
166 pub auth: AuthState,
167 pub(crate) themes: ThemeCatalog,
168 pub launch: LaunchContext,
169}
170
171pub struct AuthState {
172 builtins_allowlist: Option<HashSet<String>>,
173 external_allowlist: Option<HashSet<String>>,
174 policy_context: CommandPolicyContext,
175 builtin_policy: CommandPolicyRegistry,
176 external_policy: CommandPolicyRegistry,
177}
178
179impl AuthState {
180 pub fn from_resolved(config: &ResolvedConfig) -> Self {
181 Self {
182 builtins_allowlist: parse_allowlist(config.get_string("auth.visible.builtins")),
183 external_allowlist: parse_allowlist(config.get_string("auth.visible.plugins")),
188 policy_context: CommandPolicyContext::default(),
189 builtin_policy: CommandPolicyRegistry::default(),
190 external_policy: CommandPolicyRegistry::default(),
191 }
192 }
193
194 pub fn policy_context(&self) -> &CommandPolicyContext {
195 &self.policy_context
196 }
197
198 pub fn set_policy_context(&mut self, context: CommandPolicyContext) {
199 self.policy_context = context;
200 }
201
202 pub fn builtin_policy(&self) -> &CommandPolicyRegistry {
203 &self.builtin_policy
204 }
205
206 pub fn builtin_policy_mut(&mut self) -> &mut CommandPolicyRegistry {
207 &mut self.builtin_policy
208 }
209
210 pub fn external_policy(&self) -> &CommandPolicyRegistry {
211 &self.external_policy
212 }
213
214 pub fn external_policy_mut(&mut self) -> &mut CommandPolicyRegistry {
215 &mut self.external_policy
216 }
217
218 pub fn replace_external_policy(&mut self, registry: CommandPolicyRegistry) {
219 self.external_policy = registry;
220 }
221
222 pub fn builtin_access(&self, command: &str) -> CommandAccess {
223 command_access_for(
224 command,
225 &self.builtins_allowlist,
226 &self.builtin_policy,
227 &self.policy_context,
228 )
229 }
230
231 pub fn external_command_access(&self, command: &str) -> CommandAccess {
232 command_access_for(
233 command,
234 &self.external_allowlist,
235 &self.external_policy,
236 &self.policy_context,
237 )
238 }
239
240 pub fn is_builtin_visible(&self, command: &str) -> bool {
241 self.builtin_access(command).is_visible()
242 }
243
244 pub fn is_external_command_visible(&self, command: &str) -> bool {
245 self.external_command_access(command).is_visible()
246 }
247
248 pub fn plugin_policy(&self) -> &CommandPolicyRegistry {
249 self.external_policy()
250 }
251
252 pub fn plugin_policy_mut(&mut self) -> &mut CommandPolicyRegistry {
253 self.external_policy_mut()
254 }
255
256 pub fn replace_plugin_policy(&mut self, registry: CommandPolicyRegistry) {
257 self.replace_external_policy(registry);
258 }
259
260 pub fn plugin_command_access(&self, command: &str) -> CommandAccess {
261 self.external_command_access(command)
262 }
263
264 pub fn is_plugin_command_visible(&self, command: &str) -> bool {
265 self.is_external_command_visible(command)
266 }
267}
268
269fn parse_allowlist(raw: Option<&str>) -> Option<HashSet<String>> {
270 let raw = raw.map(str::trim).filter(|value| !value.is_empty())?;
271
272 if raw == "*" {
273 return None;
274 }
275
276 let values = raw
277 .split([',', ' '])
278 .map(str::trim)
279 .filter(|value| !value.is_empty())
280 .map(|value| value.to_ascii_lowercase())
281 .collect::<HashSet<String>>();
282 if values.is_empty() {
283 None
284 } else {
285 Some(values)
286 }
287}
288
289fn is_visible_in_allowlist(allowlist: &Option<HashSet<String>>, command: &str) -> bool {
290 match allowlist {
291 None => true,
292 Some(values) => values.contains(&command.to_ascii_lowercase()),
293 }
294}
295
296fn command_access_for(
297 command: &str,
298 allowlist: &Option<HashSet<String>>,
299 registry: &CommandPolicyRegistry,
300 context: &CommandPolicyContext,
301) -> CommandAccess {
302 let normalized = command.trim().to_ascii_lowercase();
303 let default_policy = CommandPolicy::new(crate::core::command_policy::CommandPath::new([
304 normalized.clone(),
305 ]))
306 .visibility(VisibilityMode::Public);
307 let mut access = registry
308 .evaluate(&default_policy.path, context)
309 .unwrap_or_else(|| crate::core::command_policy::evaluate_policy(&default_policy, context));
310
311 if !is_visible_in_allowlist(allowlist, &normalized) {
312 access = CommandAccess::hidden(AccessReason::HiddenByPolicy);
313 }
314
315 access
316}
317
318#[cfg(test)]
319mod tests {
320 use std::collections::HashSet;
321
322 use crate::config::{ConfigLayer, ConfigResolver, LoadedLayers, ResolveOptions};
323 use crate::core::command_policy::{
324 AccessReason, CommandPath, CommandPolicy, CommandPolicyContext, CommandPolicyRegistry,
325 VisibilityMode,
326 };
327
328 use super::{
329 AuthState, ConfigState, RuntimeContext, TerminalKind, command_access_for,
330 is_visible_in_allowlist, parse_allowlist,
331 };
332
333 fn resolved_with(entries: &[(&str, &str)]) -> crate::config::ResolvedConfig {
334 let mut file = ConfigLayer::default();
335 for (key, value) in entries {
336 file.set(*key, (*value).to_string());
337 }
338 ConfigResolver::from_loaded_layers(LoadedLayers {
339 file,
340 ..LoadedLayers::default()
341 })
342 .resolve(ResolveOptions::default())
343 .expect("config should resolve")
344 }
345
346 #[test]
347 fn runtime_context_and_allowlists_normalize_inputs() {
348 let context = RuntimeContext::new(
349 Some(" Dev ".to_string()),
350 TerminalKind::Repl,
351 Some("xterm-256color".to_string()),
352 );
353 assert_eq!(context.profile_override(), Some("dev"));
354 assert_eq!(context.terminal_kind(), TerminalKind::Repl);
355 assert_eq!(context.terminal_env(), Some("xterm-256color"));
356
357 assert_eq!(parse_allowlist(None), None);
358 assert_eq!(parse_allowlist(Some(" ")), None);
359 assert_eq!(parse_allowlist(Some("*")), None);
360 assert_eq!(
361 parse_allowlist(Some(" LDAP, mreg ldap ")),
362 Some(HashSet::from(["ldap".to_string(), "mreg".to_string()]))
363 );
364
365 let allowlist = Some(HashSet::from(["ldap".to_string()]));
366 assert!(is_visible_in_allowlist(&allowlist, "LDAP"));
367 assert!(!is_visible_in_allowlist(&allowlist, "orch"));
368 }
369
370 #[test]
371 fn config_state_tracks_noops_changes_and_transaction_errors() {
372 let resolved = resolved_with(&[]);
373 let mut state = ConfigState::new(resolved.clone());
374 assert_eq!(state.revision(), 1);
375 assert!(!state.replace_resolved(resolved.clone()));
376 assert_eq!(state.revision(), 1);
377
378 let changed = resolved_with(&[("ui.format", "json")]);
379 assert!(state.replace_resolved(changed));
380 assert_eq!(state.revision(), 2);
381
382 let changed = state
383 .transaction(|current| {
384 let _ = current;
385 Ok::<_, &'static str>(resolved_with(&[("ui.format", "mreg")]))
386 })
387 .expect("transaction should succeed");
388 assert!(changed);
389 assert_eq!(state.revision(), 3);
390
391 let err = state
392 .transaction(|_| Err::<crate::config::ResolvedConfig, _>("boom"))
393 .expect_err("transaction error should propagate");
394 assert_eq!(err, "boom");
395 assert_eq!(state.revision(), 3);
396 }
397
398 #[test]
399 fn auth_state_and_command_access_layer_policy_overrides_on_allowlists() {
400 let resolved = resolved_with(&[
401 ("auth.visible.builtins", "config"),
402 ("auth.visible.plugins", "ldap"),
403 ]);
404 let mut auth = AuthState::from_resolved(&resolved);
405 auth.set_policy_context(
406 CommandPolicyContext::default()
407 .authenticated(true)
408 .with_capabilities(["orch.approval.decide"]),
409 );
410 assert!(auth.policy_context().authenticated);
411
412 auth.builtin_policy_mut().register(
413 CommandPolicy::new(CommandPath::new(["config"]))
414 .visibility(VisibilityMode::Authenticated),
415 );
416 assert!(auth.builtin_access("config").is_runnable());
417 assert!(auth.is_builtin_visible("config"));
418 assert!(!auth.is_builtin_visible("theme"));
419
420 let mut plugin_registry = CommandPolicyRegistry::new();
421 plugin_registry.register(
422 CommandPolicy::new(CommandPath::new(["ldap"]))
423 .visibility(VisibilityMode::CapabilityGated)
424 .require_capability("orch.approval.decide"),
425 );
426 plugin_registry.register(
427 CommandPolicy::new(CommandPath::new(["orch"]))
428 .visibility(VisibilityMode::Authenticated),
429 );
430 auth.replace_plugin_policy(plugin_registry);
431
432 assert!(auth.plugin_policy().contains(&CommandPath::new(["ldap"])));
433 assert!(
434 auth.plugin_policy_mut()
435 .contains(&CommandPath::new(["ldap"]))
436 );
437 assert!(auth.plugin_command_access("ldap").is_runnable());
438 assert!(auth.is_plugin_command_visible("ldap"));
439
440 let hidden = auth.plugin_command_access("orch");
441 assert_eq!(hidden.reasons, vec![AccessReason::HiddenByPolicy]);
442 assert!(!hidden.is_visible());
443 }
444
445 #[test]
446 fn command_access_for_uses_registry_when_present_and_public_default_otherwise() {
447 let context = CommandPolicyContext::default();
448 let allowlist = Some(HashSet::from(["config".to_string()]));
449 let mut registry = CommandPolicyRegistry::new();
450 registry.register(
451 CommandPolicy::new(CommandPath::new(["config"]))
452 .visibility(VisibilityMode::Authenticated),
453 );
454
455 let denied = command_access_for("config", &allowlist, ®istry, &context);
456 assert_eq!(denied.reasons, vec![AccessReason::Unauthenticated]);
457 assert!(denied.is_visible());
458 assert!(!denied.is_runnable());
459
460 let hidden = command_access_for("theme", &allowlist, ®istry, &context);
461 assert_eq!(hidden.reasons, vec![AccessReason::HiddenByPolicy]);
462 assert!(!hidden.is_visible());
463
464 let fallback =
465 command_access_for("config", &None, &CommandPolicyRegistry::default(), &context);
466 assert!(fallback.is_visible());
467 assert!(fallback.is_runnable());
468 }
469}