Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
cli/id: Use clap
✗ CI failure Erik Kundt committed 6 months ago
commit 1fcee08a8fd3b3c863bddb8c98cdf1bcbb2cf2cc
parent 3e98589a767d9a17a3da90e52ae4abb198fa9ada
2 failed (2 total) View logs
3 files changed +276 -252
modified crates/radicle-cli/src/commands/id.rs
@@ -1,28 +1,28 @@
+
mod args;
+

use std::collections::BTreeSet;
-
use std::{ffi::OsString, io};

use anyhow::{anyhow, Context};

use radicle::cob::identity::{self, IdentityMut, Revision, RevisionId};
use radicle::cob::Title;
use radicle::identity::doc::update;
-
use radicle::identity::doc::update::EditVisibility;
use radicle::identity::{doc, Doc, Identity, RawDoc};
use radicle::node::device::Device;
use radicle::node::NodeId;
-
use radicle::prelude::{Did, RepoId};
use radicle::storage::{ReadStorage as _, WriteRepository};
use radicle::{cob, crypto, Profile};
use radicle_surf::diff::Diff;
use radicle_term::Element;
-
use serde_json as json;

use crate::git::unified_diff::Encode as _;
use crate::git::Rev;
use crate::terminal as term;
-
use crate::terminal::args::{Args, Error, Help};
+
use crate::terminal::args::{Error, Help};
use crate::terminal::patch::Message;
-
use crate::terminal::Interactive;
+

+
pub use args::Args;
+
use args::Command;

pub const HELP: Help = Help {
    name: "id",
@@ -54,232 +54,10 @@ Options
"#,
};

-
#[derive(Clone, Debug, Default)]
-
pub enum Operation {
-
    Update {
-
        title: Option<Title>,
-
        description: Option<String>,
-
        delegate: Vec<Did>,
-
        rescind: Vec<Did>,
-
        threshold: Option<usize>,
-
        visibility: Option<EditVisibility>,
-
        allow: BTreeSet<Did>,
-
        disallow: BTreeSet<Did>,
-
        payload: Vec<(doc::PayloadId, String, json::Value)>,
-
        edit: bool,
-
    },
-
    AcceptRevision {
-
        revision: Rev,
-
    },
-
    RejectRevision {
-
        revision: Rev,
-
    },
-
    EditRevision {
-
        revision: Rev,
-
        title: Option<Title>,
-
        description: Option<String>,
-
    },
-
    RedactRevision {
-
        revision: Rev,
-
    },
-
    ShowRevision {
-
        revision: Rev,
-
    },
-
    #[default]
-
    ListRevisions,
-
}
-

-
#[derive(Default, PartialEq, Eq)]
-
pub enum OperationName {
-
    Accept,
-
    Reject,
-
    Edit,
-
    Update,
-
    Show,
-
    Redact,
-
    #[default]
-
    List,
-
}
-

-
pub struct Options {
-
    pub op: Operation,
-
    pub rid: Option<RepoId>,
-
    pub interactive: Interactive,
-
    pub quiet: bool,
-
}
-

