Skip to main content

radicle_cli/commands/
id.rs

1mod args;
2
3use std::collections::BTreeSet;
4
5use anyhow::{Context, anyhow};
6
7use radicle::cob::Title;
8use radicle::cob::identity::{self, IdentityMut, Revision, RevisionId};
9use radicle::identity::doc::update;
10use radicle::identity::{Doc, Identity, RawDoc, doc};
11use radicle::node::NodeId;
12use radicle::storage::{ReadStorage as _, WriteRepository};
13use radicle::{Profile, cob, crypto};
14use radicle_surf::diff::Diff;
15use radicle_term::Element;
16
17use crate::git::Rev;
18use crate::git::unified_diff::Encode as _;
19use crate::terminal as term;
20use crate::terminal::args::{Error, rid_or_cwd};
21use crate::terminal::format::Author;
22use crate::terminal::patch::Message;
23
24pub use args::Args;
25use args::Command;
26
27pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
28    let profile = ctx.profile()?;
29    let storage = &profile.storage;
30    let (_, rid) = rid_or_cwd(args.repo)?;
31    let repo = storage
32        .repository(rid)
33        .context(anyhow!("repository `{rid}` not found in local storage"))?;
34
35    let device = profile.signer()?;
36    let mut identity = Identity::load_mut(&repo, &device)?;
37    let current = identity.current().clone();
38
39    let interactive = args.interactive();
40    let command = args.command.unwrap_or(Command::List);
41
42    match command {
43        Command::Accept { revision } => {
44            let revision = get(revision, &identity, &repo)?.clone();
45            let id = revision.id;
46
47            if !revision.is_active() {
48                anyhow::bail!("cannot vote on revision that is {}", revision.state);
49            }
50
51            if interactive.confirm(format!("Accept revision {}?", term::format::tertiary(id))) {
52                identity.accept(&revision.id)?;
53
54                if let Some(revision) = identity.revision(&id) {
55                    // Update the canonical head to point to the latest accepted revision.
56                    if revision.is_accepted() && revision.id == identity.current {
57                        repo.set_identity_head_to(revision.id)?;
58                    }
59                    // TODO: Different output if canonical changed?
60
61                    if !args.quiet {
62                        term::success!("Revision {id} accepted");
63                        print_meta(revision, &current, &profile)?;
64                    }
65                }
66            }
67        }
68        Command::Reject { revision } => {
69            let revision = get(revision, &identity, &repo)?.clone();
70
71            if !revision.is_active() {
72                anyhow::bail!("cannot vote on revision that is {}", revision.state);
73            }
74
75            if interactive.confirm(format!(
76                "Reject revision {}?",
77                term::format::tertiary(revision.id)
78            )) {
79                identity.reject(revision.id)?;
80
81                if !args.quiet {
82                    term::success!("Revision {} rejected", revision.id);
83                    print_meta(&revision, &current, &profile)?;
84                }
85            }
86        }
87        Command::Edit {
88            revision,
89            title,
90            description,
91        } => {
92            let revision = get(revision, &identity, &repo)?.clone();
93
94            if !revision.is_active() {
95                anyhow::bail!("revision can no longer be edited");
96            }
97            let Some((title, description)) = edit_title_description(title, description)? else {
98                anyhow::bail!("revision title or description missing");
99            };
100            identity.edit(revision.id, title, description)?;
101
102            if !args.quiet {
103                term::success!("Revision {} edited", revision.id);
104            }
105        }
106        Command::Update {
107            title,
108            description,
109            delegate: delegates,
110            rescind,
111            threshold,
112            visibility,
113            allow,
114            disallow,
115            payload,
116            edit,
117        } => {
118            let proposal = {
119                let mut proposal = current.doc.clone().edit();
120                let allow = allow.into_iter().collect::<BTreeSet<_>>();
121                let disallow = disallow.into_iter().collect::<BTreeSet<_>>();
122
123                proposal.threshold = threshold.unwrap_or(proposal.threshold);
124
125                let proposal = match visibility {
126                    Some(edit) => update::visibility(proposal, edit),
127                    None => proposal,
128                };
129                let proposal = match update::privacy_allow_list(proposal, allow, disallow) {
130                    Ok(proposal) => proposal,
131                    Err(e) => match e {
132                        update::error::PrivacyAllowList::Overlapping(overlap) =>anyhow::bail!("`--allow` and `--disallow` must not overlap: {overlap:?}"),
133                        update::error::PrivacyAllowList::PublicVisibility => return Err(Error::with_hint(
134                            anyhow!("`--allow` and `--disallow` should only be used for private repositories"),
135                            "use `--visibility private` to make the repository private, or perhaps you meant to use `--delegate`/`--rescind`")
136                        .into())
137                    }
138                };
139                let threshold = proposal.threshold;
140                let proposal = match update::delegates(proposal, delegates, rescind, &repo)? {
141                    Ok(proposal) => proposal,
142                    Err(errs) => {
143                        term::error(format!("failed to verify delegates for {rid}"));
144                        term::error(format!(
145                            "the threshold of {threshold} delegates cannot be met.."
146                        ));
147                        for e in errs {
148                            print_delegate_verification_error(&e);
149                        }
150                        anyhow::bail!("fatal: refusing to update identity document");
151                    }
152                };
153
154                // TODO(erikli): whenever `clap` starts supporting custom value parsers
155                // for a series of values, we can parse into `Payload` implicitly.
156                let payloads = args::parse_many_upserts(&payload).collect::<Result<Vec<_>, _>>()?;
157
158                update::payload(proposal, payloads)?
159            };
160
161            // If `--edit` is specified, the document can also be edited via a text edit.
162            let proposal = if edit {
163                match term::editor::Editor::comment()
164                    .extension("json")
165                    .initial(serde_json::to_string_pretty(&current.doc)?)?
166                    .edit()?
167                {
168                    Some(proposal) => serde_json::from_str::<RawDoc>(&proposal)?,
169                    None => {
170                        term::println(term::format::italic(
171                            "Nothing to do. The document is up to date. See `rad inspect --identity`.",
172                        ));
173                        return Ok(());
174                    }
175                }
176            } else {
177                proposal
178            };
179
180            let proposal = update::verify(proposal)?;
181            if proposal == current.doc {
182                if !args.quiet {
183                    term::println(term::format::italic(
184                        "Nothing to do. The document is up to date. See `rad inspect --identity`.",
185                    ));
186                }
187                return Ok(());
188            }
189            let revision = update(title, description, proposal, &mut identity, &profile)?;
190
191            if revision.is_accepted() && revision.parent == Some(current.id) {
192                // Update the canonical head to point to the latest accepted revision.
193                repo.set_identity_head_to(revision.id)?;
194            }
195            if args.quiet {
196                term::println(revision.id);
197            } else {
198                term::success!(
199                    "Identity revision {} created",
200                    term::format::tertiary(revision.id)
201                );
202                print(&revision, &current, &repo, &profile)?;
203            }
204        }
205        Command::List => {
206            let mut revisions =
207                term::Table::<8, term::Label>::new(term::table::TableOptions::bordered());
208
209            revisions.header([
210                term::format::dim(String::from("●")).into(),
211                term::format::bold(String::from("ID")).into(),
212                term::format::bold(String::from("Title")).into(),
213                term::format::bold(String::from("Author")).into(),
214                term::Label::blank(),
215                term::format::bold(String::from("Status")).into(),
216                term::format::bold(String::from("Created")).into(),
217                term::format::bold(String::from("Parent")).into(),
218            ]);
219            revisions.divider();
220
221            for r in identity.revisions().rev() {
222                let icon = match r.state {
223                    identity::State::Active => term::format::tertiary("●"),
224                    identity::State::Accepted => term::format::positive("●"),
225                    identity::State::Rejected(_) => term::format::negative("●"),
226                    identity::State::Redacted(_) => continue,
227                }
228                .into();
229                let state = r.state.to_string().into();
230                let id = term::format::oid(r.id).into();
231                let title = term::label(r.title.to_string());
232                let (alias, author) =
233                    term::format::Author::new(r.author.public_key(), &profile, true).labels();
234                let timestamp = term::format::timestamp(r.timestamp).into();
235                let parent = r
236                    .parent
237                    .map(term::format::oid)
238                    .unwrap_or_else(|| term::Paint::new("none".to_string()));
239
240                revisions.push([
241                    icon,
242                    id,
243                    title,
244                    alias,
245                    author,
246                    state,
247                    timestamp,
248                    parent.into(),
249                ]);
250            }
251            revisions.print();
252        }
253        Command::Redact { revision } => {
254            let revision = get(revision, &identity, &repo)?.clone();
255
256            if revision.is_accepted() {
257                anyhow::bail!("cannot redact accepted revision");
258            }
259            if interactive.confirm(format!(
260                "Redact revision {}?",
261                term::format::tertiary(revision.id)
262            )) {
263                identity.redact(revision.id)?;
264
265                if !args.quiet {
266                    term::success!("Revision {} redacted", revision.id);
267                }
268            }
269        }
270        Command::Show { revision } => {
271            let revision = get(revision, &identity, &repo)?;
272            let previous = revision.parent.unwrap_or(revision.id);
273            let previous = identity
274                .revision(&previous)
275                .ok_or(anyhow!("revision `{previous}` not found"))?;
276
277            print(revision, previous, &repo, &profile)?;
278        }
279    }
280    Ok(())
281}
282
283fn get<'a>(
284    revision: Rev,
285    identity: &'a Identity,
286    repo: &radicle::storage::git::Repository,
287) -> anyhow::Result<&'a Revision> {
288    let id = revision.resolve(&repo.backend)?;
289    let revision = identity
290        .revision(&id)
291        .filter(|revision| !matches!(revision.state, identity::State::Redacted(_)))
292        .ok_or(anyhow!("revision `{id}` not found"))?;
293
294    Ok(revision)
295}
296
297fn print_meta(revision: &Revision, previous: &Doc, profile: &Profile) -> anyhow::Result<()> {
298    let mut attrs = term::Table::<2, term::Label>::new(Default::default());
299
300    attrs.push([
301        term::format::bold("Title").into(),
302        term::label(revision.title.to_string()),
303    ]);
304    attrs.push([
305        term::format::bold("Revision").into(),
306        term::label(revision.id.to_string()),
307    ]);
308    if let Some(parent) = revision.parent {
309        attrs.push([
310            term::format::bold("Parent").into(),
311            term::label(parent.to_string()),
312        ]);
313    }
314    attrs.push([
315        term::format::bold("Blob").into(),
316        term::label(revision.blob.to_string()),
317    ]);
318    attrs.push([
319        term::format::bold("Author").into(),
320        term::label(revision.author.to_string()),
321    ]);
322    attrs.push([
323        term::format::bold("State").into(),
324        term::label(revision.state.to_string()),
325    ]);
326    attrs.push([
327        term::format::bold("Quorum").into(),
328        if revision.is_accepted() {
329            term::format::positive("yes").into()
330        } else {
331            term::format::negative("no").into()
332        },
333    ]);
334
335    let mut meta = term::VStack::default()
336        .border(Some(term::colors::FAINT))
337        .child(attrs)
338        .children(if !revision.description.is_empty() {
339            vec![
340                term::Label::blank().boxed(),
341                term::textarea(revision.description.to_owned()).boxed(),
342            ]
343        } else {
344            vec![]
345        })
346        .divider();
347
348    let accepted = {
349        let mut accepted = revision.accepted().collect::<Vec<_>>();
350        accepted.sort();
351        accepted
352    };
353
354    let rejected = {
355        let mut rejected = revision.rejected().collect::<Vec<_>>();
356        rejected.sort();
357        rejected
358    };
359
360    let unknown = {
361        let mut unknown = previous
362            .delegates()
363            .iter()
364            .filter(|id| !accepted.contains(id) && !rejected.contains(id))
365            .collect::<Vec<_>>();
366        unknown.sort();
367        unknown
368    };
369
370    let mut signatures = term::Table::<4, _>::default();
371
372    for id in accepted {
373        let author = term::format::Author::new(&id, profile, true);
374        signatures.push([
375            term::PREFIX_SUCCESS.into(),
376            id.to_string().into(),
377            author.alias().unwrap_or_default(),
378            author.you().unwrap_or_default(),
379        ]);
380    }
381    for id in rejected {
382        let author = term::format::Author::new(&id, profile, true);
383        signatures.push([
384            term::PREFIX_ERROR.into(),
385            id.to_string().into(),
386            author.alias().unwrap_or_default(),
387            author.you().unwrap_or_default(),
388        ]);
389    }
390    for id in unknown {
391        let author = term::format::Author::new(id, profile, true);
392        signatures.push([
393            term::format::dim("?").into(),
394            id.to_string().into(),
395            author.alias().unwrap_or_default(),
396            author.you().unwrap_or_default(),
397        ]);
398    }
399    meta.push(signatures);
400    meta.print();
401
402    Ok(())
403}
404
405fn print(
406    revision: &identity::Revision,
407    previous: &identity::Revision,
408    repo: &radicle::storage::git::Repository,
409    profile: &Profile,
410) -> anyhow::Result<()> {
411    print_meta(revision, previous, profile)?;
412    term::blank();
413    print_diff(revision.parent.as_ref(), &revision.id, repo)?;
414
415    Ok(())
416}
417
418fn edit_title_description(
419    title: Option<Title>,
420    description: Option<String>,
421) -> anyhow::Result<Option<(Title, String)>> {
422    const HELP: &str = r#"<!--
423Please enter a patch message for your changes. An empty
424message aborts the patch proposal.
425
426The first line is the patch title. The patch description
427follows, and must be separated with a blank line, just
428like a commit message. Markdown is supported in the title
429and description.
430-->"#;
431
432    let result = if let (Some(t), d) = (title.as_ref(), description.as_deref()) {
433        Some((t.to_owned(), d.unwrap_or_default().to_owned()))
434    } else {
435        let result = Message::edit_title_description(title, description, HELP)?;
436        if let Some((title, description)) = result {
437            Some((title, description))
438        } else {
439            None
440        }
441    };
442    Ok(result)
443}
444
445fn update(
446    title: Option<Title>,
447    description: Option<String>,
448    doc: Doc,
449    current: &mut IdentityMut<
450        impl WriteRepository + cob::Store<Namespace = NodeId>,
451        impl crypto::Signer,
452    >,
453    profile: &Profile,
454) -> anyhow::Result<Revision> {
455    if let Some((title, description)) = edit_title_description(title, description)? {
456        let id = current
457            .update(title, description, &doc)
458            .map_err(|e| on_identity_err(e, profile))?;
459        let revision = current
460            .revision(&id)
461            .ok_or(anyhow!("update failed: revision {id} is missing"))?;
462
463        Ok(revision.clone())
464    } else {
465        Err(anyhow!("you must provide a revision title and description"))
466    }
467}
468
469fn on_identity_err(e: identity::Error, profile: &Profile) -> anyhow::Error {
470    let e = anyhow::Error::from(e);
471
472    e.chain()
473        .find_map(|c| c.downcast_ref::<identity::ApplyError>())
474        .map(|e| on_apply_err(e, profile))
475        .unwrap_or(e)
476}
477
478fn on_apply_err(e: &identity::ApplyError, profile: &Profile) -> anyhow::Error {
479    match e {
480        e @ identity::ApplyError::NonDelegateUnauthorized { author, .. } => {
481            let nid = NodeId::from(*author);
482            let labels = Author::new(&nid, profile, false).labels();
483
484            Error::with_hint(
485                anyhow!(e.to_string()),
486                format!(
487                    "{} {} is attempting to modify the identity document but is not a delegate!",
488                    labels.0, labels.1
489                ),
490            )
491            .into()
492        }
493        e @ radicle::cob::identity::ApplyError::Missing(_)
494        | e @ radicle::cob::identity::ApplyError::Init(_)
495        | e @ radicle::cob::identity::ApplyError::InvalidSignature(..)
496        | e @ radicle::cob::identity::ApplyError::NotAuthorized
497        | e @ radicle::cob::identity::ApplyError::MissingParent
498        | e @ radicle::cob::identity::ApplyError::DuplicateVerdict
499        | e @ radicle::cob::identity::ApplyError::UnexpectedState
500        | e @ radicle::cob::identity::ApplyError::SiblingAccepted { .. }
501        | e @ radicle::cob::identity::ApplyError::DocUnchanged
502        | e @ radicle::cob::identity::ApplyError::Git(_)
503        | e @ radicle::cob::identity::ApplyError::Doc(_)
504        | e => {
505            anyhow!(e.to_string())
506        }
507    }
508}
509
510fn print_diff(
511    previous: Option<&RevisionId>,
512    current: &RevisionId,
513    repo: &radicle::storage::git::Repository,
514) -> anyhow::Result<()> {
515    let previous = if let Some(previous) = previous {
516        let previous = Doc::load_at(*previous, repo)?;
517        let previous = serde_json::to_string_pretty(&previous.doc)?;
518
519        Some(previous)
520    } else {
521        None
522    };
523    let current = Doc::load_at(*current, repo)?;
524    let current = serde_json::to_string_pretty(&current.doc)?;
525
526    let tmp = tempfile::tempdir()?;
527    let repo = radicle::git::raw::Repository::init_opts(
528        tmp.path(),
529        radicle::git::raw::RepositoryInitOptions::new()
530            .external_template(false)
531            .bare(true),
532    )?;
533
534    let previous = if let Some(previous) = previous {
535        let tree = radicle::git::write_tree(&doc::PATH, previous.as_bytes(), &repo)?;
536        Some(tree)
537    } else {
538        None
539    };
540    let current = radicle::git::write_tree(&doc::PATH, current.as_bytes(), &repo)?;
541    let mut opts = radicle::git::raw::DiffOptions::new();
542    opts.context_lines(u32::MAX);
543
544    let diff = repo.diff_tree_to_tree(previous.as_ref(), Some(&current), Some(&mut opts))?;
545    let diff = Diff::try_from(diff)?;
546
547    if let Some(modified) = diff.modified().next() {
548        let diff = modified.diff.to_unified_string()?;
549        term::print(diff);
550    } else {
551        term::println(term::format::italic("No changes."));
552    }
553    Ok(())
554}
555
556fn print_delegate_verification_error(err: &update::error::DelegateVerification) {
557    use update::error::DelegateVerification::*;
558    match err {
559        MissingDefaultBranch { branch, did } => term::error(format!(
560            "missing {} for {} in local storage",
561            term::format::secondary(branch),
562            term::format::did(did)
563        )),
564        MissingDelegate { did } => {
565            term::error(format!("the delegate {did} is missing"));
566            term::hint(format!(
567                "run `rad follow {did}` to follow this missing peer"
568            ));
569        }
570    }
571}