Skip to main content

radicle_cli/commands/id/
args.rs

1use std::io;
2use std::str::FromStr;
3
4use clap::{Parser, Subcommand};
5
6use serde_json as json;
7
8use thiserror::Error;
9
10use radicle::cob::{Title, TypeNameParse};
11use radicle::identity::doc::update::EditVisibility;
12use radicle::identity::doc::update::PayloadUpsert;
13use radicle::identity::doc::PayloadId;
14use radicle::prelude::{Did, RepoId};
15
16use crate::git::Rev;
17
18use crate::terminal::Interactive;
19
20const ABOUT: &str = "Manage repository identities";
21const LONG_ABOUT: &str = r#"
22The `id` command is used to manage and propose changes to the
23identity of a Radicle repository.
24
25See the rad-id(1) man page for more information.
26"#;
27
28#[derive(Debug, Error)]
29pub enum PayloadUpsertParseError {
30    #[error("could not parse payload id: {0}")]
31    IdParse(#[from] TypeNameParse),
32    #[error("could not parse json value: {0}")]
33    Value(#[from] json::Error),
34}
35
36/// Parses a slice of all payload upserts as aggregated by `clap`
37/// (see [`Command::Update::payload`]).
38/// E.g. `["com.example.one", "name", "1", "com.example.two", "name2", "2"]`
39/// will result in iterator over two [`PayloadUpsert`]s.
40///
41/// # Panics
42///
43/// If the length of `values` is not divisible by 3.
44/// (To catch errors in the definition of the parser derived from
45/// [`Command::Update`] or `clap` itself, and unexpected changes to
46/// `clap`s behaviour in the future.)
47pub(super) fn parse_many_upserts(
48    values: &[String],
49) -> impl Iterator<Item = Result<PayloadUpsert, PayloadUpsertParseError>> + use<'_> {
50    // `clap` ensures we have 3 values per option occurrence,
51    // so we can chunk the aggregated slice exactly.
52    let chunks = values.chunks_exact(3);
53
54    assert!(chunks.remainder().is_empty());
55
56    chunks.map(|chunk| {
57        // Slice accesses will not panic, guaranteed by `chunks_exact(3)`.
58        #[allow(clippy::indexing_slicing)]
59        Ok(PayloadUpsert {
60            id: PayloadId::from_str(&chunk[0])?,
61            key: chunk[1].to_owned(),
62            value: json::from_str(&chunk[2].to_owned())?,
63        })
64    })
65}
66
67#[derive(Clone, Debug)]
68struct EditVisibilityParser;
69
70impl clap::builder::TypedValueParser for EditVisibilityParser {
71    type Value = EditVisibility;
72
73    fn parse_ref(
74        &self,
75        cmd: &clap::Command,
76        arg: Option<&clap::Arg>,
77        value: &std::ffi::OsStr,
78    ) -> Result<Self::Value, clap::Error> {
79        <EditVisibility as std::str::FromStr>::from_str.parse_ref(cmd, arg, value)
80    }
81
82    fn possible_values(
83        &self,
84    ) -> Option<Box<dyn Iterator<Item = clap::builder::PossibleValue> + '_>> {
85        use clap::builder::PossibleValue;
86        Some(Box::new(
87            [PossibleValue::new("private"), PossibleValue::new("public")].into_iter(),
88        ))
89    }
90}
91
92#[derive(Debug, Parser)]
93#[command(about = ABOUT, long_about = LONG_ABOUT, disable_version_flag = true)]
94pub struct Args {
95    #[command(subcommand)]
96    pub(super) command: Option<Command>,
97
98    /// Specify the repository to operate on. Defaults to the current repository
99    ///
100    /// [example values: rad:z3Tr6bC7ctEg2EHmLvknUr29mEDLH, z3Tr6bC7ctEg2EHmLvknUr29mEDLH]
101    #[arg(long)]
102    #[arg(value_name = "RID", global = true)]
103    pub(super) repo: Option<RepoId>,
104
105    /// Do not ask for confirmation
106    #[arg(long)]
107    #[arg(global = true)]
108    no_confirm: bool,
109
110    /// Suppress output
111    #[arg(long, short)]
112    #[arg(global = true)]
113    pub(super) quiet: bool,
114}
115
116impl Args {
117    pub(super) fn interactive(&self) -> Interactive {
118        if self.no_confirm {
119            Interactive::No
120        } else {
121            Interactive::new(io::stdout())
122        }
123    }
124}
125
126#[derive(Subcommand, Debug)]
127pub(super) enum Command {
128    /// Accept a proposed revision to the identity document
129    #[clap(alias("a"))]
130    Accept {
131        /// Proposed revision to accept
132        #[arg(value_name = "REVISION_ID")]
133        revision: Rev,
134    },
135
136    /// Reject a proposed revision to the identity document
137    #[clap(alias("r"))]
138    Reject {
139        /// Proposed revision to reject
140        #[arg(value_name = "REVISION_ID")]
141        revision: Rev,
142    },
143
144    /// Edit an existing revision to the identity document
145    #[clap(alias("e"))]
146    Edit {
147        /// Proposed revision to edit
148        #[arg(value_name = "REVISION_ID")]
149        revision: Rev,
150
151        /// Title of the edit
152        #[arg(long)]
153        title: Option<Title>,
154
155        /// Description of the edit
156        #[arg(long)]
157        description: Option<String>,
158    },
159
160    /// Propose a new revision to the identity document
161    #[clap(alias("u"))]
162    Update {
163        /// Set the title for the new proposal
164        #[arg(long)]
165        title: Option<Title>,
166
167        /// Set the description for the new proposal
168        #[arg(long)]
169        description: Option<String>,
170
171        /// Update the identity by adding a new delegate, identified by their DID
172        #[arg(long, short)]
173        #[arg(value_name = "DID")]
174        #[arg(action = clap::ArgAction::Append)]
175        delegate: Vec<Did>,
176
177        /// Update the identity by removing a delegate, identified by their DID
178        #[arg(long, short)]
179        #[arg(value_name = "DID")]
180        #[arg(action = clap::ArgAction::Append)]
181        rescind: Vec<Did>,
182
183        /// Update the identity by setting the number of delegates required to accept a revision
184        #[arg(long)]
185        threshold: Option<usize>,
186
187        /// Update the identity by setting the repository's visibility to private or public
188        #[arg(long)]
189        #[arg(value_parser = EditVisibilityParser)]
190        visibility: Option<EditVisibility>,
191
192        /// Update the identity by giving a specific DID access to a private repository
193        #[arg(long)]
194        #[arg(value_name = "DID")]
195        #[arg(action = clap::ArgAction::Append)]
196        allow: Vec<Did>,
197
198        /// Update the identity by removing a specific DID's access from a private repository
199        #[arg(long)]
200        #[arg(value_name = "DID")]
201        #[arg(action = clap::ArgAction::Append)]
202        disallow: Vec<Did>,
203
204        /// Update the identity by setting metadata in one of the identity payloads
205        ///
206        /// [example values: xyz.radicle.project name '"radicle-example"']
207        // TODO(erikili:) Value parsers do not operate on series of values, yet. This will
208        // change with clap v5, so we can hopefully use `Vec<Payload>`.
209        // - https://github.com/clap-rs/clap/discussions/5930#discussioncomment-12315889
210        // - https://docs.rs/clap/latest/clap/_derive/index.html#arg-types
211        #[arg(long)]
212        #[arg(value_names = ["TYPE", "KEY", "VALUE"], num_args = 3)]
213        payload: Vec<String>,
214
215        /// Opens your $EDITOR to edit the JSON contents directly
216        #[arg(long)]
217        edit: bool,
218    },
219
220    /// Lists all proposed revisions to the identity document
221    #[clap(alias("l"))]
222    List,
223
224    /// Show a specific identity proposal
225    #[clap(alias("s"))]
226    Show {
227        /// Proposed revision to show
228        #[arg(value_name = "REVISION_ID")]
229        revision: Rev,
230    },
231
232    /// Redact a revision
233    #[clap(alias("d"))]
234    Redact {
235        /// Proposed revision to redact
236        #[arg(value_name = "REVISION_ID")]
237        revision: Rev,
238    },
239}
240
241#[cfg(test)]
242mod test {
243    use super::{parse_many_upserts, Args};
244    use clap::error::ErrorKind;
245    use clap::Parser;
246
247    #[test]
248    fn should_parse_single_payload() {
249        let args = Args::try_parse_from(["id", "update", "--payload", "key", "name", "value"]);
250        assert!(args.is_ok())
251    }
252
253    #[test]
254    fn should_not_parse_single_payload() {
255        let err = Args::try_parse_from(["id", "update", "--payload", "key", "name"]).unwrap_err();
256        assert_eq!(err.kind(), ErrorKind::WrongNumberOfValues);
257    }
258
259    #[test]
260    fn should_parse_multiple_payloads() {
261        let args = Args::try_parse_from([
262            "id",
263            "update",
264            "--payload",
265            "key_1",
266            "name_1",
267            "value_1",
268            "--payload",
269            "key_2",
270            "name_2",
271            "value_2",
272        ]);
273        assert!(args.is_ok())
274    }
275
276    #[test]
277    fn should_not_parse_single_payloads() {
278        let err = Args::try_parse_from([
279            "id",
280            "update",
281            "--payload",
282            "key_1",
283            "name_1",
284            "value_1",
285            "--payload",
286            "key_2",
287            "name_2",
288        ])
289        .unwrap_err();
290        assert_eq!(err.kind(), ErrorKind::WrongNumberOfValues);
291    }
292
293    #[test]
294    fn should_not_clobber_payload_args() {
295        let err = Args::try_parse_from([
296            "id",
297            "update",
298            "--payload",
299            "key_1",
300            "name_1",
301            "--payload", // ensure `--payload is not treated as an argument`
302            "key_2",
303            "name_2",
304            "value_2",
305        ])
306        .unwrap_err();
307        assert_eq!(err.kind(), ErrorKind::WrongNumberOfValues);
308    }
309
310    #[test]
311    fn should_parse_into_payload() {
312        let payload: Result<Vec<_>, _> = parse_many_upserts(&[
313            "xyz.radicle.project".to_string(),
314            "name".to_string(),
315            "{}".to_string(),
316        ])
317        .collect();
318        assert!(payload.is_ok())
319    }
320
321    #[test]
322    #[should_panic(expected = "assertion failed: chunks.remainder().is_empty()")]
323    fn should_not_parse_into_payload() {
324        let _: Result<Vec<_>, _> =
325            parse_many_upserts(&["xyz.radicle.project".to_string(), "name".to_string()]).collect();
326    }
327}