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_urls_as_arg() {
664        let arg = Arg::from("ws://127.0.0.1:10000");
665        assert_eq!(Arg::Flag(String::from("ws://127.0.0.1:10000")), arg);
666    }
667    #[test]
668    fn test_script_as_arg() {
669        let arg = Arg::from("scripts/assign-cores.sh");
670        assert_eq!(Arg::Flag(String::from("scripts/assign-cores.sh")), arg);
671    }
672
673    #[test]
674    fn test_arg_option_roundtrip() {
675        let arg = Arg::from(("mode", "fast"));
676        let serialized = serde_json::to_string(&arg).unwrap();
677        let deserialized: Arg = serde_json::from_str(&serialized).unwrap();
678        assert_eq!(arg, deserialized);
679    }
680
681    #[test]
682    fn test_arg_array_roundtrip() {
683        let arg = Arg::from(("items", ["a", "b", "c"].as_slice()));
684
685        let serialized = serde_json::to_string(&arg).unwrap();
686        println!("serialized = {serialized}");
687        let deserialized: Arg = serde_json::from_str(&serialized).unwrap();
688        assert_eq!(arg, deserialized);
689    }
690
691    #[test]
692    fn test_arg_option_valid_input() {
693        let expected = Arg::from(("--foo", "bar"));
694
695        // name and value delimited with =
696        let valid = "\"--foo=bar\"";
697        let result: Result<Arg, _> = serde_json::from_str(valid);
698        assert_eq!(result.unwrap(), expected);
699
700        // name and value delimited with space
701        let valid = "\"--foo bar\"";
702        let result: Result<Arg, _> = serde_json::from_str(valid);
703        assert_eq!(result.unwrap(), expected);
704
705        // value contains =
706        let expected = Arg::from(("--foo", "bar=baz"));
707        let valid = "\"--foo=bar=baz\"";
708        let result: Result<Arg, _> = serde_json::from_str(valid);
709        assert_eq!(result.unwrap(), expected);
710    }
711
712    #[test]
713    fn test_arg_array_valid_input() {
714        let expected = Arg::from(("--foo", vec!["bar", "baz"]));
715
716        // name and values delimited with =
717        let valid = "\"--foo=[bar,baz]\"";
718        let result: Result<Arg, _> = serde_json::from_str(valid);
719        assert_eq!(result.unwrap(), expected);
720
721        // name and values delimited with space
722        let valid = "\"--foo [bar,baz]\"";
723        let result: Result<Arg, _> = serde_json::from_str(valid);
724        assert_eq!(result.unwrap(), expected);
725
726        // values delimited with commas and space
727        let valid = "\"--foo [bar , baz]\"";
728        let result: Result<Arg, _> = serde_json::from_str(valid);
729        assert_eq!(result.unwrap(), expected);
730
731        // empty values array
732        let expected = Arg::from(("--foo", Vec::<&str>::new()));
733        let valid = "\"--foo []\"";
734        let result: Result<Arg, _> = serde_json::from_str(valid);
735        assert_eq!(result.unwrap(), expected);
736    }
737
738    #[test]
739    fn test_arg_positional_input() {
740        // Strings that don't match flag/option/array patterns are treated as positional
741
742        // missing = or space - treated as positional
743        let input = "\"--foo[bar]\"";
744        let result: Result<Arg, _> = serde_json::from_str(input);
745        assert_eq!(result.unwrap(), Arg::Positional("--foo[bar]".to_string()));
746
747        // value contains space - treated as positional
748        let input = "\"--foo=bar baz\"";
749        let result: Result<Arg, _> = serde_json::from_str(input);
750        assert_eq!(
751            result.unwrap(),
752            Arg::Positional("--foo=bar baz".to_string())
753        );
754    }
755
756    #[test]
757    fn test_arg_positional_valid_input() {
758        // Plain strings without prefixes are positional arguments
759        let expected = Arg::Positional("scripts/assign-cores.sh".to_string());
760        let valid = "\"scripts/assign-cores.sh\"";
761        let result: Result<Arg, _> = serde_json::from_str(valid);
762        assert_eq!(result.unwrap(), expected);
763
764        // URLs can be positional
765        let expected = Arg::Positional("ws://127.0.0.1:10000".to_string());
766        let valid = "\"ws://127.0.0.1:10000\"";
767        let result: Result<Arg, _> = serde_json::from_str(valid);
768        assert_eq!(result.unwrap(), expected);
769
770        // Numbers can be positional
771        let expected = Arg::Positional("42".to_string());
772        let valid = "\"42\"";
773        let result: Result<Arg, _> = serde_json::from_str(valid);
774        assert_eq!(result.unwrap(), expected);
775    }
776
777    #[test]
778    fn test_arg_positional_roundtrip() {
779        // Use a value that clearly doesn't match flag pattern (has special chars)
780        let arg = Arg::Positional("script.sh".to_string());
781        let serialized = serde_json::to_string(&arg).unwrap();
782        assert_eq!(serialized, "\"script.sh\"");
783        let deserialized: Arg = serde_json::from_str(&serialized).unwrap();
784        assert_eq!(arg, deserialized);
785    }
786
787    #[test]
788    fn test_arg_positional_to_vec() {
789        let arg = Arg::Positional("scripts/test.sh".to_string());
790        assert_eq!(arg.to_vec(), vec!["scripts/test.sh".to_string()]);
791    }
792
793    #[test]
794    fn converting_a_str_without_whitespaces_into_a_chain_should_succeeds() {
795        let got: Result<Chain, ConversionError> = "mychain".try_into();
796
797        assert_eq!(got.unwrap().as_str(), "mychain");
798    }
799
800    #[test]
801    fn converting_a_str_containing_tag_name_into_an_image_should_succeeds() {
802        let got: Result<Image, ConversionError> = "myimage".try_into();
803
804        assert_eq!(got.unwrap().as_str(), "myimage");
805    }
806
807    #[test]
808    fn converting_a_str_containing_tag_name_and_tag_version_into_an_image_should_succeeds() {
809        let got: Result<Image, ConversionError> = "myimage:version".try_into();
810
811        assert_eq!(got.unwrap().as_str(), "myimage:version");
812    }
813
814    #[test]
815    fn converting_a_str_containing_hostname_and_tag_name_into_an_image_should_succeeds() {
816        let got: Result<Image, ConversionError> = "myrepository.com/myimage".try_into();
817
818        assert_eq!(got.unwrap().as_str(), "myrepository.com/myimage");
819    }
820
821    #[test]
822    fn converting_a_str_containing_hostname_tag_name_and_tag_version_into_an_image_should_succeeds()
823    {
824        let got: Result<Image, ConversionError> = "myrepository.com/myimage:version".try_into();
825
826        assert_eq!(got.unwrap().as_str(), "myrepository.com/myimage:version");
827    }
828
829    #[test]
830    fn converting_a_str_containing_ip_and_tag_name_into_an_image_should_succeeds() {
831        let got: Result<Image, ConversionError> = "myrepository.com/myimage".try_into();
832
833        assert_eq!(got.unwrap().as_str(), "myrepository.com/myimage");
834    }
835
836    #[test]
837    fn converting_a_str_containing_ip_tag_name_and_tag_version_into_an_image_should_succeeds() {
838        let got: Result<Image, ConversionError> = "127.0.0.1/myimage:version".try_into();
839
840        assert_eq!(got.unwrap().as_str(), "127.0.0.1/myimage:version");
841    }
842
843    #[test]
844    fn converting_a_str_without_whitespaces_into_a_command_should_succeeds() {
845        let got: Result<Command, ConversionError> = "mycommand".try_into();
846
847        assert_eq!(got.unwrap().as_str(), "mycommand");
848    }
849
850    #[test]
851    fn converting_an_url_into_an_asset_location_should_succeeds() {
852        let url = Url::from_str("https://mycloudstorage.com/path/to/my/file.tgz").unwrap();
853        let got: AssetLocation = url.clone().into();
854
855        assert!(matches!(got, AssetLocation::Url(value) if value == url));
856    }
857
858    #[test]
859    fn converting_a_pathbuf_into_an_asset_location_should_succeeds() {
860        let pathbuf = PathBuf::from_str("/tmp/path/to/my/file").unwrap();
861        let got: AssetLocation = pathbuf.clone().into();
862
863        assert!(matches!(got, AssetLocation::FilePath(value) if value == pathbuf));
864    }
865
866    #[test]
867    fn converting_a_str_into_an_url_asset_location_should_succeeds() {
868        let url = "https://mycloudstorage.com/path/to/my/file.tgz";
869        let got: AssetLocation = url.into();
870
871        assert!(matches!(got, AssetLocation::Url(value) if value == Url::from_str(url).unwrap()));
872    }
873
874    #[test]
875    fn converting_a_str_into_an_filepath_asset_location_should_succeeds() {
876        let filepath = "/tmp/path/to/my/file";
877        let got: AssetLocation = filepath.into();
878
879        assert!(matches!(
880            got,
881            AssetLocation::FilePath(value) if value == PathBuf::from_str(filepath).unwrap()
882        ));
883    }
884
885    #[test]
886    fn converting_a_str_into_an_flag_arg_should_succeeds() {
887        let got: Arg = "myflag".into();
888
889        assert!(matches!(got, Arg::Flag(flag) if flag == "myflag"));
890    }
891
892    #[test]
893    fn converting_a_str_tuple_into_an_option_arg_should_succeeds() {
894        let got: Arg = ("name", "value").into();
895
896        assert!(matches!(got, Arg::Option(name, value) if name == "name" && value == "value"));
897    }
898
899    #[test]
900    fn converting_a_str_with_whitespaces_into_a_chain_should_fails() {
901        let got: Result<Chain, ConversionError> = "my chain".try_into();
902
903        assert!(matches!(
904            got.clone().unwrap_err(),
905            ConversionError::ContainsWhitespaces(_)
906        ));
907        assert_eq!(
908            got.unwrap_err().to_string(),
909            "'my chain' shouldn't contains whitespace"
910        );
911    }
912
913    #[test]
914    fn converting_an_empty_str_into_a_chain_should_fails() {
915        let got: Result<Chain, ConversionError> = "".try_into();
916
917        assert!(matches!(
918            got.clone().unwrap_err(),
919            ConversionError::CantBeEmpty
920        ));
921        assert_eq!(got.unwrap_err().to_string(), "can't be empty");
922    }
923
924    #[test]
925    fn converting_a_str_containing_only_ip_into_an_image_should_fails() {
926        let got: Result<Image, ConversionError> = "127.0.0.1".try_into();
927
928        assert!(matches!(
929            got.clone().unwrap_err(),
930            ConversionError::DoesntMatchRegex { value: _, regex: _ }
931        ));
932        assert_eq!(
933            got.unwrap_err().to_string(),
934            "'127.0.0.1' doesn't match regex '^([ip]|[hostname]/)?[tag_name]:[tag_version]?$'"
935        );
936    }
937
938    #[test]
939    fn converting_a_str_containing_only_ip_and_tag_version_into_an_image_should_fails() {
940        let got: Result<Image, ConversionError> = "127.0.0.1:version".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(), "'127.0.0.1:version' doesn't match regex '^([ip]|[hostname]/)?[tag_name]:[tag_version]?$'");
947    }
948
949    #[test]
950    fn converting_a_str_containing_only_hostname_into_an_image_should_fails() {
951        let got: Result<Image, ConversionError> = "myrepository.com".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' doesn't match regex '^([ip]|[hostname]/)?[tag_name]:[tag_version]?$'");
958    }
959
960    #[test]
961    fn converting_a_str_containing_only_hostname_and_tag_version_into_an_image_should_fails() {
962        let got: Result<Image, ConversionError> = "myrepository.com:version".try_into();
963
964        assert!(matches!(
965            got.clone().unwrap_err(),
966            ConversionError::DoesntMatchRegex { value: _, regex: _ }
967        ));
968        assert_eq!(got.unwrap_err().to_string(), "'myrepository.com:version' doesn't match regex '^([ip]|[hostname]/)?[tag_name]:[tag_version]?$'");
969    }
970
971    #[test]
972    fn converting_a_str_with_whitespaces_into_a_command_should_fails() {
973        let got: Result<Command, ConversionError> = "my command".try_into();
974
975        assert!(matches!(
976            got.clone().unwrap_err(),
977            ConversionError::ContainsWhitespaces(_)
978        ));
979        assert_eq!(
980            got.unwrap_err().to_string(),
981            "'my command' shouldn't contains whitespace"
982        );
983    }
984
985    #[test]
986    fn test_convert_to_json_overrides() {
987        let url: AssetLocation = "https://example.com/overrides.json".into();
988        assert!(matches!(
989            url.into(),
990            JsonOverrides::Location(AssetLocation::Url(_))
991        ));
992
993        let path: AssetLocation = "/path/to/overrides.json".into();
994        assert!(matches!(
995            path.into(),
996            JsonOverrides::Location(AssetLocation::FilePath(_))
997        ));
998
999        let inline = serde_json::json!({ "para_id": 2000});
1000        assert!(matches!(
1001            inline.into(),
1002            JsonOverrides::Json(serde_json::Value::Object(_))
1003        ));
1004    }
1005}