radicle_cli/commands/
id.rs

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                    // Update the canonical head to point to the latest accepted revision.
333                    if revision.is_accepted() && revision.id == identity.current {
334                        repo.set_identity_head_to(revision.id)?;
335                    }
336                    // TODO: Different output if canonical changed?
337
338                    if !options.quiet {
339                        term::success!("Revision {id} accepted");
340                        print_meta(revision, &current, &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, &current, &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)) => { /* no-op */ },
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                        // We ignore disallow since only allowing matters and the sets are disjoint.
428                        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            // If `--edit` is specified, the document can also be edited via a text edit.
474            let proposal = if edit {
475                match term::editor::Editor::comment()
476                    .extension("json")
477                    .initial(serde_json::to_string_pretty(&current.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            // Verify that the project payload can still be parsed into the `Project` type.
493            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                // Update the canonical head to point to the latest accepted revision.
510                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, &current, &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(&current.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(&current), 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}