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