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