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 if revision.is_accepted() && revision.id == identity.current {
313 repo.set_identity_head_to(revision.id)?;
314 }
315 if !options.quiet {
318 term::success!("Revision {id} accepted");
319 print_meta(revision, ¤t, &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, ¤t, &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 let proposal = if edit {
415 match term::editor::Editor::comment()
416 .extension("json")
417 .initial(serde_json::to_string_pretty(¤t.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 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, ¤t, &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(¤t.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(¤t), 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}