cli: Introduce `cob [create|update]`
This commit is contained in:
parent
547614a5bb
commit
e4d23fe56a
|
|
@ -36,6 +36,9 @@ Make sure all tests are passing with:
|
|||
|
||||
$ cargo test --workspace
|
||||
|
||||
Some tests require `jq`. If `jq` is not detected, these tests will succeed
|
||||
without effectively testing anything.
|
||||
|
||||
## Checking the docs
|
||||
|
||||
If you make documentation changes, you may want to check whether there are any
|
||||
|
|
|
|||
|
|
@ -179,7 +179,7 @@
|
|||
cargoExtraArgs = "-p ${name}";
|
||||
doCheck = false;
|
||||
|
||||
nativeBuildInputs = with pkgs; [asciidoctor installShellFiles];
|
||||
nativeBuildInputs = with pkgs; [asciidoctor installShellFiles jq];
|
||||
postInstall = ''
|
||||
for page in ${lib.escapeShellArgs pages}; do
|
||||
asciidoctor -d manpage -b manpage $page
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
#! /usr/bin/env -S jq --from-file --sort-keys --compact-output
|
||||
#
|
||||
# Models a multiset as a JSON object.
|
||||
#
|
||||
# Takes an operation which contains actions, each in one of two shapes:
|
||||
#
|
||||
# Add x:
|
||||
#
|
||||
# { "+": $x }
|
||||
#
|
||||
# Remove x:
|
||||
#
|
||||
# { "-": $x }
|
||||
#
|
||||
# (where `$x` is a string).
|
||||
#
|
||||
# These actions are reduced to a multiset represented by an
|
||||
# object. The key being the item to count (corresponding to `$x` above),
|
||||
# and the value being the count itself (corresponding to how many times
|
||||
# `$x` was added, minus how many times `$x` was removed).
|
||||
#
|
||||
# Errors if any unrecognizable action is encountered.
|
||||
#
|
||||
# For an example, see `rad-cob-multiset.md`.
|
||||
reduce
|
||||
.op.actions[]
|
||||
as {"+": $p, "-": $m} (
|
||||
.value;
|
||||
.[$p // $m // ("invalid" | halt_error)] |= (
|
||||
[. + (if $p then 1 else -1 end), 0] | max
|
||||
)
|
||||
)
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
This example demonstrates handling of arbitrary Collaborative Objects (COBs) by means of an external program.
|
||||
The external program is called a "COB helper" (analogous to "remote helpers" that can be used to extend Git).
|
||||
It consumes the current state of the COB and at least one operation (all in [JSON]) to be applied to it via files whose names are passed as arguments.
|
||||
It returns the resulting COB by printing it to standard output in [JSON].
|
||||
|
||||
For the sake of example, consider a simplified shopping list, where every item on the list is associated with an integral quantity:
|
||||
|
||||
- 5 Bananas
|
||||
- 3 Zucchini
|
||||
- 1 Bar of Chocolate
|
||||
|
||||
One data structure that maps to this concept very well is a [multiset] (sometimes also called "bag").
|
||||
So, let us introduce a new COB type with the name `com.example.multiset` that implements multisets, which we will then use to model our shopping list.
|
||||
|
||||
The COB we implement should allow two actions:
|
||||
|
||||
- The action `+`, which adds an item or, if already present, increases the associated quantity.
|
||||
- The action `-`, which decreases the associated quantity of an item, if it is non-zero.
|
||||
|
||||
We model actions as objects in [JSON], and a sequence of actions in [JSON Lines].
|
||||
An example sequence of actions looks as follows:
|
||||
|
||||
``` ./groceries.jsonl
|
||||
{ "+": "jelly" }
|
||||
{ "+": "peanut butter" }
|
||||
{ "-": "jelly" }
|
||||
{ "-": "jelly" }
|
||||
{ "+": "salad" }
|
||||
{ "+": "salad" }
|
||||
```
|
||||
|
||||
Starting with an empty grocery list, the expected result after evaluating all actions is:
|
||||
|
||||
- 0 Jelly (this could be omitted)
|
||||
- 1 Peanut Butter
|
||||
- 2 Salad
|
||||
|
||||
We have a COB helper, named `rad-cob-multiset`, that implements evaluation of these actions using [jq].
|
||||
It reads the current state of the grocery list and operations containing actions from files given as arguments and writes the resulting grocery list to standard output.
|
||||
|
||||
We do not invoke the program directly, but instead use `rad cob create`:
|
||||
|
||||
```
|
||||
$ rad cob create --repo rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --type com.example.multiset --message "Create grocery shopping multiset" groceries.jsonl
|
||||
9bba8e6f83ef56b11151ef6ad02cc4595f982aab
|
||||
```
|
||||
|
||||
We can verify that the COB evaluated as expected:
|
||||
|
||||
```
|
||||
$ rad cob show --repo rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --type com.example.multiset --object 9bba8e6f83ef56b11151ef6ad02cc4595f982aab
|
||||
{"jelly":0,"peanut butter":1,"salad":2}
|
||||
```
|
||||
|
||||
To apply actions to COBs that already exist, we can use `rad cob update`:
|
||||
|
||||
```
|
||||
$ rad cob update --repo rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --type com.example.multiset --object 9bba8e6f83ef56b11151ef6ad02cc4595f982aab --message "Modify grocery shopping multiset" groceries.jsonl
|
||||
d36aac77be13c1ca80edbfe7b7bf9b42c723f019
|
||||
```
|
||||
|
||||
Again, we verify the result with `rad cob show`:
|
||||
|
||||
```
|
||||
$ rad cob show --repo rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --type com.example.multiset --object 9bba8e6f83ef56b11151ef6ad02cc4595f982aab
|
||||
{"jelly":0,"peanut butter":2,"salad":4}
|
||||
```
|
||||
|
||||
[multisets]: https://wikipedia.org/wiki/Multiset
|
||||
[JSON]: https://tools.ietf.org/html/std90
|
||||
[JSON Lines]: https://jsonlines.org/
|
||||
[jq]: https://github.com/jqlang/jq
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
Updating the repository identity via `rad cob update` is forbidden:
|
||||
|
||||
``` (fail)
|
||||
$ rad cob update --repo rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --type xyz.radicle.id --object 0656c217f917c3e06234771e9ecae53aba5e173e --message "Danger" /dev/null
|
||||
✗ Error: Update of collaborative objects of type xyz.radicle.id is not supported.
|
||||
```
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
First off, we set up a patch.
|
||||
|
||||
```
|
||||
$ git checkout -b changes
|
||||
$ touch README.md
|
||||
$ git add README.md
|
||||
$ git commit --message "Add README, just for the fun"
|
||||
[changes 03c02af] Add README, just for the fun
|
||||
1 file changed, 0 insertions(+), 0 deletions(-)
|
||||
create mode 100644 README.md
|
||||
```
|
||||
|
||||
``` (stderr)
|
||||
$ git push rad -o patch.message="Add README, just for the fun" HEAD:refs/patches
|
||||
✓ Patch 89f7afb1511b976482b21f6b2f39aef7f4fb88a2 opened
|
||||
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
|
||||
* [new reference] HEAD -> refs/patches
|
||||
```
|
||||
|
||||
```
|
||||
$ touch LICENSE
|
||||
$ git add LICENSE
|
||||
$ git commit -v -m "Define the LICENSE"
|
||||
[changes 8945f61] Define the LICENSE
|
||||
1 file changed, 0 insertions(+), 0 deletions(-)
|
||||
create mode 100644 LICENSE
|
||||
```
|
||||
|
||||
``` (stderr)
|
||||
$ git push -f -o patch.message="Add License"
|
||||
✓ Patch 89f7afb updated to revision 5d78dd5376453e25df5988ec86951c99cb73742c
|
||||
To compare against your previous revision 89f7afb, run:
|
||||
|
||||
git range-diff f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354 03c02af4b12a593d17a06d38fae50a57fc3c339a 8945f6189adf027892c85ac57f7e9341049c2537
|
||||
|
||||
To rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi
|
||||
03c02af..8945f61 changes -> patches/89f7afb1511b976482b21f6b2f39aef7f4fb88a2
|
||||
```
|
||||
|
||||
Let's look at the patch, to see what it looks like before editing it:
|
||||
|
||||
```
|
||||
$ rad patch show 89f7afb
|
||||
╭─────────────────────────────────────────────────────────────────────╮
|
||||
│ Title Add README, just for the fun │
|
||||
│ Patch 89f7afb1511b976482b21f6b2f39aef7f4fb88a2 │
|
||||
│ Author alice (you) │
|
||||
│ Head 8945f6189adf027892c85ac57f7e9341049c2537 │
|
||||
│ Branches changes │
|
||||
│ Commits ahead 2, behind 0 │
|
||||
│ Status open │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ 8945f61 Define the LICENSE │
|
||||
│ 03c02af Add README, just for the fun │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ ● opened by alice (you) (03c02af) now │
|
||||
│ ↑ updated to 5d78dd5376453e25df5988ec86951c99cb73742c (8945f61) now │
|
||||
╰─────────────────────────────────────────────────────────────────────╯
|
||||
```
|
||||
|
||||
We can change the title and description of the patch itself by using a
|
||||
multi-line message (using two `--message` options here):
|
||||
|
||||
```
|
||||
$ rad patch edit 89f7afb --message "Add Metadata" --message "Add README & LICENSE" --no-announce
|
||||
$ rad patch show 89f7afb
|
||||
╭─────────────────────────────────────────────────────────────────────╮
|
||||
│ Title Add Metadata │
|
||||
│ Patch 89f7afb1511b976482b21f6b2f39aef7f4fb88a2 │
|
||||
│ Author alice (you) │
|
||||
│ Head 8945f6189adf027892c85ac57f7e9341049c2537 │
|
||||
│ Branches changes │
|
||||
│ Commits ahead 2, behind 0 │
|
||||
│ Status open │
|
||||
│ │
|
||||
│ Add README & LICENSE │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ 8945f61 Define the LICENSE │
|
||||
│ 03c02af Add README, just for the fun │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ ● opened by alice (you) (03c02af) now │
|
||||
│ ↑ updated to 5d78dd5376453e25df5988ec86951c99cb73742c (8945f61) now │
|
||||
╰─────────────────────────────────────────────────────────────────────╯
|
||||
```
|
||||
|
||||
We prepare the file `revision-edit.json` which contains one action (thus one line) to be applied.
|
||||
|
||||
``` ./revision-edit.jsonl
|
||||
{"type": "revision.edit", "description": "Add README and LICENSE", "revision": "89f7afb1511b976482b21f6b2f39aef7f4fb88a2"}
|
||||
```
|
||||
|
||||
We now use `rad cob update` to edit the patch another time, rewriting the description.
|
||||
The action itself is of type `revision.edit` and carries the parameters `revision`,
|
||||
specifying the revision for which the description should be changed, and `description`,
|
||||
specifying the new description.
|
||||
|
||||
```
|
||||
$ rad cob update --repo rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --type xyz.radicle.patch --object 89f7afb1511b976482b21f6b2f39aef7f4fb88a2 --message "Edit patch" revision-edit.jsonl
|
||||
79b816e92735c49b33d93a31890fdf040b36234c
|
||||
$ rad patch show --verbose 89f7afb
|
||||
╭─────────────────────────────────────────────────────────────────────╮
|
||||
│ Title Add Metadata │
|
||||
│ Patch 89f7afb1511b976482b21f6b2f39aef7f4fb88a2 │
|
||||
│ Author alice (you) │
|
||||
│ Head 8945f6189adf027892c85ac57f7e9341049c2537 │
|
||||
│ Base f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354 │
|
||||
│ Branches changes │
|
||||
│ Commits ahead 2, behind 0 │
|
||||
│ Status open │
|
||||
│ │
|
||||
│ Add README and LICENSE │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ 8945f61 Define the LICENSE │
|
||||
│ 03c02af Add README, just for the fun │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ ● opened by alice (you) (03c02af) now │
|
||||
│ ↑ updated to 5d78dd5376453e25df5988ec86951c99cb73742c (8945f61) now │
|
||||
╰─────────────────────────────────────────────────────────────────────╯
|
||||
```
|
||||
|
||||
Notice that the patch now has the description `Add README and LICENSE`.
|
||||
|
||||
We may use `rad cob update` to create a new revision altogether, as well.
|
||||
Let's create yet another commit, an empty one this time, and do that.
|
||||
|
||||
```
|
||||
$ git commit --allow-empty --message="Dummy commit for a new revision"
|
||||
[changes f1339dd] Dummy commit for a new revision
|
||||
```
|
||||
|
||||
We prepare the file `revision-create.jsonl` which contains one action.
|
||||
|
||||
``` ./revision.jsonl
|
||||
{"type": "revision", "description": "A new revision", "base": "f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354", "oid": "f1339dd109e538c6b3a7fed3e72403e1b4db08c9"}
|
||||
```
|
||||
|
||||
Attempting to create the new revision right away would fail:
|
||||
|
||||
``` (fail)
|
||||
$ rad cob update --repo rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --type xyz.radicle.patch --object 89f7afb1511b976482b21f6b2f39aef7f4fb88a2 --message "Create new revision" revision.jsonl
|
||||
✗ Error: store: update error: failed to read 'f1339dd109e538c6b3a7fed3e72403e1b4db08c9' from git odb
|
||||
```
|
||||
|
||||
Since we are not using the remote helper `git-remote-rad` here, we need to push
|
||||
the new commit to storage manually. See `fn patch_open` in `/radicle-remote-helper/src/push.rs`
|
||||
for more details.
|
||||
|
||||
```
|
||||
$ git push rad HEAD:tmp/heads/f1339dd109e538c6b3a7fed3e72403e1b4db08c9
|
||||
$ git push rad :tmp/heads/f1339dd109e538c6b3a7fed3e72403e1b4db08c9
|
||||
```
|
||||
|
||||
Now we can invoke `rad cob update`:
|
||||
|
||||
```
|
||||
$ rad cob update --repo rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji --type xyz.radicle.patch --object 89f7afb1511b976482b21f6b2f39aef7f4fb88a2 --message "Create new revision" revision.jsonl
|
||||
2da9c025a1d14d93c4f2cec60a7878afbc5e2a3c
|
||||
$ rad patch show 89f7afb
|
||||
╭─────────────────────────────────────────────────────────────────────╮
|
||||
│ Title Add Metadata │
|
||||
│ Patch 89f7afb1511b976482b21f6b2f39aef7f4fb88a2 │
|
||||
│ Author alice (you) │
|
||||
│ Head f1339dd109e538c6b3a7fed3e72403e1b4db08c9 │
|
||||
│ Branches changes │
|
||||
│ Commits ahead 3, behind 0 │
|
||||
│ Status open │
|
||||
│ │
|
||||
│ Add README and LICENSE │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ f1339dd Dummy commit for a new revision │
|
||||
│ 8945f61 Define the LICENSE │
|
||||
│ 03c02af Add README, just for the fun │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ ● opened by alice (you) (03c02af) now │
|
||||
│ ↑ updated to 5d78dd5376453e25df5988ec86951c99cb73742c (8945f61) now │
|
||||
│ ↑ updated to 2da9c025a1d14d93c4f2cec60a7878afbc5e2a3c (f1339dd) now │
|
||||
╰─────────────────────────────────────────────────────────────────────╯
|
||||
```
|
||||
|
|
@ -1,21 +1,19 @@
|
|||
use std::ffi::OsString;
|
||||
use std::io;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::str::FromStr;
|
||||
use std::{fs, io};
|
||||
|
||||
use anyhow::{anyhow, bail};
|
||||
|
||||
use anyhow::anyhow;
|
||||
use chrono::prelude::*;
|
||||
|
||||
use nonempty::NonEmpty;
|
||||
|
||||
use radicle::cob;
|
||||
use radicle::cob::Op;
|
||||
use radicle::identity::Identity;
|
||||
use radicle::issue::cache::Issues;
|
||||
use radicle::patch::cache::Patches;
|
||||
use radicle::prelude::RepoId;
|
||||
use radicle::cob::store::CobAction;
|
||||
use radicle::prelude::*;
|
||||
use radicle::storage::git;
|
||||
use radicle::storage::ReadStorage;
|
||||
use radicle::Profile;
|
||||
use radicle_cob::object::collaboration::list;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::git::Rev;
|
||||
|
|
@ -30,33 +28,47 @@ pub const HELP: Help = Help {
|
|||
Usage
|
||||
|
||||
rad cob <command> [<option>...]
|
||||
rad cob list --repo <rid> --type <typename>
|
||||
rad cob log --repo <rid> --type <typename> --object <oid> [<option>...]
|
||||
rad cob show --repo <rid> --type <typename> --object <oid> [<option>...]
|
||||
|
||||
rad cob create --repo <rid> --type <typename> <filename> [<option>...]
|
||||
rad cob list --repo <rid> --type <typename>
|
||||
rad cob log --repo <rid> --type <typename> --object <oid> [<option>...]
|
||||
rad cob migrate [<option>...]
|
||||
rad cob show --repo <rid> --type <typename> --object <oid> [<option>...]
|
||||
rad cob update --repo <rid> --type <typename> --object <oid> <filename>
|
||||
[<option>...]
|
||||
|
||||
Commands
|
||||
|
||||
list List all COBs of a given type (--object is not needed)
|
||||
log Print a log of all raw operations on a COB
|
||||
migrate Migrate the COB database to the latest version
|
||||
create Create a new COB of a given type given initial actions
|
||||
list List all COBs of a given type (--object is not needed)
|
||||
log Print a log of all raw operations on a COB
|
||||
migrate Migrate the COB database to the latest version
|
||||
update Add actions to a COB
|
||||
show Print the state of COBs
|
||||
|
||||
Create, Update options
|
||||
|
||||
--embed-file <name> <path> Supply embed of given name via file at given path
|
||||
--embed-hash <name> <oid> Supply embed of given name via object ID of blob
|
||||
|
||||
Log options
|
||||
|
||||
--format (pretty | json) Desired output format (default: pretty)
|
||||
--format (pretty | json) Desired output format (default: pretty)
|
||||
|
||||
Show options
|
||||
|
||||
--format json Desired output format (default: json)
|
||||
--format json Desired output format (default: json)
|
||||
|
||||
Other options
|
||||
|
||||
--help Print help
|
||||
--help Print help
|
||||
"#,
|
||||
};
|
||||
|
||||
#[derive(PartialEq)]
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum OperationName {
|
||||
Update,
|
||||
Create,
|
||||
List,
|
||||
Log,
|
||||
Migrate,
|
||||
|
|
@ -64,20 +76,36 @@ enum OperationName {
|
|||
}
|
||||
|
||||
enum Operation {
|
||||
Create {
|
||||
rid: RepoId,
|
||||
type_name: FilteredTypeName,
|
||||
message: String,
|
||||
actions: PathBuf,
|
||||
embeds: Vec<Embed>,
|
||||
},
|
||||
List {
|
||||
repo: RepoId,
|
||||
type_name: cob::TypeName,
|
||||
rid: RepoId,
|
||||
type_name: FilteredTypeName,
|
||||
},
|
||||
Log {
|
||||
repo: RepoId,
|
||||
rev: Rev,
|
||||
type_name: cob::TypeName,
|
||||
rid: RepoId,
|
||||
type_name: FilteredTypeName,
|
||||
oid: Rev,
|
||||
format: Format,
|
||||
},
|
||||
Migrate,
|
||||
Show {
|
||||
repo: RepoId,
|
||||
revs: Vec<Rev>,
|
||||
type_name: cob::TypeName,
|
||||
rid: RepoId,
|
||||
type_name: FilteredTypeName,
|
||||
oids: Vec<Rev>,
|
||||
},
|
||||
Update {
|
||||
rid: RepoId,
|
||||
type_name: FilteredTypeName,
|
||||
oid: Rev,
|
||||
message: String,
|
||||
actions: PathBuf,
|
||||
embeds: Vec<Embed>,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -88,132 +116,290 @@ enum Format {
|
|||
|
||||
pub struct Options {
|
||||
op: Operation,
|
||||
format: Format,
|
||||
}
|
||||
|
||||
/// A precursor to [`cob::Embed`] used for parsing
|
||||
/// that can be initialized without relying on a [`git::Repository`].
|
||||
struct Embed {
|
||||
name: String,
|
||||
content: EmbedContent,
|
||||
}
|
||||
|
||||
enum EmbedContent {
|
||||
Path(PathBuf),
|
||||
Hash(Rev),
|
||||
}
|
||||
|
||||
/// A thin wrapper around [`cob::TypeName`] used for parsing.
|
||||
/// Well known COB type names are captured as variants,
|
||||
/// with [`FilteredTypeName::Other`] as an escape hatch for type names
|
||||
/// that are not well known.
|
||||
enum FilteredTypeName {
|
||||
Issue,
|
||||
Patch,
|
||||
Identity,
|
||||
Other(cob::TypeName),
|
||||
}
|
||||
|
||||
impl From<cob::TypeName> for FilteredTypeName {
|
||||
fn from(value: cob::TypeName) -> Self {
|
||||
if value == *cob::issue::TYPENAME {
|
||||
FilteredTypeName::Issue
|
||||
} else if value == *cob::patch::TYPENAME {
|
||||
FilteredTypeName::Patch
|
||||
} else if value == *cob::identity::TYPENAME {
|
||||
FilteredTypeName::Identity
|
||||
} else {
|
||||
FilteredTypeName::Other(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<cob::TypeName> for FilteredTypeName {
|
||||
fn as_ref(&self) -> &cob::TypeName {
|
||||
match self {
|
||||
FilteredTypeName::Issue => &cob::issue::TYPENAME,
|
||||
FilteredTypeName::Patch => &cob::patch::TYPENAME,
|
||||
FilteredTypeName::Identity => &cob::identity::TYPENAME,
|
||||
FilteredTypeName::Other(value) => value,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for FilteredTypeName {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
self.as_ref().fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
impl Embed {
|
||||
fn try_into_bytes(self, repo: &git::Repository) -> anyhow::Result<cob::Embed<cob::Uri>> {
|
||||
Ok(match self.content {
|
||||
EmbedContent::Hash(hash) => cob::Embed {
|
||||
name: self.name,
|
||||
content: hash.resolve::<git::Oid>(&repo.backend)?.into(),
|
||||
},
|
||||
EmbedContent::Path(path) => {
|
||||
cob::Embed::store(self.name, &std::fs::read(path)?, &repo.backend)?
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Args for Options {
|
||||
fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
|
||||
use lexopt::prelude::*;
|
||||
use term::args::string;
|
||||
use OperationName::*;
|
||||
|
||||
let mut parser = lexopt::Parser::from_args(args);
|
||||
let mut op: Option<OperationName> = None;
|
||||
let mut type_name: Option<cob::TypeName> = None;
|
||||
let mut revs: Vec<Rev> = vec![];
|
||||
|
||||
let op = match parser.next()? {
|
||||
None | Some(Long("help") | Short('h')) => {
|
||||
return Err(Error::Help.into());
|
||||
}
|
||||
Some(Value(val)) => match val.to_string_lossy().as_ref() {
|
||||
"update" => Update,
|
||||
"create" => Create,
|
||||
"list" => List,
|
||||
"log" => Log,
|
||||
"migrate" => Migrate,
|
||||
"show" => Show,
|
||||
unknown => bail!("unknown operation '{unknown}'"),
|
||||
},
|
||||
Some(arg) => return Err(anyhow!(arg.unexpected())),
|
||||
};
|
||||
|
||||
let mut type_name: Option<FilteredTypeName> = None;
|
||||
let mut oids: Vec<Rev> = vec![];
|
||||
let mut rid: Option<RepoId> = None;
|
||||
let mut format: Option<Format> = None;
|
||||
let mut format: Format = Format::Pretty;
|
||||
let mut message: Option<String> = None;
|
||||
let mut embeds: Vec<Embed> = vec![];
|
||||
let mut actions: Option<PathBuf> = None;
|
||||
|
||||
while let Some(arg) = parser.next()? {
|
||||
match arg {
|
||||
Value(val) if op.is_none() => match val.to_string_lossy().as_ref() {
|
||||
"list" => op = Some(OperationName::List),
|
||||
"log" => op = Some(OperationName::Log),
|
||||
"migrate" => op = Some(OperationName::Migrate),
|
||||
"show" => op = Some(OperationName::Show),
|
||||
unknown => anyhow::bail!("unknown operation '{unknown}'"),
|
||||
},
|
||||
Long("type") | Short('t') => {
|
||||
let v = parser.value()?;
|
||||
let v = term::args::string(&v);
|
||||
let v = cob::TypeName::from_str(&v)?;
|
||||
|
||||
type_name = Some(v);
|
||||
}
|
||||
Long("object") => {
|
||||
let v = parser.value()?;
|
||||
let v = term::args::string(&v);
|
||||
|
||||
revs.push(Rev::from(v));
|
||||
}
|
||||
Long("repo") => {
|
||||
let v = parser.value()?;
|
||||
let v = term::args::rid(&v)?;
|
||||
|
||||
rid = Some(v);
|
||||
}
|
||||
Long("help") | Short('h') => {
|
||||
match (&op, &arg) {
|
||||
(_, Long("help") | Short('h')) => {
|
||||
return Err(Error::Help.into());
|
||||
}
|
||||
Long("format")
|
||||
if op == Some(OperationName::Log) || op == Some(OperationName::Show) =>
|
||||
{
|
||||
let v: String = term::args::string(&parser.value()?);
|
||||
match v.as_ref() {
|
||||
"pretty" if op == Some(OperationName::Log) => format = Some(Format::Pretty),
|
||||
"json" => format = Some(Format::Json),
|
||||
unknown => anyhow::bail!("unknown format '{unknown}'"),
|
||||
}
|
||||
(_, Long("repo") | Short('r')) => {
|
||||
rid = Some(term::args::rid(&parser.value()?)?);
|
||||
}
|
||||
_ => return Err(anyhow::anyhow!(arg.unexpected())),
|
||||
(_, Long("type") | Short('t')) => {
|
||||
let v = string(&parser.value()?);
|
||||
type_name = Some(FilteredTypeName::from(cob::TypeName::from_str(&v)?));
|
||||
}
|
||||
(Update | Log | Show, Long("object") | Short('o')) => {
|
||||
let v = string(&parser.value()?);
|
||||
oids.push(Rev::from(v));
|
||||
}
|
||||
(Update | Create, Long("message") | Short('m')) => {
|
||||
message = Some(string(&parser.value()?));
|
||||
}
|
||||
(Log | Show | Update, Long("format")) => {
|
||||
format = match (op, string(&parser.value()?).as_ref()) {
|
||||
(Log, "pretty") => Format::Pretty,
|
||||
(Log | Show | Update, "json") => Format::Json,
|
||||
(_, unknown) => bail!("unknown format '{unknown}'"),
|
||||
};
|
||||
}
|
||||
(Update | Create, Long("embed-file")) => {
|
||||
let mut values = parser.values()?;
|
||||
|
||||
let name = values
|
||||
.next()
|
||||
.map(|s| term::args::string(&s))
|
||||
.ok_or(anyhow!("expected name of embed"))?;
|
||||
|
||||
let content = EmbedContent::Path(PathBuf::from(
|
||||
values
|
||||
.next()
|
||||
.ok_or(anyhow!("expected path to file to embed"))?,
|
||||
));
|
||||
|
||||
embeds.push(Embed { name, content });
|
||||
}
|
||||
(Update | Create, Long("embed-hash")) => {
|
||||
let mut values = parser.values()?;
|
||||
|
||||
let name = values
|
||||
.next()
|
||||
.map(|s| term::args::string(&s))
|
||||
.ok_or(anyhow!("expected name of embed"))?;
|
||||
|
||||
let content = EmbedContent::Hash(Rev::from(term::args::string(
|
||||
&values
|
||||
.next()
|
||||
.ok_or(anyhow!("expected hash of file to embed"))?,
|
||||
)));
|
||||
|
||||
embeds.push(Embed { name, content });
|
||||
}
|
||||
(Update | Create, Value(val)) => {
|
||||
actions = Some(PathBuf::from(term::args::string(val)));
|
||||
}
|
||||
_ => return Err(anyhow!(arg.unexpected())),
|
||||
}
|
||||
}
|
||||
let repo = rid.ok_or_else(|| anyhow!("a repository id must be specified with `--repo`"));
|
||||
|
||||
if op == OperationName::Migrate {
|
||||
return Ok((
|
||||
Options {
|
||||
op: Operation::Migrate,
|
||||
},
|
||||
vec![],
|
||||
));
|
||||
}
|
||||
|
||||
let rid = rid.ok_or_else(|| anyhow!("a repository id must be specified with `--repo`"))?;
|
||||
let type_name =
|
||||
type_name.ok_or_else(|| anyhow!("an object type must be specified with `--type`"));
|
||||
type_name.ok_or_else(|| anyhow!("an object type must be specified with `--type`"))?;
|
||||
|
||||
let missing_oid = || anyhow!("an object id must be specified with `--object`");
|
||||
let missing_message = || anyhow!("a message must be specified with `--message`");
|
||||
|
||||
Ok((
|
||||
Options {
|
||||
op: {
|
||||
match op.ok_or_else(|| anyhow!("a command must be specified"))? {
|
||||
OperationName::List => Operation::List {
|
||||
repo: repo?,
|
||||
type_name: type_name?,
|
||||
},
|
||||
OperationName::Log => Operation::Log {
|
||||
repo: repo?,
|
||||
rev: revs.pop().ok_or_else(|| {
|
||||
anyhow!("an object id must be specified with `--object`")
|
||||
})?,
|
||||
type_name: type_name?,
|
||||
},
|
||||
OperationName::Migrate => Operation::Migrate,
|
||||
OperationName::Show => {
|
||||
if revs.is_empty() {
|
||||
anyhow::bail!("an object id must be specified with `--object`")
|
||||
}
|
||||
Operation::Show {
|
||||
repo: repo?,
|
||||
revs,
|
||||
type_name: type_name?,
|
||||
}
|
||||
op: match op {
|
||||
Create => Operation::Create {
|
||||
rid,
|
||||
type_name,
|
||||
message: message.ok_or_else(missing_message)?,
|
||||
actions: actions.ok_or_else(|| {
|
||||
anyhow!("a file containing initial actions must be specified")
|
||||
})?,
|
||||
embeds,
|
||||
},
|
||||
List => Operation::List { rid, type_name },
|
||||
Log => Operation::Log {
|
||||
rid,
|
||||
type_name,
|
||||
oid: oids.pop().ok_or_else(missing_oid)?,
|
||||
format,
|
||||
},
|
||||
Migrate => Operation::Migrate,
|
||||
Show => {
|
||||
if oids.is_empty() {
|
||||
return Err(missing_oid());
|
||||
}
|
||||
Operation::Show {
|
||||
rid,
|
||||
oids,
|
||||
type_name,
|
||||
}
|
||||
}
|
||||
Update => Operation::Update {
|
||||
rid,
|
||||
type_name,
|
||||
oid: oids.pop().ok_or_else(missing_oid)?,
|
||||
message: message.ok_or_else(missing_message)?,
|
||||
actions: actions.ok_or_else(|| {
|
||||
anyhow!("a file containing actions must be specified")
|
||||
})?,
|
||||
embeds,
|
||||
},
|
||||
},
|
||||
format: format.unwrap_or(Format::Pretty),
|
||||
},
|
||||
vec![],
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
|
||||
pub fn run(Options { op }: Options, ctx: impl term::Context) -> anyhow::Result<()> {
|
||||
use cob::store::Store;
|
||||
use FilteredTypeName::*;
|
||||
use Operation::*;
|
||||
|
||||
let profile = ctx.profile()?;
|
||||
let storage = &profile.storage;
|
||||
|
||||
match options.op {
|
||||
Operation::List { repo, type_name } => {
|
||||
let repo = storage.repository(repo)?;
|
||||
let cobs = list::<NonEmpty<cob::Entry>, _>(&repo, &type_name)?;
|
||||
for cob in cobs {
|
||||
println!("{}", cob.id);
|
||||
}
|
||||
}
|
||||
Operation::Log {
|
||||
repo,
|
||||
rev: oid,
|
||||
match op {
|
||||
Create {
|
||||
rid,
|
||||
type_name,
|
||||
message,
|
||||
embeds,
|
||||
actions,
|
||||
} => {
|
||||
let repo = storage.repository(repo)?;
|
||||
let oid = oid.resolve(&repo.backend)?;
|
||||
let ops = cob::store::ops(&oid, &type_name, &repo)?;
|
||||
let signer = &profile.signer()?;
|
||||
let repo = storage.repository_mut(rid)?;
|
||||
|
||||
for op in ops.into_iter().rev() {
|
||||
match options.format {
|
||||
Format::Json => print_op_json(op)?,
|
||||
Format::Pretty => print_op_pretty(op)?,
|
||||
let reader = io::BufReader::new(fs::File::open(actions)?);
|
||||
|
||||
let embeds = embeds
|
||||
.into_iter()
|
||||
.map(|embed| embed.try_into_bytes(&repo))
|
||||
.collect::<anyhow::Result<Vec<_>>>()?;
|
||||
|
||||
let oid = match type_name {
|
||||
Patch => {
|
||||
let store: Store<cob::patch::Patch, _> = Store::open(&repo)?;
|
||||
let actions = read_jsonl_actions(reader)?;
|
||||
let (oid, _) = store.create(&message, actions, embeds, signer)?;
|
||||
oid
|
||||
}
|
||||
}
|
||||
Issue => {
|
||||
let store: Store<cob::issue::Issue, _> = Store::open(&repo)?;
|
||||
let actions = read_jsonl_actions(reader)?;
|
||||
let (oid, _) = store.create(&message, actions, embeds, signer)?;
|
||||
oid
|
||||
}
|
||||
Identity => anyhow::bail!(
|
||||
"Creation of collaborative objects of type {} is not supported.",
|
||||
&type_name
|
||||
),
|
||||
Other(type_name) => {
|
||||
let store: Store<cob::external::External, _> =
|
||||
Store::open_for(&type_name, &repo)?;
|
||||
let actions = read_jsonl_actions(reader)?;
|
||||
let (oid, _) = store.create(&message, actions, embeds, signer)?;
|
||||
oid
|
||||
}
|
||||
};
|
||||
println!("{}", oid);
|
||||
}
|
||||
Operation::Migrate => {
|
||||
Migrate => {
|
||||
let mut db = profile.cobs_db_mut()?;
|
||||
if db.check_version().is_ok() {
|
||||
term::success!("Collaborative objects database is already up to date");
|
||||
|
|
@ -224,71 +410,175 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
|
|||
);
|
||||
}
|
||||
}
|
||||
Operation::Show {
|
||||
repo,
|
||||
revs,
|
||||
List { rid, type_name } => {
|
||||
let repo = storage.repository(rid)?;
|
||||
let cobs = radicle_cob::list::<NonEmpty<cob::Entry>, _>(&repo, type_name.as_ref())?;
|
||||
for cob in cobs {
|
||||
println!("{}", cob.id);
|
||||
}
|
||||
}
|
||||
Log {
|
||||
rid,
|
||||
type_name,
|
||||
oid,
|
||||
format,
|
||||
} => {
|
||||
let repo = storage.repository(rid)?;
|
||||
let oid = oid.resolve(&repo.backend)?;
|
||||
let ops = cob::store::ops(&oid, type_name.as_ref(), &repo)?;
|
||||
|
||||
for op in ops.into_iter().rev() {
|
||||
match format {
|
||||
Format::Json => print_op_json(op)?,
|
||||
Format::Pretty => print_op_pretty(op)?,
|
||||
}
|
||||
}
|
||||
}
|
||||
Show {
|
||||
rid,
|
||||
oids,
|
||||
type_name,
|
||||
} => {
|
||||
let repo = storage.repository(repo)?;
|
||||
|
||||
if let Err(e) = show(revs, &repo, type_name, &profile) {
|
||||
if let Some(err) = e.downcast_ref::<io::Error>() {
|
||||
if err.kind() == io::ErrorKind::BrokenPipe {
|
||||
let repo = storage.repository(rid)?;
|
||||
if let Err(e) = show(oids, &repo, type_name, &profile) {
|
||||
if let Some(err) = e.downcast_ref::<std::io::Error>() {
|
||||
if err.kind() == std::io::ErrorKind::BrokenPipe {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Update {
|
||||
rid,
|
||||
type_name,
|
||||
oid,
|
||||
message,
|
||||
actions,
|
||||
embeds,
|
||||
} => {
|
||||
let signer = &profile.signer()?;
|
||||
let repo = storage.repository_mut(rid)?;
|
||||
let reader = io::BufReader::new(fs::File::open(actions)?);
|
||||
let oid = &oid.resolve(&repo.backend)?;
|
||||
let embeds = embeds
|
||||
.into_iter()
|
||||
.map(|embed| embed.try_into_bytes(&repo))
|
||||
.collect::<anyhow::Result<Vec<_>>>()?;
|
||||
|
||||
let oid = match type_name {
|
||||
Patch => {
|
||||
let actions: Vec<cob::patch::Action> = read_jsonl(reader)?;
|
||||
let mut patches = profile.patches_mut(&repo)?;
|
||||
let mut patch = patches.get_mut(oid)?;
|
||||
patch.transaction(&message, &profile.signer()?, |tx| {
|
||||
tx.extend(actions)?;
|
||||
tx.embed(embeds)?;
|
||||
Ok(())
|
||||
})?
|
||||
}
|
||||
Issue => {
|
||||
let actions: Vec<cob::issue::Action> = read_jsonl(reader)?;
|
||||
let mut issues = profile.issues_mut(&repo)?;
|
||||
let mut issue = issues.get_mut(oid)?;
|
||||
issue.transaction(&message, &profile.signer()?, |tx| {
|
||||
tx.extend(actions)?;
|
||||
tx.embed(embeds)?;
|
||||
Ok(())
|
||||
})?
|
||||
}
|
||||
Identity => anyhow::bail!(
|
||||
"Update of collaborative objects of type {} is not supported.",
|
||||
&type_name
|
||||
),
|
||||
Other(type_name) => {
|
||||
use cob::external::{Action, External};
|
||||
let actions: Vec<Action> = read_jsonl(reader)?;
|
||||
let mut store: Store<External, _> = Store::open_for(&type_name, &repo)?;
|
||||
let tx = cob::store::Transaction::new(type_name.clone(), actions, embeds);
|
||||
let (_, oid) = tx.commit(&message, *oid, &mut store, signer)?;
|
||||
oid
|
||||
}
|
||||
};
|
||||
|
||||
println!("{}", oid);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn show(
|
||||
revs: Vec<Rev>,
|
||||
oids: Vec<Rev>,
|
||||
repo: &git::Repository,
|
||||
type_name: cob::TypeName,
|
||||
type_name: FilteredTypeName,
|
||||
profile: &Profile,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
use io::Write as _;
|
||||
let mut stdout = std::io::stdout();
|
||||
|
||||
if type_name == cob::patch::TYPENAME.clone() {
|
||||
let patches = term::cob::patches(profile, repo)?;
|
||||
for oid in revs {
|
||||
let oid = &oid.resolve(&repo.backend)?;
|
||||
let Some(patch) = patches.get(oid)? else {
|
||||
anyhow::bail!(cob::store::Error::NotFound(type_name, *oid));
|
||||
};
|
||||
serde_json::to_writer(&stdout, &patch)?;
|
||||
stdout.write_all(b"\n")?;
|
||||
match type_name {
|
||||
FilteredTypeName::Identity => {
|
||||
use cob::identity;
|
||||
for oid in oids {
|
||||
let oid = &oid.resolve(&repo.backend)?;
|
||||
let Some(cob) = cob::get::<identity::Identity, _>(repo, type_name.as_ref(), oid)?
|
||||
else {
|
||||
bail!(cob::store::Error::NotFound(
|
||||
type_name.as_ref().clone(),
|
||||
*oid
|
||||
));
|
||||
};
|
||||
serde_json::to_writer(&stdout, &cob.object)?;
|
||||
stdout.write_all(b"\n")?;
|
||||
}
|
||||
}
|
||||
} else if type_name == cob::issue::TYPENAME.clone() {
|
||||
let issues = term::cob::issues(profile, repo)?;
|
||||
for oid in revs {
|
||||
let oid = &oid.resolve(&repo.backend)?;
|
||||
let Some(issue) = issues.get(oid)? else {
|
||||
anyhow::bail!(cob::store::Error::NotFound(type_name, *oid))
|
||||
};
|
||||
serde_json::to_writer(&stdout, &issue)?;
|
||||
stdout.write_all(b"\n")?;
|
||||
FilteredTypeName::Issue => {
|
||||
use radicle::issue::cache::Issues as _;
|
||||
let issues = term::cob::issues(profile, repo)?;
|
||||
for oid in oids {
|
||||
let oid = &oid.resolve(&repo.backend)?;
|
||||
let Some(issue) = issues.get(oid)? else {
|
||||
bail!(cob::store::Error::NotFound(
|
||||
type_name.as_ref().clone(),
|
||||
*oid
|
||||
))
|
||||
};
|
||||
serde_json::to_writer(&stdout, &issue)?;
|
||||
stdout.write_all(b"\n")?;
|
||||
}
|
||||
}
|
||||
} else if type_name == cob::identity::TYPENAME.clone() {
|
||||
for oid in revs {
|
||||
let oid = &oid.resolve(&repo.backend)?;
|
||||
let Some(cob) = cob::get::<Identity, _>(repo, &type_name, oid)? else {
|
||||
anyhow::bail!(cob::store::Error::NotFound(type_name, *oid));
|
||||
};
|
||||
serde_json::to_writer(&stdout, &cob.object)?;
|
||||
stdout.write_all(b"\n")?;
|
||||
FilteredTypeName::Patch => {
|
||||
use radicle::patch::cache::Patches as _;
|
||||
let patches = term::cob::patches(profile, repo)?;
|
||||
for oid in oids {
|
||||
let oid = &oid.resolve(&repo.backend)?;
|
||||
let Some(patch) = patches.get(oid)? else {
|
||||
bail!(cob::store::Error::NotFound(
|
||||
type_name.as_ref().clone(),
|
||||
*oid
|
||||
));
|
||||
};
|
||||
serde_json::to_writer(&stdout, &patch)?;
|
||||
stdout.write_all(b"\n")?;
|
||||
}
|
||||
}
|
||||
FilteredTypeName::Other(type_name) => {
|
||||
let store =
|
||||
cob::store::Store::<cob::external::External, _>::open_for(&type_name, repo)?;
|
||||
for oid in oids {
|
||||
let oid = &oid.resolve(&repo.backend)?;
|
||||
let cob = store
|
||||
.get(oid)?
|
||||
.ok_or_else(|| anyhow!(cob::store::Error::NotFound(type_name.clone(), *oid)))?;
|
||||
serde_json::to_writer(&stdout, &cob)?;
|
||||
stdout.write_all(b"\n")?;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
anyhow::bail!("the type name '{type_name}' is unknown");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_op_pretty(op: Op<Vec<u8>>) -> anyhow::Result<()> {
|
||||
fn print_op_pretty(op: cob::Op<Vec<u8>>) -> anyhow::Result<()> {
|
||||
let time = DateTime::<Utc>::from(
|
||||
std::time::UNIX_EPOCH + std::time::Duration::from_secs(op.timestamp.as_secs()),
|
||||
)
|
||||
|
|
@ -317,18 +607,46 @@ fn print_op_pretty(op: Op<Vec<u8>>) -> anyhow::Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn print_op_json(op: Op<Vec<u8>>) -> anyhow::Result<()> {
|
||||
fn print_op_json(op: cob::Op<Vec<u8>>) -> anyhow::Result<()> {
|
||||
let mut ser = json!(op);
|
||||
ser.as_object_mut().unwrap().insert(
|
||||
"actions".to_string(),
|
||||
json!(op
|
||||
.actions
|
||||
.iter()
|
||||
.map(|action: &Vec<u8>| -> Result<serde_json::Value, _> {
|
||||
serde_json::from_slice(action)
|
||||
})
|
||||
.collect::<Result<Vec<serde_json::Value>, _>>()?),
|
||||
);
|
||||
ser.as_object_mut()
|
||||
.expect("ops must serialize to objects")
|
||||
.insert(
|
||||
"actions".to_string(),
|
||||
json!(op
|
||||
.actions
|
||||
.iter()
|
||||
.map(|action: &Vec<u8>| -> Result<serde_json::Value, _> {
|
||||
serde_json::from_slice(action)
|
||||
})
|
||||
.collect::<Result<Vec<serde_json::Value>, _>>()?),
|
||||
);
|
||||
term::print(ser);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Naive implementation for reading JSONL streams,
|
||||
/// see <https://jsonlines.org/>.
|
||||
fn read_jsonl<R, T>(reader: io::BufReader<R>) -> anyhow::Result<Vec<T>>
|
||||
where
|
||||
R: io::Read,
|
||||
T: serde::de::DeserializeOwned,
|
||||
{
|
||||
use io::BufRead as _;
|
||||
let mut result: Vec<T> = Vec::new();
|
||||
for line in reader.lines() {
|
||||
result.push(serde_json::from_str(&line?)?);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Tiny utility to read a [`NonEmpty`] of COB actions.
|
||||
/// This is used for `rad cob create` and `rad cob update`.
|
||||
fn read_jsonl_actions<R, A>(reader: io::BufReader<R>) -> anyhow::Result<NonEmpty<A>>
|
||||
where
|
||||
R: io::Read,
|
||||
A: CobAction + serde::de::DeserializeOwned,
|
||||
{
|
||||
NonEmpty::from_vec(read_jsonl(reader)?)
|
||||
.ok_or_else(|| anyhow!("at least one action is required"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -158,6 +158,92 @@ fn rad_issue() {
|
|||
test("examples/rad-issue.md", &working, Some(home), []).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rad_cob_update() {
|
||||
let mut environment = Environment::new();
|
||||
let profile = environment.profile(config::profile("alice"));
|
||||
let working = environment.tmp().join("working");
|
||||
let home = &profile.home;
|
||||
|
||||
let base = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
|
||||
std::fs::create_dir_all(base).unwrap();
|
||||
std::fs::create_dir_all(working.clone()).unwrap();
|
||||
|
||||
// Setup a test repository.
|
||||
fixtures::repository(&working);
|
||||
|
||||
test("examples/rad-init.md", &working, Some(home), []).unwrap();
|
||||
test("examples/rad-cob-update.md", &working, Some(home), []).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rad_cob_update_identity() {
|
||||
let mut environment = Environment::new();
|
||||
let profile = environment.profile(config::profile("alice"));
|
||||
let working = environment.tmp().join("working");
|
||||
let home = &profile.home;
|
||||
|
||||
let base = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
|
||||
std::fs::create_dir_all(base).unwrap();
|
||||
std::fs::create_dir_all(working.clone()).unwrap();
|
||||
|
||||
// Setup a test repository.
|
||||
fixtures::repository(&working);
|
||||
|
||||
test("examples/rad-init.md", &working, Some(home), []).unwrap();
|
||||
test(
|
||||
"examples/rad-cob-update-identity.md",
|
||||
&working,
|
||||
Some(home),
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rad_cob_multiset() {
|
||||
{
|
||||
// `rad-cob-multiset` is a `jq` script, which requires `jq` to be installed.
|
||||
// We test whether `jq` is installed, and have this test succeed if it is not.
|
||||
// Programmatic skipping of tests is not supported as of 2024-08.
|
||||
|
||||
let output = std::process::Command::new("/usr/bin/env")
|
||||
.arg("jq")
|
||||
.arg("-V")
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
if !output.status.success() {
|
||||
log::warn!(target: "test", "`jq` not found. Succeeding prematurely. {:?}", output);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let mut environment = Environment::new();
|
||||
let profile = environment.profile(config::profile("alice"));
|
||||
let home = &profile.home;
|
||||
let working = environment.tmp().join("working");
|
||||
|
||||
let base = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
std::fs::create_dir_all(base).unwrap();
|
||||
std::fs::create_dir_all(working.clone()).unwrap();
|
||||
|
||||
// Copy over the script that implements the multiset COB.
|
||||
std::fs::copy(
|
||||
base.join("examples").join("rad-cob-multiset"),
|
||||
working.join("rad-cob-multiset"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Setup a test repository.
|
||||
fixtures::repository(&working);
|
||||
|
||||
test("examples/rad-init.md", &working, Some(home), []).unwrap();
|
||||
test("examples/rad-cob-multiset.md", &working, Some(home), []).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rad_cob_log() {
|
||||
let mut environment = Environment::new();
|
||||
|
|
|
|||
|
|
@ -342,7 +342,7 @@ fn update_or_remove<R, C, T>(
|
|||
) -> Result<(), error::Cache>
|
||||
where
|
||||
R: cob::Store + ReadRepository,
|
||||
T: cob::Evaluate<R> + cob::store::Cob,
|
||||
T: cob::Evaluate<R> + cob::store::Cob + cob::store::CobWithType,
|
||||
C: cob::cache::Update<T> + cob::cache::Remove<T>,
|
||||
{
|
||||
match store.get(&tid.id) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
#![warn(clippy::unwrap_used)]
|
||||
pub mod cache;
|
||||
pub mod common;
|
||||
pub mod external;
|
||||
pub mod identity;
|
||||
pub mod issue;
|
||||
pub mod job;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,240 @@
|
|||
//! # External Collaborative Objects
|
||||
//!
|
||||
//! This module provides an interface for external helper programs to provide
|
||||
//! the evaluation logic of Collaborative Objects (COBs).
|
||||
//!
|
||||
//! An external COB is one that relies on an external program (so called
|
||||
//! "helper", is an executable file, for example a script, or a binary), that
|
||||
//! implements the evaluation logic for that particular COB:
|
||||
//! Whenever an operation is to be applied to an external COB, the helper is
|
||||
//! invoked with the current state of the COB and the operation to be applied.
|
||||
//! It then returns the new state of the COB, according to its internal logic.
|
||||
//! This concept is borrowed from Git, which supports [remote helpers] and
|
||||
//! [credential helpers].
|
||||
//!
|
||||
//! External COBs must be based on JSON, that is, the COB itself and associated
|
||||
//! actions must serialize to and deserialize from JSON.
|
||||
//! Further, the helper must be able to communicate to Radicle using
|
||||
//! [JSON Lines] (see further details below).
|
||||
//!
|
||||
//! # Invocation
|
||||
//!
|
||||
//! The helper is invoked by Radicle without command line arguments.
|
||||
//! In the future, more arguments might be added.
|
||||
//!
|
||||
//! # Helper Protocol
|
||||
//!
|
||||
//! Radicle and the helper communicate back and forth [JSON Lines] via standard
|
||||
//! streams.
|
||||
//!
|
||||
//! 1. The helper must read and process at least one JSON Line (containing one
|
||||
//! operation) from standard input, which represents the operation to be
|
||||
//! applied to the COB, along with possible concurrent operations.
|
||||
//! 2. The helper must write the new state of COB to standard output in a JSON
|
||||
//! Line.
|
||||
//! 3. The helper may read additional JSON Lines from standard input, these are
|
||||
//! to be applied "on top of" the previous operations.
|
||||
//! 4. The helper must reply with the state of the COB in a JSON Line after
|
||||
//! processing each operation.
|
||||
//! 5. The helper must exit with a status code of zero on success, and a
|
||||
//! non-zero status code on failure.
|
||||
//! 6. The helper may write to standard error for logging and debugging
|
||||
//! purposes.
|
||||
//!
|
||||
//! # Syntax of Operations
|
||||
//!
|
||||
//! The operations sent from Radicle to the helper are of the following shape:
|
||||
//!
|
||||
//! ```json
|
||||
//! {
|
||||
//! "title": "Operation as sent to helper"
|
||||
//! "type": "object",
|
||||
//! "properties": {
|
||||
//! "concurrent": {
|
||||
//! "type": "array",
|
||||
//! "items": {
|
||||
//! "$ref": "#/definitions/radicle::cob::Op"
|
||||
//! }
|
||||
//! },
|
||||
//! "value": {
|
||||
//! "type": "object",
|
||||
//! "properties": {
|
||||
//! "prop": {
|
||||
//! "$ref": "#/definitions/radicle::cob::external::External"
|
||||
//! }
|
||||
//! }
|
||||
//! },
|
||||
//! "op": {
|
||||
//! "type": "object",
|
||||
//! "properties": {
|
||||
//! "prop": {
|
||||
//! "$ref": "#/definitions/radicle::cob::Op"
|
||||
//! }
|
||||
//! }
|
||||
//! }
|
||||
//! },
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! [JSON Lines]: https://jsonlines.org/
|
||||
//! [credential helpers]: https://git-scm.com/doc/credential-helpers
|
||||
//! [remote helpers]: https://git-scm.com/docs/gitremote-helpers
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::{Error as IoError, ErrorKind};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use serde_json::{from_slice, to_writer, Error as JsonError, Map, Value};
|
||||
|
||||
use crate::cob::object::collaboration::Evaluate;
|
||||
use crate::cob::op::{Op as CobOp, OpEncodingError};
|
||||
use crate::cob::store::{Cob, CobAction};
|
||||
use crate::git::Oid;
|
||||
use crate::storage::ReadRepository;
|
||||
|
||||
/// This prefix is used to generate the name of the command,
|
||||
/// which is executed by the helper to apply operations.
|
||||
static COB_EXTERNAL_COMMAND_PREFIX: &str = "rad-cob-";
|
||||
|
||||
#[derive(PartialEq, Debug, Serialize, Deserialize)]
|
||||
pub struct External(Value);
|
||||
|
||||
impl Default for External {
|
||||
fn default() -> Self {
|
||||
Self(Value::Object(Map::default()))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum Error {
|
||||
#[error("op decoding failed: {0}")]
|
||||
Op(#[from] OpEncodingError),
|
||||
#[error("serde_json: {0}")]
|
||||
Serde(#[from] JsonError),
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] IoError),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Action {
|
||||
#[serde(default)]
|
||||
parents: Vec<Oid>,
|
||||
|
||||
#[serde(flatten)]
|
||||
map: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
impl CobAction for Action {
|
||||
fn parents(&self) -> Vec<Oid> {
|
||||
self.parents.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Action> for nonempty::NonEmpty<Action> {
|
||||
fn from(action: Action) -> Self {
|
||||
Self::new(action)
|
||||
}
|
||||
}
|
||||
|
||||
pub type Op = CobOp<Action>;
|
||||
|
||||
impl External {
|
||||
fn handle<R: ReadRepository>(
|
||||
&mut self,
|
||||
op: Op,
|
||||
concurrent: Vec<Op>,
|
||||
_repo: &R,
|
||||
) -> Result<(), Error> {
|
||||
let command_name = {
|
||||
let prefix = String::from(COB_EXTERNAL_COMMAND_PREFIX);
|
||||
let type_name = op.manifest.type_name.to_string();
|
||||
let suffix = type_name
|
||||
.rsplit_once('.')
|
||||
.map(|(_, suffix)| suffix)
|
||||
.unwrap_or(type_name.as_str());
|
||||
prefix + suffix
|
||||
};
|
||||
|
||||
let child = Command::new(command_name)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.spawn()?;
|
||||
|
||||
let Some(stdin) = &child.stdin else {
|
||||
return Err(Error::Io(IoError::new(
|
||||
ErrorKind::BrokenPipe,
|
||||
"stdin not available",
|
||||
)));
|
||||
};
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct OpMessage {
|
||||
value: Value,
|
||||
op: Op,
|
||||
concurrent: Vec<Op>,
|
||||
}
|
||||
|
||||
to_writer(
|
||||
stdin,
|
||||
&OpMessage {
|
||||
value: self.0.clone(),
|
||||
op,
|
||||
concurrent,
|
||||
},
|
||||
)?;
|
||||
|
||||
self.0 = from_slice(&child.wait_with_output()?.stdout)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Cob for External {
|
||||
type Action = Action;
|
||||
type Error = Error;
|
||||
|
||||
fn from_root<R: ReadRepository>(
|
||||
op: super::Op<Self::Action>,
|
||||
repo: &R,
|
||||
) -> Result<Self, Self::Error> {
|
||||
let mut root = Self::default();
|
||||
root.handle(op, vec![], repo)?;
|
||||
Ok(root)
|
||||
}
|
||||
|
||||
fn op<'a, R: ReadRepository, I: IntoIterator<Item = &'a super::Entry>>(
|
||||
&mut self,
|
||||
op: super::Op<Self::Action>,
|
||||
concurrent: I,
|
||||
repo: &R,
|
||||
) -> Result<(), <Self as Cob>::Error> {
|
||||
let concurrent: Vec<Op> = concurrent
|
||||
.into_iter()
|
||||
.map(Op::try_from)
|
||||
.collect::<Result<Vec<Op>, _>>()?;
|
||||
self.handle(op, concurrent, repo)
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: ReadRepository> Evaluate<R> for External {
|
||||
type Error = Error;
|
||||
|
||||
fn init(entry: &radicle_cob::Entry, store: &R) -> Result<Self, Self::Error> {
|
||||
Self::from_root(Op::try_from(entry)?, store)
|
||||
}
|
||||
|
||||
fn apply<'a, I: Iterator<Item = (&'a radicle_git_ext::Oid, &'a radicle_cob::Entry)>>(
|
||||
&mut self,
|
||||
entry: &radicle_cob::Entry,
|
||||
concurrent: I,
|
||||
repo: &R,
|
||||
) -> Result<(), Self::Error> {
|
||||
let concurrent: Vec<Op> = concurrent
|
||||
.map(|(_, e)| e)
|
||||
.map(Op::try_from)
|
||||
.collect::<Result<Vec<Op>, _>>()?;
|
||||
self.handle(Op::try_from(entry)?, concurrent, repo)
|
||||
}
|
||||
}
|
||||
|
|
@ -155,6 +155,12 @@ pub struct Identity {
|
|||
timeline: Vec<EntryId>,
|
||||
}
|
||||
|
||||
impl cob::store::CobWithType for Identity {
|
||||
fn type_name() -> &'static TypeName {
|
||||
&TYPENAME
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Deref for Identity {
|
||||
type Target = Revision;
|
||||
|
||||
|
|
@ -206,6 +212,8 @@ impl Identity {
|
|||
object: &ObjectId,
|
||||
repo: &R,
|
||||
) -> Result<Identity, store::Error> {
|
||||
use cob::store::CobWithType;
|
||||
|
||||
cob::get::<Self, _>(repo, Self::type_name(), object)
|
||||
.map(|r| r.map(|cob| cob.object))?
|
||||
.ok_or_else(move || store::Error::NotFound(TYPENAME.clone(), *object))
|
||||
|
|
@ -293,10 +301,6 @@ impl store::Cob for Identity {
|
|||
type Action = Action;
|
||||
type Error = ApplyError;
|
||||
|
||||
fn type_name() -> &'static TypeName {
|
||||
&TYPENAME
|
||||
}
|
||||
|
||||
fn from_root<R: ReadRepository>(op: Op, repo: &R) -> Result<Self, Self::Error> {
|
||||
let mut actions = op.actions.into_iter();
|
||||
let Some(Action::Revision {
|
||||
|
|
|
|||
|
|
@ -142,13 +142,15 @@ pub struct Issue {
|
|||
pub(super) thread: Thread,
|
||||
}
|
||||
|
||||
impl store::Cob for Issue {
|
||||
type Action = Action;
|
||||
type Error = Error;
|
||||
|
||||
impl cob::store::CobWithType for Issue {
|
||||
fn type_name() -> &'static TypeName {
|
||||
&TYPENAME
|
||||
}
|
||||
}
|
||||
|
||||
impl store::Cob for Issue {
|
||||
type Action = Action;
|
||||
type Error = Error;
|
||||
|
||||
fn from_root<R: ReadRepository>(op: Op, repo: &R) -> Result<Self, Self::Error> {
|
||||
let doc = op.identity_doc(repo)?.ok_or(Error::MissingIdentity)?;
|
||||
|
|
@ -941,7 +943,7 @@ mod test {
|
|||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::*;
|
||||
use crate::cob::{ActorId, Reaction};
|
||||
use crate::cob::{store::CobWithType, ActorId, Reaction};
|
||||
use crate::git::Oid;
|
||||
use crate::issue::cache::Issues as _;
|
||||
use crate::test;
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ use crate::git;
|
|||
use crate::prelude::ReadRepository;
|
||||
use crate::storage::{Oid, WriteRepository};
|
||||
|
||||
use super::store::CobWithType;
|
||||
|
||||
/// The name of this COB type. Note that this is a "beta" COB, which
|
||||
/// means it's not meant for others to rely on yet, and we may change
|
||||
/// it without warning.
|
||||
|
|
@ -213,10 +215,6 @@ impl Cob for Job {
|
|||
type Action = Action;
|
||||
type Error = Error;
|
||||
|
||||
fn type_name() -> &'static TypeName {
|
||||
&TYPENAME
|
||||
}
|
||||
|
||||
fn from_root<R: ReadRepository>(op: Op, _repo: &R) -> Result<Self, Self::Error> {
|
||||
let mut actions = op.actions.into_iter();
|
||||
let Some(Action::Trigger { commit }) = actions.next() else {
|
||||
|
|
@ -245,6 +243,12 @@ impl Cob for Job {
|
|||
}
|
||||
}
|
||||
|
||||
impl CobWithType for Job {
|
||||
fn type_name() -> &'static TypeName {
|
||||
&TYPENAME
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: ReadRepository> cob::Evaluate<R> for Job {
|
||||
type Error = Error;
|
||||
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ pub enum Error {
|
|||
/// This error indicates that the operations are not being applied
|
||||
/// in causal order, which is a requirement for this CRDT.
|
||||
///
|
||||
/// For example, this can occur if an operation references anothern operation
|
||||
/// For example, this can occur if an operation references another operation
|
||||
/// that hasn't happened yet.
|
||||
#[error("causal dependency {0:?} missing")]
|
||||
Missing(EntryId),
|
||||
|
|
@ -1156,13 +1156,15 @@ impl Patch {
|
|||
}
|
||||
}
|
||||
|
||||
impl store::Cob for Patch {
|
||||
type Action = Action;
|
||||
type Error = Error;
|
||||
|
||||
impl cob::store::CobWithType for Patch {
|
||||
fn type_name() -> &'static TypeName {
|
||||
&TYPENAME
|
||||
}
|
||||
}
|
||||
|
||||
impl store::Cob for Patch {
|
||||
type Action = Action;
|
||||
type Error = Error;
|
||||
|
||||
fn from_root<R: ReadRepository>(op: Op, repo: &R) -> Result<Self, Self::Error> {
|
||||
let doc = op.identity_doc(repo)?.ok_or(Error::MissingIdentity)?;
|
||||
|
|
|
|||
|
|
@ -31,9 +31,6 @@ pub trait Cob: Sized + PartialEq + Debug {
|
|||
/// Error returned by `apply` function.
|
||||
type Error: std::error::Error + Send + Sync + 'static;
|
||||
|
||||
/// The object type name.
|
||||
fn type_name() -> &'static TypeName;
|
||||
|
||||
/// Initialize a collarorative object from a root operation.
|
||||
fn from_root<R: ReadRepository>(op: Op<Self::Action>, repo: &R) -> Result<Self, Self::Error>;
|
||||
|
||||
|
|
@ -50,7 +47,10 @@ pub trait Cob: Sized + PartialEq + Debug {
|
|||
fn from_history<R: ReadRepository>(
|
||||
history: &crate::cob::History,
|
||||
repo: &R,
|
||||
) -> Result<Self, test::HistoryError<Self>> {
|
||||
) -> Result<Self, test::HistoryError<Self>>
|
||||
where
|
||||
Self: CobWithType,
|
||||
{
|
||||
test::from_history::<R, Self>(history, repo)
|
||||
}
|
||||
|
||||
|
|
@ -73,6 +73,15 @@ pub trait Cob: Sized + PartialEq + Debug {
|
|||
}
|
||||
}
|
||||
|
||||
/// Implementations are statically associated with a particular
|
||||
/// type name of a collaborative object.
|
||||
///
|
||||
/// In most cases, this trait should be used in tandem with [`Cob`].
|
||||
pub trait CobWithType {
|
||||
/// The type name of the collaborative object type which backs this implementation.
|
||||
fn type_name() -> &'static TypeName;
|
||||
}
|
||||
|
||||
/// Store error.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
|
|
@ -109,6 +118,7 @@ pub struct Store<'a, T, R> {
|
|||
identity: Option<git::Oid>,
|
||||
repo: &'a R,
|
||||
witness: PhantomData<T>,
|
||||
type_name: &'a TypeName,
|
||||
}
|
||||
|
||||
impl<T, R> AsRef<R> for Store<'_, T, R> {
|
||||
|
|
@ -122,11 +132,12 @@ where
|
|||
R: ReadRepository + cob::Store,
|
||||
{
|
||||
/// Open a new generic store.
|
||||
pub fn open(repo: &'a R) -> Result<Self, Error> {
|
||||
pub fn open_for(type_name: &'a TypeName, repo: &'a R) -> Result<Self, Error> {
|
||||
Ok(Self {
|
||||
repo,
|
||||
identity: None,
|
||||
witness: PhantomData,
|
||||
type_name,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -136,10 +147,41 @@ where
|
|||
repo: self.repo,
|
||||
witness: self.witness,
|
||||
identity: Some(identity),
|
||||
type_name: self.type_name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, T, R> Store<'a, T, R>
|
||||
where
|
||||
R: ReadRepository + cob::Store,
|
||||
T: CobWithType,
|
||||
{
|
||||
/// Open a new generic store.
|
||||
pub fn open(repo: &'a R) -> Result<Self, Error> {
|
||||
Ok(Self {
|
||||
repo,
|
||||
identity: None,
|
||||
witness: PhantomData,
|
||||
type_name: T::type_name(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<T, R> Store<'_, T, R>
|
||||
where
|
||||
R: ReadRepository + cob::Store,
|
||||
T: Cob + cob::Evaluate<R>,
|
||||
{
|
||||
pub fn transaction(
|
||||
&self,
|
||||
actions: Vec<T::Action>,
|
||||
embeds: Vec<Embed<Uri>>,
|
||||
) -> Transaction<T, R> {
|
||||
Transaction::new(self.type_name.clone(), actions, embeds)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T, R> Store<'_, T, R>
|
||||
where
|
||||
R: ReadRepository + SignRepository + cob::Store,
|
||||
|
|
@ -149,6 +191,7 @@ where
|
|||
/// Update an object.
|
||||
pub fn update<G: Signer>(
|
||||
&self,
|
||||
type_name: &TypeName,
|
||||
object_id: ObjectId,
|
||||
message: &str,
|
||||
actions: impl Into<NonEmpty<T::Action>>,
|
||||
|
|
@ -175,7 +218,7 @@ where
|
|||
signer.public_key(),
|
||||
Update {
|
||||
object_id,
|
||||
type_name: T::type_name().clone(),
|
||||
type_name: type_name.clone(),
|
||||
message: message.to_owned(),
|
||||
embeds,
|
||||
changes,
|
||||
|
|
@ -215,7 +258,7 @@ where
|
|||
parents,
|
||||
signer.public_key(),
|
||||
Create {
|
||||
type_name: T::type_name().clone(),
|
||||
type_name: self.type_name.clone(),
|
||||
version: Version::default(),
|
||||
message: message.to_owned(),
|
||||
embeds,
|
||||
|
|
@ -225,7 +268,7 @@ where
|
|||
// Nb. We can't sign our refs before the identity refs exist, which are created after
|
||||
// the identity COB is created. Therefore we manually sign refs when creating identity
|
||||
// COBs.
|
||||
if T::type_name() != &*crate::cob::identity::TYPENAME {
|
||||
if self.type_name != &*crate::cob::identity::TYPENAME {
|
||||
self.repo
|
||||
.sign_refs(signer)
|
||||
.map_err(|e| Error::SignRefs(Box::new(e)))?;
|
||||
|
|
@ -235,13 +278,13 @@ where
|
|||
|
||||
/// Remove an object.
|
||||
pub fn remove<G: Signer>(&self, id: &ObjectId, signer: &G) -> Result<(), Error> {
|
||||
let name = git::refs::storage::cob(signer.public_key(), T::type_name(), id);
|
||||
let name = git::refs::storage::cob(signer.public_key(), self.type_name, id);
|
||||
match self
|
||||
.repo
|
||||
.reference_oid(signer.public_key(), &name.strip_namespace())
|
||||
{
|
||||
Ok(_) => {
|
||||
cob::remove(self.repo, signer.public_key(), T::type_name(), id)?;
|
||||
cob::remove(self.repo, signer.public_key(), self.type_name, id)?;
|
||||
self.repo
|
||||
.sign_refs(signer)
|
||||
.map_err(|e| Error::SignRefs(Box::new(e)))?;
|
||||
|
|
@ -259,12 +302,12 @@ where
|
|||
impl<'a, T, R> Store<'a, T, R>
|
||||
where
|
||||
R: ReadRepository + cob::Store,
|
||||
T: cob::Evaluate<R> + Cob,
|
||||
T: Cob + cob::Evaluate<R>,
|
||||
T::Action: Serialize,
|
||||
{
|
||||
/// Get an object.
|
||||
pub fn get(&self, id: &ObjectId) -> Result<Option<T>, Error> {
|
||||
cob::get::<T, _>(self.repo, T::type_name(), id)
|
||||
cob::get::<T, _>(self.repo, self.type_name, id)
|
||||
.map(|r| r.map(|cob| cob.object))
|
||||
.map_err(Error::from)
|
||||
}
|
||||
|
|
@ -273,7 +316,7 @@ where
|
|||
pub fn all(
|
||||
&self,
|
||||
) -> Result<impl ExactSizeIterator<Item = Result<(ObjectId, T), Error>> + 'a, Error> {
|
||||
let raw = cob::list::<T, _>(self.repo, T::type_name())?;
|
||||
let raw = cob::list::<T, _>(self.repo, self.type_name)?;
|
||||
|
||||
Ok(raw.into_iter().map(|o| Ok((*o.id(), o.object))))
|
||||
}
|
||||
|
|
@ -285,7 +328,7 @@ where
|
|||
|
||||
/// Return objects count.
|
||||
pub fn count(&self) -> Result<usize, Error> {
|
||||
let raw = cob::list::<T, _>(self.repo, T::type_name())?;
|
||||
let raw = cob::list::<T, _>(self.repo, self.type_name)?;
|
||||
|
||||
Ok(raw.len())
|
||||
}
|
||||
|
|
@ -296,15 +339,18 @@ where
|
|||
pub struct Transaction<T: Cob + cob::Evaluate<R>, R> {
|
||||
actions: Vec<T::Action>,
|
||||
embeds: Vec<Embed<Uri>>,
|
||||
|
||||
repo: PhantomData<R>,
|
||||
type_name: TypeName,
|
||||
}
|
||||
|
||||
impl<T: Cob + cob::Evaluate<R>, R> Default for Transaction<T, R> {
|
||||
impl<T: Cob + CobWithType + cob::Evaluate<R>, R> Default for Transaction<T, R> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
actions: Vec::new(),
|
||||
embeds: Vec::new(),
|
||||
repo: PhantomData,
|
||||
type_name: T::type_name().clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -312,6 +358,20 @@ impl<T: Cob + cob::Evaluate<R>, R> Default for Transaction<T, R> {
|
|||
impl<T, R> Transaction<T, R>
|
||||
where
|
||||
T: Cob + cob::Evaluate<R>,
|
||||
{
|
||||
pub fn new(type_name: TypeName, actions: Vec<T::Action>, embeds: Vec<Embed<Uri>>) -> Self {
|
||||
Self {
|
||||
actions,
|
||||
embeds,
|
||||
repo: PhantomData,
|
||||
type_name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T, R> Transaction<T, R>
|
||||
where
|
||||
T: Cob + CobWithType + cob::Evaluate<R>,
|
||||
{
|
||||
/// Create a new transaction to be used as the initial set of operations for a COB.
|
||||
pub fn initial<G, F, Tx>(
|
||||
|
|
@ -337,14 +397,26 @@ where
|
|||
|
||||
store.create(message, actions, tx.embeds, signer)
|
||||
}
|
||||
}
|
||||
|
||||
/// Add an operation to this transaction.
|
||||
impl<T, R> Transaction<T, R>
|
||||
where
|
||||
T: Cob + cob::Evaluate<R>,
|
||||
{
|
||||
/// Add an action to this transaction.
|
||||
pub fn push(&mut self, action: T::Action) -> Result<(), Error> {
|
||||
self.actions.push(action);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add actions to this transaction.
|
||||
pub fn extend(&mut self, actions: impl IntoIterator<Item = T::Action>) -> Result<(), Error> {
|
||||
self.actions.extend(actions);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Embed media into the transaction.
|
||||
pub fn embed(&mut self, embeds: impl IntoIterator<Item = Embed<Uri>>) -> Result<(), Error> {
|
||||
self.embeds.extend(embeds);
|
||||
|
|
@ -372,7 +444,7 @@ where
|
|||
head,
|
||||
object: CollaborativeObject { object, .. },
|
||||
..
|
||||
} = store.update(id, msg, actions, self.embeds, signer)?;
|
||||
} = store.update(&self.type_name, id, msg, actions, self.embeds, signer)?;
|
||||
|
||||
Ok((object, head))
|
||||
}
|
||||
|
|
@ -424,7 +496,7 @@ pub mod test {
|
|||
|
||||
/// Turn a history into a concrete type, by traversing the history and applying each operation
|
||||
/// to the state, skipping branches that return errors.
|
||||
pub fn from_history<R: ReadRepository, T: Cob>(
|
||||
pub fn from_history<R: ReadRepository, T: Cob + CobWithType>(
|
||||
history: &crate::cob::History,
|
||||
repo: &R,
|
||||
) -> Result<T, HistoryError<T>> {
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ use crate::profile::env;
|
|||
use crate::storage::ReadRepository;
|
||||
use crate::test::arbitrary;
|
||||
|
||||
use super::store::Cob;
|
||||
use super::store::{Cob, CobWithType};
|
||||
use super::thread;
|
||||
|
||||
/// Convenience type for building histories.
|
||||
|
|
@ -57,6 +57,7 @@ impl HistoryBuilder<thread::Thread> {
|
|||
|
||||
impl<T: Cob> HistoryBuilder<T>
|
||||
where
|
||||
T: CobWithType,
|
||||
T::Action: for<'de> Deserialize<'de> + Serialize + Eq + 'static,
|
||||
{
|
||||
pub fn new<G: Signer>(actions: &[T::Action], time: Timestamp, signer: &G) -> HistoryBuilder<T> {
|
||||
|
|
@ -133,12 +134,13 @@ impl<A> Deref for HistoryBuilder<A> {
|
|||
}
|
||||
|
||||
/// Create a new test history.
|
||||
pub fn history<T: Cob, G: Signer>(
|
||||
pub fn history<T, G: Signer>(
|
||||
actions: &[T::Action],
|
||||
time: Timestamp,
|
||||
signer: &G,
|
||||
) -> HistoryBuilder<T>
|
||||
where
|
||||
T: Cob + CobWithType,
|
||||
T::Action: Serialize + Eq + 'static,
|
||||
{
|
||||
HistoryBuilder::new(actions, time, signer)
|
||||
|
|
@ -163,13 +165,14 @@ impl<G> Actor<G> {
|
|||
|
||||
impl<G: Signer> Actor<G> {
|
||||
/// Create a new operation.
|
||||
pub fn op_with<T: Cob>(
|
||||
pub fn op_with<T>(
|
||||
&mut self,
|
||||
actions: impl IntoIterator<Item = T::Action>,
|
||||
identity: Option<Oid>,
|
||||
timestamp: Timestamp,
|
||||
) -> Op<T::Action>
|
||||
where
|
||||
T: Cob + CobWithType,
|
||||
T::Action: Clone + Serialize,
|
||||
{
|
||||
let actions = actions.into_iter().collect::<Vec<_>>();
|
||||
|
|
@ -199,8 +202,9 @@ impl<G: Signer> Actor<G> {
|
|||
}
|
||||
|
||||
/// Create a new operation.
|
||||
pub fn op<T: Cob>(&mut self, actions: impl IntoIterator<Item = T::Action>) -> Op<T::Action>
|
||||
pub fn op<T>(&mut self, actions: impl IntoIterator<Item = T::Action>) -> Op<T::Action>
|
||||
where
|
||||
T: Cob + CobWithType,
|
||||
T::Action: Clone + Serialize,
|
||||
{
|
||||
let identity = arbitrary::oid();
|
||||
|
|
|
|||
|
|
@ -299,6 +299,12 @@ impl<T> Default for Thread<T> {
|
|||
}
|
||||
}
|
||||
|
||||
impl<T> cob::store::CobWithType for Thread<T> {
|
||||
fn type_name() -> &'static radicle_cob::TypeName {
|
||||
&TYPENAME
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Thread<T> {
|
||||
pub fn new(id: CommentId, comment: T) -> Self {
|
||||
Self {
|
||||
|
|
@ -403,10 +409,6 @@ impl cob::store::Cob for Thread {
|
|||
type Action = Action;
|
||||
type Error = Error;
|
||||
|
||||
fn type_name() -> &'static radicle_cob::TypeName {
|
||||
&TYPENAME
|
||||
}
|
||||
|
||||
fn from_root<R: ReadRepository>(op: Op<Action>, repo: &R) -> Result<Self, Self::Error> {
|
||||
let author = op.author;
|
||||
let entry = op.id;
|
||||
|
|
|
|||
|
|
@ -8,4 +8,4 @@ pub use did::Did;
|
|||
pub use doc::{Doc, DocAt, DocError, IdError, PayloadError, RawDoc, RepoId, Visibility};
|
||||
pub use project::Project;
|
||||
|
||||
pub use crate::cob::identity::{Error, Identity, IdentityMut};
|
||||
pub use crate::cob::identity::{Action, Error, Identity, IdentityMut, TYPENAME};
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ pub mod test;
|
|||
pub mod version;
|
||||
pub mod web;
|
||||
|
||||
pub use cob::{issue, patch};
|
||||
pub use cob::{external, issue, patch};
|
||||
pub use node::Node;
|
||||
pub use profile::Profile;
|
||||
pub use storage::git::Storage;
|
||||
|
|
|
|||
Loading…
Reference in New Issue