Skip to main content

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