radicle_cli/commands/
id.rs

1use std::collections::BTreeSet;
2use std::{ffi::OsString, io};
3
4use anyhow::{anyhow, Context};
5
6use radicle::cob::identity::{self, IdentityMut, Revision, RevisionId};
7use radicle::cob::Title;
8use radicle::identity::doc::update;
9use radicle::identity::doc::update::EditVisibility;
10use radicle::identity::{doc, Doc, Identity, RawDoc};
11use radicle::node::device::Device;
12use radicle::node::NodeId;
13use radicle::prelude::{Did, RepoId};
14use radicle::storage::{ReadStorage as _, WriteRepository};
15use radicle::{cob, crypto, Profile};
16use radicle_surf::diff::Diff;
17use radicle_term::Element;
18use serde_json as json;
19
20use crate::git::unified_diff::Encode as _;
21use crate::git::Rev;
22use crate::terminal as term;
23use crate::terminal::args::{Args, Error, Help};
24use crate::terminal::patch::Message;
25use crate::terminal::Interactive;
26
27pub const HELP: Help = Help {
28    name: "id",
29    description: "Manage repository identities",
30    version: env!("RADICLE_VERSION"),
31    usage: r#"
32Usage
33
34    rad id list [<option>...]
35    rad id update [--title <string>] [--description <string>]
36                  [--delegate <did>] [--rescind <did>]
37                  [--threshold <num>] [--visibility <private | public>]
38                  [--allow <did>] [--disallow <did>]
39                  [--no-confirm] [--payload <id> <key> <val>...] [--edit] [<option>...]
40    rad id edit <revision-id> [--title <string>] [--description <string>] [<option>...]
41    rad id show <revision-id> [<option>...]
42    rad id <accept | reject | redact> <revision-id> [<option>...]
43
44    The *rad id* command is used to manage and propose changes to the
45    identity of a Radicle repository.
46
47    See the rad-id(1) man page for more information.
48
49Options
50
51    --repo <rid>           Repository (defaults to the current repository)
52    --quiet, -q            Don't print anything
53    --help                 Print help
54"#,
55};
56
57#[derive(Clone, Debug, Default)]
58pub enum Operation {
59    Update {
60        title: Option<Title>,
61        description: Option<String>,
62        delegate: Vec<Did>,
63        rescind: Vec<Did>,
64        threshold: Option<usize>,
65        visibility: Option<EditVisibility>,
66        allow: BTreeSet<Did>,
67        disallow: BTreeSet<Did>,
68        payload: Vec<(doc::PayloadId, String, json::Value)>,
69        edit: bool,
70    },
71    AcceptRevision {
72        revision: Rev,
73    },
74    RejectRevision {
75        revision: Rev,
76    },
77    EditRevision {
78        revision: Rev,
79        title: Option<Title>,
80        description: Option<String>,
81    },
82    RedactRevision {
83        revision: Rev,
84    },
85    ShowRevision {
86        revision: Rev,
87    },
88    #[default]
89    ListRevisions,
90}
91
92#[derive(Default, PartialEq, Eq)]
93pub enum OperationName {
94    Accept,
95    Reject,
96    Edit,
97    Update,
98    Show,
99    Redact,
100    #[default]
101    List,
102}
103
104pub struct Options {
105    pub op: Operation,
106    pub rid: Option<RepoId>,
107    pub interactive: Interactive,
108    pub quiet: bool,
109}
110
111impl Args for Options {
112    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
113        use lexopt::prelude::*;
114
115        let mut parser = lexopt::Parser::from_args(args);
116        let mut op: Option<OperationName> = None;
117        let mut revision: Option<Rev> = None;
118        let mut rid: Option<RepoId> = None;
119        let mut title: Option<Title> = None;
120        let mut description: Option<String> = None;
121        let mut delegate: Vec<Did> = Vec::new();
122        let mut rescind: Vec<Did> = Vec::new();
123        let mut visibility: Option<EditVisibility> = None;
124        let mut allow: BTreeSet<Did> = BTreeSet::new();
125        let mut disallow: BTreeSet<Did> = BTreeSet::new();
126        let mut threshold: Option<usize> = None;
127        let mut interactive = Interactive::new(io::stdout());
128        let mut payload = Vec::new();
129        let mut edit = false;
130        let mut quiet = false;
131
132        while let Some(arg) = parser.next()? {
133            match arg {
134                Long("help") => {
135                    return Err(Error::HelpManual { name: "rad-id" }.into());
136                }
137                Short('h') => {
138                    return Err(Error::Help.into());
139                }
140                Long("title")
141                    if op == Some(OperationName::Edit) || op == Some(OperationName::Update) =>
142                {
143                    let val = parser.value()?;
144                    title = Some(term::args::string(&val).try_into()?);
145                }
146                Long("description")
147                    if op == Some(OperationName::Edit) || op == Some(OperationName::Update) =>
148                {
149                    description = Some(parser.value()?.to_string_lossy().into());
150                }
151                Long("quiet") | Short('q') => {
152                    quiet = true;
153                }
154                Long("no-confirm") => {
155                    interactive = Interactive::No;
156                }
157                Value(val) if op.is_none() => match val.to_string_lossy().as_ref() {
158                    "e" | "edit" => op = Some(OperationName::Edit),
159                    "u" | "update" => op = Some(OperationName::Update),
160                    "l" | "list" => op = Some(OperationName::List),
161                    "s" | "show" => op = Some(OperationName::Show),
162                    "a" | "accept" => op = Some(OperationName::Accept),
163                    "r" | "reject" => op = Some(OperationName::Reject),
164                    "d" | "redact" => op = Some(OperationName::Redact),
165
166                    unknown => anyhow::bail!("unknown operation '{}'", unknown),
167                },
168                Long("repo") => {
169                    let val = parser.value()?;
170                    let val = term::args::rid(&val)?;
171
172                    rid = Some(val);
173                }
174                Long("delegate") => {
175                    let did = term::args::did(&parser.value()?)?;
176                    delegate.push(did);
177                }
178                Long("rescind") => {
179                    let did = term::args::did(&parser.value()?)?;
180                    rescind.push(did);
181                }
182                Long("allow") => {
183                    let value = parser.value()?;
184                    let did = term::args::did(&value)?;
185                    allow.insert(did);
186                }
187                Long("disallow") => {
188                    let value = parser.value()?;
189                    let did = term::args::did(&value)?;
190                    disallow.insert(did);
191                }
192                Long("visibility") => {
193                    let value = parser.value()?;
194                    let value = term::args::parse_value("visibility", value)?;
195
196                    visibility = Some(value);
197                }
198                Long("threshold") => {
199                    threshold = Some(parser.value()?.to_string_lossy().parse()?);
200                }
201                Long("payload") => {
202                    let mut values = parser.values()?;
203                    let id = values
204                        .next()
205                        .ok_or(anyhow!("expected payload id, eg. `xyz.radicle.project`"))?;
206                    let id: doc::PayloadId = term::args::parse_value("payload", id)?;
207
208                    let key = values
209                        .next()
210                        .ok_or(anyhow!("expected payload key, eg. 'defaultBranch'"))?;
211                    let key = term::args::string(&key);
212
213                    let val = values
214                        .next()
215                        .ok_or(anyhow!("expected payload value, eg. '\"heartwood\"'"))?;
216                    let val = val.to_string_lossy().to_string();
217                    let val = json::from_str(val.as_str())
218                        .map_err(|e| anyhow!("invalid JSON value `{val}`: {e}"))?;
219
220                    payload.push((id, key, val));
221                }
222                Long("edit") => {
223                    edit = true;
224                }
225                Value(val) => {
226                    let val = term::args::rev(&val)?;
227                    revision = Some(val);
228                }
229                _ => {
230                    return Err(anyhow!(arg.unexpected()));
231                }
232            }
233        }
234
235        let op = match op.unwrap_or_default() {
236            OperationName::Accept => Operation::AcceptRevision {
237                revision: revision.ok_or_else(|| anyhow!("a revision must be provided"))?,
238            },
239            OperationName::Reject => Operation::RejectRevision {
240                revision: revision.ok_or_else(|| anyhow!("a revision must be provided"))?,
241            },
242            OperationName::Edit => Operation::EditRevision {
243                title,
244                description,
245                revision: revision.ok_or_else(|| anyhow!("a revision must be provided"))?,
246            },
247            OperationName::Show => Operation::ShowRevision {
248                revision: revision.ok_or_else(|| anyhow!("a revision must be provided"))?,
249            },
250            OperationName::List => Operation::ListRevisions,
251            OperationName::Redact => Operation::RedactRevision {
252                revision: revision.ok_or_else(|| anyhow!("a revision must be provided"))?,
253            },
254            OperationName::Update => Operation::Update {
255                title,
256                description,
257                delegate,
258                rescind,
259                threshold,
260                visibility,
261                allow,
262                disallow,
263                payload,
264                edit,
265            },
266        };
267        Ok((
268            Options {
269                rid,
270                op,
271                interactive,
272                quiet,
273            },
274            vec![],
275        ))
276    }
277}
278
279pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
280    let profile = ctx.profile()?;
281    let storage = &profile.storage;
282    let rid = if let Some(rid) = options.rid {
283        rid
284    } else {
285        let (_, rid) = radicle::rad::cwd()?;
286        rid
287    };
288    let repo = storage
289        .repository(rid)
290        .context(anyhow!("repository `{rid}` not found in local storage"))?;
291    let mut identity = Identity::load_mut(&repo)?;
292    let current = identity.current().clone();
293
294    match options.op {
295        Operation::AcceptRevision { revision } => {
296            let revision = get(revision, &identity, &repo)?.clone();
297            let id = revision.id;
298            let signer = term::signer(&profile)?;
299
300            if !revision.is_active() {
301                anyhow::bail!("cannot vote on revision that is {}", revision.state);
302            }
303
304            if options
305                .interactive
306                .confirm(format!("Accept revision {}?", term::format::tertiary(id)))
307            {
308                identity.accept(&revision.id, &signer)?;
309
310                if let Some(revision) = identity.revision(&id) {
311                    // Update the canonical head to point to the latest accepted revision.
312                    if revision.is_accepted() && revision.id == identity.current {
313                        repo.set_identity_head_to(revision.id)?;
314                    }
315                    // TODO: Different output if canonical changed?
316
317                    if !options.quiet {
318                        term::success!("Revision {id} accepted");
319                        print_meta(revision, &current, &profile)?;
320                    }
321                }
322            }
323        }
324        Operation::RejectRevision { revision } => {
325            let revision = get(revision, &identity, &repo)?.clone();
326            let signer = term::signer(&profile)?;
327
328            if !revision.is_active() {
329                anyhow::bail!("cannot vote on revision that is {}", revision.state);
330            }
331
332            if options.interactive.confirm(format!(
333                "Reject revision {}?",
334                term::format::tertiary(revision.id)
335            )) {
336                identity.reject(revision.id, &signer)?;
337
338                if !options.quiet {
339                    term::success!("Revision {} rejected", revision.id);
340                    print_meta(&revision, &current, &profile)?;
341                }
342            }
343        }
344        Operation::EditRevision {
345            revision,
346            title,
347            description,
348        } => {
349            let revision = get(revision, &identity, &repo)?.clone();
350            let signer = term::signer(&profile)?;
351
352            if !revision.is_active() {
353                anyhow::bail!("revision can no longer be edited");
354            }
355            let Some((title, description)) = edit_title_description(title, description)? else {
356                anyhow::bail!("revision title or description missing");
357            };
358            identity.edit(revision.id, title, description, &signer)?;
359
360            if !options.quiet {
361                term::success!("Revision {} edited", revision.id);
362            }
363        }
364        Operation::Update {
365            title,
366            description,
367            delegate: delegates,
368            rescind,
369            threshold,
370            visibility,
371            allow,
372            disallow,
373            payload,
374            edit,
375        } => {
376            let proposal = {
377                let mut proposal = current.doc.clone().edit();
378                proposal.threshold = threshold.unwrap_or(proposal.threshold);
379
380                let proposal = match visibility {
381                    Some(edit) => update::visibility(proposal, edit),
382                    None => proposal,
383                };
384                let proposal = match update::privacy_allow_list(proposal, allow, disallow) {
385                    Ok(proposal) => proposal,
386                    Err(e) => match e {
387                        update::error::PrivacyAllowList::Overlapping(overlap) =>                     anyhow::bail!("`--allow` and `--disallow` must not overlap: {overlap:?}"),
388                        update::error::PrivacyAllowList::PublicVisibility =>                         return Err(Error::WithHint {
389                            err:
390                            anyhow!("`--allow` and `--disallow` should only be used for private repositories"),
391                            hint: "use `--visibility private` to make the repository private, or perhaps you meant to use `--delegate`/`--rescind`",
392                        }.into())
393                    }
394                };
395                let threshold = proposal.threshold;
396                let proposal = match update::delegates(proposal, delegates, rescind, &repo)? {
397                    Ok(proposal) => proposal,
398                    Err(errs) => {
399                        term::error(format!("failed to verify delegates for {rid}"));
400                        term::error(format!(
401                            "the threshold of {threshold} delegates cannot be met.."
402                        ));
403                        for e in errs {
404                            print_delegate_verification_error(&e);
405                        }
406                        anyhow::bail!("fatal: refusing to update identity document");
407                    }
408                };
409
410                update::payload(proposal, payload)?
411            };
412
413            // If `--edit` is specified, the document can also be edited via a text edit.
414            let proposal = if edit {
415                match term::editor::Editor::comment()
416                    .extension("json")
417                    .initial(serde_json::to_string_pretty(&current.doc)?)?
418                    .edit()?
419                {
420                    Some(proposal) => serde_json::from_str::<RawDoc>(&proposal)?,
421                    None => {
422                        term::print(term::format::italic(
423                            "Nothing to do. The document is up to date. See `rad inspect --identity`.",
424                        ));
425                        return Ok(());
426                    }
427                }
428            } else {
429                proposal
430            };
431
432            let proposal = update::verify(proposal)?;
433            if proposal == current.doc {
434                if !options.quiet {
435                    term::print(term::format::italic(
436                        "Nothing to do. The document is up to date. See `rad inspect --identity`.",
437                    ));
438                }
439                return Ok(());
440            }
441            let signer = term::signer(&profile)?;
442            let revision = update(title, description, proposal, &mut identity, &signer)?;
443
444            if revision.is_accepted() && revision.parent == Some(current.id) {
445                // Update the canonical head to point to the latest accepted revision.
446                repo.set_identity_head_to(revision.id)?;
447            }
448            if options.quiet {
449                term::print(revision.id);
450            } else {
451                term::success!(
452                    "Identity revision {} created",
453                    term::format::tertiary(revision.id)
454                );
455                print(&revision, &current, &repo, &profile)?;
456            }
457        }
458        Operation::ListRevisions => {
459            let mut revisions =
460                term::Table::<7, term::Label>::new(term::table::TableOptions::bordered());
461
462            revisions.header([
463                term::format::dim(String::from("●")).into(),
464                term::format::bold(String::from("ID")).into(),
465                term::format::bold(String::from("Title")).into(),
466                term::format::bold(String::from("Author")).into(),
467                term::Label::blank(),
468                term::format::bold(String::from("Status")).into(),
469                term::format::bold(String::from("Created")).into(),
470            ]);
471            revisions.divider();
472
473            for r in identity.revisions().rev() {
474                let icon = match r.state {
475                    identity::State::Active => term::format::tertiary("●"),
476                    identity::State::Accepted => term::format::positive("●"),
477                    identity::State::Rejected => term::format::negative("●"),
478                    identity::State::Stale => term::format::dim("●"),
479                }
480                .into();
481                let state = r.state.to_string().into();
482                let id = term::format::oid(r.id).into();
483                let title = term::label(r.title.to_string());
484                let (alias, author) =
485                    term::format::Author::new(r.author.public_key(), &profile, true).labels();
486                let timestamp = term::format::timestamp(r.timestamp).into();
487
488                revisions.push([icon, id, title, alias, author, state, timestamp]);
489            }
490            revisions.print();
491        }
492        Operation::RedactRevision { revision } => {
493            let revision = get(revision, &identity, &repo)?.clone();
494            let signer = term::signer(&profile)?;
495
496            if revision.is_accepted() {
497                anyhow::bail!("cannot redact accepted revision");
498            }
499            if options.interactive.confirm(format!(
500                "Redact revision {}?",
501                term::format::tertiary(revision.id)
502            )) {
503                identity.redact(revision.id, &signer)?;
504
505                if !options.quiet {
506                    term::success!("Revision {} redacted", revision.id);
507                }
508            }
509        }
510        Operation::ShowRevision { revision } => {
511            let revision = get(revision, &identity, &repo)?;
512            let previous = revision.parent.unwrap_or(revision.id);
513            let previous = identity
514                .revision(&previous)
515                .ok_or(anyhow!("revision `{previous}` not found"))?;
516
517            print(revision, previous, &repo, &profile)?;
518        }
519    }
520    Ok(())
521}
522
523fn get<'a>(
524    revision: Rev,
525    identity: &'a Identity,
526    repo: &radicle::storage::git::Repository,
527) -> anyhow::Result<&'a Revision> {
528    let id = revision.resolve(&repo.backend)?;
529    let revision = identity
530        .revision(&id)
531        .ok_or(anyhow!("revision `{id}` not found"))?;
532
533    Ok(revision)
534}
535
536fn print_meta(revision: &Revision, previous: &Doc, profile: &Profile) -> anyhow::Result<()> {
537    let mut attrs = term::Table::<2, term::Label>::new(Default::default());
538
539    attrs.push([
540        term::format::bold("Title").into(),
541        term::label(revision.title.to_string()),
542    ]);
543    attrs.push([
544        term::format::bold("Revision").into(),
545        term::label(revision.id.to_string()),
546    ]);
547    attrs.push([
548        term::format::bold("Blob").into(),
549        term::label(revision.blob.to_string()),
550    ]);
551    attrs.push([
552        term::format::bold("Author").into(),
553        term::label(revision.author.to_string()),
554    ]);
555    attrs.push([
556        term::format::bold("State").into(),
557        term::label(revision.state.to_string()),
558    ]);
559    attrs.push([
560        term::format::bold("Quorum").into(),
561        if revision.is_accepted() {
562            term::format::positive("yes").into()
563        } else {
564            term::format::negative("no").into()
565        },
566    ]);
567
568    let mut meta = term::VStack::default()
569        .border(Some(term::colors::FAINT))
570        .child(attrs)
571        .children(if !revision.description.is_empty() {
572            vec![
573                term::Label::blank().boxed(),
574                term::textarea(revision.description.to_owned()).boxed(),
575            ]
576        } else {
577            vec![]
578        })
579        .divider();
580
581    let accepted = revision.accepted().collect::<Vec<_>>();
582    let rejected = revision.rejected().collect::<Vec<_>>();
583    let unknown = previous
584        .delegates()
585        .iter()
586        .filter(|id| !accepted.contains(id) && !rejected.contains(id))
587        .collect::<Vec<_>>();
588    let mut signatures = term::Table::<4, _>::default();
589
590    for id in accepted {
591        let author = term::format::Author::new(&id, profile, true);
592        signatures.push([
593            term::PREFIX_SUCCESS.into(),
594            id.to_string().into(),
595            author.alias().unwrap_or_default(),
596            author.you().unwrap_or_default(),
597        ]);
598    }
599    for id in rejected {
600        let author = term::format::Author::new(&id, profile, true);
601        signatures.push([
602            term::PREFIX_ERROR.into(),
603            id.to_string().into(),
604            author.alias().unwrap_or_default(),
605            author.you().unwrap_or_default(),
606        ]);
607    }
608    for id in unknown {
609        let author = term::format::Author::new(id, profile, true);
610        signatures.push([
611            term::format::dim("?").into(),
612            id.to_string().into(),
613            author.alias().unwrap_or_default(),
614            author.you().unwrap_or_default(),
615        ]);
616    }
617    meta.push(signatures);
618    meta.print();
619
620    Ok(())
621}
622
623fn print(
624    revision: &identity::Revision,
625    previous: &identity::Revision,
626    repo: &radicle::storage::git::Repository,
627    profile: &Profile,
628) -> anyhow::Result<()> {
629    print_meta(revision, previous, profile)?;
630    println!();
631    print_diff(revision.parent.as_ref(), &revision.id, repo)?;
632
633    Ok(())
634}
635
636fn edit_title_description(
637    title: Option<Title>,
638    description: Option<String>,
639) -> anyhow::Result<Option<(Title, String)>> {
640    const HELP: &str = r#"<!--
641Please enter a patch message for your changes. An empty
642message aborts the patch proposal.
643
644The first line is the patch title. The patch description
645follows, and must be separated with a blank line, just
646like a commit message. Markdown is supported in the title
647and description.
648-->"#;
649
650    let result = if let (Some(t), d) = (title.as_ref(), description.as_deref()) {
651        Some((t.to_owned(), d.unwrap_or_default().to_owned()))
652    } else {
653        let result = Message::edit_title_description(title, description, HELP)?;
654        if let Some((title, description)) = result {
655            Some((title, description))
656        } else {
657            None
658        }
659    };
660    Ok(result)
661}
662
663fn update<R, G>(
664    title: Option<Title>,
665    description: Option<String>,
666    doc: Doc,
667    current: &mut IdentityMut<R>,
668    signer: &Device<G>,
669) -> anyhow::Result<Revision>
670where
671    R: WriteRepository + cob::Store<Namespace = NodeId>,
672    G: crypto::signature::Signer<crypto::Signature>,
673{
674    if let Some((title, description)) = edit_title_description(title, description)? {
675        let id = current.update(title, description, &doc, signer)?;
676        let revision = current
677            .revision(&id)
678            .ok_or(anyhow!("update failed: revision {id} is missing"))?;
679
680        Ok(revision.clone())
681    } else {
682        Err(anyhow!("you must provide a revision title and description"))
683    }
684}
685
686fn print_diff(
687    previous: Option<&RevisionId>,
688    current: &RevisionId,
689    repo: &radicle::storage::git::Repository,
690) -> anyhow::Result<()> {
691    let previous = if let Some(previous) = previous {
692        let previous = Doc::load_at(*previous, repo)?;
693        let previous = serde_json::to_string_pretty(&previous.doc)?;
694
695        Some(previous)
696    } else {
697        None
698    };
699    let current = Doc::load_at(*current, repo)?;
700    let current = serde_json::to_string_pretty(&current.doc)?;
701
702    let tmp = tempfile::tempdir()?;
703    let repo = radicle::git::raw::Repository::init_opts(
704        tmp.path(),
705        radicle::git::raw::RepositoryInitOptions::new()
706            .external_template(false)
707            .bare(true),
708    )?;
709
710    let previous = if let Some(previous) = previous {
711        let tree = radicle::git::write_tree(&doc::PATH, previous.as_bytes(), &repo)?;
712        Some(tree)
713    } else {
714        None
715    };
716    let current = radicle::git::write_tree(&doc::PATH, current.as_bytes(), &repo)?;
717    let mut opts = radicle::git::raw::DiffOptions::new();
718    opts.context_lines(u32::MAX);
719
720    let diff = repo.diff_tree_to_tree(previous.as_ref(), Some(&current), Some(&mut opts))?;
721    let diff = Diff::try_from(diff)?;
722
723    if let Some(modified) = diff.modified().next() {
724        let diff = modified.diff.to_unified_string()?;
725        print!("{diff}");
726    } else {
727        term::print(term::format::italic("No changes."));
728    }
729    Ok(())
730}
731
732fn print_delegate_verification_error(err: &update::error::DelegateVerification) {
733    use update::error::DelegateVerification::*;
734    match err {
735        MissingDefaultBranch { branch, did } => term::error(format!(
736            "missing {} for {} in local storage",
737            term::format::secondary(branch),
738            term::format::did(did)
739        )),
740        MissingDelegate { did } => {
741            term::error(format!("the delegate {did} is missing"));
742            term::hint(format!(
743                "run `rad follow {did}` to follow this missing peer"
744            ));
745        }
746    }
747}