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