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(#[clap(flatten)] Create),
28
29 List {
31 #[arg(long, short, value_name = "RID")]
33 repo: RepoId,
34
35 #[arg(long = "type", short, value_name = "TYPENAME")]
37 type_name: cob::TypeName,
38 },
39
40 Log {
42 #[arg(long, short, value_name = "RID")]
44 repo: RepoId,
45
46 #[arg(long = "type", short, value_name = "TYPENAME")]
48 type_name: cob::TypeName,
49
50 #[arg(long, short, value_name = "OID")]
52 object: Rev,
53
54 #[arg(long, default_value_t = Format::Pretty, value_parser = FormatParser)]
56 format: Format,
57
58 #[arg(long, value_name = "OID")]
60 from: Option<Rev>,
61
62 #[arg(long, value_name = "OID")]
64 until: Option<Rev>,
65 },
66
67 Migrate,
69
70 Show {
72 #[arg(long, short, value_name = "RID")]
74 repo: RepoId,
75
76 #[arg(long = "type", short, value_name = "TYPENAME")]
78 type_name: cob::TypeName,
79
80 #[arg(long = "object", short, value_name = "OID", action = clap::ArgAction::Append, required = true)]
82 objects: Vec<Rev>,
83
84 #[arg(long, default_value_t = Format::Json, value_parser = FormatParser)]
86 format: Format,
87 },
88
89 Update(#[clap(flatten)] Update),
91}
92
93#[derive(Parser, Debug)]
94pub(super) struct Operation {
95 #[arg(long, short)]
97 pub(super) message: String,
98
99 #[arg(long = "embed-file", value_names = ["NAME", "PATH"], num_args = 2)]
101 pub(super) embed_files: Vec<String>,
102
103 #[arg(long = "embed-hash", value_names = ["NAME", "OID"], num_args = 2)]
105 pub(super) embed_hashes: Vec<String>,
106
107 #[arg(value_name = "FILENAME")]
109 pub(super) actions: PathBuf,
110}
111
112#[derive(Parser, Debug)]
113pub(super) struct Create {
114 #[arg(long, short, value_name = "RID")]
116 pub(super) repo: RepoId,
117
118 #[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 #[arg(long, short)]
130 pub(super) repo: RepoId,
131
132 #[arg(long = "type", short, value_name = "TYPENAME")]
134 pub(super) type_name: FilteredTypeName,
135
136 #[arg(long, short, value_name = "OID")]
138 pub(super) object: Rev,
139
140 #[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#[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
192pub(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 let chunks = values.chunks_exact(2);
208
209 assert!(chunks.remainder().is_empty());
210
211 chunks.map(|chunk| {
212 #[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#[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}