Skip to main content

osp_cli/core/
command_policy.rs

1//! Runtime command visibility and access policy evaluation.
2//!
3//! This module exists to answer two related questions consistently:
4//! should a command be shown, and may the current caller run it? Command
5//! metadata can carry coarse auth requirements, but this module owns the
6//! normalized runtime evaluation rules.
7//!
8//! In broad terms:
9//!
10//! - [`crate::core::command_policy::CommandPolicy`] describes one command's
11//!   visibility and prerequisites
12//! - [`crate::core::command_policy::CommandPolicyContext`] captures the runtime
13//!   facts used during evaluation
14//! - [`crate::core::command_policy::evaluate_policy`] turns the two into a
15//!   concrete access decision
16//! - [`crate::core::command_policy::CommandPolicyRegistry`] stores policies and
17//!   applies per-path overrides
18//!
19//! Contract:
20//!
21//! - this module owns normalized policy evaluation, not command metadata shape
22//! - visibility and runnability are distinct outcomes and should stay distinct
23//! - callers should rely on the returned
24//!   [`crate::core::command_policy::CommandAccess`] instead of re-deriving
25//!   access rules ad hoc
26//!
27//! Public API shape:
28//!
29//! - [`crate::core::command_policy::CommandPolicy`] remains a fluent semantic
30//!   policy DSL
31//! - [`crate::core::command_policy::CommandPolicyOverride`] uses an explicit
32//!   constructor plus `with_*` normalization helpers so overrides follow the
33//!   same normalization rules as base policies
34
35use std::collections::{BTreeMap, BTreeSet};
36
37use serde::{Deserialize, Serialize};
38
39/// Normalized command path used as the lookup key for policy evaluation.
40#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
41pub struct CommandPath(Vec<String>);
42
43impl CommandPath {
44    /// Builds a normalized command path, lowercasing segments and dropping
45    /// empty values after trimming.
46    ///
47    /// # Examples
48    ///
49    /// ```
50    /// use osp_cli::core::command_policy::CommandPath;
51    ///
52    /// let path = CommandPath::new([" Orch ", "", "Approval", "  Decide  "]);
53    ///
54    /// assert_eq!(path.as_slice(), &["orch", "approval", "decide"]);
55    /// ```
56    pub fn new<I, S>(segments: I) -> Self
57    where
58        I: IntoIterator<Item = S>,
59        S: Into<String>,
60    {
61        Self(
62            segments
63                .into_iter()
64                .map(Into::into)
65                .map(|segment| segment.trim().to_ascii_lowercase())
66                .filter(|segment| !segment.is_empty())
67                .collect(),
68        )
69    }
70
71    /// Returns the normalized path segments.
72    pub fn as_slice(&self) -> &[String] {
73        self.0.as_slice()
74    }
75
76    /// Returns `true` when the path contains no usable segments.
77    pub fn is_empty(&self) -> bool {
78        self.0.is_empty()
79    }
80}
81
82/// Visibility contract applied before runtime capability checks.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(rename_all = "snake_case")]
85pub enum VisibilityMode {
86    /// Show and allow the command without authentication.
87    Public,
88    /// Show the command, but require authentication to run it.
89    Authenticated,
90    /// Show the command only when capability checks pass.
91    CapabilityGated,
92    /// Hide the command regardless of runtime context.
93    Hidden,
94}
95
96/// Product-level availability state for a command.
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub enum CommandAvailability {
99    /// The product currently exposes the command.
100    Available,
101    /// The product disables the command entirely.
102    Disabled,
103}
104
105/// Declarative policy used to decide whether a command is visible and runnable.
106#[derive(Debug, Clone, PartialEq, Eq)]
107#[must_use]
108pub struct CommandPolicy {
109    /// Normalized command path used as the registry key.
110    pub path: CommandPath,
111    /// Baseline visibility rule for the command.
112    pub visibility: VisibilityMode,
113    /// Product-level availability state for the command.
114    pub availability: CommandAvailability,
115    /// Capabilities that must be present for capability-gated commands.
116    pub required_capabilities: BTreeSet<String>,
117    /// Feature flags that must be enabled before the command is exposed.
118    pub feature_flags: BTreeSet<String>,
119    /// Profiles allowed to see the command, when restricted.
120    pub allowed_profiles: Option<BTreeSet<String>>,
121    /// Optional message shown when the command is visible but denied.
122    pub denied_message: Option<String>,
123    /// Optional explanation for why the command is hidden.
124    pub hidden_reason: Option<String>,
125}
126
127impl CommandPolicy {
128    /// Creates a public, available policy for the given command path.
129    ///
130    /// # Examples
131    ///
132    /// ```
133    /// use osp_cli::core::command_policy::{CommandPath, CommandPolicy, VisibilityMode};
134    ///
135    /// let policy = CommandPolicy::new(CommandPath::new([" Orch ", "Approval "]))
136    ///     .visibility(VisibilityMode::CapabilityGated)
137    ///     .require_capability(" Orch.Approval.Decide ")
138    ///     .feature_flag(" Orch ")
139    ///     .allow_profiles([" Dev ", ""]);
140    ///
141    /// assert_eq!(policy.path.as_slice(), &["orch", "approval"]);
142    /// assert!(policy.required_capabilities.contains("orch.approval.decide"));
143    /// assert!(policy.feature_flags.contains("orch"));
144    /// assert!(policy.allowed_profiles.as_ref().unwrap().contains("dev"));
145    /// ```
146    pub fn new(path: CommandPath) -> Self {
147        Self {
148            path,
149            visibility: VisibilityMode::Public,
150            availability: CommandAvailability::Available,
151            required_capabilities: BTreeSet::new(),
152            feature_flags: BTreeSet::new(),
153            allowed_profiles: None,
154            denied_message: None,
155            hidden_reason: None,
156        }
157    }
158
159    /// Sets the visibility mode applied during policy evaluation.
160    pub fn visibility(mut self, visibility: VisibilityMode) -> Self {
161        self.visibility = visibility;
162        self
163    }
164
165    /// Adds a required capability after trimming and lowercasing it.
166    pub fn require_capability(mut self, capability: impl Into<String>) -> Self {
167        let normalized = capability.into().trim().to_ascii_lowercase();
168        if !normalized.is_empty() {
169            self.required_capabilities.insert(normalized);
170        }
171        self
172    }
173
174    /// Adds a feature flag prerequisite after trimming and lowercasing it.
175    pub fn feature_flag(mut self, flag: impl Into<String>) -> Self {
176        let normalized = flag.into().trim().to_ascii_lowercase();
177        if !normalized.is_empty() {
178            self.feature_flags.insert(normalized);
179        }
180        self
181    }
182
183    /// Restricts the policy to the provided normalized profile names.
184    pub fn allow_profiles<I, S>(mut self, profiles: I) -> Self
185    where
186        I: IntoIterator<Item = S>,
187        S: Into<String>,
188    {
189        let values = profiles
190            .into_iter()
191            .map(Into::into)
192            .map(|profile| profile.trim().to_ascii_lowercase())
193            .filter(|profile| !profile.is_empty())
194            .collect::<BTreeSet<_>>();
195        self.allowed_profiles = (!values.is_empty()).then_some(values);
196        self
197    }
198
199    /// Sets the user-facing denial message when the command is visible but not runnable.
200    pub fn denied_message(mut self, message: impl Into<String>) -> Self {
201        let normalized = message.into().trim().to_string();
202        self.denied_message = (!normalized.is_empty()).then_some(normalized);
203        self
204    }
205
206    /// Sets the hidden-reason metadata after trimming empty values away.
207    pub fn hidden_reason(mut self, reason: impl Into<String>) -> Self {
208        let normalized = reason.into().trim().to_string();
209        self.hidden_reason = (!normalized.is_empty()).then_some(normalized);
210        self
211    }
212}
213
214/// Partial override applied on top of a registered [`CommandPolicy`].
215#[derive(Debug, Clone, PartialEq, Eq, Default)]
216#[non_exhaustive]
217#[must_use]
218pub struct CommandPolicyOverride {
219    /// Replacement visibility mode, when overridden.
220    pub visibility: Option<VisibilityMode>,
221    /// Replacement availability state, when overridden.
222    pub availability: Option<CommandAvailability>,
223    /// Additional required capabilities merged into the base policy.
224    pub required_capabilities: BTreeSet<String>,
225    /// Replacement hidden-reason metadata, when overridden.
226    pub hidden_reason: Option<String>,
227    /// Replacement denial message, when overridden.
228    pub denied_message: Option<String>,
229}
230
231impl CommandPolicyOverride {
232    /// Creates an empty override.
233    ///
234    /// # Examples
235    ///
236    /// ```
237    /// use osp_cli::core::command_policy::{
238    ///     CommandAvailability, CommandPolicyOverride, VisibilityMode,
239    /// };
240    ///
241    /// let override_policy = CommandPolicyOverride::new()
242    ///     .with_visibility(Some(VisibilityMode::CapabilityGated))
243    ///     .with_availability(Some(CommandAvailability::Disabled))
244    ///     .with_required_capabilities([" Orch.Policy.Write ", ""]);
245    ///
246    /// assert_eq!(
247    ///     override_policy.visibility,
248    ///     Some(VisibilityMode::CapabilityGated)
249    /// );
250    /// assert_eq!(
251    ///     override_policy.availability,
252    ///     Some(CommandAvailability::Disabled)
253    /// );
254    /// assert!(override_policy.required_capabilities.contains("orch.policy.write"));
255    /// ```
256    pub fn new() -> Self {
257        Self::default()
258    }
259
260    /// Replaces the overridden visibility mode.
261    pub fn with_visibility(mut self, visibility: Option<VisibilityMode>) -> Self {
262        self.visibility = visibility;
263        self
264    }
265
266    /// Replaces the overridden availability state.
267    pub fn with_availability(mut self, availability: Option<CommandAvailability>) -> Self {
268        self.availability = availability;
269        self
270    }
271
272    /// Replaces the merged required-capability set with normalized values.
273    pub fn with_required_capabilities<I, S>(mut self, required_capabilities: I) -> Self
274    where
275        I: IntoIterator<Item = S>,
276        S: Into<String>,
277    {
278        self.required_capabilities = required_capabilities
279            .into_iter()
280            .map(Into::into)
281            .map(|capability| capability.trim().to_ascii_lowercase())
282            .filter(|capability| !capability.is_empty())
283            .collect();
284        self
285    }
286
287    /// Replaces the optional hidden-reason metadata.
288    pub fn with_hidden_reason(mut self, hidden_reason: Option<String>) -> Self {
289        self.hidden_reason = hidden_reason
290            .map(|reason| reason.trim().to_string())
291            .filter(|reason| !reason.is_empty());
292        self
293    }
294
295    /// Replaces the optional denial message.
296    pub fn with_denied_message(mut self, denied_message: Option<String>) -> Self {
297        self.denied_message = denied_message
298            .map(|message| message.trim().to_string())
299            .filter(|message| !message.is_empty());
300        self
301    }
302}
303
304/// Runtime facts used to evaluate a command policy.
305#[derive(Debug, Clone, Default, PartialEq, Eq)]
306#[must_use]
307pub struct CommandPolicyContext {
308    /// Whether the current caller is authenticated.
309    pub authenticated: bool,
310    /// Normalized capabilities available to the caller.
311    pub capabilities: BTreeSet<String>,
312    /// Normalized feature flags enabled in the current product build.
313    pub enabled_features: BTreeSet<String>,
314    /// Active normalized profile name, when one is selected.
315    pub active_profile: Option<String>,
316}
317
318impl CommandPolicyContext {
319    /// Sets whether the current user is authenticated.
320    pub fn authenticated(mut self, value: bool) -> Self {
321        self.authenticated = value;
322        self
323    }
324
325    /// Replaces the current capability set with normalized capability names.
326    pub fn with_capabilities<I, S>(mut self, capabilities: I) -> Self
327    where
328        I: IntoIterator<Item = S>,
329        S: Into<String>,
330    {
331        self.capabilities = capabilities
332            .into_iter()
333            .map(Into::into)
334            .map(|capability| capability.trim().to_ascii_lowercase())
335            .filter(|capability| !capability.is_empty())
336            .collect();
337        self
338    }
339
340    /// Replaces the enabled feature set with normalized feature names.
341    pub fn with_features<I, S>(mut self, features: I) -> Self
342    where
343        I: IntoIterator<Item = S>,
344        S: Into<String>,
345    {
346        self.enabled_features = features
347            .into_iter()
348            .map(Into::into)
349            .map(|feature| feature.trim().to_ascii_lowercase())
350            .filter(|feature| !feature.is_empty())
351            .collect();
352        self
353    }
354
355    /// Sets the active profile after trimming and lowercasing it.
356    pub fn with_profile(mut self, profile: impl Into<String>) -> Self {
357        let normalized = profile.into().trim().to_ascii_lowercase();
358        self.active_profile = (!normalized.is_empty()).then_some(normalized);
359        self
360    }
361}
362
363/// Visibility outcome produced by policy evaluation.
364#[derive(Debug, Clone, Copy, PartialEq, Eq)]
365pub enum CommandVisibility {
366    /// The command should not be shown to the caller.
367    Hidden,
368    /// The command should be shown to the caller.
369    Visible,
370}
371
372/// Runnable outcome produced by policy evaluation.
373#[derive(Debug, Clone, Copy, PartialEq, Eq)]
374pub enum CommandRunnable {
375    /// The caller may execute the command.
376    Runnable,
377    /// The caller may see the command but not run it.
378    Denied,
379}
380
381/// Reason codes attached to denied or hidden command access.
382#[derive(Debug, Clone, PartialEq, Eq)]
383pub enum AccessReason {
384    /// The policy explicitly hides the command.
385    HiddenByPolicy,
386    /// Product configuration disables the command entirely.
387    DisabledByProduct,
388    /// Authentication is required before the command may run.
389    Unauthenticated,
390    /// One or more required capabilities are missing.
391    MissingCapabilities,
392    /// A required feature flag is disabled.
393    FeatureDisabled(String),
394    /// The command is unavailable in the active profile.
395    ProfileUnavailable(String),
396}
397
398/// Effective access decision for a command under a specific context.
399#[derive(Debug, Clone, PartialEq, Eq)]
400pub struct CommandAccess {
401    /// Whether the command should be shown to the caller.
402    pub visibility: CommandVisibility,
403    /// Whether the caller may execute the command.
404    pub runnable: CommandRunnable,
405    /// Reasons that explain why access was restricted.
406    pub reasons: Vec<AccessReason>,
407    /// Required capabilities absent from the current context.
408    pub missing_capabilities: BTreeSet<String>,
409}
410
411impl CommandAccess {
412    /// Returns an access result that is visible and runnable.
413    pub fn visible_runnable() -> Self {
414        Self {
415            visibility: CommandVisibility::Visible,
416            runnable: CommandRunnable::Runnable,
417            reasons: Vec::new(),
418            missing_capabilities: BTreeSet::new(),
419        }
420    }
421
422    /// Returns an access result that is hidden and denied for the given reason.
423    pub fn hidden(reason: AccessReason) -> Self {
424        Self {
425            visibility: CommandVisibility::Hidden,
426            runnable: CommandRunnable::Denied,
427            reasons: vec![reason],
428            missing_capabilities: BTreeSet::new(),
429        }
430    }
431
432    /// Returns an access result that is visible but denied for the given reason.
433    pub fn visible_denied(reason: AccessReason) -> Self {
434        Self {
435            visibility: CommandVisibility::Visible,
436            runnable: CommandRunnable::Denied,
437            reasons: vec![reason],
438            missing_capabilities: BTreeSet::new(),
439        }
440    }
441
442    /// Returns `true` when the command should be shown to the user.
443    pub fn is_visible(&self) -> bool {
444        matches!(self.visibility, CommandVisibility::Visible)
445    }
446
447    /// Returns `true` when the command may be executed.
448    pub fn is_runnable(&self) -> bool {
449        matches!(self.runnable, CommandRunnable::Runnable)
450    }
451}
452
453/// Registry of command policies and per-path overrides.
454#[derive(Debug, Clone, Default)]
455pub struct CommandPolicyRegistry {
456    entries: BTreeMap<CommandPath, CommandPolicy>,
457    overrides: BTreeMap<CommandPath, CommandPolicyOverride>,
458}
459
460impl CommandPolicyRegistry {
461    /// Creates an empty policy registry.
462    pub fn new() -> Self {
463        Self::default()
464    }
465
466    /// Returns `true` when no base policies are registered.
467    pub fn is_empty(&self) -> bool {
468        self.entries.is_empty()
469    }
470
471    /// Registers a policy and returns the previous policy for the same path, if any.
472    pub fn register(&mut self, policy: CommandPolicy) -> Option<CommandPolicy> {
473        self.entries.insert(policy.path.clone(), policy)
474    }
475
476    /// Stores an override for a path and returns the previous override, if any.
477    pub fn override_policy(
478        &mut self,
479        path: CommandPath,
480        value: CommandPolicyOverride,
481    ) -> Option<CommandPolicyOverride> {
482        self.overrides.insert(path, value)
483    }
484
485    /// Returns the registered policy merged with any override for the same
486    /// path.
487    ///
488    /// # Examples
489    ///
490    /// ```
491    /// use osp_cli::core::command_policy::{
492    ///     CommandAvailability, CommandPath, CommandPolicy, CommandPolicyOverride,
493    ///     CommandPolicyRegistry, VisibilityMode,
494    /// };
495    ///
496    /// let path = CommandPath::new(["orch", "policy"]);
497    /// let mut registry = CommandPolicyRegistry::new();
498    /// registry.register(CommandPolicy::new(path.clone()).visibility(VisibilityMode::Authenticated));
499    /// registry.override_policy(
500    ///     path.clone(),
501    ///     CommandPolicyOverride::new()
502    ///         .with_availability(Some(CommandAvailability::Disabled))
503    ///         .with_required_capabilities(["orch.policy.write"]),
504    /// );
505    ///
506    /// let resolved = registry.resolved_policy(&path).unwrap();
507    ///
508    /// assert_eq!(resolved.visibility, VisibilityMode::Authenticated);
509    /// assert_eq!(resolved.availability, CommandAvailability::Disabled);
510    /// assert!(resolved.required_capabilities.contains("orch.policy.write"));
511    /// ```
512    pub fn resolved_policy(&self, path: &CommandPath) -> Option<CommandPolicy> {
513        let mut policy = self.entries.get(path)?.clone();
514        if let Some(override_policy) = self.overrides.get(path) {
515            if let Some(visibility) = override_policy.visibility {
516                policy.visibility = visibility;
517            }
518            if let Some(availability) = override_policy.availability {
519                policy.availability = availability;
520            }
521            policy
522                .required_capabilities
523                .extend(override_policy.required_capabilities.iter().cloned());
524            if let Some(hidden_reason) = &override_policy.hidden_reason {
525                policy.hidden_reason = Some(hidden_reason.clone());
526            }
527            if let Some(denied_message) = &override_policy.denied_message {
528                policy.denied_message = Some(denied_message.clone());
529            }
530        }
531        Some(policy)
532    }
533
534    /// Evaluates the resolved policy for `path`, or `None` if the path is unknown.
535    pub fn evaluate(
536        &self,
537        path: &CommandPath,
538        context: &CommandPolicyContext,
539    ) -> Option<CommandAccess> {
540        self.resolved_policy(path)
541            .map(|policy| evaluate_policy(&policy, context))
542    }
543
544    /// Returns `true` when a policy is registered for `path`.
545    pub fn contains(&self, path: &CommandPath) -> bool {
546        self.entries.contains_key(path)
547    }
548
549    /// Iterates over the registered base policies.
550    pub fn entries(&self) -> impl Iterator<Item = &CommandPolicy> {
551        self.entries.values()
552    }
553}
554
555/// Evaluates a single policy against the supplied runtime context.
556///
557/// Visibility and runnability are evaluated separately. For example, an
558/// authenticated-only command stays visible to unauthenticated users, but is
559/// denied at execution time.
560///
561/// # Examples
562///
563/// ```
564/// use osp_cli::core::command_policy::{
565///     AccessReason, CommandPath, CommandPolicy, CommandPolicyContext,
566///     CommandRunnable, CommandVisibility, VisibilityMode, evaluate_policy,
567/// };
568///
569/// let policy = CommandPolicy::new(CommandPath::new(["orch", "approval", "decide"]))
570///     .visibility(VisibilityMode::CapabilityGated)
571///     .require_capability("orch.approval.decide");
572///
573/// let denied = evaluate_policy(
574///     &policy,
575///     &CommandPolicyContext::default().authenticated(true),
576/// );
577/// assert_eq!(denied.visibility, CommandVisibility::Visible);
578/// assert_eq!(denied.runnable, CommandRunnable::Denied);
579/// assert_eq!(denied.reasons, vec![AccessReason::MissingCapabilities]);
580///
581/// let allowed = evaluate_policy(
582///     &policy,
583///     &CommandPolicyContext::default()
584///         .authenticated(true)
585///         .with_capabilities(["orch.approval.decide"]),
586/// );
587/// assert!(allowed.is_runnable());
588/// ```
589pub fn evaluate_policy(policy: &CommandPolicy, context: &CommandPolicyContext) -> CommandAccess {
590    if matches!(policy.availability, CommandAvailability::Disabled) {
591        return CommandAccess::hidden(AccessReason::DisabledByProduct);
592    }
593    if matches!(policy.visibility, VisibilityMode::Hidden) {
594        return CommandAccess::hidden(AccessReason::HiddenByPolicy);
595    }
596    if let Some(allowed_profiles) = &policy.allowed_profiles {
597        match context.active_profile.as_ref() {
598            Some(profile) if allowed_profiles.contains(profile) => {}
599            Some(profile) => {
600                return CommandAccess::hidden(AccessReason::ProfileUnavailable(profile.clone()));
601            }
602            None => return CommandAccess::hidden(AccessReason::ProfileUnavailable(String::new())),
603        }
604    }
605    if let Some(feature) = policy
606        .feature_flags
607        .iter()
608        .find(|feature| !context.enabled_features.contains(*feature))
609    {
610        return CommandAccess::hidden(AccessReason::FeatureDisabled(feature.clone()));
611    }
612
613    match policy.visibility {
614        VisibilityMode::Public => CommandAccess::visible_runnable(),
615        VisibilityMode::Authenticated => {
616            if context.authenticated {
617                CommandAccess::visible_runnable()
618            } else {
619                CommandAccess::visible_denied(AccessReason::Unauthenticated)
620            }
621        }
622        VisibilityMode::CapabilityGated => {
623            if !context.authenticated {
624                return CommandAccess::visible_denied(AccessReason::Unauthenticated);
625            }
626            let missing = policy
627                .required_capabilities
628                .iter()
629                .filter(|capability| !context.capabilities.contains(*capability))
630                .cloned()
631                .collect::<BTreeSet<_>>();
632            if missing.is_empty() {
633                CommandAccess::visible_runnable()
634            } else {
635                CommandAccess {
636                    visibility: CommandVisibility::Visible,
637                    runnable: CommandRunnable::Denied,
638                    reasons: vec![AccessReason::MissingCapabilities],
639                    missing_capabilities: missing,
640                }
641            }
642        }
643        VisibilityMode::Hidden => CommandAccess::hidden(AccessReason::HiddenByPolicy),
644    }
645}
646
647#[cfg(test)]
648mod tests {
649    use std::collections::BTreeSet;
650
651    use super::{
652        AccessReason, CommandAccess, CommandAvailability, CommandPath, CommandPolicy,
653        CommandPolicyContext, CommandPolicyOverride, CommandPolicyRegistry, CommandRunnable,
654        CommandVisibility, VisibilityMode, evaluate_policy,
655    };
656
657    #[test]
658    fn command_path_and_policy_builders_normalize_inputs() {
659        let path = CommandPath::new([" Orch ", "", "Approval", "  Decide  "]);
660        assert_eq!(
661            path.as_slice(),
662            &[
663                "orch".to_string(),
664                "approval".to_string(),
665                "decide".to_string()
666            ]
667        );
668        assert!(!path.is_empty());
669        assert!(CommandPath::new(["", "   "]).is_empty());
670
671        let policy = CommandPolicy::new(path.clone())
672            .visibility(VisibilityMode::CapabilityGated)
673            .require_capability(" Orch.Approval.Decide ")
674            .require_capability("   ")
675            .feature_flag(" Orch ")
676            .feature_flag("")
677            .allow_profiles([" Dev ", " ", "Prod"])
678            .denied_message("  Sign in first  ")
679            .hidden_reason("  hidden upstream  ");
680
681        assert_eq!(policy.path, path);
682        assert_eq!(policy.visibility, VisibilityMode::CapabilityGated);
683        assert_eq!(
684            policy.required_capabilities,
685            BTreeSet::from(["orch.approval.decide".to_string()])
686        );
687        assert_eq!(policy.feature_flags, BTreeSet::from(["orch".to_string()]));
688        assert_eq!(
689            policy.allowed_profiles,
690            Some(BTreeSet::from(["dev".to_string(), "prod".to_string()]))
691        );
692        assert_eq!(policy.denied_message.as_deref(), Some("Sign in first"));
693        assert_eq!(policy.hidden_reason.as_deref(), Some("hidden upstream"));
694    }
695
696    #[test]
697    fn policy_context_builders_normalize_inputs() {
698        let context = CommandPolicyContext::default()
699            .authenticated(true)
700            .with_capabilities([" Orch.Read ", "", "orch.write"])
701            .with_features([" Orch ", " "])
702            .with_profile(" Dev ");
703
704        assert!(context.authenticated);
705        assert_eq!(
706            context.capabilities,
707            BTreeSet::from(["orch.read".to_string(), "orch.write".to_string()])
708        );
709        assert_eq!(
710            context.enabled_features,
711            BTreeSet::from(["orch".to_string()])
712        );
713        assert_eq!(context.active_profile.as_deref(), Some("dev"));
714        assert_eq!(
715            CommandPolicyContext::default()
716                .with_profile("   ")
717                .active_profile,
718            None
719        );
720    }
721
722    #[test]
723    fn capability_gated_command_is_visible_but_denied_when_capability_missing() {
724        let mut registry = CommandPolicyRegistry::new();
725        let path = CommandPath::new(["orch", "approval", "decide"]);
726        registry.register(
727            CommandPolicy::new(path.clone())
728                .visibility(VisibilityMode::CapabilityGated)
729                .require_capability("orch.approval.decide"),
730        );
731
732        let access = registry
733            .evaluate(&path, &CommandPolicyContext::default().authenticated(true))
734            .expect("policy should exist");
735
736        assert_eq!(access.visibility, CommandVisibility::Visible);
737        assert_eq!(access.runnable, CommandRunnable::Denied);
738        assert_eq!(access.reasons, vec![AccessReason::MissingCapabilities]);
739    }
740
741    #[test]
742    fn required_capabilities_are_simple_conjunction() {
743        let mut registry = CommandPolicyRegistry::new();
744        let path = CommandPath::new(["orch", "policy", "add"]);
745        registry.register(
746            CommandPolicy::new(path.clone())
747                .visibility(VisibilityMode::CapabilityGated)
748                .require_capability("orch.policy.read")
749                .require_capability("orch.policy.write"),
750        );
751
752        let access = registry
753            .evaluate(
754                &path,
755                &CommandPolicyContext::default()
756                    .authenticated(true)
757                    .with_capabilities(["orch.policy.read"]),
758            )
759            .expect("policy should exist");
760
761        assert!(access.missing_capabilities.contains("orch.policy.write"));
762    }
763
764    #[test]
765    fn public_commands_can_remain_unauthenticated() {
766        let policy = CommandPolicy::new(CommandPath::new(["help"]));
767        let access = evaluate_policy(&policy, &CommandPolicyContext::default());
768        assert_eq!(access, CommandAccess::visible_runnable());
769    }
770
771    #[test]
772    fn overrides_can_hide_commands() {
773        let mut registry = CommandPolicyRegistry::new();
774        let path = CommandPath::new(["nh", "audit"]);
775        registry.register(CommandPolicy::new(path.clone()));
776        registry.override_policy(
777            path.clone(),
778            CommandPolicyOverride::new().with_visibility(Some(VisibilityMode::Hidden)),
779        );
780
781        let access = registry
782            .evaluate(&path, &CommandPolicyContext::default())
783            .expect("policy should exist");
784        assert_eq!(access.visibility, CommandVisibility::Hidden);
785    }
786
787    #[test]
788    fn access_helpers_reflect_visibility_and_runnability() {
789        let access = CommandAccess::visible_denied(AccessReason::Unauthenticated);
790        assert!(access.is_visible());
791        assert!(!access.is_runnable());
792    }
793
794    #[test]
795    fn evaluate_policy_covers_disabled_hidden_feature_profile_and_auth_variants() {
796        let disabled = CommandPolicy::new(CommandPath::new(["orch"]))
797            .visibility(VisibilityMode::Authenticated);
798        let mut disabled = disabled;
799        disabled.availability = CommandAvailability::Disabled;
800        assert_eq!(
801            evaluate_policy(&disabled, &CommandPolicyContext::default()),
802            CommandAccess::hidden(AccessReason::DisabledByProduct)
803        );
804
805        let hidden =
806            CommandPolicy::new(CommandPath::new(["orch"])).visibility(VisibilityMode::Hidden);
807        assert_eq!(
808            evaluate_policy(&hidden, &CommandPolicyContext::default()),
809            CommandAccess::hidden(AccessReason::HiddenByPolicy)
810        );
811
812        let profiled = CommandPolicy::new(CommandPath::new(["orch"]))
813            .allow_profiles(["dev"])
814            .feature_flag("orch");
815        assert_eq!(
816            evaluate_policy(&profiled, &CommandPolicyContext::default()),
817            CommandAccess::hidden(AccessReason::ProfileUnavailable(String::new()))
818        );
819        assert_eq!(
820            evaluate_policy(
821                &profiled,
822                &CommandPolicyContext::default().with_profile("prod")
823            ),
824            CommandAccess::hidden(AccessReason::ProfileUnavailable("prod".to_string()))
825        );
826        assert_eq!(
827            evaluate_policy(
828                &profiled,
829                &CommandPolicyContext::default().with_profile("dev")
830            ),
831            CommandAccess::hidden(AccessReason::FeatureDisabled("orch".to_string()))
832        );
833
834        let auth_only = CommandPolicy::new(CommandPath::new(["auth", "status"]))
835            .visibility(VisibilityMode::Authenticated);
836        assert_eq!(
837            evaluate_policy(&auth_only, &CommandPolicyContext::default()),
838            CommandAccess::visible_denied(AccessReason::Unauthenticated)
839        );
840        assert_eq!(
841            evaluate_policy(
842                &auth_only,
843                &CommandPolicyContext::default().authenticated(true)
844            ),
845            CommandAccess::visible_runnable()
846        );
847
848        let capability = CommandPolicy::new(CommandPath::new(["orch", "approval"]))
849            .visibility(VisibilityMode::CapabilityGated)
850            .require_capability("orch.approval.decide");
851        assert_eq!(
852            evaluate_policy(
853                &capability,
854                &CommandPolicyContext::default()
855                    .authenticated(true)
856                    .with_capabilities(["orch.approval.decide"])
857            ),
858            CommandAccess::visible_runnable()
859        );
860    }
861
862    #[test]
863    fn registry_resolution_applies_overrides_and_contains_lookup() {
864        let path = CommandPath::new(["orch", "policy"]);
865        let mut registry = CommandPolicyRegistry::new();
866        assert!(!registry.contains(&path));
867        assert!(registry.resolved_policy(&path).is_none());
868
869        registry.register(
870            CommandPolicy::new(path.clone())
871                .visibility(VisibilityMode::Authenticated)
872                .allow_profiles(["dev"])
873                .denied_message("sign in")
874                .hidden_reason("base hidden"),
875        );
876        assert!(registry.contains(&path));
877
878        registry.override_policy(
879            path.clone(),
880            CommandPolicyOverride::new()
881                .with_visibility(Some(VisibilityMode::CapabilityGated))
882                .with_availability(Some(CommandAvailability::Disabled))
883                .with_required_capabilities(["orch.policy.write"])
884                .with_hidden_reason(Some("override hidden".to_string()))
885                .with_denied_message(Some("override denied".to_string())),
886        );
887
888        let resolved = registry
889            .resolved_policy(&path)
890            .expect("policy should resolve");
891        assert_eq!(resolved.visibility, VisibilityMode::CapabilityGated);
892        assert_eq!(resolved.availability, CommandAvailability::Disabled);
893        assert_eq!(
894            resolved.required_capabilities,
895            BTreeSet::from(["orch.policy.write".to_string()])
896        );
897        assert_eq!(resolved.hidden_reason.as_deref(), Some("override hidden"));
898        assert_eq!(resolved.denied_message.as_deref(), Some("override denied"));
899        assert_eq!(
900            registry.evaluate(&path, &CommandPolicyContext::default()),
901            Some(CommandAccess::hidden(AccessReason::DisabledByProduct))
902        );
903    }
904}