isoloom_spec/syntax/
names.rs1use 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
15macro_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 #[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 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
93pub(crate) use name_type;
95
96name_type!(
97 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 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 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 #[must_use]
139 pub fn name(&self) -> &str {
140 self.0.split_once('@').map_or(self.0.as_str(), |(n, _)| n)
141 }
142}
143
144pub(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#[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#[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}