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}