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