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