-
impl Args for Options {
-
    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
-
        use lexopt::prelude::*;
-

-
        let mut parser = lexopt::Parser::from_args(args);
-
        let mut op: Option<OperationName> = None;
-
        let mut revision: Option<Rev> = None;
-
        let mut rid: Option<RepoId> = None;
-
        let mut title: Option<Title> = None;
-
        let mut description: Option<String> = None;
-
        let mut delegate: Vec<Did> = Vec::new();
-
        let mut rescind: Vec<Did> = Vec::new();
-
        let mut visibility: Option<EditVisibility> = None;
-
        let mut allow: BTreeSet<Did> = BTreeSet::new();
-
        let mut disallow: BTreeSet<Did> = BTreeSet::new();
-
        let mut threshold: Option<usize> = None;
-
        let mut interactive = Interactive::new(io::stdout());
-
        let mut payload = Vec::new();
-
        let mut edit = false;
-
        let mut quiet = false;
-

-
        while let Some(arg) = parser.next()? {
-
            match arg {
-
                Long("help") => {
-
                    return Err(Error::HelpManual { name: "rad-id" }.into());
-
                }
-
                Short('h') => {
-
                    return Err(Error::Help.into());
-
                }
-
                Long("title")
-
                    if op == Some(OperationName::Edit) || op == Some(OperationName::Update) =>
-
                {
-
                    let val = parser.value()?;
-
                    title = Some(term::args::string(&val).try_into()?);
-
                }
-
                Long("description")
-
                    if op == Some(OperationName::Edit) || op == Some(OperationName::Update) =>
-
                {
-
                    description = Some(parser.value()?.to_string_lossy().into());
-
                }
-
                Long("quiet") | Short('q') => {
-
                    quiet = true;
-
                }
-
                Long("no-confirm") => {
-
                    interactive = Interactive::No;
-
                }
-
                Value(val) if op.is_none() => match val.to_string_lossy().as_ref() {
-
                    "e" | "edit" => op = Some(OperationName::Edit),
-
                    "u" | "update" => op = Some(OperationName::Update),
-
                    "l" | "list" => op = Some(OperationName::List),
-
                    "s" | "show" => op = Some(OperationName::Show),
-
                    "a" | "accept" => op = Some(OperationName::Accept),
-
                    "r" | "reject" => op = Some(OperationName::Reject),
-
                    "d" | "redact" => op = Some(OperationName::Redact),
-

-
                    unknown => anyhow::bail!("unknown operation '{}'", unknown),
-
                },
-
                Long("repo") => {
-
                    let val = parser.value()?;
-
                    let val = term::args::rid(&val)?;
-

-
                    rid = Some(val);
-
                }
-
                Long("delegate") => {
-
                    let did = term::args::did(&parser.value()?)?;
-
                    delegate.push(did);
-
                }
-
                Long("rescind") => {
-
                    let did = term::args::did(&parser.value()?)?;
-
                    rescind.push(did);
-
                }
-
                Long("allow") => {
-
                    let value = parser.value()?;
-
                    let did = term::args::did(&value)?;
-
                    allow.insert(did);
-
                }
-
                Long("disallow") => {
-
                    let value = parser.value()?;
-
                    let did = term::args::did(&value)?;
-
                    disallow.insert(did);
-
                }
-
                Long("visibility") => {
-
                    let value = parser.value()?;
-
                    let value = term::args::parse_value("visibility", value)?;
-

-
                    visibility = Some(value);
-
                }
-
                Long("threshold") => {
-
                    threshold = Some(parser.value()?.to_string_lossy().parse()?);
-
                }
-
                Long("payload") => {
-
                    let mut values = parser.values()?;
-
                    let id = values
-
                        .next()
-
                        .ok_or(anyhow!("expected payload id, eg. `xyz.radicle.project`"))?;
-
                    let id: doc::PayloadId = term::args::parse_value("payload", id)?;
-

-
                    let key = values
-
                        .next()
-
                        .ok_or(anyhow!("expected payload key, eg. 'defaultBranch'"))?;
-
                    let key = term::args::string(&key);
-

-
                    let val = values
-
                        .next()
-
                        .ok_or(anyhow!("expected payload value, eg. '\"heartwood\"'"))?;
-
                    let val = val.to_string_lossy().to_string();
-
                    let val = json::from_str(val.as_str())
-
                        .map_err(|e| anyhow!("invalid JSON value `{val}`: {e}"))?;
-

-
                    payload.push((id, key, val));
-
                }
-
                Long("edit") => {
-
                    edit = true;
-
                }
-
                Value(val) => {
-
                    let val = term::args::rev(&val)?;
-
                    revision = Some(val);
-
                }
-
                _ => {
-
                    return Err(anyhow!(arg.unexpected()));
-
                }
-
            }
-
        }
-

-
        let op = match op.unwrap_or_default() {
-
            OperationName::Accept => Operation::AcceptRevision {
-
                revision: revision.ok_or_else(|| anyhow!("a revision must be provided"))?,
-
            },
-
            OperationName::Reject => Operation::RejectRevision {
-
                revision: revision.ok_or_else(|| anyhow!("a revision must be provided"))?,
-
            },
-
            OperationName::Edit => Operation::EditRevision {
-
                title,
-
                description,
-
                revision: revision.ok_or_else(|| anyhow!("a revision must be provided"))?,
-
            },
-
            OperationName::Show => Operation::ShowRevision {
-
                revision: revision.ok_or_else(|| anyhow!("a revision must be provided"))?,
-
            },
-
            OperationName::List => Operation::ListRevisions,
-
            OperationName::Redact => Operation::RedactRevision {
-
                revision: revision.ok_or_else(|| anyhow!("a revision must be provided"))?,
-
            },
-
            OperationName::Update => Operation::Update {
-
                title,
-
                description,
-
                delegate,
-
                rescind,
-
                threshold,
-
                visibility,
-
                allow,
-
                disallow,
-
                payload,
-
                edit,
-
            },
-
        };
-
        Ok((
-
            Options {
-
                rid,
-
                op,
-
                interactive,
-
                quiet,
-
            },
-
            vec![],
-
        ))
-
    }
