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