Skip to main content

isoloom_spec/syntax/
names.rs

1//! The names a spec gives: ports, inputs, catalogue services. Each is a newtype that only holds a
2//! value matching its rule, so a parsed spec has no malformed name anywhere; the JSON Schema
3//! carries the same pattern.
4
5use std::borrow::Cow;
6use std::fmt;
7use std::str::FromStr;
8
9use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
10use serde::de::{self, Visitor};
11use serde::{Deserialize, Deserializer, Serialize, Serializer};
12
13use crate::Error;
14
15/// A newtype over `String` holding only values `$check` accepts; `$pattern` is the same rule as
16/// a JSON Schema pattern, `$rule` how an author fixes a value that breaks it.
17macro_rules! name_type {
18    ($(#[$doc:meta])* $ty:ident, $what:literal, $pattern:literal, $rule:literal, $check:expr) => {
19        $(#[$doc])*
20        #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
21        pub struct $ty(String);
22
23        impl $ty {
24            /// The name as written.
25            #[must_use]
26            pub fn as_str(&self) -> &str {
27                &self.0
28            }
29        }
30
31        impl FromStr for $ty {
32            type Err = Error;
33
34            /// The name, when it follows the rule.
35            ///
36            /// # Errors
37            ///
38            /// [`Error::Name`] with the rule, as an author reads it.
39            fn from_str(value: &str) -> Result<$ty, Error> {
40                let check: fn(&str) -> bool = $check;
41                if check(value) {
42                    Ok($ty(value.to_string()))
43                } else {
44                    Err(Error::Name {
45                        what: $what,
46                        value: value.to_string(),
47                        rule: $rule,
48                    })
49                }
50            }
51        }
52
53        impl AsRef<str> for $ty {
54            fn as_ref(&self) -> &str {
55                &self.0
56            }
57        }
58
59        impl fmt::Display for $ty {
60            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61                f.write_str(&self.0)
62            }
63        }
64
65        impl Serialize for $ty {
66            fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
67                s.serialize_str(&self.0)
68            }
69        }
70
71        impl<'de> Deserialize<'de> for $ty {
72            fn deserialize<D: Deserializer<'de>>(d: D) -> Result<$ty, D::Error> {
73                d.deserialize_any(Text("a name"))?.parse().map_err(serde::de::Error::custom)
74            }
75        }
76
77        impl JsonSchema for $ty {
78            fn schema_name() -> Cow<'static, str> {
79                stringify!($ty).into()
80            }
81
82            fn inline_schema() -> bool {
83                true
84            }
85
86            fn json_schema(_: &mut SchemaGenerator) -> Schema {
87                json_schema!({ "type": "string", "pattern": $pattern })
88            }
89        }
90    };
91}
92
93// The step's names (`syntax::step::words`) follow the same pattern.
94pub(crate) use name_type;
95
96name_type!(
97    /// A port's name (`site`, `dns-udp`): how messages, checks and outputs refer to it
98    /// (`machines.web.ports.site.published`). Lowercase letters, digits and dashes, from a letter,
99    /// at most 31 characters.
100    PortName,
101    "port name",
102    "^[a-z][a-z0-9-]{0,30}$",
103    "a port's name is lowercase letters, digits and dashes, starting with a letter, at most 31 characters (`web`, `dns-udp`)",
104    |s| {
105        let mut chars = s.chars();
106        chars.next().is_some_and(|c| c.is_ascii_lowercase())
107            && s.len() <= 31
108            && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
109    }
110);
111
112name_type!(
113    /// An input's name: an environment variable's (letters, digits and `_`, not starting with a
114    /// digit), never starting with `ISOLOOM_` (Isoloom's own variables).
115    InputName,
116    "input name",
117    "^(?!ISOLOOM_)[A-Za-z_][A-Za-z0-9_]*$",
118    "an input is named like an environment variable (letters, digits and `_`, not starting with a digit) and doesn't start with `ISOLOOM_`",
119    |s| is_variable(s) && !s.starts_with("ISOLOOM_")
120);
121
122name_type!(
123    /// A catalogue service, with its version or without (`postgres@16`, `nginx`).
124    ServiceRef,
125    "service",
126    "^[a-z][a-z0-9-]*(@[A-Za-z0-9.+-]+)?$",
127    "a service is `<name>` or `<name>@<version>` (`postgres@16`)",
128    |s| {
129        let (name, version) = s.split_once('@').map_or((s, None), |(n, v)| (n, Some(v)));
130        name.starts_with(|c: char| c.is_ascii_lowercase())
131            && name.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
132            && version.is_none_or(|v| !v.is_empty() && v.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '+' | '-')))
133    }
134);
135
136impl ServiceRef {
137    /// The service's name, without its version.
138    #[must_use]
139    pub fn name(&self) -> &str {
140        self.0.split_once('@').map_or(self.0.as_str(), |(n, _)| n)
141    }
142}
143
144/// A value as written, a text or a number read as its digits, before its own rule reads it (so
145/// `80:` as a port's name is reported against the naming rule, not as a type error). It holds
146/// what a value of another type says it expected.
147pub(crate) struct Text(pub(crate) &'static str);
148
149impl Visitor<'_> for Text {
150    type Value = String;
151
152    fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
153        f.write_str(self.0)
154    }
155
156    fn visit_str<E: de::Error>(self, v: &str) -> Result<String, E> {
157        Ok(v.to_owned())
158    }
159
160    fn visit_u64<E: de::Error>(self, v: u64) -> Result<String, E> {
161        Ok(v.to_string())
162    }
163
164    fn visit_i64<E: de::Error>(self, v: i64) -> Result<String, E> {
165        Ok(v.to_string())
166    }
167}
168
169/// Whether `s` is an environment variable's name: letters, digits and `_`, not starting with a
170/// digit.
171#[must_use]
172pub(crate) fn is_variable(s: &str) -> bool {
173    s.chars().next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_') && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
174}
175
176/// The format version, `1`: the only one.
177#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
178pub struct Version;
179
180impl Serialize for Version {
181    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
182        s.serialize_u32(1)
183    }
184}
185
186impl<'de> Deserialize<'de> for Version {
187    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Version, D::Error> {
188        match u32::deserialize(d)? {
189            1 => Ok(Version),
190            n => Err(serde::de::Error::custom(format!(
191                "version {n} isn't one this isoloom reads; write `version: 1`"
192            ))),
193        }
194    }
195}
196
197impl JsonSchema for Version {
198    fn schema_name() -> Cow<'static, str> {
199        "Version".into()
200    }
201
202    fn inline_schema() -> bool {
203        true
204    }
205
206    fn json_schema(_: &mut SchemaGenerator) -> Schema {
207        json_schema!({ "description": "The version of the `isoloom.yml` format the file is written in: `1`, the only one.", "const": 1 })
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    #[test]
216    fn a_number_as_a_name_is_refused_against_the_naming_rule() {
217        let ports = |yaml: &str| serde_yaml_ng::from_str::<indexmap::IndexMap<PortName, u16>>(yaml).unwrap_err().to_string();
218        assert!(ports("{ 80: 80 }").contains("`80` isn't a valid port name"), "{}", ports("{ 80: 80 }"));
219        assert!(ports("{ -5: 80 }").contains("`-5` isn't a valid port name"), "{}", ports("{ -5: 80 }"));
220        let wrong = ports("{ [a]: 80 }");
221        assert!(wrong.contains("expected a name"), "{wrong}");
222    }
223
224    #[test]
225    fn a_name_that_breaks_its_rule_is_refused_with_the_rule() {
226        assert!("dns-udp".parse::<PortName>().is_ok() && "80".parse::<PortName>().is_err() && "Web".parse::<PortName>().is_err());
227        assert!("a".repeat(31).parse::<PortName>().is_ok() && "a".repeat(32).parse::<PortName>().is_err());
228        assert!("CTF_FLAG_MAIN".parse::<InputName>().is_ok() && "1FLAG".parse::<InputName>().is_err());
229        assert!("ISOLOOM_X".parse::<InputName>().unwrap_err().to_string().contains("ISOLOOM_"));
230        assert_eq!("postgres@16".parse::<ServiceRef>().unwrap().name(), "postgres");
231        assert!("postgres@".parse::<ServiceRef>().is_err() && "Nginx".parse::<ServiceRef>().is_err());
232    }
233
234    #[test]
235    fn only_version_one_is_read() {
236        assert!(serde_yaml_ng::from_str::<Version>("1").is_ok());
237        assert!(serde_yaml_ng::from_str::<Version>("2").unwrap_err().to_string().contains("version: 1"));
238    }
239}