Skip to main content

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}