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    /// Extract name (last part) from asset_location
361    pub fn extract_name(&self) -> String {
362        match self {
363            AssetLocation::Url(url) => {
364                if let Some(mut segment) = url.path_segments() {
365                    let last = segment.next_back().unwrap_or(url.as_str());
366                    last.to_string()
367                } else {
368                    url.as_str().to_string()
369                }
370            },
371            AssetLocation::FilePath(path_buf) => {
372                let name = path_buf.file_name().unwrap_or(path_buf.as_os_str());
373                name.to_str()
374                    .expect("file path should be valid")
375                    .to_string()
376            },
377        }
378    }
379}
380
381impl Serialize for AssetLocation {
382    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
383    where
384        S: serde::Serializer,
385    {
386        serializer.serialize_str(&self.to_string())
387    }
388}
389
390struct AssetLocationVisitor;
391
392impl de::Visitor<'_> for AssetLocationVisitor {
393    type Value = AssetLocation;
394
395    fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
396        formatter.write_str("a string")
397    }
398
399    fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
400    where
401        E: de::Error,
402    {
403        Ok(AssetLocation::from(v))
404    }
405}
406
407impl<'de> Deserialize<'de> for AssetLocation {
408    fn deserialize<D>(deserializer: D) -> Result<AssetLocation, D::Error>
409    where
410        D: Deserializer<'de>,
411    {
412        deserializer.deserialize_any(AssetLocationVisitor)
413    }
414}
415
416/// A CLI argument passed to an executed command.
417///
418/// # Variants
419/// - `Flag(String)`: A command-line flag (e.g., `--verbose`, `-v`)
420/// - `Option(String, String)`: A key-value pair (e.g., `--port 8080`, `--name=alice`)
421/// - `Array(String, Vec<String>)`: A key with multiple values (e.g., `--items [a,b,c]`)
422/// - `Positional(String)`: A positional argument without prefix (e.g., `script.sh`, `ws://127.0.0.1:10000`)
423///
424/// # Examples:
425/// ```
426/// use zombienet_configuration::shared::types::Arg;
427///
428/// let flag_arg: Arg = "myflag".into();
429/// let option_arg: Arg = ("name", "value").into();
430///
431/// assert!(matches!(flag_arg, Arg::Flag(value) if value == "myflag"));
432/// assert!(matches!(option_arg, Arg::Option(name, value) if name == "name" && value == "value"));
433/// ```
434#[derive(Debug, Clone, PartialEq)]
435pub enum Arg {
436    Flag(String),
437    Option(String, String),
438    Array(String, Vec<String>),
439    Positional(String),
440}
441
442impl From<&str> for Arg {
443    fn from(flag: &str) -> Self {
444        Self::Flag(flag.to_owned())
445    }
446}
447
448impl From<(&str, &str)> for Arg {
449    fn from((option, value): (&str, &str)) -> Self {
450        Self::Option(option.to_owned(), value.to_owned())
451    }
452}
453
454impl<T> From<(&str, &[T])> for Arg
455where
456    T: AsRef<str> + Clone,
457{
458    fn from((option, values): (&str, &[T])) -> Self {
459        Self::Array(
460            option.to_owned(),
461            values.iter().map(|v| v.as_ref().to_string()).collect(),
462        )
463    }
464}
465
466impl<T> From<(&str, Vec<T>)> for Arg
467where
468    T: AsRef<str>,
469{
470    fn from((option, values): (&str, Vec<T>)) -> Self {
471        Self::Array(
472            option.to_owned(),
473            values.into_iter().map(|v| v.as_ref().to_string()).collect(),
474        )
475    }
476}
477
478impl Arg {
479    /// Convert Arg to a vec of String
480    pub fn to_vec(&self) -> Vec<String> {
481        match self {
482            Arg::Flag(arg) => vec![arg.to_string()],
483            Arg::Option(k, v) => vec![k.to_string(), v.to_string()],
484            Arg::Array(k, items) => [
485                vec![k.to_string()],
486                items.iter().map(|x| x.to_string()).collect::<Vec<String>>(),
487            ]
488            .concat(),
489            Arg::Positional(value) => vec![value.to_string()],
490        }
491    }
492}
493impl Serialize for Arg {
494    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
495    where
496        S: serde::Serializer,
497    {
498        match self {
499            Arg::Flag(value) => serializer.serialize_str(value),
500            Arg::Option(option, value) => serializer.serialize_str(&format!("{option}={value}")),
501            Arg::Array(option, values) => {
502                serializer.serialize_str(&format!("{}=[{}]", option, values.join(",")))
503            },
504            Arg::Positional(value) => serializer.serialize_str(value),
505        }
506    }
507}
508
509struct ArgVisitor;
510
511impl de::Visitor<'_> for ArgVisitor {
512    type Value = Arg;
513
514    fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
515        formatter.write_str("a string")
516    }
517
518    fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
519    where
520        E: de::Error,
521    {
522        // covers the "-lruntime=debug,parachain=trace" case
523        // TODO: Make this more generic by adding the scenario in the regex below
524        if v.starts_with("-l") || v.starts_with("-log") {
525            return Ok(Arg::Flag(v.to_string()));
526        }
527        // Handle argument removal syntax: -:--flag-name
528        if v.starts_with("-:") {
529            return Ok(Arg::Flag(v.to_string()));
530        }
531
532        let re = Regex::new("^(?<name_prefix>(?<prefix>-{1,2})?(?<name>[a-zA-Z]+(-[a-zA-Z]+)*))((?<separator>=| )(?<value>\\[[^\\]]*\\]|[^ ]+))?$").unwrap();
533
534        let captures = re.captures(v);
535        if let Some(captures) = captures {
536            if let Some(value) = captures.name("value") {
537                let name_prefix = captures
538                    .name("name_prefix")
539                    .expect("BUG: name_prefix capture group missing")
540                    .as_str()
541                    .to_string();
542
543                let val = value.as_str();
544                if val.starts_with('[') && val.ends_with(']') {
545                    // Remove brackets and split by comma
546                    let inner = &val[1..val.len() - 1];
547                    let items: Vec<String> = inner
548                        .split(',')
549                        .map(|s| s.trim().to_string())
550                        .filter(|s| !s.is_empty())
551                        .collect();
552                    return Ok(Arg::Array(name_prefix, items));
553                } else {
554                    return Ok(Arg::Option(name_prefix, val.to_string()));
555                }
556            }
557            if let Some(name_prefix) = captures.name("name_prefix") {
558                return Ok(Arg::Flag(name_prefix.as_str().to_string()));
559            }
560        }
561
562        // Fallback: treat as positional argument
563        Ok(Arg::Positional(v.to_string()))
564    }
565}
566
567impl<'de> Deserialize<'de> for Arg {
568    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
569    where
570        D: Deserializer<'de>,
571    {
572        deserializer.deserialize_any(ArgVisitor)
573    }
574}
575
576#[derive(Debug, Default, Clone)]
577pub struct ValidationContext {
578    pub used_ports: Vec<Port>,
579    pub used_nodes_names: HashSet<String>,
580    // Store para_id already used
581    pub used_para_ids: HashMap<ParaId, u8>,
582}
583
584#[derive(Default, Debug, Clone, PartialEq, Deserialize)]
585pub struct ChainDefaultContext {
586    pub(crate) default_command: Option<Command>,
587    pub(crate) default_image: Option<Image>,
588    pub(crate) default_resources: Option<Resources>,
589    pub(crate) default_db_snapshot: Option<AssetLocation>,
590    #[serde(default)]
591    pub(crate) default_args: Vec<Arg>,
592}
593
594/// Represent a runtime (.wasm) asset location and an
595/// optional preset to use for chain-spec generation.
596#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
597pub struct ChainSpecRuntime {
598    pub location: AssetLocation,
599    pub preset: Option<String>,
600}
601
602impl ChainSpecRuntime {
603    pub fn new(location: AssetLocation) -> Self {
604        ChainSpecRuntime {
605            location,
606            preset: None,
607        }
608    }
609
610    pub fn with_preset(location: AssetLocation, preset: impl Into<String>) -> Self {
611        ChainSpecRuntime {
612            location,
613            preset: Some(preset.into()),
614        }
615    }
616}
617
618/// Represents a set of JSON overrides for a configuration.
619///
620/// The overrides can be provided as an inline JSON object or loaded from a
621/// separate file via a path or URL.
622#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
623#[serde(untagged)]
624pub enum JsonOverrides {
625    /// A path or URL pointing to a JSON file containing the overrides.
626    Location(AssetLocation),
627    /// An inline JSON value representing the overrides.
628    Json(serde_json::Value),
629}
630
631impl From<AssetLocation> for JsonOverrides {
632    fn from(value: AssetLocation) -> Self {
633        Self::Location(value)
634    }
635}
636
637impl From<serde_json::Value> for JsonOverrides {
638    fn from(value: serde_json::Value) -> Self {
639        Self::Json(value)
640    }
641}
642
643impl From<&str> for JsonOverrides {
644    fn from(value: &str) -> Self {
645        Self::Location(AssetLocation::from(value))
646    }
647}
648
649impl Display for JsonOverrides {
650    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
651        match self {
652            JsonOverrides::Location(location) => write!(f, "{location}"),
653            JsonOverrides::Json(json) => write!(f, "{json}"),
654        }
655    }
656}
657
658impl JsonOverrides {
659    pub async fn get(&self) -> Result<serde_json::Value, anyhow::Error> {
660        let contents = match self {
661            Self::Location(location) => serde_json::from_slice(&location.get_asset().await?)
662                .map_err(|err| anyhow!("Error converting asset to json {location} - {err}")),
663            Self::Json(json) => Ok(json.clone()),
664        };
665
666        contents
667    }
668}
669
670#[cfg(test)]
671mod tests {
672    use super::*;
673
674    #[test]
675    fn test_arg_flag_roundtrip() {
676        let arg = Arg::from("verbose");
677        let serialized = serde_json::to_string(&arg).unwrap();
678        let deserialized: Arg = serde_json::from_str(&serialized).unwrap();
679        assert_eq!(arg, deserialized);
680    }
681
682    #[test]
683    fn test_urls_as_arg() {
684        let arg = Arg::from("ws://127.0.0.1:10000");
685        assert_eq!(Arg::Flag(String::from("ws://127.0.0.1:10000")), arg);
686    }
687    #[test]
688    fn test_script_as_arg() {
689        let arg = Arg::from("scripts/assign-cores.sh");
690        assert_eq!(Arg::Flag(String::from("scripts/assign-cores.sh")), arg);
691    }
692
693    #[test]
694    fn test_arg_option_roundtrip() {
695        let arg = Arg::from(("mode", "fast"));
696        let serialized = serde_json::to_string(&arg).unwrap();
697        let deserialized: Arg = serde_json::from_str(&serialized).unwrap();
698        assert_eq!(arg, deserialized);
699    }
700
701    #[test]
702    fn test_arg_array_roundtrip() {
703        let arg = Arg::from(("items", ["a", "b", "c"].as_slice()));
704
705        let serialized = serde_json::to_string(&arg).unwrap();
706        println!("serialized = {serialized}");
707        let deserialized: Arg = serde_json::from_str(&serialized).unwrap();
708        assert_eq!(arg, deserialized);
709    }
710
711    #[test]
712    fn test_arg_option_valid_input() {
713        let expected = Arg::from(("--foo", "bar"));
714
715        // name and value delimited with =
716        let valid = "\"--foo=bar\"";
717        let result: Result<Arg, _> = serde_json::from_str(valid);
718        assert_eq!(result.unwrap(), expected);
719
720        // name and value delimited with space
721        let valid = "\"--foo bar\"";
722        let result: Result<Arg, _> = serde_json::from_str(valid);
723        assert_eq!(result.unwrap(), expected);
724
725        // value contains =
726        let expected = Arg::from(("--foo", "bar=baz"));
727        let valid = "\"--foo=bar=baz\"";
728        let result: Result<Arg, _> = serde_json::from_str(valid);
729        assert_eq!(result.unwrap(), expected);
730    }
731
732    #[test]
733    fn test_arg_array_valid_input() {
734        let expected = Arg::from(("--foo", vec!["bar", "baz"]));
735
736        // name and values delimited with =
737        let valid = "\"--foo=[bar,baz]\"";
738        let result: Result<Arg, _> = serde_json::from_str(valid);
739        assert_eq!(result.unwrap(), expected);
740
741        // name and values delimited with space
742        let valid = "\"--foo [bar,baz]\"";
743        let result: Result<Arg, _> = serde_json::from_str(valid);
744        assert_eq!(result.unwrap(), expected);
745
746        // values delimited with commas and space
747        let valid = "\"--foo [bar , baz]\"";
748        let result: Result<Arg, _> = serde_json::from_str(valid);
749        assert_eq!(result.unwrap(), expected);
750
751        // empty values array
752        let expected = Arg::from(("--foo", Vec::<&str>::new()));
753        let valid = "\"--foo []\"";
754        let result: Result<Arg, _> = serde_json::from_str(valid);
755        assert_eq!(result.unwrap(), expected);
756    }
757
758    #[test]
759    fn test_arg_positional_input() {
760        // Strings that don't match flag/option/array patterns are treated as positional
761
762        // missing = or space - treated as positional
763        let input = "\"--foo[bar]\"";
764        let result: Result<Arg, _> = serde_json::from_str(input);
765        assert_eq!(result.unwrap(), Arg::Positional("--foo[bar]".to_string()));
766
767        // value contains space - treated as positional
768        let input = "\"--foo=bar baz\"";
769        let result: Result<Arg, _> = serde_json::from_str(input);
770        assert_eq!(
771            result.unwrap(),
772            Arg::Positional("--foo=bar baz".to_string())
773        );
774    }
775
776    #[test]
777    fn test_arg_positional_valid_input() {
778        // Plain strings without prefixes are positional arguments
779        let expected = Arg::Positional("scripts/assign-cores.sh".to_string());
780        let valid = "\"scripts/assign-cores.sh\"";
781        let result: Result<Arg, _> = serde_json::from_str(valid);
782        assert_eq!(result.unwrap(), expected);
783
784        // URLs can be positional
785        let expected = Arg::Positional("ws://127.0.0.1:10000".to_string());
786        let valid = "\"ws://127.0.0.1:10000\"";
787        let result: Result<Arg, _> = serde_json::from_str(valid);
788        assert_eq!(result.unwrap(), expected);
789
790        // Numbers can be positional
791        let expected = Arg::Positional("42".to_string());
792        let valid = "\"42\"";
793        let result: Result<Arg, _> = serde_json::from_str(valid);
794        assert_eq!(result.unwrap(), expected);
795    }
796
797    #[test]
798    fn test_arg_positional_roundtrip() {
799        // Use a value that clearly doesn't match flag pattern (has special chars)
800        let arg = Arg::Positional("script.sh".to_string());
801        let serialized = serde_json::to_string(&arg).unwrap();
802        assert_eq!(serialized, "\"script.sh\"");
803        let deserialized: Arg = serde_json::from_str(&serialized).unwrap();
804        assert_eq!(arg, deserialized);
805    }
806
807    #[test]
808    fn test_arg_positional_to_vec() {
809        let arg = Arg::Positional("scripts/test.sh".to_string());
810        assert_eq!(arg.to_vec(), vec!["scripts/test.sh".to_string()]);
811    }
812
813    #[test]
814    fn converting_a_str_without_whitespaces_into_a_chain_should_succeeds() {
815        let got: Result<Chain, ConversionError> = "mychain".try_into();
816
817        assert_eq!(got.unwrap().as_str(), "mychain");
818    }
819
820    #[test]
821    fn converting_a_str_containing_tag_name_into_an_image_should_succeeds() {
822        let got: Result<Image, ConversionError> = "myimage".try_into();
823
824        assert_eq!(got.unwrap().as_str(), "myimage");
825    }
826
827    #[test]
828    fn converting_a_str_containing_tag_name_and_tag_version_into_an_image_should_succeeds() {
829        let got: Result<Image, ConversionError> = "myimage:version".try_into();
830
831        assert_eq!(got.unwrap().as_str(), "myimage:version");
832    }
833
834    #[test]
835    fn converting_a_str_containing_hostname_and_tag_name_into_an_image_should_succeeds() {
836        let got: Result<Image, ConversionError> = "myrepository.com/myimage".try_into();
837
838        assert_eq!(got.unwrap().as_str(), "myrepository.com/myimage");
839    }
840
841    #[test]
842    fn converting_a_str_containing_hostname_tag_name_and_tag_version_into_an_image_should_succeeds()
843    {
844        let got: Result<Image, ConversionError> = "myrepository.com/myimage:version".try_into();
845
846        assert_eq!(got.unwrap().as_str(), "myrepository.com/myimage:version");
847    }
848
849    #[test]
850    fn converting_a_str_containing_ip_and_tag_name_into_an_image_should_succeeds() {
851        let got: Result<Image, ConversionError> = "myrepository.com/myimage".try_into();
852
853        assert_eq!(got.unwrap().as_str(), "myrepository.com/myimage");
854    }
855
856    #[test]
857    fn converting_a_str_containing_ip_tag_name_and_tag_version_into_an_image_should_succeeds() {
858        let got: Result<Image, ConversionError> = "127.0.0.1/myimage:version".try_into();
859
860        assert_eq!(got.unwrap().as_str(), "127.0.0.1/myimage:version");
861    }
862
863    #[test]
864    fn converting_a_str_without_whitespaces_into_a_command_should_succeeds() {
865        let got: Result<Command, ConversionError> = "mycommand".try_into();
866
867        assert_eq!(got.unwrap().as_str(), "mycommand");
868    }
869
870    #[test]
871    fn converting_an_url_into_an_asset_location_should_succeeds() {
872        let url = Url::from_str("https://mycloudstorage.com/path/to/my/file.tgz").unwrap();
873        let got: AssetLocation = url.clone().into();
874
875        assert!(matches!(got, AssetLocation::Url(value) if value == url));
876    }
877
878    #[test]
879    fn converting_a_pathbuf_into_an_asset_location_should_succeeds() {
880        let pathbuf = PathBuf::from_str("/tmp/path/to/my/file").unwrap();
881        let got: AssetLocation = pathbuf.clone().into();
882
883        assert!(matches!(got, AssetLocation::FilePath(value) if value == pathbuf));
884    }
885
886    #[test]
887    fn converting_a_str_into_an_url_asset_location_should_succeeds() {
888        let url = "https://mycloudstorage.com/path/to/my/file.tgz";
889        let got: AssetLocation = url.into();
890
891        assert!(matches!(got, AssetLocation::Url(value) if value == Url::from_str(url).unwrap()));
892    }
893
894    #[test]
895    fn converting_a_str_into_an_filepath_asset_location_should_succeeds() {
896        let filepath = "/tmp/path/to/my/file";
897        let got: AssetLocation = filepath.into();
898
899        assert!(matches!(
900            got,
901            AssetLocation::FilePath(value) if value == PathBuf::from_str(filepath).unwrap()
902        ));
903    }
904
905    #[test]
906    fn converting_a_str_into_an_flag_arg_should_succeeds() {
907        let got: Arg = "myflag".into();
908
909        assert!(matches!(got, Arg::Flag(flag) if flag == "myflag"));
910    }
911
912    #[test]
913    fn converting_a_str_tuple_into_an_option_arg_should_succeeds() {
914        let got: Arg = ("name", "value").into();
915
916        assert!(matches!(got, Arg::Option(name, value) if name == "name" && value == "value"));
917    }
918
919    #[test]
920    fn converting_a_str_with_whitespaces_into_a_chain_should_fails() {
921        let got: Result<Chain, ConversionError> = "my chain".try_into();
922
923        assert!(matches!(
924            got.clone().unwrap_err(),
925            ConversionError::ContainsWhitespaces(_)
926        ));
927        assert_eq!(
928            got.unwrap_err().to_string(),
929            "'my chain' shouldn't contains whitespace"
930        );
931    }
932
933    #[test]
934    fn converting_an_empty_str_into_a_chain_should_fails() {
935        let got: Result<Chain, ConversionError> = "".try_into();
936
937        assert!(matches!(
938            got.clone().unwrap_err(),
939            ConversionError::CantBeEmpty
940        ));
941        assert_eq!(got.unwrap_err().to_string(), "can't be empty");
942    }
943
944    #[test]
945    fn converting_a_str_containing_only_ip_into_an_image_should_fails() {
946        let got: Result<Image, ConversionError> = "127.0.0.1".try_into();
947
948        assert!(matches!(
949            got.clone().unwrap_err(),
950            ConversionError::DoesntMatchRegex { value: _, regex: _ }
951        ));
952        assert_eq!(
953            got.unwrap_err().to_string(),
954            "'127.0.0.1' doesn't match regex '^([ip]|[hostname]/)?[tag_name]:[tag_version]?$'"
955        );
956    }
957
958    #[test]
959    fn converting_a_str_containing_only_ip_and_tag_version_into_an_image_should_fails() {
960        let got: Result<Image, ConversionError> = "127.0.0.1:version".try_into();
961
962        assert!(matches!(
963            got.clone().unwrap_err(),
964            ConversionError::DoesntMatchRegex { value: _, regex: _ }
965        ));
966        assert_eq!(got.unwrap_err().to_string(), "'127.0.0.1:version' doesn't match regex '^([ip]|[hostname]/)?[tag_name]:[tag_version]?$'");
967    }
968
969    #[test]
970    fn converting_a_str_containing_only_hostname_into_an_image_should_fails() {
971        let got: Result<Image, ConversionError> = "myrepository.com".try_into();
972
973        assert!(matches!(
974            got.clone().unwrap_err(),
975            ConversionError::DoesntMatchRegex { value: _, regex: _ }
976        ));
977        assert_eq!(got.unwrap_err().to_string(), "'myrepository.com' doesn't match regex '^([ip]|[hostname]/)?[tag_name]:[tag_version]?$'");
978    }
979
980    #[test]
981    fn converting_a_str_containing_only_hostname_and_tag_version_into_an_image_should_fails() {
982        let got: Result<Image, ConversionError> = "myrepository.com:version".try_into();
983
984        assert!(matches!(
985            got.clone().unwrap_err(),
986            ConversionError::DoesntMatchRegex { value: _, regex: _ }
987        ));
988        assert_eq!(got.unwrap_err().to_string(), "'myrepository.com:version' doesn't match regex '^([ip]|[hostname]/)?[tag_name]:[tag_version]?$'");
989    }
990
991    #[test]
992    fn converting_a_str_with_whitespaces_into_a_command_should_fails() {
993        let got: Result<Command, ConversionError> = "my command".try_into();
994
995        assert!(matches!(
996            got.clone().unwrap_err(),
997            ConversionError::ContainsWhitespaces(_)
998        ));
999        assert_eq!(
1000            got.unwrap_err().to_string(),
1001            "'my command' shouldn't contains whitespace"
1002        );
1003    }
1004
1005    #[test]
1006    fn test_convert_to_json_overrides() {
1007        let url: AssetLocation = "https://example.com/overrides.json".into();
1008        assert!(matches!(
1009            url.into(),
1010            JsonOverrides::Location(AssetLocation::Url(_))
1011        ));
1012
1013        let path: AssetLocation = "/path/to/overrides.json".into();
1014        assert!(matches!(
1015            path.into(),
1016            JsonOverrides::Location(AssetLocation::FilePath(_))
1017        ));
1018
1019        let inline = serde_json::json!({ "para_id": 2000});
1020        assert!(matches!(
1021            inline.into(),
1022            JsonOverrides::Json(serde_json::Value::Object(_))
1023        ));
1024    }
1025}