Skip to main content

cli/cli/cli_args/
commands_visibility.rs

1// SPDX-License-Identifier: Apache-2.0
2//! `heddle visibility` — declare and inspect a state's audience tier.
3//!
4//! The visibility primitive (spike #266) attaches an additive, per-state
5//! `StateVisibility` sidecar record outside the hashed state bytes, so a
6//! tier change never mutates the state or invalidates its signature. The
7//! verb family mirrors `redact`:
8//!
9//! - `set` declares a tier on a state (`OpRecord::StateVisibilitySet`).
10//! - `promote` appends a superseding, less-restrictive declaration
11//!   (`OpRecord::StateVisibilityPromote`).
12//! - `show` reports a state's effective tier (public-by-absence when none).
13//! - `list` enumerates every state carrying a non-public tier.
14//!
15//! Capture binds the inherited default tier automatically (Invariant A); the
16//! `set`/`promote` verbs are the explicit operator overrides on top of that.
17
18use clap::{Args, Subcommand, ValueEnum};
19use objects::object::VisibilityTier;
20
21#[derive(Clone, Debug, Subcommand)]
22pub enum VisibilityCommands {
23    /// Declare a visibility tier on a state. Public is the default and stays
24    /// record-free (absence ≡ public); a non-public tier writes a per-state
25    /// sidecar record and an oplog audit entry.
26    Set(VisibilitySetArgs),
27    /// Promote a state to a less-restrictive tier by appending a superseding
28    /// record. Requires an existing visibility record to supersede.
29    Promote(VisibilityPromoteArgs),
30    /// Show a state's effective visibility tier.
31    Show(VisibilityShowArgs),
32    /// List every state that carries a non-public visibility tier.
33    List(VisibilityListArgs),
34}
35
36/// CLI surface for the tier enum. `VisibilityTier` carries a label on its
37/// team-scoped / restricted / private variants, so it can't derive `ValueEnum`
38/// directly; this flat enum + `--label` reconstructs it. Kept in lockstep
39/// with `VisibilityTier` by [`VisibilityTierArg::into_tier`].
40#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
41#[value(rename_all = "kebab-case")]
42pub enum VisibilityTierArg {
43    Public,
44    Internal,
45    TeamScoped,
46    Restricted,
47    Private,
48}
49
50impl VisibilityTierArg {
51    /// Build the [`VisibilityTier`] this arg denotes. `team-scoped`,
52    /// `restricted`, and `private` (the strictest/embargo tier, withheld from
53    /// every audience incl. `internal`) require `--label` (the team id / scope
54    /// label); the label is ignored for `public` / `internal`. Returns the
55    /// human-facing error string when a required label is missing.
56    pub fn into_tier(self, label: Option<String>) -> Result<VisibilityTier, String> {
57        match self {
58            VisibilityTierArg::Public => Ok(VisibilityTier::Public),
59            VisibilityTierArg::Internal => Ok(VisibilityTier::Internal),
60            VisibilityTierArg::TeamScoped => match label {
61                Some(team_id) if !team_id.trim().is_empty() => {
62                    Ok(VisibilityTier::TeamScoped { team_id })
63                }
64                _ => Err("the team-scoped tier requires --label <team-id>".to_string()),
65            },
66            VisibilityTierArg::Restricted => match label {
67                Some(scope_label) if !scope_label.trim().is_empty() => {
68                    Ok(VisibilityTier::Restricted { scope_label })
69                }
70                _ => Err("the restricted tier requires --label <scope-label>".to_string()),
71            },
72            VisibilityTierArg::Private => match label {
73                Some(scope_label) if !scope_label.trim().is_empty() => {
74                    Ok(VisibilityTier::Private { scope_label })
75                }
76                _ => Err("the private tier requires --label <scope-label>".to_string()),
77            },
78        }
79    }
80}
81
82#[derive(Clone, Debug, Args)]
83pub struct VisibilitySetArgs {
84    /// State to declare the tier on. Accepts short or full state IDs, marker
85    /// names, `HEAD`, `@`, or `HEAD~N`.
86    pub state: String,
87    /// The audience tier to declare.
88    #[arg(long, value_enum)]
89    pub tier: VisibilityTierArg,
90    /// Label for the `team-scoped` (team id) or `restricted` / `private`
91    /// (scope label) tiers. Ignored for `public` / `internal`.
92    #[arg(long)]
93    pub label: Option<String>,
94}
95
96#[derive(Clone, Debug, Args)]
97pub struct VisibilityPromoteArgs {
98    /// State to promote. Accepts short or full state IDs, marker names,
99    /// `HEAD`, `@`, or `HEAD~N`.
100    pub state: String,
101    /// The less-restrictive tier to promote to.
102    #[arg(long, value_enum)]
103    pub tier: VisibilityTierArg,
104    /// Label for the `team-scoped` / `restricted` / `private` target tier.
105    #[arg(long)]
106    pub label: Option<String>,
107}
108
109#[derive(Clone, Debug, Args)]
110pub struct VisibilityShowArgs {
111    /// State to inspect.
112    pub state: String,
113}
114
115#[derive(Clone, Debug, Args)]
116pub struct VisibilityListArgs {}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn restricted_requires_non_empty_label() {
124        assert_eq!(
125            VisibilityTierArg::Restricted.into_tier(Some("legal".to_string())),
126            Ok(VisibilityTier::Restricted {
127                scope_label: "legal".to_string()
128            })
129        );
130        assert!(VisibilityTierArg::Restricted.into_tier(None).is_err());
131        assert!(
132            VisibilityTierArg::Restricted
133                .into_tier(Some("   ".to_string()))
134                .is_err()
135        );
136    }
137
138    #[test]
139    fn private_maps_to_private_tier_with_non_empty_label() {
140        assert_eq!(
141            VisibilityTierArg::Private.into_tier(Some("embargo-x".to_string())),
142            Ok(VisibilityTier::Private {
143                scope_label: "embargo-x".to_string()
144            })
145        );
146    }
147
148    #[test]
149    fn private_requires_a_label() {
150        assert_eq!(
151            VisibilityTierArg::Private.into_tier(None),
152            Err("the private tier requires --label <scope-label>".to_string())
153        );
154        assert_eq!(
155            VisibilityTierArg::Private.into_tier(Some("  ".to_string())),
156            Err("the private tier requires --label <scope-label>".to_string())
157        );
158    }
159
160    #[test]
161    fn private_flows_through_the_317_monotonicity_check_as_rank_4() {
162        // `promote` is the *opening* verb (#317): it appends a superseding,
163        // strictly-LESS-restrictive declaration. Private (rank 4) is the most
164        // restrictive tier, so it is the embargo tier you reach via `set`; a
165        // `promote` AWAY from private to any lower tier is the valid opening,
166        // and a `promote` TO private is correctly rejected as a narrowing.
167        // This just confirms the new arg's tier flows through that check with
168        // the right rank — the monotonicity logic itself is unchanged.
169        let private = VisibilityTier::Private {
170            scope_label: "embargo-x".to_string(),
171        };
172        assert_eq!(private.restrictiveness_rank(), 4);
173        // Opening away from private is allowed.
174        assert!(VisibilityTier::Internal.is_strictly_less_restrictive_than(&private));
175        assert!(VisibilityTier::Public.is_strictly_less_restrictive_than(&private));
176        // Promoting *to* private (a narrowing) is not an opening — use `set`.
177        assert!(!private.is_strictly_less_restrictive_than(&VisibilityTier::Internal));
178    }
179}