-
}
-

-
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
+
pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
    let profile = ctx.profile()?;
    let storage = &profile.storage;
-
    let rid = if let Some(rid) = options.rid {
+
    let rid = if let Some(rid) = args.repo {
        rid
    } else {
        let (_, rid) = radicle::rad::cwd()?;
@@ -291,8 +69,11 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
    let mut identity = Identity::load_mut(&repo)?;
    let current = identity.current().clone();

-
    match options.op {
-
        Operation::AcceptRevision { revision } => {
+
    let interactive = args.interactive();
+
    let command = args.command.unwrap_or(Command::List);
+

+
    match command {
+
        Command::Accept { revision } => {
            let revision = get(revision, &identity, &repo)?.clone();
            let id = revision.id;
            let signer = term::signer(&profile)?;
@@ -301,10 +82,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                anyhow::bail!("cannot vote on revision that is {}", revision.state);
            }

-
            if options
-
                .interactive
-
                .confirm(format!("Accept revision {}?", term::format::tertiary(id)))
-
            {
+
            if interactive.confirm(format!("Accept revision {}?", term::format::tertiary(id))) {
                identity.accept(&revision.id, &signer)?;

                if let Some(revision) = identity.revision(&id) {
@@ -314,14 +92,14 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                    }
                    // TODO: Different output if canonical changed?

-
                    if !options.quiet {
+
                    if !args.quiet {
                        term::success!("Revision {id} accepted");
                        print_meta(revision, &current, &profile)?;
                    }
                }
            }
        }
-
        Operation::RejectRevision { revision } => {
+
        Command::Reject { revision } => {
            let revision = get(revision, &identity, &repo)?.clone();
            let signer = term::signer(&profile)?;

@@ -329,19 +107,19 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                anyhow::bail!("cannot vote on revision that is {}", revision.state);
            }

-
            if options.interactive.confirm(format!(
+
            if interactive.confirm(format!(
                "Reject revision {}?",
                term::format::tertiary(revision.id)
            )) {
                identity.reject(revision.id, &signer)?;

-
                if !options.quiet {
+
                if !args.quiet {
                    term::success!("Revision {} rejected", revision.id);
                    print_meta(&revision, &current, &profile)?;
                }
            }
        }
-
        Operation::EditRevision {
+
        Command::Edit {
            revision,
            title,
            description,
@@ -357,11 +135,11 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
            };
            identity.edit(revision.id, title, description, &signer)?;

-
            if !options.quiet {
+
            if !args.quiet {
                term::success!("Revision {} edited", revision.id);
            }
        }
-
        Operation::Update {
+
        Command::Update {
            title,
            description,
            delegate: delegates,
@@ -375,6 +153,9 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
        } => {
            let proposal = {
                let mut proposal = current.doc.clone().edit();
+
                let allow = allow.into_iter().collect::<BTreeSet<_>>();
+
                let disallow = disallow.into_iter().collect::<BTreeSet<_>>();
+

                proposal.threshold = threshold.unwrap_or(proposal.threshold);

                let proposal = match visibility {
@@ -407,7 +188,14 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                    }
                };

-
                update::payload(proposal, payload)?
+
                // TODO(erikli): whenever `clap` starts supporting custom value parsers
+
                // for a series of values, we can parse into `Payload` implicitely.
+
                let payloads = args::Payload::try_parse_many(&payload)?
+
                    .into_iter()
+
                    .map(|p| (p.id, p.key, p.value))
+
                    .collect::<Vec<_>>();
+

+
                update::payload(proposal, payloads)?
            };

            // If `--edit` is specified, the document can also be edited via a text edit.
@@ -431,7 +219,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {

            let proposal = update::verify(proposal)?;
            if proposal == current.doc {
-
                if !options.quiet {
+
                if !args.quiet {
                    term::print(term::format::italic(
                        "Nothing to do. The document is up to date. See `rad inspect --identity`.",
                    ));
@@ -445,7 +233,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                // Update the canonical head to point to the latest accepted revision.
                repo.set_identity_head_to(revision.id)?;
            }
-
            if options.quiet {
+
            if args.quiet {
                term::print(revision.id);
            } else {
                term::success!(
@@ -455,7 +243,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
                print(&revision, &current, &repo, &profile)?;
            }
        }
-
        Operation::ListRevisions => {
+
        Command::List => {
            let mut revisions =
                term::Table::<7, term::Label>::new(term::table::TableOptions::bordered());

@@ -489,25 +277,25 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
            }
            revisions.print();
        }
-
        Operation::RedactRevision { revision } => {
+
        Command::Redact { revision } => {
            let revision = get(revision, &identity, &repo)?.clone();
            let signer = term::signer(&profile)?;

            if revision.is_accepted() {
                anyhow::bail!("cannot redact accepted revision");
            }
-
            if options.interactive.confirm(format!(
+
            if interactive.confirm(format!(
                "Redact revision {}?",
                term::format::tertiary(revision.id)
            )) {
                identity.redact(revision.id, &signer)?;

-
                if !options.quiet {
+
                if !args.quiet {
                    term::success!("Revision {} redacted", revision.id);
                }
            }
        }
-
        Operation::ShowRevision { revision } => {
+
        Command::Show { revision } => {
            let revision = get(revision, &identity, &repo)?;
            let previous = revision.parent.unwrap_or(revision.id);
            let previous = identity
added crates/radicle-cli/src/commands/id/args.rs
@@ -0,0 +1,233 @@
+
use std::io;
+
use std::str::FromStr;
+

+
use clap::{Parser, Subcommand};
+

+
use serde_json as json;
+

+
use thiserror::Error;
+

+
use radicle::cob::{Title, TypeNameParse};
+
use radicle::identity::doc::update::EditVisibility;
+
use radicle::identity::doc::PayloadId;
+
use radicle::prelude::{Did, RepoId};
+

+
use crate::git::Rev;
+

+
use crate::terminal::Interactive;
+

+
pub(crate) const ABOUT: &str = "Manage repository identities";
+
const LONG_ABOUT: &str = r#"
+
The *rad id* command is used to manage and propose changes to the
+
identity of a Radicle repository.
+

+
See the rad-id(1) man page for more information.
+
"#;
+

+
#[derive(Debug, Error)]
+
pub enum PayloadParseError {
+
    #[error("could not parse payload id: {0}")]
+
    IdParse(#[from] TypeNameParse),
+
    #[error("could not parse json value: {0}")]
+
    Value(#[from] json::Error),
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct Payload {
+
    pub(super) id: PayloadId,
+
    pub(super) key: String,
+
    pub(super) value: json::Value,
+
}
+

+
impl Payload {
+
    /// Parses the list of all payload values that were aggregated by `clap`.
+
    /// E.g. `--payload key name value --payload key name value` will result
+
    /// in `["key","name","value","key","name","value"]`.
+
    pub fn try_parse_many(values: &[String]) -> Result<Vec<Self>, PayloadParseError> {
+
        // `clap` makes sure we don't have 3 values per option occurence, so we can just
+
        // chunk the aggregated list
+
        values
+
            .chunks(3)
+
            .map(|chunk| {
+
                Ok(Payload {
+
                    id: PayloadId::from_str(&chunk[0])?,
+
                    key: chunk[1].to_owned(),
+
                    value: json::from_str(&chunk[2].to_owned())?,
+
                })
+
            })
+
            .collect()
+
    }
+
}
+

+
#[derive(Clone, Debug)]
+
struct EditVisibilityParser;
+

+
impl clap::builder::TypedValueParser for EditVisibilityParser {
+
    type Value = EditVisibility;
+

+
    fn parse_ref(
+
        &self,
+
        cmd: &clap::Command,
+
        arg: Option<&clap::Arg>,
+
        value: &std::ffi::OsStr,
+
    ) -> Result<Self::Value, clap::Error> {
+
        <EditVisibility as std::str::FromStr>::from_str.parse_ref(cmd, arg, value)
+
    }
+

+
    fn possible_values(
+
        &self,
+
    ) -> Option<Box<dyn Iterator<Item = clap::builder::PossibleValue> + '_>> {
+
        use clap::builder::PossibleValue;
+
        Some(Box::new(
+
            [PossibleValue::new("private"), PossibleValue::new("public")].into_iter(),
+
        ))
+
    }
+
}
+

+
#[derive(Debug, Parser)]
+
#[command(about = ABOUT, long_about = LONG_ABOUT, disable_version_flag = true)]
+
pub struct Args {
+
    #[command(subcommand)]
+
    pub(super) command: Option<Command>,
+

+
    /// Specify the repository to operate on. Defaults to the current repository
+
    ///
+
    /// [example values: rad:z3Tr6bC7ctEg2EHmLvknUr29mEDLH, z3Tr6bC7ctEg2EHmLvknUr29mEDLH]
+
    #[arg(long)]
+
    #[arg(value_name = "RID", global = true)]
+
    pub(super) repo: Option<RepoId>,
+

+
    /// Don’t ask for confirmation
+
    #[arg(long)]
+
    #[arg(global = true)]
+
    pub(super) no_confirm: bool,
+

+
    /// Suppress output
+
    #[arg(long, short)]
+
    #[arg(global = true)]
+
    pub(super) quiet: bool,
+
}
+

+
impl Args {
+
    pub(super) fn interactive(&self) -> Interactive {
+
        if self.no_confirm {
+
            Interactive::No
+
        } else {
+
            Interactive::new(io::stdout())
+
        }
+
    }
+
}
+

+
#[derive(Subcommand, Debug)]
+
pub(super) enum Command {
+
    /// Accept a proposed revision to the identity document
+
    #[clap(alias("a"))]
+
    Accept {
+
        /// REV of the revision to accept
+
        #[arg(value_name = "REV")]
+
        revision: Rev,
+
    },
+
    /// Reject a proposed revision to the identity document
+
    #[clap(alias("r"))]
+
    Reject {
+
        /// REV of the revision to reject
+
        #[arg(value_name = "REV")]
+
        revision: Rev,
+
    },
+
    /// Edit an existing revision to the identity document
+
    #[clap(alias("e"))]
+
    Edit {
+
        /// REV of the edit
+
        #[arg(value_name = "REV")]
+
        revision: Rev,
+

+
        /// Title of the edit
+
        #[arg(long)]
+
        #[arg(value_name = "STRING")]
+
        title: Option<Title>,
+

+
        /// Description of the edit
+
        #[arg(long)]
+
        #[arg(value_name = "STRING")]
+
        description: Option<String>,
+
    },
+
    /// Propose a new revision to the identity document
+
    #[clap(alias("u"))]
+
    Update {
+
        /// Set the title for the new revision
+
        #[arg(long)]
+
        #[arg(value_name = "STRING")]
+
        title: Option<Title>,
+

+
        /// Set the description for the new revision
+
        #[arg(long)]
+
        #[arg(value_name = "STRING")]
+
        description: Option<String>,
+

+
        /// Update the identity by adding a new delegate, identified by their DID
+
        #[arg(long, short)]
+
        #[arg(value_name = "DID")]
+
        #[arg(action = clap::ArgAction::Append)]
+
        delegate: Vec<Did>,
+

+
        /// Update the identity by removing a delegate identified by their DID
+
        #[arg(long, short)]
+
        #[arg(value_name = "DID")]
+
        #[arg(action = clap::ArgAction::Append)]
+
        rescind: Vec<Did>,
+

+
        /// Update the identity by setting the number of delegates required to accept a revision
+
        #[arg(long)]
+
        #[arg(value_name = "NUM")]
+
        threshold: Option<usize>,
+

+
        /// Update the identity by setting the repository visibility to private or public
+
        #[arg(long)]
+
        #[arg(value_name = "VISIBILITY", value_parser = EditVisibilityParser)]
+
        visibility: Option<EditVisibility>,
+

+
        /// Update the identity by giving a specific peer access to a private repository
+
        #[arg(long)]
+
        #[arg(value_name = "DID")]
+
        #[arg(action = clap::ArgAction::Append)]
+
        allow: Vec<Did>,
+

+
        /// Update the identity by removing a specific peer’s access to a private repository
+
        #[arg(long)]
+
        #[arg(value_name = "DID")]
+
        #[arg(action = clap::ArgAction::Append)]
+
        disallow: Vec<Did>,
+

+
        /// Update the identity by setting metadata in one of the identity payloads
+
        ///
+
        /// [example values: xyz.radicle.project name '"radicle-example"']
+
        // TODO(erikili:) Value parsers do not operate on series of values, yet. This will
+
        // change with clap v5, so we can hopefully use `Vec<Payload>`.
+
        // - https://github.com/clap-rs/clap/discussions/5930#discussioncomment-12315889
+
        // - https://docs.rs/clap/latest/clap/_derive/index.html#arg-types
+
        #[arg(long)]
+
        #[arg(value_names = ["TYPE", "KEY", "VALUE"], num_args = 3)]
+
        payload: Vec<String>,
+

+
        /// Opens your $EDITOR to edit the JSON contents directly
+
        #[arg(long)]
+
        edit: bool,
+
    },
+
    /// Lists all revisions to the identity document
+
    #[clap(alias("l"))]
+
    List,
+
    /// Show a specific revision of the identity documen
+
    #[clap(alias("s"))]
+
    Show {
+
        /// REV of the revision to show
+
        #[arg(value_name = "REV")]
+
        revision: Rev,
+
    },
+
    /// Redact an revision
+
    #[clap(alias("d"))]
+
    Redact {
+
        /// REV of the revision to redact
+
        #[arg(value_name = "REV")]
+
        revision: Rev,
+
    },
+
}
modified crates/radicle-cli/src/main.rs
@@ -50,6 +50,7 @@ enum Commands {
    Clone(clone::Args),
    Debug(debug::Args),
    Fork(fork::Args),
+
    Id(id::Args),
    Init(init::Args),
    Issue(issue::Args),
    Ls(ls::Args),
@@ -227,7 +228,9 @@ pub(crate) fn run_other(exe: &str, args: &[OsString]) -> Result<(), Option<anyho
            term::run_command_args::<help::Options, _>(help::HELP, help::run, args.to_vec());
        }
        "id" => {
-
            term::run_command_args::<id::Options, _>(id::HELP, id::run, args.to_vec());
+
            if let Some(Commands::Id(args)) = CliArgs::parse().command {
+
                term::run_command_fn(id::run, args);
+
            }
        }
        "inbox" => {
            term::run_command_args::<inbox::Options, _>(inbox::HELP, inbox::run, args.to_vec())