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