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