zombienet_configuration/shared/
types.rs

1use std::{
2    collections::{HashMap, HashSet},
3    error::Error,
4    fmt::{self, Display},
5    path::PathBuf,
6    str::FromStr,
7};
8
9use anyhow::anyhow;
10use lazy_static::lazy_static;
11use regex::Regex;
12use serde::{
13    de::{self, IntoDeserializer},
14    Deserialize, Deserializer, Serialize,
15};
16use support::constants::{INFAILABLE, SHOULD_COMPILE, THIS_IS_A_BUG};
17use tokio::fs;
18use url::Url;
19
20use super::{errors::ConversionError, resources::Resources};
21
22/// An alias for a duration in seconds.
23pub type Duration = u32;
24
25/// An alias for a port.
26pub type Port = u16;
27
28/// An alias for a parachain ID.
29pub type ParaId = u32;
30
31/// Custom type wrapping u128 to add custom Serialization/Deserialization logic because it's not supported
32/// issue tracking the problem: <https://github.com/toml-rs/toml/issues/540>
33#[derive(Default, Debug, Clone, PartialEq)]
34pub struct U128(pub(crate) u128);
35
36impl From<u128> for U128 {
37    fn from(value: u128) -> Self {
38        Self(value)
39    }
40}
41
42impl TryFrom<&str> for U128 {
43    type Error = Box<dyn Error>;
44
45    fn try_from(value: &str) -> Result<Self, Self::Error> {
46        Ok(Self(value.to_string().parse::<u128>()?))
47    }
48}
49
50impl Serialize for U128 {
51    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
52    where
53        S: serde::Serializer,
54    {
55        // here we add a prefix to the string to be able to replace the wrapped
56        // value with "" to a value without "" in the TOML string
57        serializer.serialize_str(&format!("U128%{}", self.0))
58    }
59}
60
61struct U128Visitor;
62
63impl de::Visitor<'_> for U128Visitor {
64    type Value = U128;
65
66    fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
67        formatter.write_str("an integer between 0 and 2^128 − 1.")
68    }
69
70    fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
71    where
72        E: de::Error,
73    {
74        v.try_into().map_err(de::Error::custom)
75    }
76}
77
78impl<'de> Deserialize<'de> for U128 {
79    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
80    where
81        D: Deserializer<'de>,
82    {
83        deserializer.deserialize_str(U128Visitor)
84    }
85}
86
87/// A chain name.
88/// It can be constructed for an `&str`, if it fails, it will returns a [`ConversionError`].
89///
90/// # Examples:
91/// ```
92/// use zombienet_configuration::shared::types::Chain;
93///
94/// let polkadot: Chain = "polkadot".try_into().unwrap();
95/// let kusama: Chain = "kusama".try_into().unwrap();
96/// let myparachain: Chain = "myparachain".try_into().unwrap();
97///
98/// assert_eq!(polkadot.as_str(), "polkadot");
99/// assert_eq!(kusama.as_str(), "kusama");
100/// assert_eq!(myparachain.as_str(), "myparachain");
101/// ```
102#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
103pub struct Chain(String);
104
105impl TryFrom<&str> for Chain {
106    type Error = ConversionError;
107
108    fn try_from(value: &str) -> Result<Self, Self::Error> {
109        if value.contains(char::is_whitespace) {
110            return Err(ConversionError::ContainsWhitespaces(value.to_string()));
111        }
112
113        if value.is_empty() {
114            return Err(ConversionError::CantBeEmpty);
115        }
116
117        Ok(Self(value.to_string()))
118    }
119}
120
121impl Chain {
122    pub fn as_str(&self) -> &str {
123        &self.0
124    }
125}
126
127/// A container image.
128/// It can be constructed from an `&str` including a combination of name, version, IPv4 or/and hostname, if it fails, it will returns a [`ConversionError`].
129///
130/// # Examples:
131/// ```
132/// use zombienet_configuration::shared::types::Image;
133///
134/// let image1: Image = "name".try_into().unwrap();
135/// let image2: Image = "name:version".try_into().unwrap();
136/// let image3: Image = "myrepo.com/name:version".try_into().unwrap();
137/// let image4: Image = "10.15.43.155/name:version".try_into().unwrap();
138///
139/// assert_eq!(image1.as_str(), "name");
140/// assert_eq!(image2.as_str(), "name:version");
141/// assert_eq!(image3.as_str(), "myrepo.com/name:version");
142/// assert_eq!(image4.as_str(), "10.15.43.155/name:version");
143/// ```
144#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
145pub struct Image(String);
146
147impl TryFrom<&str> for Image {
148    type Error = ConversionError;
149
150    fn try_from(value: &str) -> Result<Self, Self::Error> {
151        static IP_PART: &str = "((([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]).){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))";
152        static HOSTNAME_PART: &str = "((([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]).)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9]))";
153        static TAG_NAME_PART: &str = "([a-z0-9](-*[a-z0-9])*)";
154        static TAG_VERSION_PART: &str = "([a-z0-9_]([-._a-z0-9])*)";
155        lazy_static! {
156            static ref RE: Regex = Regex::new(&format!(
157                "^({IP_PART}|{HOSTNAME_PART}/)?{TAG_NAME_PART}(:{TAG_VERSION_PART})?$",
158            ))
159            .expect(&format!("{SHOULD_COMPILE}, {THIS_IS_A_BUG}"));
160        };
161
162        if !RE.is_match(value) {
163            return Err(ConversionError::DoesntMatchRegex {
164                value: value.to_string(),
165                regex: "^([ip]|[hostname]/)?[tag_name]:[tag_version]?$".to_string(),
166            });
167        }
168
169        Ok(Self(value.to_string()))
170    }
171}
172
173impl Image {
174    pub fn as_str(&self) -> &str {
175        &self.0
176    }
177}
178
179/// A command that will be executed natively (native provider) or in a container (podman/k8s).
180/// It can be constructed from an `&str`, if it fails, it will returns a [`ConversionError`].
181///
182/// # Examples:
183/// ```
184/// use zombienet_configuration::shared::types::Command;
185///
186/// let command1: Command = "mycommand".try_into().unwrap();
187/// let command2: Command = "myothercommand".try_into().unwrap();
188///
189/// assert_eq!(command1.as_str(), "mycommand");
190/// assert_eq!(command2.as_str(), "myothercommand");
191/// ```
192#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
193pub struct Command(String);
194
195impl TryFrom<&str> for Command {
196    type Error = ConversionError;
197
198    fn try_from(value: &str) -> Result<Self, Self::Error> {
199        if value.contains(char::is_whitespace) {
200            return Err(ConversionError::ContainsWhitespaces(value.to_string()));
201        }
202
203        Ok(Self(value.to_string()))
204    }
205}
206impl Default for Command {
207    fn default() -> Self {
208        Self(String::from("polkadot"))
209    }
210}
211
212impl Command {
213    pub fn as_str(&self) -> &str {
214        &self.0
215    }
216}
217
218/// A command with optional custom arguments, the command will be executed natively (native provider) or in a container (podman/k8s).
219/// It can be constructed from an `&str`, if it fails, it will returns a [`ConversionError`].
220///
221/// # Examples:
222/// ```
223/// use zombienet_configuration::shared::types::CommandWithCustomArgs;
224///
225/// let command1: CommandWithCustomArgs = "mycommand --demo=2 --other-flag".try_into().unwrap();
226/// let command2: CommandWithCustomArgs = "my_other_cmd_without_args".try_into().unwrap();
227///
228/// assert_eq!(command1.cmd().as_str(), "mycommand");
229/// assert_eq!(command2.cmd().as_str(), "my_other_cmd_without_args");
230/// ```
231#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
232pub struct CommandWithCustomArgs(Command, Vec<Arg>);
233
234impl TryFrom<&str> for CommandWithCustomArgs {
235    type Error = ConversionError;
236
237    fn try_from(value: &str) -> Result<Self, Self::Error> {
238        if value.is_empty() {
239            return Err(ConversionError::CantBeEmpty);
240        }
241
242        let mut parts = value.split_whitespace().collect::<Vec<&str>>();
243        let cmd = parts.remove(0).try_into().unwrap();
244        let args = parts
245            .iter()
246            .map(|x| {
247                Arg::deserialize(x.into_deserializer()).map_err(|_: serde_json::Error| {
248                    ConversionError::DeserializeError(String::from(*x))
249                })
250            })
251            .collect::<Result<Vec<Arg>, _>>()?;
252
253        Ok(Self(cmd, args))
254    }
255}
256impl Default for CommandWithCustomArgs {
257    fn default() -> Self {
258        Self("polkadot".try_into().unwrap(), vec![])
259    }
260}
261
262impl CommandWithCustomArgs {
263    pub fn cmd(&self) -> &Command {
264        &self.0
265    }
266
267    pub fn args(&self) -> &Vec<Arg> {
268        &self.1
269    }
270}
271
272/// A location for a locally or remotely stored asset.
273/// It can be constructed from an [`url::Url`], a [`std::path::PathBuf`] or an `&str`.
274///
275/// # Examples:
276/// ```
277/// use url::Url;
278/// use std::{path::PathBuf, str::FromStr};
279/// use zombienet_configuration::shared::types::AssetLocation;
280///
281/// let url_location: AssetLocation = Url::from_str("https://mycloudstorage.com/path/to/my/file.tgz").unwrap().into();
282/// let url_location2: AssetLocation = "https://mycloudstorage.com/path/to/my/file.tgz".into();
283/// let path_location: AssetLocation = PathBuf::from_str("/tmp/path/to/my/file").unwrap().into();
284/// let path_location2: AssetLocation = "/tmp/path/to/my/file".into();
285///
286/// assert!(matches!(url_location, AssetLocation::Url(value) if value.as_str() == "https://mycloudstorage.com/path/to/my/file.tgz"));
287/// assert!(matches!(url_location2, AssetLocation::Url(value) if value.as_str() == "https://mycloudstorage.com/path/to/my/file.tgz"));
288/// assert!(matches!(path_location, AssetLocation::FilePath(value) if value.to_str().unwrap() == "/tmp/path/to/my/file"));
289/// assert!(matches!(path_location2, AssetLocation::FilePath(value) if value.to_str().unwrap() == "/tmp/path/to/my/file"));
290/// ```
291#[derive(Debug, Clone, PartialEq)]
292pub enum AssetLocation {
293    Url(Url),
294    FilePath(PathBuf),
295}
296
297impl From<Url> for AssetLocation {
298    fn from(value: Url) -> Self {
299        Self::Url(value)
300    }
301}
302
303impl From<PathBuf> for AssetLocation {
304    fn from(value: PathBuf) -> Self {
305        Self::FilePath(value)
306    }
307}
308
309impl From<&str> for AssetLocation {
310    fn from(value: &str) -> Self {
311        if let Ok(parsed_url) = Url::parse(value) {
312            return Self::Url(parsed_url);
313        }
314
315        Self::FilePath(PathBuf::from_str(value).expect(&format!("{INFAILABLE}, {THIS_IS_A_BUG}")))
316    }
317}
318
319impl Display for AssetLocation {
320    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
321        match self {
322            AssetLocation::Url(value) => write!(f, "{}", value.as_str()),
323            AssetLocation::FilePath(value) => write!(f, "{}", value.display()),
324        }
325    }
326}
327
328impl AssetLocation {
329    /// Get the current asset (from file or url) and return the content
330    pub async fn get_asset(&self) -> Result<Vec<u8>, anyhow::Error> {
331        let contents = match self {
332            AssetLocation::Url(location) => {
333                let res = reqwest::get(location.as_ref()).await.map_err(|err| {
334                    anyhow!("Error dowinloding asset from url {location} - {err}")
335                })?;
336
337                res.bytes().await.unwrap().into()
338            },
339            AssetLocation::FilePath(filepath) => {
340                tokio::fs::read(filepath).await.map_err(|err| {
341                    anyhow!(
342                        "Error reading asset from path {} - {}",
343                        filepath.to_string_lossy(),
344                        err
345                    )
346                })?
347            },
348        };
349
350        Ok(contents)
351    }
352
353    /// Write asset (from file or url) to the destination path.
354    pub async fn dump_asset(&self, dst_path: impl Into<PathBuf>) -> Result<(), anyhow::Error> {
355        let contents = self.get_asset().await?;
356        fs::write(dst_path.into(), contents).await?;
357        Ok(())
358    }
359}
360
361impl Serialize for AssetLocation {
362    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
363    where
364        S: serde::Serializer,
365    {
366        serializer.serialize_str(&self.to_string())
367    }
368}
369
370struct AssetLocationVisitor;
371
372impl de::Visitor<'_> for AssetLocationVisitor {
373    type Value = AssetLocation;
374
375    fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
376        formatter.write_str("a string")
377    }
378
379    fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
380    where
381        E: de::Error,
382    {
383        Ok(AssetLocation::from(v))
384    }
385}
386
387impl<'de> Deserialize<'de> for AssetLocation {
388    fn deserialize<D>(deserializer: D) -> Result<AssetLocation, D::Error>
389    where
390        D: Deserializer<'de>,
391    {
392        deserializer.deserialize_any(AssetLocationVisitor)
393    }
394}
395
396/// A CLI argument passed to an executed command, can be an option with an assigned value or a simple flag to enable/disable a feature.
397/// A flag arg can be constructed from a `&str` and a option arg can be constructed from a `(&str, &str)`.
398///
399/// # Examples:
400/// ```
401/// use zombienet_configuration::shared::types::Arg;
402///
403/// let flag_arg: Arg = "myflag".into();
404/// let option_arg: Arg = ("name", "value").into();
405///
406/// assert!(matches!(flag_arg, Arg::Flag(value) if value == "myflag"));
407/// assert!(matches!(option_arg, Arg::Option(name, value) if name == "name" && value == "value"));
408/// ```
409#[derive(Debug, Clone, PartialEq)]
410pub enum Arg {
411    Flag(String),
412    Option(String, String),
413    Array(String, Vec<String>),
414}
415
416impl From<&str> for Arg {
417    fn from(flag: &str) -> Self {
418        Self::Flag(flag.to_owned())
419    }
420}
421
422impl From<(&str, &str)> for Arg {
423    fn from((option, value): (&str, &str)) -> Self {
424        Self::Option(option.to_owned(), value.to_owned())
425    }
426}
427
428impl<T> From<(&str, &[T])> for Arg
429where
430    T: AsRef<str> + Clone,
431{
432    fn from((option, values): (&str, &[T])) -> Self {
433        Self::Array(
434            option.to_owned(),
435            values.iter().map(|v| v.as_ref().to_string()).collect(),
436        )
437    }
438}
439
440impl<T> From<(&str, Vec<T>)> for Arg
441where
442    T: AsRef<str>,
443{
444    fn from((option, values): (&str, Vec<T>)) -> Self {
445        Self::Array(
446            option.to_owned(),
447            values.into_iter().map(|v| v.as_ref().to_string()).collect(),
448        )
449    }
450}
451
452impl Serialize for Arg {
453    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
454    where
455        S: serde::Serializer,
456    {
457        match self {
458            Arg::Flag(value) => serializer.serialize_str(value),
459            Arg::Option(option, value) => serializer.serialize_str(&format!("{option}={value}")),
460            Arg::Array(option, values) => {
461                serializer.serialize_str(&format!("{}=[{}]", option, values.join(",")))
462            },
463        }
464    }
465}
466
467struct ArgVisitor;
468
469impl de::Visitor<'_> for ArgVisitor {
470    type Value = Arg;
471
472    fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
473        formatter.write_str("a string")
474    }
475
476    fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
477    where
478        E: de::Error,
479    {
480        // covers the "-lruntime=debug,parachain=trace" case
481        // TODO: Make this more generic by adding the scenario in the regex below
482        if v.starts_with("-l") || v.starts_with("-log") {
483            return Ok(Arg::Flag(v.to_string()));
484        }
485        // Handle argument removal syntax: -:--flag-name
486        if v.starts_with("-:") {
487            return Ok(Arg::Flag(v.to_string()));
488        }
489        let re = Regex::new("^(?<name_prefix>(?<prefix>-{1,2})?(?<name>[a-zA-Z]+(-[a-zA-Z]+)*))((?<separator>=| )(?<value>\\[[^\\]]*\\]|[^ ]+))?$").unwrap();
490
491        let captures = re.captures(v);
492        if let Some(captures) = captures {
493            if let Some(value) = captures.name("value") {
494                let name_prefix = captures
495                    .name("name_prefix")
496                    .expect("BUG: name_prefix capture group missing")
497                    .as_str()
498                    .to_string();
499
500                let val = value.as_str();
501                if val.starts_with('[') && val.ends_with(']') {
502                    // Remove brackets and split by comma
503                    let inner = &val[1..val.len() - 1];
504                    let items: Vec<String> = inner
505                        .split(',')
506                        .map(|s| s.trim().to_string())
507                        .filter(|s| !s.is_empty())
508                        .collect();
509                    return Ok(Arg::Array(name_prefix, items));
510                } else {
511                    return Ok(Arg::Option(name_prefix, val.to_string()));
512                }
513            }
514            if let Some(name_prefix) = captures.name("name_prefix") {
515                return Ok(Arg::Flag(name_prefix.as_str().to_string()));
516            }
517        }
518
519        Err(de::Error::custom(
520            "the provided argument is invalid and doesn't match Arg::Option, Arg::Flag or Arg::Array",
521        ))
522    }
523}
524
525impl<'de> Deserialize<'de> for Arg {
526    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
527    where
528        D: Deserializer<'de>,
529    {
530        deserializer.deserialize_any(ArgVisitor)
531    }
532}
533
534#[derive(Debug, Default, Clone)]
535pub struct ValidationContext {
536    pub used_ports: Vec<Port>,
537    pub used_nodes_names: HashSet<String>,
538    // Store para_id already used
539    pub used_para_ids: HashMap<ParaId, u8>,
540}
541
542#[derive(Default, Debug, Clone, PartialEq, Deserialize)]
543pub struct ChainDefaultContext {
544    pub(crate) default_command: Option<Command>,
545    pub(crate) default_image: Option<Image>,
546    pub(crate) default_resources: Option<Resources>,
547    pub(crate) default_db_snapshot: Option<AssetLocation>,
548    #[serde(default)]
549    pub(crate) default_args: Vec<Arg>,
550}
551
552/// Represent a runtime (.wasm) asset location and an
553/// optional preset to use for chain-spec generation.
554#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
555pub struct ChainSpecRuntime {
556    pub location: AssetLocation,
557    pub preset: Option<String>,
558}
559
560impl ChainSpecRuntime {
561    pub fn new(location: AssetLocation) -> Self {
562        ChainSpecRuntime {
563            location,
564            preset: None,
565        }
566    }
567
568    pub fn with_preset(location: AssetLocation, preset: impl Into<String>) -> Self {
569        ChainSpecRuntime {
570            location,
571            preset: Some(preset.into()),
572        }
573    }
574}
575
576/// Represents a set of JSON overrides for a configuration.
577///
578/// The overrides can be provided as an inline JSON object or loaded from a
579/// separate file via a path or URL.
580#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
581#[serde(untagged)]
582pub enum JsonOverrides {
583    /// A path or URL pointing to a JSON file containing the overrides.
584    Location(AssetLocation),
585    /// An inline JSON value representing the overrides.
586    Json(serde_json::Value),
587}
588
589impl From<AssetLocation> for JsonOverrides {
590    fn from(value: AssetLocation) -> Self {
591        Self::Location(value)
592    }
593}
594
595impl From<serde_json::Value> for JsonOverrides {
596    fn from(value: serde_json::Value) -> Self {
597        Self::Json(value)
598    }
599}
600
601impl From<&str> for JsonOverrides {
602    fn from(value: &str) -> Self {
603        Self::Location(AssetLocation::from(value))
604    }
605}
606
607impl Display for JsonOverrides {
608    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
609        match self {
610            JsonOverrides::Location(location) => write!(f, "{location}"),
611            JsonOverrides::Json(json) => write!(f, "{json}"),
612        }
613    }
614}
615
616impl JsonOverrides {
617    pub async fn get(&self) -> Result<serde_json::Value, anyhow::Error> {
618        let contents = match self {
619            Self::Location(location) => serde_json::from_slice(&location.get_asset().await?)
620                .map_err(|err| anyhow!("Error converting asset to json {location} - {err}")),
621            Self::Json(json) => Ok(json.clone()),
622        };
623
624        contents
625    }
626}
627
628#[cfg(test)]
629mod tests {
630    use super::*;
631
632    #[test]
633    fn test_arg_flag_roundtrip() {
634        let arg = Arg::from("verbose");
635        let serialized = serde_json::to_string(&arg).unwrap();
636        let deserialized: Arg = serde_json::from_str(&serialized).unwrap();
637        assert_eq!(arg, deserialized);
638    }
639    #[test]
640    fn test_arg_option_roundtrip() {
641        let arg = Arg::from(("mode", "fast"));
642        let serialized = serde_json::to_string(&arg).unwrap();
643        let deserialized: Arg = serde_json::from_str(&serialized).unwrap();
644        assert_eq!(arg, deserialized);
645    }
646
647    #[test]
648    fn test_arg_array_roundtrip() {
649        let arg = Arg::from(("items", ["a", "b", "c"].as_slice()));
650
651        let serialized = serde_json::to_string(&arg).unwrap();
652        println!("serialized = {serialized}");
653        let deserialized: Arg = serde_json::from_str(&serialized).unwrap();
654        assert_eq!(arg, deserialized);
655    }
656
657    #[test]
658    fn test_arg_option_valid_input() {
659        let expected = Arg::from(("--foo", "bar"));
660
661        // name and value delimited with =
662        let valid = "\"--foo=bar\"";
663        let result: Result<Arg, _> = serde_json::from_str(valid);
664        assert_eq!(result.unwrap(), expected);
665
666        // name and value delimited with space
667        let valid = "\"--foo bar\"";
668        let result: Result<Arg, _> = serde_json::from_str(valid);
669        assert_eq!(result.unwrap(), expected);
670
671        // value contains =
672        let expected = Arg::from(("--foo", "bar=baz"));
673        let valid = "\"--foo=bar=baz\"";
674        let result: Result<Arg, _> = serde_json::from_str(valid);
675        assert_eq!(result.unwrap(), expected);
676    }
677
678    #[test]
679    fn test_arg_array_valid_input() {
680        let expected = Arg::from(("--foo", vec!["bar", "baz"]));
681
682        // name and values delimited with =
683        let valid = "\"--foo=[bar,baz]\"";
684        let result: Result<Arg, _> = serde_json::from_str(valid);
685        assert_eq!(result.unwrap(), expected);
686
687        // name and values delimited with space
688        let valid = "\"--foo [bar,baz]\"";
689        let result: Result<Arg, _> = serde_json::from_str(valid);
690        assert_eq!(result.unwrap(), expected);
691
692        // values delimited with commas and space
693        let valid = "\"--foo [bar , baz]\"";
694        let result: Result<Arg, _> = serde_json::from_str(valid);
695        assert_eq!(result.unwrap(), expected);
696
697        // empty values array
698        let expected = Arg::from(("--foo", Vec::<&str>::new()));
699        let valid = "\"--foo []\"";
700        let result: Result<Arg, _> = serde_json::from_str(valid);
701        assert_eq!(result.unwrap(), expected);
702    }
703
704    #[test]
705    fn test_arg_invalid_input() {
706        // missing = or space
707        let invalid = "\"--foo[bar]\"";
708        let result: Result<Arg, _> = serde_json::from_str(invalid);
709        assert!(result.is_err());
710
711        // value contains space
712        let invalid = "\"--foo=bar baz\"";
713        let result: Result<Arg, _> = serde_json::from_str(invalid);
714        println!("result = {result:?}");
715        assert!(result.is_err());
716    }
717
718    #[test]
719    fn converting_a_str_without_whitespaces_into_a_chain_should_succeeds() {
720        let got: Result<Chain, ConversionError> = "mychain".try_into();
721
722        assert_eq!(got.unwrap().as_str(), "mychain");
723    }
724
725    #[test]
726    fn converting_a_str_containing_tag_name_into_an_image_should_succeeds() {
727        let got: Result<Image, ConversionError> = "myimage".try_into();
728
729        assert_eq!(got.unwrap().as_str(), "myimage");
730    }
731
732    #[test]
733    fn converting_a_str_containing_tag_name_and_tag_version_into_an_image_should_succeeds() {
734        let got: Result<Image, ConversionError> = "myimage:version".try_into();
735
736        assert_eq!(got.unwrap().as_str(), "myimage:version");
737    }
738
739    #[test]
740    fn converting_a_str_containing_hostname_and_tag_name_into_an_image_should_succeeds() {
741        let got: Result<Image, ConversionError> = "myrepository.com/myimage".try_into();
742
743        assert_eq!(got.unwrap().as_str(), "myrepository.com/myimage");
744    }
745
746    #[test]
747    fn converting_a_str_containing_hostname_tag_name_and_tag_version_into_an_image_should_succeeds()
748    {
749        let got: Result<Image, ConversionError> = "myrepository.com/myimage:version".try_into();
750
751        assert_eq!(got.unwrap().as_str(), "myrepository.com/myimage:version");
752    }
753
754    #[test]
755    fn converting_a_str_containing_ip_and_tag_name_into_an_image_should_succeeds() {
756        let got: Result<Image, ConversionError> = "myrepository.com/myimage".try_into();
757
758        assert_eq!(got.unwrap().as_str(), "myrepository.com/myimage");
759    }
760
761    #[test]
762    fn converting_a_str_containing_ip_tag_name_and_tag_version_into_an_image_should_succeeds() {
763        let got: Result<Image, ConversionError> = "127.0.0.1/myimage:version".try_into();
764
765        assert_eq!(got.unwrap().as_str(), "127.0.0.1/myimage:version");
766    }
767
768    #[test]
769    fn converting_a_str_without_whitespaces_into_a_command_should_succeeds() {
770        let got: Result<Command, ConversionError> = "mycommand".try_into();
771
772        assert_eq!(got.unwrap().as_str(), "mycommand");
773    }
774
775    #[test]
776    fn converting_an_url_into_an_asset_location_should_succeeds() {
777        let url = Url::from_str("https://mycloudstorage.com/path/to/my/file.tgz").unwrap();
778        let got: AssetLocation = url.clone().into();
779
780        assert!(matches!(got, AssetLocation::Url(value) if value == url));
781    }
782
783    #[test]
784    fn converting_a_pathbuf_into_an_asset_location_should_succeeds() {
785        let pathbuf = PathBuf::from_str("/tmp/path/to/my/file").unwrap();
786        let got: AssetLocation = pathbuf.clone().into();
787
788        assert!(matches!(got, AssetLocation::FilePath(value) if value == pathbuf));
789    }
790
791    #[test]
792    fn converting_a_str_into_an_url_asset_location_should_succeeds() {
793        let url = "https://mycloudstorage.com/path/to/my/file.tgz";
794        let got: AssetLocation = url.into();
795
796        assert!(matches!(got, AssetLocation::Url(value) if value == Url::from_str(url).unwrap()));
797    }
798
799    #[test]
800    fn converting_a_str_into_an_filepath_asset_location_should_succeeds() {
801        let filepath = "/tmp/path/to/my/file";
802        let got: AssetLocation = filepath.into();
803
804        assert!(matches!(
805            got,
806            AssetLocation::FilePath(value) if value == PathBuf::from_str(filepath).unwrap()
807        ));
808    }
809
810    #[test]
811    fn converting_a_str_into_an_flag_arg_should_succeeds() {
812        let got: Arg = "myflag".into();
813
814        assert!(matches!(got, Arg::Flag(flag) if flag == "myflag"));
815    }
816
817    #[test]
818    fn converting_a_str_tuple_into_an_option_arg_should_succeeds() {
819        let got: Arg = ("name", "value").into();
820
821        assert!(matches!(got, Arg::Option(name, value) if name == "name" && value == "value"));
822    }
823
824    #[test]
825    fn converting_a_str_with_whitespaces_into_a_chain_should_fails() {
826        let got: Result<Chain, ConversionError> = "my chain".try_into();
827
828        assert!(matches!(
829            got.clone().unwrap_err(),
830            ConversionError::ContainsWhitespaces(_)
831        ));
832        assert_eq!(
833            got.unwrap_err().to_string(),
834            "'my chain' shouldn't contains whitespace"
835        );
836    }
837
838    #[test]
839    fn converting_an_empty_str_into_a_chain_should_fails() {
840        let got: Result<Chain, ConversionError> = "".try_into();
841
842        assert!(matches!(
843            got.clone().unwrap_err(),
844            ConversionError::CantBeEmpty
845        ));
846        assert_eq!(got.unwrap_err().to_string(), "can't be empty");
847    }
848
849    #[test]
850    fn converting_a_str_containing_only_ip_into_an_image_should_fails() {
851        let got: Result<Image, ConversionError> = "127.0.0.1".try_into();
852
853        assert!(matches!(
854            got.clone().unwrap_err(),
855            ConversionError::DoesntMatchRegex { value: _, regex: _ }
856        ));
857        assert_eq!(
858            got.unwrap_err().to_string(),
859            "'127.0.0.1' doesn't match regex '^([ip]|[hostname]/)?[tag_name]:[tag_version]?$'"
860        );
861    }
862
863    #[test]
864    fn converting_a_str_containing_only_ip_and_tag_version_into_an_image_should_fails() {
865        let got: Result<Image, ConversionError> = "127.0.0.1:version".try_into();
866
867        assert!(matches!(
868            got.clone().unwrap_err(),
869            ConversionError::DoesntMatchRegex { value: _, regex: _ }
870        ));
871        assert_eq!(got.unwrap_err().to_string(), "'127.0.0.1:version' doesn't match regex '^([ip]|[hostname]/)?[tag_name]:[tag_version]?$'");
872    }
873
874    #[test]
875    fn converting_a_str_containing_only_hostname_into_an_image_should_fails() {
876        let got: Result<Image, ConversionError> = "myrepository.com".try_into();
877
878        assert!(matches!(
879            got.clone().unwrap_err(),
880            ConversionError::DoesntMatchRegex { value: _, regex: _ }
881        ));
882        assert_eq!(got.unwrap_err().to_string(), "'myrepository.com' doesn't match regex '^([ip]|[hostname]/)?[tag_name]:[tag_version]?$'");
883    }
884
885    #[test]
886    fn converting_a_str_containing_only_hostname_and_tag_version_into_an_image_should_fails() {
887        let got: Result<Image, ConversionError> = "myrepository.com:version".try_into();
888
889        assert!(matches!(
890            got.clone().unwrap_err(),
891            ConversionError::DoesntMatchRegex { value: _, regex: _ }
892        ));
893        assert_eq!(got.unwrap_err().to_string(), "'myrepository.com:version' doesn't match regex '^([ip]|[hostname]/)?[tag_name]:[tag_version]?$'");
894    }
895
896    #[test]
897    fn converting_a_str_with_whitespaces_into_a_command_should_fails() {
898        let got: Result<Command, ConversionError> = "my command".try_into();
899
900        assert!(matches!(
901            got.clone().unwrap_err(),
902            ConversionError::ContainsWhitespaces(_)
903        ));
904        assert_eq!(
905            got.unwrap_err().to_string(),
906            "'my command' shouldn't contains whitespace"
907        );
908    }
909
910    #[test]
911    fn test_convert_to_json_overrides() {
912        let url: AssetLocation = "https://example.com/overrides.json".into();
913        assert!(matches!(
914            url.into(),
915            JsonOverrides::Location(AssetLocation::Url(_))
916        ));
917
918        let path: AssetLocation = "/path/to/overrides.json".into();
919        assert!(matches!(
920            path.into(),
921            JsonOverrides::Location(AssetLocation::FilePath(_))
922        ));
923
924        let inline = serde_json::json!({ "para_id": 2000});
925        assert!(matches!(
926            inline.into(),
927            JsonOverrides::Json(serde_json::Value::Object(_))
928        ));
929    }
930}