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::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
121/// A precursor to [`cob::Embed`] used for parsing
122/// that can be initialized without relying on a [`git::Repository`].
123struct Embed {
124    name: String,
125    content: EmbedContent,
126}
127
128enum EmbedContent {
129    Path(PathBuf),
130    Hash(Rev),
131}
132
133/// A thin wrapper around [`cob::TypeName`] used for parsing.
134/// Well known COB type names are captured as variants,
135/// with [`FilteredTypeName::Other`] as an escape hatch for type names
136/// that are not well known.
137enum 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
628/// Naive implementation for reading JSONL streams,
629/// see <https://jsonlines.org/>.
630fn 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
643/// Tiny utility to read a [`NonEmpty`] of COB actions.
644/// This is used for `rad cob create` and `rad cob update`.
645fn 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}