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