1use std::ffi::OsString;
2use std::path::PathBuf;
3use std::str::FromStr;
4use std::{fs, io};
5
6use anyhow::{anyhow, bail};
7
8use chrono::prelude::*;
9
10use nonempty::NonEmpty;
11
12use radicle::cob;
13use radicle::cob::store::CobAction;
14use radicle::cob::stream::CobStream as _;
15use radicle::git;
16use radicle::prelude::*;
17use radicle::storage;
18
19use crate::git::Rev;
20use crate::terminal as term;
21use crate::terminal::args::{Args, Error, Help};
22
23pub const HELP: Help = Help {
24 name: "cob",
25 description: "Manage collaborative objects",
26 version: env!("RADICLE_VERSION"),
27 usage: r#"
28Usage
29
30 rad cob <command> [<option>...]
31
32 rad cob create --repo <rid> --type <typename> <filename> [<option>...]
33 rad cob list --repo <rid> --type <typename>
34 rad cob log --repo <rid> --type <typename> --object <oid> [<option>...]
35 rad cob migrate [<option>...]
36 rad cob show --repo <rid> --type <typename> --object <oid> [<option>...]
37 rad cob update --repo <rid> --type <typename> --object <oid> <filename>
38 [<option>...]
39
40Commands
41
42 create Create a new COB of a given type given initial actions
43 list List all COBs of a given type (--object is not needed)
44 log Print a log of all raw operations on a COB
45 migrate Migrate the COB database to the latest version
46 update Add actions to a COB
47 show Print the state of COBs
48
49Create, Update options
50
51 --embed-file <name> <path> Supply embed of given name via file at given path
52 --embed-hash <name> <oid> Supply embed of given name via object ID of blob
53
54Log options
55
56 --format (pretty | json) Desired output format (default: pretty)
57 --from <oid> Git object ID of the commit of the operation to
58 start iterating at.
59 --until <oid> Git object ID of the commit of the operation to
60 stop iterating at.
61
62Show options
63
64 --format json Desired output format (default: json)
65
66Other options
67
68 --help Print help
69"#,
70};
71
72#[derive(Clone, Copy, PartialEq)]
73enum OperationName {
74 Update,
75 Create,
76 List,
77 Log,
78 Migrate,
79 Show,
80}
81
82enum Operation {
83 Create {
84 rid: RepoId,
85 type_name: FilteredTypeName,
86 message: String,
87 actions: PathBuf,
88 embeds: Vec<Embed>,
89 },
90 List {
91 rid: RepoId,
92 type_name: FilteredTypeName,
93 },
94 Log {
95 rid: RepoId,
96 type_name: FilteredTypeName,
97 oid: Rev,
98 format: Format,
99 from: Option<Rev>,
100 until: Option<Rev>,
101 },
102 Migrate,
103 Show {
104 rid: RepoId,
105 type_name: FilteredTypeName,
106 oids: Vec<Rev>,
107 },
108 Update {
109 rid: RepoId,
110 type_name: FilteredTypeName,
111 oid: Rev,
112 message: String,
113 actions: PathBuf,
114 embeds: Vec<Embed>,
115 },
116}
117
118enum Format {
119 Json,
120 Pretty,
121}
122
123pub struct Options {
124 op: Operation,
125}
126
127struct Embed {
130 name: String,
131 content: EmbedContent,
132}
133
134enum EmbedContent {
135 Path(PathBuf),
136 Hash(Rev),
137}
138
139enum FilteredTypeName {
144 Issue,
145 Patch,
146 Identity,
147 Other(cob::TypeName),
148}
149
150impl From<cob::TypeName> for FilteredTypeName {
151 fn from(value: cob::TypeName) -> Self {
152 if value == *cob::issue::TYPENAME {
153 FilteredTypeName::Issue
154 } else if value == *cob::patch::TYPENAME {
155 FilteredTypeName::Patch
156 } else if value == *cob::identity::TYPENAME {
157 FilteredTypeName::Identity
158 } else {
159 FilteredTypeName::Other(value)
160 }
161 }
162}
163
164impl AsRef<cob::TypeName> for FilteredTypeName {
165 fn as_ref(&self) -> &cob::TypeName {
166 match self {
167 FilteredTypeName::Issue => &cob::issue::TYPENAME,
168 FilteredTypeName::Patch => &cob::patch::TYPENAME,
169 FilteredTypeName::Identity => &cob::identity::TYPENAME,
170 FilteredTypeName::Other(value) => value,
171 }
172 }
173}
174
175impl std::fmt::Display for FilteredTypeName {
176 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
177 self.as_ref().fmt(f)
178 }
179}
180
181impl Embed {
182 fn try_into_bytes(
183 self,
184 repo: &storage::git::Repository,
185 ) -> anyhow::Result<cob::Embed<cob::Uri>> {
186 Ok(match self.content {
187 EmbedContent::Hash(hash) => cob::Embed {
188 name: self.name,
189 content: hash.resolve::<git::Oid>(&repo.backend)?.into(),
190 },
191 EmbedContent::Path(path) => {
192 cob::Embed::store(self.name, &std::fs::read(path)?, &repo.backend)?
193 }
194 })
195 }
196}
197
198impl Args for Options {
199 fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
200 use lexopt::prelude::*;
201 use term::args::string;
202 use OperationName::*;
203
204 let mut parser = lexopt::Parser::from_args(args);
205
206 let op = match parser.next()? {
207 None | Some(Long("help") | Short('h')) => {
208 return Err(Error::Help.into());
209 }
210 Some(Value(val)) => match val.to_string_lossy().as_ref() {
211 "update" => Update,
212 "create" => Create,
213 "list" => List,
214 "log" => Log,
215 "migrate" => Migrate,
216 "show" => Show,
217 unknown => bail!("unknown operation '{unknown}'"),
218 },
219 Some(arg) => return Err(anyhow!(arg.unexpected())),
220 };
221
222 let mut type_name: Option<FilteredTypeName> = None;
223 let mut oids: Vec<Rev> = vec![];
224 let mut rid: Option<RepoId> = None;
225 let mut format: Format = Format::Pretty;
226 let mut message: Option<String> = None;
227 let mut embeds: Vec<Embed> = vec![];
228 let mut actions: Option<PathBuf> = None;
229 let mut from: Option<Rev> = None;
230 let mut until: Option<Rev> = None;
231
232 while let Some(arg) = parser.next()? {
233 match (&op, &arg) {
234 (_, Long("help") | Short('h')) => {
235 return Err(Error::Help.into());
236 }
237 (_, Long("repo") | Short('r')) => {
238 rid = Some(term::args::rid(&parser.value()?)?);
239 }
240 (_, Long("type") | Short('t')) => {
241 let v = string(&parser.value()?);
242 type_name = Some(FilteredTypeName::from(cob::TypeName::from_str(&v)?));
243 }
244 (Update | Log | Show, Long("object") | Short('o')) => {
245 let v = string(&parser.value()?);
246 oids.push(Rev::from(v));
247 }
248 (Update | Create, Long("message") | Short('m')) => {
249 message = Some(string(&parser.value()?));
250 }
251 (Log | Show | Update, Long("format")) => {
252 format = match (op, string(&parser.value()?).as_ref()) {
253 (Log, "pretty") => Format::Pretty,
254 (Log | Show | Update, "json") => Format::Json,
255 (_, unknown) => bail!("unknown format '{unknown}'"),
256 };
257 }
258 (Update | Create, Long("embed-file")) => {
259 let mut values = parser.values()?;
260
261 let name = values
262 .next()
263 .map(|s| term::args::string(&s))
264 .ok_or(anyhow!("expected name of embed"))?;
265
266 let content = EmbedContent::Path(PathBuf::from(
267 values
268 .next()
269 .ok_or(anyhow!("expected path to file to embed"))?,
270 ));
271
272 embeds.push(Embed { name, content });
273 }
274 (Update | Create, Long("embed-hash")) => {
275 let mut values = parser.values()?;
276
277 let name = values
278 .next()
279 .map(|s| term::args::string(&s))
280 .ok_or(anyhow!("expected name of embed"))?;
281
282 let content = EmbedContent::Hash(Rev::from(term::args::string(
283 &values
284 .next()
285 .ok_or(anyhow!("expected hash of file to embed"))?,
286 )));
287
288 embeds.push(Embed { name, content });
289 }
290 (Update | Create, Value(val)) => {
291 actions = Some(PathBuf::from(term::args::string(val)));
292 }
293 (Log, Long("from")) => {
294 let v = parser.value()?;
295 from = Some(term::args::rev(&v)?);
296 }
297 (Log, Long("until")) => {
298 let v = parser.value()?;
299 until = Some(term::args::rev(&v)?);
300 }
301 _ => return Err(anyhow!(arg.unexpected())),
302 }
303 }
304
305 if op == OperationName::Migrate {
306 return Ok((
307 Options {
308 op: Operation::Migrate,
309 },
310 vec![],
311 ));
312 }
313
314 let rid = rid.ok_or_else(|| anyhow!("a repository id must be specified with `--repo`"))?;
315 let type_name =
316 type_name.ok_or_else(|| anyhow!("an object type must be specified with `--type`"))?;
317
318 let missing_oid = || anyhow!("an object id must be specified with `--object`");
319 let missing_message = || anyhow!("a message must be specified with `--message`");
320
321 Ok((
322 Options {
323 op: match op {
324 Create => Operation::Create {
325 rid,
326 type_name,
327 message: message.ok_or_else(missing_message)?,
328 actions: actions.ok_or_else(|| {
329 anyhow!("a file containing initial actions must be specified")
330 })?,
331 embeds,
332 },
333 List => Operation::List { rid, type_name },
334 Log => Operation::Log {
335 rid,
336 type_name,
337 oid: oids.pop().ok_or_else(missing_oid)?,
338 format,
339 from,
340 until,
341 },
342 Migrate => Operation::Migrate,
343 Show => {
344 if oids.is_empty() {
345 return Err(missing_oid());
346 }
347 Operation::Show {
348 rid,
349 oids,
350 type_name,
351 }
352 }
353 Update => Operation::Update {
354 rid,
355 type_name,
356 oid: oids.pop().ok_or_else(missing_oid)?,
357 message: message.ok_or_else(missing_message)?,
358 actions: actions.ok_or_else(|| {
359 anyhow!("a file containing actions must be specified")
360 })?,
361 embeds,
362 },
363 },
364 },
365 vec![],
366 ))
367 }
368}
369
370pub fn run(Options { op }: Options, ctx: impl term::Context) -> anyhow::Result<()> {
371 use cob::store::Store;
372 use FilteredTypeName::*;
373 use Operation::*;
374
375 let profile = ctx.profile()?;
376 let storage = &profile.storage;
377
378 match op {
379 Create {
380 rid,
381 type_name,
382 message,
383 embeds,
384 actions,
385 } => {
386 let signer = &profile.signer()?;
387 let repo = storage.repository_mut(rid)?;
388
389 let reader = io::BufReader::new(fs::File::open(actions)?);
390
391 let embeds = embeds
392 .into_iter()
393 .map(|embed| embed.try_into_bytes(&repo))
394 .collect::<anyhow::Result<Vec<_>>>()?;
395
396 let oid = match type_name {
397 Patch => {
398 let store: Store<cob::patch::Patch, _> = Store::open(&repo)?;
399 let actions = read_jsonl_actions(reader)?;
400 let (oid, _) = store.create(&message, actions, embeds, signer)?;
401 oid
402 }
403 Issue => {
404 let store: Store<cob::issue::Issue, _> = Store::open(&repo)?;
405 let actions = read_jsonl_actions(reader)?;
406 let (oid, _) = store.create(&message, actions, embeds, signer)?;
407 oid
408 }
409 Identity => anyhow::bail!(
410 "Creation of collaborative objects of type {} is not supported.",
411 &type_name
412 ),
413 Other(type_name) => {
414 let store: Store<cob::external::External, _> =
415 Store::open_for(&type_name, &repo)?;
416 let actions = read_jsonl_actions(reader)?;
417 let (oid, _) = store.create(&message, actions, embeds, signer)?;
418 oid
419 }
420 };
421 println!("{oid}");
422 }
423 Migrate => {
424 let mut db = profile.cobs_db_mut()?;
425 if db.check_version().is_ok() {
426 term::success!("Collaborative objects database is already up to date");
427 } else {
428 let version = db.migrate(term::cob::migrate::spinner())?;
429 term::success!(
430 "Migrated collaborative objects database successfully (version={version})"
431 );
432 }
433 }
434 List { rid, type_name } => {
435 let repo = storage.repository(rid)?;
436 let cobs = radicle_cob::list::<NonEmpty<cob::Entry>, _>(&repo, type_name.as_ref())?;
437 for cob in cobs {
438 println!("{}", cob.id);
439 }
440 }
441 Log {
442 rid,
443 type_name,
444 oid,
445 format,
446 from,
447 until,
448 } => {
449 let repo = storage.repository(rid)?;
450 let oid = oid.resolve(&repo.backend)?;
451
452 let from = from.map(|from| from.resolve(&repo.backend)).transpose()?;
453 let until = until
454 .map(|until| until.resolve(&repo.backend))
455 .transpose()?;
456
457 match type_name {
458 Issue => operations::<cob::issue::Action>(
459 &cob::issue::TYPENAME,
460 oid,
461 from,
462 until,
463 &repo,
464 format,
465 )?,
466 Patch => operations::<cob::patch::Action>(
467 &cob::patch::TYPENAME,
468 oid,
469 from,
470 until,
471 &repo,
472 format,
473 )?,
474 Identity => operations::<cob::identity::Action>(
475 &cob::identity::TYPENAME,
476 oid,
477 from,
478 until,
479 &repo,
480 format,
481 )?,
482 Other(type_name) => {
483 operations::<serde_json::Value>(&type_name, oid, from, until, &repo, format)?
484 }
485 }
486 }
487 Show {
488 rid,
489 oids,
490 type_name,
491 } => {
492 let repo = storage.repository(rid)?;
493 if let Err(e) = show(oids, &repo, type_name, &profile) {
494 if let Some(err) = e.downcast_ref::<std::io::Error>() {
495 if err.kind() == std::io::ErrorKind::BrokenPipe {
496 return Ok(());
497 }
498 }
499 return Err(e);
500 }
501 }
502 Update {
503 rid,
504 type_name,
505 oid,
506 message,
507 actions,
508 embeds,
509 } => {
510 let signer = &profile.signer()?;
511 let repo = storage.repository_mut(rid)?;
512 let reader = io::BufReader::new(fs::File::open(actions)?);
513 let oid = &oid.resolve(&repo.backend)?;
514 let embeds = embeds
515 .into_iter()
516 .map(|embed| embed.try_into_bytes(&repo))
517 .collect::<anyhow::Result<Vec<_>>>()?;
518
519 let oid = match type_name {
520 Patch => {
521 let actions: Vec<cob::patch::Action> = read_jsonl(reader)?;
522 let mut patches = profile.patches_mut(&repo)?;
523 let mut patch = patches.get_mut(oid)?;
524 patch.transaction(&message, &*profile.signer()?, |tx| {
525 tx.extend(actions)?;
526 tx.embed(embeds)?;
527 Ok(())
528 })?
529 }
530 Issue => {
531 let actions: Vec<cob::issue::Action> = read_jsonl(reader)?;
532 let mut issues = profile.issues_mut(&repo)?;
533 let mut issue = issues.get_mut(oid)?;
534 issue.transaction(&message, &*profile.signer()?, |tx| {
535 tx.extend(actions)?;
536 tx.embed(embeds)?;
537 Ok(())
538 })?
539 }
540 Identity => anyhow::bail!(
541 "Update of collaborative objects of type {} is not supported.",
542 &type_name
543 ),
544 Other(type_name) => {
545 use cob::external::{Action, External};
546 let actions: Vec<Action> = read_jsonl(reader)?;
547 let mut store: Store<External, _> = Store::open_for(&type_name, &repo)?;
548 let tx = cob::store::Transaction::new(type_name.clone(), actions, embeds);
549 let (_, oid) = tx.commit(&message, *oid, &mut store, signer)?;
550 oid
551 }
552 };
553
554 println!("{oid}");
555 }
556 }
557 Ok(())
558}
559
560fn show(
561 oids: Vec<Rev>,
562 repo: &storage::git::Repository,
563 type_name: FilteredTypeName,
564 profile: &Profile,
565) -> Result<(), anyhow::Error> {
566 use io::Write as _;
567 let mut stdout = std::io::stdout();
568
569 match type_name {
570 FilteredTypeName::Identity => {
571 use cob::identity;
572 for oid in oids {
573 let oid = &oid.resolve(&repo.backend)?;
574 let Some(cob) = cob::get::<identity::Identity, _>(repo, type_name.as_ref(), oid)?
575 else {
576 bail!(cob::store::Error::NotFound(
577 type_name.as_ref().clone(),
578 *oid
579 ));
580 };
581 serde_json::to_writer(&stdout, &cob.object)?;
582 stdout.write_all(b"\n")?;
583 }
584 }
585 FilteredTypeName::Issue => {
586 use radicle::issue::cache::Issues as _;
587 let issues = term::cob::issues(profile, repo)?;
588 for oid in oids {
589 let oid = &oid.resolve(&repo.backend)?;
590 let Some(issue) = issues.get(oid)? else {
591 bail!(cob::store::Error::NotFound(
592 type_name.as_ref().clone(),
593 *oid
594 ))
595 };
596 serde_json::to_writer(&stdout, &issue)?;
597 stdout.write_all(b"\n")?;
598 }
599 }
600 FilteredTypeName::Patch => {
601 use radicle::patch::cache::Patches as _;
602 let patches = term::cob::patches(profile, repo)?;
603 for oid in oids {
604 let oid = &oid.resolve(&repo.backend)?;
605 let Some(patch) = patches.get(oid)? else {
606 bail!(cob::store::Error::NotFound(
607 type_name.as_ref().clone(),
608 *oid
609 ));
610 };
611 serde_json::to_writer(&stdout, &patch)?;
612 stdout.write_all(b"\n")?;
613 }
614 }
615 FilteredTypeName::Other(type_name) => {
616 let store =
617 cob::store::Store::<cob::external::External, _>::open_for(&type_name, repo)?;
618 for oid in oids {
619 let oid = &oid.resolve(&repo.backend)?;
620 let cob = store
621 .get(oid)?
622 .ok_or_else(|| anyhow!(cob::store::Error::NotFound(type_name.clone(), *oid)))?;
623 serde_json::to_writer(&stdout, &cob)?;
624 stdout.write_all(b"\n")?;
625 }
626 }
627 }
628 Ok(())
629}
630
631fn print_op_pretty<A>(op: cob::Op<A>) -> anyhow::Result<()>
632where
633 A: serde::Serialize,
634{
635 let time = DateTime::<Utc>::from(
636 std::time::UNIX_EPOCH + std::time::Duration::from_secs(op.timestamp.as_secs()),
637 )
638 .to_rfc2822();
639 term::print(term::format::yellow(format!("commit {}", op.id)));
640 if let Some(oid) = op.identity {
641 term::print(term::format::tertiary(format!("resource {oid}")));
642 }
643 for parent in op.parents {
644 term::print(format!("parent {parent}"));
645 }
646 for parent in op.related {
647 term::print(format!("rel {parent}"));
648 }
649 term::print(format!("author {}", op.author));
650 term::print(format!("date {time}"));
651 term::blank();
652 for action in op.actions {
653 let val = serde_json::to_string_pretty(&action)?;
654 for line in val.lines() {
655 term::indented(term::format::dim(line));
656 }
657 term::blank();
658 }
659 Ok(())
660}
661
662fn print_op_json<A>(op: cob::Op<A>) -> anyhow::Result<()>
663where
664 A: serde::Serialize,
665{
666 term::print(serde_json::to_value(&op)?);
667 Ok(())
668}
669
670fn read_jsonl<R, T>(reader: io::BufReader<R>) -> anyhow::Result<Vec<T>>
673where
674 R: io::Read,
675 T: serde::de::DeserializeOwned,
676{
677 use io::BufRead as _;
678 let mut result: Vec<T> = Vec::new();
679 for line in reader.lines() {
680 result.push(serde_json::from_str(&line?)?);
681 }
682 Ok(result)
683}
684
685fn read_jsonl_actions<R, A>(reader: io::BufReader<R>) -> anyhow::Result<NonEmpty<A>>
688where
689 R: io::Read,
690 A: CobAction + serde::de::DeserializeOwned,
691{
692 NonEmpty::from_vec(read_jsonl(reader)?)
693 .ok_or_else(|| anyhow!("at least one action is required"))
694}
695
696fn operations<A>(
697 typename: &cob::TypeName,
698 oid: cob::ObjectId,
699 from: Option<git::Oid>,
700 until: Option<git::Oid>,
701 repo: &storage::git::Repository,
702 format: Format,
703) -> anyhow::Result<()>
704where
705 A: serde::Serialize,
706 A: for<'de> serde::Deserialize<'de>,
707{
708 let history = cob::stream::CobRange::new(typename, &oid);
709 let stream = cob::stream::Stream::<A>::new(&repo.backend, history, typename.clone());
710 let iter = match (from, until) {
711 (None, None) => stream.all()?,
712 (None, Some(until)) => stream.until(until)?,
713 (Some(from), None) => stream.since(from)?,
714 (Some(from), Some(until)) => stream.range(from, until)?,
715 };
716
717 let iter = iter.collect::<Vec<_>>().into_iter().rev();
719
720 for op in iter {
721 let op = op?;
722 match format {
723 Format::Json => print_op_json(op)?,
724 Format::Pretty => print_op_pretty(op)?,
725 }
726 }
727
728 Ok(())
729}