cli/cob: Use clap
This commit is contained in:
parent
38ca038a0d
commit
8d90699c30
|
|
@ -1,7 +1,7 @@
|
||||||
use std::ffi::OsString;
|
mod args;
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::str::FromStr;
|
use std::io;
|
||||||
use std::{fs, io};
|
use std::path::Path;
|
||||||
|
|
||||||
use anyhow::{anyhow, bail};
|
use anyhow::{anyhow, bail};
|
||||||
|
|
||||||
|
|
@ -18,392 +18,51 @@ use radicle::storage;
|
||||||
|
|
||||||
use crate::git::Rev;
|
use crate::git::Rev;
|
||||||
use crate::terminal as term;
|
use crate::terminal as term;
|
||||||
use crate::terminal::args::{Args, Error, Help};
|
|
||||||
|
|
||||||
pub const HELP: Help = Help {
|
pub use args::Args;
|
||||||
name: "cob",
|
|
||||||
description: "Manage collaborative objects",
|
|
||||||
version: env!("RADICLE_VERSION"),
|
|
||||||
usage: r#"
|
|
||||||
Usage
|
|
||||||
|
|
||||||
rad cob <command> [<option>...]
|
use args::{parse_many_embeds, FilteredTypeName, Format};
|
||||||
|
|
||||||
rad cob create --repo <rid> --type <typename> <filename> [<option>...]
|
fn embeds(
|
||||||
rad cob list --repo <rid> --type <typename>
|
repo: &storage::git::Repository,
|
||||||
rad cob log --repo <rid> --type <typename> --object <oid> [<option>...]
|
files: Vec<String>,
|
||||||
rad cob migrate [<option>...]
|
hashes: Vec<String>,
|
||||||
rad cob show --repo <rid> --type <typename> --object <oid> [<option>...]
|
) -> anyhow::Result<Vec<cob::Embed<cob::Uri>>> {
|
||||||
rad cob update --repo <rid> --type <typename> --object <oid> <filename>
|
parse_many_embeds::<std::path::PathBuf>(&files)
|
||||||
[<option>...]
|
.chain(parse_many_embeds::<Rev>(&hashes))
|
||||||
|
.map(|embed| embed.try_into_bytes(repo))
|
||||||
Commands
|
.collect::<anyhow::Result<Vec<_>>>()
|
||||||
|
|
||||||
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)
|
|
||||||
--from <oid> Git object ID of the commit of the operation to
|
|
||||||
start iterating at.
|
|
||||||
--until <oid> Git object ID of the commit of the operation to
|
|
||||||
stop iterating at.
|
|
||||||
|
|
||||||
Show options
|
|
||||||
|
|
||||||
--format json Desired output format (default: json)
|
|
||||||
|
|
||||||
Other options
|
|
||||||
|
|
||||||
--help Print help
|
|
||||||
"#,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq)]
|
|
||||||
enum OperationName {
|
|
||||||
Update,
|
|
||||||
Create,
|
|
||||||
List,
|
|
||||||
Log,
|
|
||||||
Migrate,
|
|
||||||
Show,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Operation {
|
pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
|
||||||
Create {
|
use args::Command::*;
|
||||||
rid: RepoId,
|
use args::FilteredTypeName::*;
|
||||||
type_name: FilteredTypeName,
|
|
||||||
message: String,
|
|
||||||
actions: PathBuf,
|
|
||||||
embeds: Vec<Embed>,
|
|
||||||
},
|
|
||||||
List {
|
|
||||||
rid: RepoId,
|
|
||||||
type_name: FilteredTypeName,
|
|
||||||
},
|
|
||||||
Log {
|
|
||||||
rid: RepoId,
|
|
||||||
type_name: FilteredTypeName,
|
|
||||||
oid: Rev,
|
|
||||||
format: Format,
|
|
||||||
from: Option<Rev>,
|
|
||||||
until: Option<Rev>,
|
|
||||||
},
|
|
||||||
Migrate,
|
|
||||||
Show {
|
|
||||||
rid: RepoId,
|
|
||||||
type_name: FilteredTypeName,
|
|
||||||
oids: Vec<Rev>,
|
|
||||||
},
|
|
||||||
Update {
|
|
||||||
rid: RepoId,
|
|
||||||
type_name: FilteredTypeName,
|
|
||||||
oid: Rev,
|
|
||||||
message: String,
|
|
||||||
actions: PathBuf,
|
|
||||||
embeds: Vec<Embed>,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
enum Format {
|
|
||||||
Json,
|
|
||||||
Pretty,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct Options {
|
|
||||||
op: Operation,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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: &storage::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 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: Format = Format::Pretty;
|
|
||||||
let mut message: Option<String> = None;
|
|
||||||
let mut embeds: Vec<Embed> = vec![];
|
|
||||||
let mut actions: Option<PathBuf> = None;
|
|
||||||
let mut from: Option<Rev> = None;
|
|
||||||
let mut until: Option<Rev> = None;
|
|
||||||
|
|
||||||
while let Some(arg) = parser.next()? {
|
|
||||||
match (&op, &arg) {
|
|
||||||
(_, Long("help") | Short('h')) => {
|
|
||||||
return Err(Error::Help.into());
|
|
||||||
}
|
|
||||||
(_, Long("repo") | Short('r')) => {
|
|
||||||
rid = Some(term::args::rid(&parser.value()?)?);
|
|
||||||
}
|
|
||||||
(_, 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)));
|
|
||||||
}
|
|
||||||
(Log, Long("from")) => {
|
|
||||||
let v = parser.value()?;
|
|
||||||
from = Some(term::args::rev(&v)?);
|
|
||||||
}
|
|
||||||
(Log, Long("until")) => {
|
|
||||||
let v = parser.value()?;
|
|
||||||
until = Some(term::args::rev(&v)?);
|
|
||||||
}
|
|
||||||
_ => return Err(anyhow!(arg.unexpected())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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`"))?;
|
|
||||||
|
|
||||||
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 {
|
|
||||||
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,
|
|
||||||
from,
|
|
||||||
until,
|
|
||||||
},
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
vec![],
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn run(Options { op }: Options, ctx: impl term::Context) -> anyhow::Result<()> {
|
|
||||||
use cob::store::Store;
|
use cob::store::Store;
|
||||||
use FilteredTypeName::*;
|
|
||||||
use Operation::*;
|
|
||||||
|
|
||||||
let profile = ctx.profile()?;
|
let profile = ctx.profile()?;
|
||||||
let storage = &profile.storage;
|
let storage = &profile.storage;
|
||||||
|
|
||||||
match op {
|
match args.command {
|
||||||
Create {
|
Create(args::Create {
|
||||||
rid,
|
repo,
|
||||||
type_name,
|
type_name,
|
||||||
message,
|
operation,
|
||||||
embeds,
|
}) => {
|
||||||
actions,
|
|
||||||
} => {
|
|
||||||
let signer = &profile.signer()?;
|
let signer = &profile.signer()?;
|
||||||
let repo = storage.repository_mut(rid)?;
|
let repo = storage.repository_mut(repo)?;
|
||||||
|
let embeds = embeds(&repo, operation.embed_files, operation.embed_hashes)?;
|
||||||
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 {
|
let oid = match type_name {
|
||||||
Patch => {
|
Patch => {
|
||||||
let store: Store<cob::patch::Patch, _> = Store::open(&repo)?;
|
let store: Store<cob::patch::Patch, _> = Store::open(&repo)?;
|
||||||
let actions = read_jsonl_actions(reader)?;
|
let actions = read_jsonl_actions(&operation.actions)?;
|
||||||
let (oid, _) = store.create(&message, actions, embeds, signer)?;
|
let (oid, _) = store.create(&operation.message, actions, embeds, signer)?;
|
||||||
oid
|
oid
|
||||||
}
|
}
|
||||||
Issue => {
|
Issue => {
|
||||||
let store: Store<cob::issue::Issue, _> = Store::open(&repo)?;
|
let store: Store<cob::issue::Issue, _> = Store::open(&repo)?;
|
||||||
let actions = read_jsonl_actions(reader)?;
|
let actions = read_jsonl_actions(&operation.actions)?;
|
||||||
let (oid, _) = store.create(&message, actions, embeds, signer)?;
|
let (oid, _) = store.create(&operation.message, actions, embeds, signer)?;
|
||||||
oid
|
oid
|
||||||
}
|
}
|
||||||
Identity => anyhow::bail!(
|
Identity => anyhow::bail!(
|
||||||
|
|
@ -413,8 +72,8 @@ pub fn run(Options { op }: Options, ctx: impl term::Context) -> anyhow::Result<(
|
||||||
Other(type_name) => {
|
Other(type_name) => {
|
||||||
let store: Store<cob::external::External, _> =
|
let store: Store<cob::external::External, _> =
|
||||||
Store::open_for(&type_name, &repo)?;
|
Store::open_for(&type_name, &repo)?;
|
||||||
let actions = read_jsonl_actions(reader)?;
|
let actions = read_jsonl_actions(&operation.actions)?;
|
||||||
let (oid, _) = store.create(&message, actions, embeds, signer)?;
|
let (oid, _) = store.create(&operation.message, actions, embeds, signer)?;
|
||||||
oid
|
oid
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -431,30 +90,33 @@ pub fn run(Options { op }: Options, ctx: impl term::Context) -> anyhow::Result<(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
List { rid, type_name } => {
|
List { repo, type_name } => {
|
||||||
let repo = storage.repository(rid)?;
|
let repo = storage.repository(repo)?;
|
||||||
let cobs = radicle_cob::list::<NonEmpty<cob::Entry>, _>(&repo, type_name.as_ref())?;
|
let cobs = radicle_cob::list::<NonEmpty<cob::Entry>, _>(
|
||||||
|
&repo,
|
||||||
|
FilteredTypeName::from(type_name).as_ref(),
|
||||||
|
)?;
|
||||||
for cob in cobs {
|
for cob in cobs {
|
||||||
println!("{}", cob.id);
|
println!("{}", cob.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Log {
|
Log {
|
||||||
rid,
|
repo,
|
||||||
type_name,
|
type_name,
|
||||||
oid,
|
object,
|
||||||
format,
|
format,
|
||||||
from,
|
from,
|
||||||
until,
|
until,
|
||||||
} => {
|
} => {
|
||||||
let repo = storage.repository(rid)?;
|
let repo = storage.repository(repo)?;
|
||||||
let oid = oid.resolve(&repo.backend)?;
|
let oid = object.resolve(&repo.backend)?;
|
||||||
|
|
||||||
let from = from.map(|from| from.resolve(&repo.backend)).transpose()?;
|
let from = from.map(|from| from.resolve(&repo.backend)).transpose()?;
|
||||||
let until = until
|
let until = until
|
||||||
.map(|until| until.resolve(&repo.backend))
|
.map(|until| until.resolve(&repo.backend))
|
||||||
.transpose()?;
|
.transpose()?;
|
||||||
|
|
||||||
match type_name {
|
match type_name.into() {
|
||||||
Issue => operations::<cob::issue::Action>(
|
Issue => operations::<cob::issue::Action>(
|
||||||
&cob::issue::TYPENAME,
|
&cob::issue::TYPENAME,
|
||||||
oid,
|
oid,
|
||||||
|
|
@ -485,12 +147,13 @@ pub fn run(Options { op }: Options, ctx: impl term::Context) -> anyhow::Result<(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Show {
|
Show {
|
||||||
rid,
|
repo,
|
||||||
oids,
|
objects,
|
||||||
type_name,
|
type_name,
|
||||||
|
format: _,
|
||||||
} => {
|
} => {
|
||||||
let repo = storage.repository(rid)?;
|
let repo = storage.repository(repo)?;
|
||||||
if let Err(e) = show(oids, &repo, type_name, &profile) {
|
if let Err(e) = show(objects, &repo, type_name.into(), &profile) {
|
||||||
if let Some(err) = e.downcast_ref::<std::io::Error>() {
|
if let Some(err) = e.downcast_ref::<std::io::Error>() {
|
||||||
if err.kind() == std::io::ErrorKind::BrokenPipe {
|
if err.kind() == std::io::ErrorKind::BrokenPipe {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
|
|
@ -499,39 +162,36 @@ pub fn run(Options { op }: Options, ctx: impl term::Context) -> anyhow::Result<(
|
||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Update {
|
Update(args::Update {
|
||||||
rid,
|
repo,
|
||||||
type_name,
|
type_name,
|
||||||
oid,
|
object,
|
||||||
message,
|
operation,
|
||||||
actions,
|
format: _,
|
||||||
embeds,
|
}) => {
|
||||||
} => {
|
|
||||||
let signer = &profile.signer()?;
|
let signer = &profile.signer()?;
|
||||||
let repo = storage.repository_mut(rid)?;
|
let repo = storage.repository_mut(repo)?;
|
||||||
let reader = io::BufReader::new(fs::File::open(actions)?);
|
let oid = object.resolve::<radicle::git::Oid>(&repo.backend)?.into();
|
||||||
let oid = &oid.resolve(&repo.backend)?;
|
let embeds = embeds(&repo, operation.embed_files, operation.embed_hashes)?;
|
||||||
let embeds = embeds
|
|
||||||
.into_iter()
|
|
||||||
.map(|embed| embed.try_into_bytes(&repo))
|
|
||||||
.collect::<anyhow::Result<Vec<_>>>()?;
|
|
||||||
|
|
||||||
let oid = match type_name {
|
let oid = match type_name {
|
||||||
Patch => {
|
Patch => {
|
||||||
let actions: Vec<cob::patch::Action> = read_jsonl(reader)?;
|
let actions: Vec<cob::patch::Action> =
|
||||||
|
read_jsonl_actions(&operation.actions)?.into();
|
||||||
let mut patches = profile.patches_mut(&repo)?;
|
let mut patches = profile.patches_mut(&repo)?;
|
||||||
let mut patch = patches.get_mut(oid)?;
|
let mut patch = patches.get_mut(&oid)?;
|
||||||
patch.transaction(&message, &*profile.signer()?, |tx| {
|
patch.transaction(&operation.message, &*profile.signer()?, |tx| {
|
||||||
tx.extend(actions)?;
|
tx.extend(actions)?;
|
||||||
tx.embed(embeds)?;
|
tx.embed(embeds)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
})?
|
})?
|
||||||
}
|
}
|
||||||
Issue => {
|
Issue => {
|
||||||
let actions: Vec<cob::issue::Action> = read_jsonl(reader)?;
|
let actions: Vec<cob::issue::Action> =
|
||||||
|
read_jsonl_actions(&operation.actions)?.into();
|
||||||
let mut issues = profile.issues_mut(&repo)?;
|
let mut issues = profile.issues_mut(&repo)?;
|
||||||
let mut issue = issues.get_mut(oid)?;
|
let mut issue = issues.get_mut(&oid)?;
|
||||||
issue.transaction(&message, &*profile.signer()?, |tx| {
|
issue.transaction(&operation.message, &*profile.signer()?, |tx| {
|
||||||
tx.extend(actions)?;
|
tx.extend(actions)?;
|
||||||
tx.embed(embeds)?;
|
tx.embed(embeds)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -543,10 +203,10 @@ pub fn run(Options { op }: Options, ctx: impl term::Context) -> anyhow::Result<(
|
||||||
),
|
),
|
||||||
Other(type_name) => {
|
Other(type_name) => {
|
||||||
use cob::external::{Action, External};
|
use cob::external::{Action, External};
|
||||||
let actions: Vec<Action> = read_jsonl(reader)?;
|
let actions: Vec<Action> = read_jsonl_actions(&operation.actions)?.into();
|
||||||
let mut store: Store<External, _> = Store::open_for(&type_name, &repo)?;
|
let mut store: Store<External, _> = Store::open_for(&type_name, &repo)?;
|
||||||
let tx = cob::store::Transaction::new(type_name.clone(), actions, embeds);
|
let tx = cob::store::Transaction::new(type_name.clone(), actions, embeds);
|
||||||
let (_, oid) = tx.commit(&message, *oid, &mut store, signer)?;
|
let (_, oid) = tx.commit(&operation.message, oid, &mut store, signer)?;
|
||||||
oid
|
oid
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -684,11 +344,12 @@ where
|
||||||
|
|
||||||
/// Tiny utility to read a [`NonEmpty`] of COB actions.
|
/// Tiny utility to read a [`NonEmpty`] of COB actions.
|
||||||
/// This is used for `rad cob create` and `rad cob update`.
|
/// 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>>
|
fn read_jsonl_actions<A>(path: impl AsRef<Path>) -> anyhow::Result<NonEmpty<A>>
|
||||||
where
|
where
|
||||||
R: io::Read,
|
|
||||||
A: CobAction + serde::de::DeserializeOwned,
|
A: CobAction + serde::de::DeserializeOwned,
|
||||||
{
|
{
|
||||||
|
let reader = io::BufReader::new(std::fs::File::open(&path)?);
|
||||||
|
|
||||||
NonEmpty::from_vec(read_jsonl(reader)?)
|
NonEmpty::from_vec(read_jsonl(reader)?)
|
||||||
.ok_or_else(|| anyhow!("at least one action is required"))
|
.ok_or_else(|| anyhow!("at least one action is required"))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,417 @@
|
||||||
|
use std::fmt;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
|
||||||
|
use radicle::cob;
|
||||||
|
use radicle::git;
|
||||||
|
use radicle::prelude::*;
|
||||||
|
use radicle::storage;
|
||||||
|
|
||||||
|
use crate::git::Rev;
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(disable_version_flag = true)]
|
||||||
|
pub struct Args {
|
||||||
|
#[command(subcommand)]
|
||||||
|
pub(super) command: Command,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand, Debug)]
|
||||||
|
pub(super) enum Command {
|
||||||
|
/// Create a new COB of a given type given initial actions
|
||||||
|
Create(#[clap(flatten)] Create),
|
||||||
|
|
||||||
|
/// List all COBs of a given type
|
||||||
|
List {
|
||||||
|
/// Repository ID of the repository to operate on
|
||||||
|
#[arg(long, short, value_name = "RID")]
|
||||||
|
repo: RepoId,
|
||||||
|
|
||||||
|
/// Typename of the object(s) to list
|
||||||
|
#[arg(long = "type", short, value_name = "TYPENAME")]
|
||||||
|
type_name: cob::TypeName,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Print a log of all raw operations on a COB
|
||||||
|
Log {
|
||||||
|
/// Tepository ID of the repository to operate on
|
||||||
|
#[arg(long, short, value_name = "RID")]
|
||||||
|
repo: RepoId,
|
||||||
|
|
||||||
|
/// Typename of the object(s) to show
|
||||||
|
#[arg(long = "type", short, value_name = "TYPENAME")]
|
||||||
|
type_name: cob::TypeName,
|
||||||
|
|
||||||
|
/// Object ID of the object to log
|
||||||
|
#[arg(long, short, value_name = "OID")]
|
||||||
|
object: Rev,
|
||||||
|
|
||||||
|
/// Desired output format
|
||||||
|
#[arg(long, default_value_t = Format::Pretty, value_parser = FormatParser)]
|
||||||
|
format: Format,
|
||||||
|
|
||||||
|
/// Object ID of the commit of the operation to start iterating at
|
||||||
|
#[arg(long, value_name = "OID")]
|
||||||
|
from: Option<Rev>,
|
||||||
|
|
||||||
|
/// Object ID of the commit of the operation to stop iterating at
|
||||||
|
#[arg(long, value_name = "OID")]
|
||||||
|
until: Option<Rev>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Migrate the COB database to the latest version
|
||||||
|
Migrate,
|
||||||
|
|
||||||
|
/// Print the state of COBs
|
||||||
|
Show {
|
||||||
|
/// Repository ID of the repository to operate on
|
||||||
|
#[arg(long, short, value_name = "RID")]
|
||||||
|
repo: RepoId,
|
||||||
|
|
||||||
|
/// Typename of the object(s) to show
|
||||||
|
#[arg(long = "type", short, value_name = "TYPENAME")]
|
||||||
|
type_name: cob::TypeName,
|
||||||
|
|
||||||
|
/// Object ID(s) of the objects to show
|
||||||
|
#[arg(long = "object", short, value_name = "OID", action = clap::ArgAction::Append, required = true)]
|
||||||
|
objects: Vec<Rev>,
|
||||||
|
|
||||||
|
/// Desired output format
|
||||||
|
#[arg(long, default_value_t = Format::Json, value_parser = FormatParser)]
|
||||||
|
format: Format,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Add actions to a COB
|
||||||
|
Update(#[clap(flatten)] Update),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
pub(super) struct Operation {
|
||||||
|
/// Message describing the operation
|
||||||
|
#[arg(long, short)]
|
||||||
|
pub(super) message: String,
|
||||||
|
|
||||||
|
/// Supply embed of given name via file at given path
|
||||||
|
#[arg(long = "embed-file", value_names = ["NAME", "PATH"], num_args = 2)]
|
||||||
|
pub(super) embed_files: Vec<String>,
|
||||||
|
|
||||||
|
/// Supply embed of given name via object ID of blob
|
||||||
|
#[arg(long = "embed-hash", value_names = ["NAME", "OID"], num_args = 2)]
|
||||||
|
pub(super) embed_hashes: Vec<String>,
|
||||||
|
|
||||||
|
/// A file that contains a sequence actions (in JSONL format) to apply.
|
||||||
|
#[arg(value_name = "FILENAME")]
|
||||||
|
pub(super) actions: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
pub(super) struct Create {
|
||||||
|
/// Repository ID of the repository to operate on
|
||||||
|
#[arg(long, short, value_name = "RID")]
|
||||||
|
pub(super) repo: RepoId,
|
||||||
|
|
||||||
|
/// Typename of the object to create
|
||||||
|
#[arg(long = "type", short, value_name = "TYPENAME")]
|
||||||
|
pub(super) type_name: FilteredTypeName,
|
||||||
|
|
||||||
|
#[clap(flatten)]
|
||||||
|
pub(super) operation: Operation,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
pub(super) struct Update {
|
||||||
|
/// Repository ID of the repository to operate on
|
||||||
|
#[arg(long, short)]
|
||||||
|
pub(super) repo: RepoId,
|
||||||
|
|
||||||
|
/// Typename of the object to update
|
||||||
|
#[arg(long = "type", short, value_name = "TYPENAME")]
|
||||||
|
pub(super) type_name: FilteredTypeName,
|
||||||
|
|
||||||
|
/// Object ID of the object to update
|
||||||
|
#[arg(long, short, value_name = "OID")]
|
||||||
|
pub(super) object: Rev,
|
||||||
|
|
||||||
|
// TODO(finto): `Format` is unused and is obsolete for this command
|
||||||
|
/// Desired output format
|
||||||
|
#[arg(long, default_value_t = Format::Json, value_parser = FormatParser)]
|
||||||
|
pub(super) format: Format,
|
||||||
|
|
||||||
|
#[clap(flatten)]
|
||||||
|
pub(super) operation: Operation,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A precursor to [`cob::Embed`] used for parsing
|
||||||
|
/// that can be initialized without relying on a [`git::Repository`].
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub(super) struct Embed {
|
||||||
|
name: String,
|
||||||
|
content: EmbedContent,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Embed {
|
||||||
|
pub(super) fn try_into_bytes(
|
||||||
|
self,
|
||||||
|
repo: &storage::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, &fs::read(path)?, &repo.backend)?
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub(super) enum EmbedContent {
|
||||||
|
Path(PathBuf),
|
||||||
|
Hash(Rev),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<PathBuf> for EmbedContent {
|
||||||
|
fn from(path: PathBuf) -> Self {
|
||||||
|
EmbedContent::Path(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Rev> for EmbedContent {
|
||||||
|
fn from(rev: Rev) -> Self {
|
||||||
|
EmbedContent::Hash(rev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a slice of all embeds as name-path or name-oid pairs as aggregated by
|
||||||
|
/// `clap`.
|
||||||
|
/// E.g. `["image", "./image.png", "code", "d87dcfe8c2b3200e78b128d9b959cfdf7063fefe"]`
|
||||||
|
/// will result a `Vec` of two [`Embed`]s.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// If the length of `values` is not divisible by 2.
|
||||||
|
pub(super) fn parse_many_embeds<T>(values: &[String]) -> impl Iterator<Item = Embed> + use<'_, T>
|
||||||
|
where
|
||||||
|
T: From<String>,
|
||||||
|
EmbedContent: From<T>,
|
||||||
|
{
|
||||||
|
// `clap` ensures we have 2 values per option occurrence,
|
||||||
|
// so we can chunk the aggregated slice exactly.
|
||||||
|
let chunks = values.chunks_exact(2);
|
||||||
|
|
||||||
|
assert!(chunks.remainder().is_empty());
|
||||||
|
|
||||||
|
chunks.map(|chunk| {
|
||||||
|
// Slice accesses will not panic, guaranteed by `chunks_exact(2)`.
|
||||||
|
Embed {
|
||||||
|
name: chunk[0].to_string(),
|
||||||
|
content: EmbedContent::from(T::from(chunk[1].clone())),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub(super) enum Format {
|
||||||
|
Json,
|
||||||
|
Pretty,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for Format {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Format::Json => f.write_str("json"),
|
||||||
|
Format::Pretty => f.write_str("pretty"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[non_exhaustive]
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
#[error("invalid format value: {0:?}")]
|
||||||
|
pub struct FormatParseError(String);
|
||||||
|
|
||||||
|
impl FromStr for Format {
|
||||||
|
type Err = FormatParseError;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
match s {
|
||||||
|
"json" => Ok(Self::Json),
|
||||||
|
"pretty" => Ok(Self::Pretty),
|
||||||
|
_ => Err(FormatParseError(s.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct FormatParser;
|
||||||
|
|
||||||
|
impl clap::builder::TypedValueParser for FormatParser {
|
||||||
|
type Value = Format;
|
||||||
|
|
||||||
|
fn parse_ref(
|
||||||
|
&self,
|
||||||
|
cmd: &clap::Command,
|
||||||
|
arg: Option<&clap::Arg>,
|
||||||
|
value: &std::ffi::OsStr,
|
||||||
|
) -> Result<Self::Value, clap::Error> {
|
||||||
|
use clap::error::ErrorKind;
|
||||||
|
|
||||||
|
let format = <Format as std::str::FromStr>::from_str.parse_ref(cmd, arg, value)?;
|
||||||
|
match cmd.get_name() {
|
||||||
|
"show" | "update" if format == Format::Pretty => Err(clap::Error::raw(
|
||||||
|
ErrorKind::ValueValidation,
|
||||||
|
format!("output format `{format}` is not allowed in this command"),
|
||||||
|
)
|
||||||
|
.with_cmd(cmd)),
|
||||||
|
_ => Ok(format),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn possible_values(
|
||||||
|
&self,
|
||||||
|
) -> Option<Box<dyn Iterator<Item = clap::builder::PossibleValue> + '_>> {
|
||||||
|
use clap::builder::PossibleValue;
|
||||||
|
Some(Box::new(
|
||||||
|
[PossibleValue::new("json"), PossibleValue::new("pretty")].into_iter(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub(super) enum FilteredTypeName {
|
||||||
|
Issue,
|
||||||
|
Patch,
|
||||||
|
Identity,
|
||||||
|
Other(cob::TypeName),
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 std::fmt::Display for FilteredTypeName {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
self.as_ref().fmt(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::str::FromStr for FilteredTypeName {
|
||||||
|
type Err = cob::TypeNameParse;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
Ok(Self::from(s.parse::<cob::TypeName>()?))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test {
|
||||||
|
use super::Args;
|
||||||
|
use clap::error::ErrorKind;
|
||||||
|
use clap::Parser;
|
||||||
|
|
||||||
|
const ARGS: &[&str] = &[
|
||||||
|
"--repo",
|
||||||
|
"rad:z3Tr6bC7ctEg2EHmLvknUr29mEDLH",
|
||||||
|
"--type",
|
||||||
|
"xyz.radicle.issue",
|
||||||
|
"--object",
|
||||||
|
"f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354",
|
||||||
|
];
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_allow_log_json_format() {
|
||||||
|
let args = Args::try_parse_from(
|
||||||
|
["cob", "log", "--format", "json"]
|
||||||
|
.iter()
|
||||||
|
.chain(ARGS.iter())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
);
|
||||||
|
assert!(args.is_ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_allow_log_pretty_format() {
|
||||||
|
let args = Args::try_parse_from(
|
||||||
|
["cob", "log", "--format", "pretty"]
|
||||||
|
.iter()
|
||||||
|
.chain(ARGS.iter())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
);
|
||||||
|
assert!(args.is_ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_allow_show_json_format() {
|
||||||
|
let args = Args::try_parse_from(
|
||||||
|
["cob", "show", "--format", "json"]
|
||||||
|
.iter()
|
||||||
|
.chain(ARGS.iter())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
);
|
||||||
|
assert!(args.is_ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_allow_update_json_format() {
|
||||||
|
let args = Args::try_parse_from(
|
||||||
|
[
|
||||||
|
"cob",
|
||||||
|
"update",
|
||||||
|
"--format",
|
||||||
|
"json",
|
||||||
|
"--message",
|
||||||
|
"",
|
||||||
|
"/dev/null",
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.chain(ARGS.iter())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
);
|
||||||
|
println!("{args:?}");
|
||||||
|
assert!(args.is_ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_not_allow_show_pretty_format() {
|
||||||
|
let err = Args::try_parse_from(["cob", "show", "--format", "pretty"]).unwrap_err();
|
||||||
|
assert_eq!(err.kind(), ErrorKind::ValueValidation);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_not_allow_update_pretty_format() {
|
||||||
|
let err = Args::try_parse_from(["cob", "update", "--format", "pretty"]).unwrap_err();
|
||||||
|
assert_eq!(err.kind(), ErrorKind::ValueValidation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -50,6 +50,8 @@ enum Commands {
|
||||||
Checkout(checkout::Args),
|
Checkout(checkout::Args),
|
||||||
Clean(clean::Args),
|
Clean(clean::Args),
|
||||||
Clone(clone::Args),
|
Clone(clone::Args),
|
||||||
|
#[command(hide = true)]
|
||||||
|
Cob(cob::Args),
|
||||||
Debug(debug::Args),
|
Debug(debug::Args),
|
||||||
|
|
||||||
/// This command is deprecated and delegates to `git diff`.
|
/// This command is deprecated and delegates to `git diff`.
|
||||||
|
|
@ -223,7 +225,9 @@ pub(crate) fn run_other(exe: &str, args: &[OsString]) -> Result<(), Option<anyho
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"cob" => {
|
"cob" => {
|
||||||
term::run_command_args::<cob::Options, _>(cob::HELP, cob::run, args.to_vec());
|
if let Some(Commands::Cob(args)) = CliArgs::parse().command {
|
||||||
|
term::run_command_fn(cob::run, args);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
"config" => {
|
"config" => {
|
||||||
term::run_command_args::<config::Options, _>(config::HELP, config::run, args.to_vec());
|
term::run_command_args::<config::Options, _>(config::HELP, config::run, args.to_vec());
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue