radicle_cli/commands/
cob.rs

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
127/// A precursor to [`cob::Embed`] used for parsing
128/// that can be initialized without relying on a [`git::Repository`].
129struct Embed {
130    name: String,
131    content: EmbedContent,
132}
133
134enum EmbedContent {
135    Path(PathBuf),
136    Hash(Rev),
137}
138
139/// A thin wrapper around [`cob::TypeName`] used for parsing.
140/// Well known COB type names are captured as variants,
141/// with [`FilteredTypeName::Other`] as an escape hatch for type names
142/// that are not well known.
143enum 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
670/// Naive implementation for reading JSONL streams,
671/// see <https://jsonlines.org/>.
672fn 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
685/// Tiny utility to read a [`NonEmpty`] of COB actions.
686/// This is used for `rad cob create` and `rad cob update`.
687fn 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    // Reverse
718    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}