Skip to main content

radicle_cli/commands/cob/
args.rs

1use std::fmt;
2use std::fs;
3use std::path::PathBuf;
4use std::str::FromStr;
5
6use thiserror::Error;
7
8use clap::{Parser, Subcommand};
9
10use radicle::cob;
11use radicle::git;
12use radicle::prelude::*;
13use radicle::storage;
14
15use crate::git::Rev;
16
17#[derive(Parser, Debug)]
18#[command(disable_version_flag = true)]
19pub struct Args {
20    #[command(subcommand)]
21    pub(super) command: Command,
22}
23
24#[derive(Subcommand, Debug)]
25pub(super) enum Command {
26    /// Create a new COB of a given type given initial actions
27    Create(#[clap(flatten)] Create),
28
29    /// List all COBs of a given type
30    List {
31        /// Repository ID of the repository to operate on
32        #[arg(long, short, value_name = "RID")]
33        repo: RepoId,
34
35        /// Typename of the object(s) to list
36        #[arg(long = "type", short, value_name = "TYPENAME")]
37        type_name: cob::TypeName,
38    },
39
40    /// Print a log of all raw operations on a COB
41    Log {
42        /// Tepository ID of the repository to operate on
43        #[arg(long, short, value_name = "RID")]
44        repo: RepoId,
45
46        /// Typename of the object(s) to show
47        #[arg(long = "type", short, value_name = "TYPENAME")]
48        type_name: cob::TypeName,
49
50        /// Object ID of the object to log
51        #[arg(long, short, value_name = "OID")]
52        object: Rev,
53
54        /// Desired output format
55        #[arg(long, default_value_t = Format::Pretty, value_parser = FormatParser)]
56        format: Format,
57
58        /// Object ID of the commit of the operation to start iterating at
59        #[arg(long, value_name = "OID")]
60        from: Option<Rev>,
61
62        /// Object ID of the commit of the operation to stop iterating at
63        #[arg(long, value_name = "OID")]
64        until: Option<Rev>,
65    },
66
67    /// Migrate the COB database to the latest version
68    Migrate,
69
70    /// Print the state of COBs
71    Show {
72        /// Repository ID of the repository to operate on
73        #[arg(long, short, value_name = "RID")]
74        repo: RepoId,
75
76        /// Typename of the object(s) to show
77        #[arg(long = "type", short, value_name = "TYPENAME")]
78        type_name: cob::TypeName,
79
80        /// Object ID(s) of the objects to show
81        #[arg(long = "object", short, value_name = "OID", action = clap::ArgAction::Append, required = true)]
82        objects: Vec<Rev>,
83
84        /// Desired output format
85        #[arg(long, default_value_t = Format::Json, value_parser = FormatParser)]
86        format: Format,
87    },
88
89    /// Add actions to a COB
90    Update(#[clap(flatten)] Update),
91}
92
93#[derive(Parser, Debug)]
94pub(super) struct Operation {
95    /// Message describing the operation
96    #[arg(long, short)]
97    pub(super) message: String,
98
99    /// Supply embed of given name via file at given path
100    #[arg(long = "embed-file", value_names = ["NAME", "PATH"], num_args = 2)]
101    pub(super) embed_files: Vec<String>,
102
103    /// Supply embed of given name via object ID of blob
104    #[arg(long = "embed-hash", value_names = ["NAME", "OID"], num_args = 2)]
105    pub(super) embed_hashes: Vec<String>,
106
107    /// A file that contains a sequence actions (in JSONL format) to apply.
108    #[arg(value_name = "FILENAME")]
109    pub(super) actions: PathBuf,
110}
111
112#[derive(Parser, Debug)]
113pub(super) struct Create {
114    /// Repository ID of the repository to operate on
115    #[arg(long, short, value_name = "RID")]
116    pub(super) repo: RepoId,
117
118    /// Typename of the object to create
119    #[arg(long = "type", short, value_name = "TYPENAME")]
120    pub(super) type_name: FilteredTypeName,
121
122    #[clap(flatten)]
123    pub(super) operation: Operation,
124}
125
126#[derive(Parser, Debug)]
127pub(super) struct Update {
128    /// Repository ID of the repository to operate on
129    #[arg(long, short)]
130    pub(super) repo: RepoId,
131
132    /// Typename of the object to update
133    #[arg(long = "type", short, value_name = "TYPENAME")]
134    pub(super) type_name: FilteredTypeName,
135
136    /// Object ID of the object to update
137    #[arg(long, short, value_name = "OID")]
138    pub(super) object: Rev,
139
140    // TODO(finto): `Format` is unused and is obsolete for this command
141    /// Desired output format
142    #[arg(long, default_value_t = Format::Json, value_parser = FormatParser)]
143    pub(super) format: Format,
144
145    #[clap(flatten)]
146    pub(super) operation: Operation,
147}
148
149/// A precursor to [`cob::Embed`] used for parsing
150/// that can be initialized without relying on a [`git::Repository`].
151#[derive(Clone, Debug)]
152pub(super) struct Embed {
153    name: String,
154    content: EmbedContent,
155}
156
157impl Embed {
158    pub(super) fn try_into_bytes(
159        self,
160        repo: &storage::git::Repository,
161    ) -> anyhow::Result<cob::Embed<cob::Uri>> {
162        Ok(match self.content {
163            EmbedContent::Hash(hash) => cob::Embed {
164                name: self.name,
165                content: hash.resolve::<git::Oid>(&repo.backend)?.into(),
166            },
167            EmbedContent::Path(path) => {
168                cob::Embed::store(self.name, &fs::read(path)?, &repo.backend)?
169            }
170        })
171    }
172}
173
174#[derive(Clone, Debug)]
175pub(super) enum EmbedContent {
176    Path(PathBuf),
177    Hash(Rev),
178}
179
180impl From<PathBuf> for EmbedContent {
181    fn from(path: PathBuf) -> Self {
182        EmbedContent::Path(path)
183    }
184}
185
186impl From<Rev> for EmbedContent {
187    fn from(rev: Rev) -> Self {
188        EmbedContent::Hash(rev)
189    }
190}
191
192/// Parses a slice of all embeds as name-path or name-oid pairs as aggregated by
193/// `clap`.
194/// E.g. `["image", "./image.png", "code", "d87dcfe8c2b3200e78b128d9b959cfdf7063fefe"]`
195/// will result a `Vec` of two [`Embed`]s.
196///
197/// # Panics
198///
199/// If the length of `values` is not divisible by 2.
200pub(super) fn parse_many_embeds<T>(values: &[String]) -> impl Iterator<Item = Embed> + use<'_, T>
201where
202    T: From<String>,
203    EmbedContent: From<T>,
204{
205    // `clap` ensures we have 2 values per option occurrence,
206    // so we can chunk the aggregated slice exactly.
207    let chunks = values.chunks_exact(2);
208
209    assert!(chunks.remainder().is_empty());
210
211    chunks.map(|chunk| {
212        // Slice accesses will not panic, guaranteed by `chunks_exact(2)`.
213        #[allow(clippy::indexing_slicing)]
214        Embed {
215            name: chunk[0].to_string(),
216            content: EmbedContent::from(T::from(chunk[1].clone())),
217        }
218    })
219}
220
221#[derive(Clone, Debug, PartialEq)]
222pub(super) enum Format {
223    Json,
224    Pretty,
225}
226
227impl fmt::Display for Format {
228    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
229        match self {
230            Format::Json => f.write_str("json"),
231            Format::Pretty => f.write_str("pretty"),
232        }
233    }
234}
235
236#[non_exhaustive]
237#[derive(Debug, Error)]
238#[error("invalid format value: {0:?}")]
239pub struct FormatParseError(String);
240
241impl FromStr for Format {
242    type Err = FormatParseError;
243
244    fn from_str(s: &str) -> Result<Self, Self::Err> {
245        match s {
246            "json" => Ok(Self::Json),
247            "pretty" => Ok(Self::Pretty),
248            _ => Err(FormatParseError(s.to_string())),
249        }
250    }
251}
252
253#[derive(Clone, Debug)]
254struct FormatParser;
255
256impl clap::builder::TypedValueParser for FormatParser {
257    type Value = Format;
258
259    fn parse_ref(
260        &self,
261        cmd: &clap::Command,
262        arg: Option<&clap::Arg>,
263        value: &std::ffi::OsStr,
264    ) -> Result<Self::Value, clap::Error> {
265        use clap::error::ErrorKind;
266
267        let format = <Format as std::str::FromStr>::from_str.parse_ref(cmd, arg, value)?;
268        match cmd.get_name() {
269            "show" | "update" if format == Format::Pretty => Err(clap::Error::raw(
270                ErrorKind::ValueValidation,
271                format!("output format `{format}` is not allowed in this command"),
272            )
273            .with_cmd(cmd)),
274            _ => Ok(format),
275        }
276    }
277
278    fn possible_values(
279        &self,
280    ) -> Option<Box<dyn Iterator<Item = clap::builder::PossibleValue> + '_>> {
281        use clap::builder::PossibleValue;
282        Some(Box::new(
283            [PossibleValue::new("json"), PossibleValue::new("pretty")].into_iter(),
284        ))
285    }
286}
287
288/// A thin wrapper around [`cob::TypeName`] used for parsing.
289/// Well known COB type names are captured as variants,
290/// with [`FilteredTypeName::Other`] as an escape hatch for type names
291/// that are not well known.
292#[derive(Clone, Debug)]
293pub(super) enum FilteredTypeName {
294    Issue,
295    Patch,
296    Identity,
297    Other(cob::TypeName),
298}
299
300impl AsRef<cob::TypeName> for FilteredTypeName {
301    fn as_ref(&self) -> &cob::TypeName {
302        match self {
303            FilteredTypeName::Issue => &cob::issue::TYPENAME,
304            FilteredTypeName::Patch => &cob::patch::TYPENAME,
305            FilteredTypeName::Identity => &cob::identity::TYPENAME,
306            FilteredTypeName::Other(value) => value,
307        }
308    }
309}
310
311impl From<cob::TypeName> for FilteredTypeName {
312    fn from(value: cob::TypeName) -> Self {
313        if value == *cob::issue::TYPENAME {
314            FilteredTypeName::Issue
315        } else if value == *cob::patch::TYPENAME {
316            FilteredTypeName::Patch
317        } else if value == *cob::identity::TYPENAME {
318            FilteredTypeName::Identity
319        } else {
320            FilteredTypeName::Other(value)
321        }
322    }
323}
324
325impl std::fmt::Display for FilteredTypeName {
326    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
327        self.as_ref().fmt(f)
328    }
329}
330
331impl std::str::FromStr for FilteredTypeName {
332    type Err = cob::TypeNameParse;
333
334    fn from_str(s: &str) -> Result<Self, Self::Err> {
335        Ok(Self::from(s.parse::<cob::TypeName>()?))
336    }
337}
338
339#[cfg(test)]
340mod test {
341    use super::Args;
342    use clap::error::ErrorKind;
343    use clap::Parser;
344
345    const ARGS: &[&str] = &[
346        "--repo",
347        "rad:z3Tr6bC7ctEg2EHmLvknUr29mEDLH",
348        "--type",
349        "xyz.radicle.issue",
350        "--object",
351        "f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354",
352    ];
353
354    #[test]
355    fn should_allow_log_json_format() {
356        let args = Args::try_parse_from(
357            ["cob", "log", "--format", "json"]
358                .iter()
359                .chain(ARGS.iter())
360                .collect::<Vec<_>>(),
361        );
362        assert!(args.is_ok())
363    }
364
365    #[test]
366    fn should_allow_log_pretty_format() {
367        let args = Args::try_parse_from(
368            ["cob", "log", "--format", "pretty"]
369                .iter()
370                .chain(ARGS.iter())
371                .collect::<Vec<_>>(),
372        );
373        assert!(args.is_ok())
374    }
375
376    #[test]
377    fn should_allow_show_json_format() {
378        let args = Args::try_parse_from(
379            ["cob", "show", "--format", "json"]
380                .iter()
381                .chain(ARGS.iter())
382                .collect::<Vec<_>>(),
383        );
384        assert!(args.is_ok())
385    }
386
387    #[test]
388    fn should_allow_update_json_format() {
389        let args = Args::try_parse_from(
390            [
391                "cob",
392                "update",
393                "--format",
394                "json",
395                "--message",
396                "",
397                "/dev/null",
398            ]
399            .iter()
400            .chain(ARGS.iter())
401            .collect::<Vec<_>>(),
402        );
403        println!("{args:?}");
404        assert!(args.is_ok())
405    }
406
407    #[test]
408    fn should_not_allow_show_pretty_format() {
409        let err = Args::try_parse_from(["cob", "show", "--format", "pretty"]).unwrap_err();
410        assert_eq!(err.kind(), ErrorKind::ValueValidation);
411    }
412
413    #[test]
414    fn should_not_allow_update_pretty_format() {
415        let err = Args::try_parse_from(["cob", "update", "--format", "pretty"]).unwrap_err();
416        assert_eq!(err.kind(), ErrorKind::ValueValidation);
417    }
418}