1use crate::envfile::{unquote, valid_key};
6
7#[derive(Debug, thiserror::Error)]
8#[error("schema parse error on line {line}: {msg}")]
9pub struct SchemaError {
10 pub line: usize,
11 pub msg: String,
12}
13
14fn err(line: usize, msg: impl Into<String>) -> SchemaError {
15 SchemaError {
16 line,
17 msg: msg.into(),
18 }
19}
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum VarType {
23 String,
24 Int,
25 Number,
26 Bool,
27 Port,
28 Url,
29 Secret,
30 Enum(Vec<String>),
31}
32
33impl VarType {
34 pub fn name(&self) -> String {
35 match self {
36 VarType::String => "string".into(),
37 VarType::Int => "int".into(),
38 VarType::Number => "number".into(),
39 VarType::Bool => "bool".into(),
40 VarType::Port => "port".into(),
41 VarType::Url => "url".into(),
42 VarType::Secret => "secret".into(),
43 VarType::Enum(vs) => format!("enum({})", vs.join(", ")),
44 }
45 }
46
47 pub fn is_secret(&self) -> bool {
48 matches!(self, VarType::Secret)
49 }
50}
51
52#[derive(Debug, Clone)]
53pub struct VarSpec {
54 pub name: String,
55 pub ty: VarType,
56 pub default: Option<String>,
57 pub optional: bool,
58}
59
60impl VarSpec {
61 pub fn required(&self) -> bool {
63 !self.optional && self.default.is_none()
64 }
65}
66
67#[derive(Debug, Clone, Default)]
68pub struct Schema {
69 pub vars: Vec<VarSpec>,
70}
71
72impl Schema {
73 pub fn get(&self, name: &str) -> Option<&VarSpec> {
74 self.vars.iter().find(|v| v.name == name)
75 }
76
77 pub fn parse(text: &str) -> Result<Schema, SchemaError> {
78 let mut vars: Vec<VarSpec> = Vec::new();
79 for (i, raw) in text.lines().enumerate() {
80 let line_no = i + 1;
81 let line = strip_comment(raw).trim();
82 if line.is_empty() {
83 continue;
84 }
85 let spec = parse_line(line, line_no)?;
86 if vars.iter().any(|v| v.name == spec.name) {
87 return Err(err(line_no, format!("duplicate variable `{}`", spec.name)));
88 }
89 vars.push(spec);
90 }
91 Ok(Schema { vars })
92 }
93}
94
95pub fn validate_value(ty: &VarType, value: &str) -> Result<(), String> {
98 let v = value.trim();
99 match ty {
100 VarType::String | VarType::Secret => Ok(()),
101 VarType::Int => v
102 .parse::<i64>()
103 .map(|_| ())
104 .map_err(|_| format!("`{value}` is not an integer")),
105 VarType::Number => v
106 .parse::<f64>()
107 .map(|_| ())
108 .map_err(|_| format!("`{value}` is not a number")),
109 VarType::Bool => match v.to_ascii_lowercase().as_str() {
110 "true" | "false" | "1" | "0" | "yes" | "no" | "on" | "off" => Ok(()),
111 _ => Err(format!(
112 "`{value}` is not a bool (true/false/1/0/yes/no/on/off)"
113 )),
114 },
115 VarType::Port => match v.parse::<u32>() {
116 Ok(p) if (1..=65535).contains(&p) => Ok(()),
117 _ => Err(format!("`{value}` is not a valid port (1-65535)")),
118 },
119 VarType::Url => {
120 if is_url(v) {
121 Ok(())
122 } else {
123 Err(format!(
124 "`{value}` is not a valid URL (expected scheme://...)"
125 ))
126 }
127 }
128 VarType::Enum(vs) => {
129 if vs.iter().any(|x| x == v) {
130 Ok(())
131 } else {
132 Err(format!(
133 "`{value}` not allowed (expected one of: {})",
134 vs.join(", ")
135 ))
136 }
137 }
138 }
139}
140
141fn is_url(s: &str) -> bool {
142 if let Some(idx) = s.find("://") {
143 let scheme = &s[..idx];
144 let rest = &s[idx + 3..];
145 !scheme.is_empty()
146 && scheme
147 .chars()
148 .next()
149 .is_some_and(|c| c.is_ascii_alphabetic())
150 && scheme
151 .chars()
152 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '.' | '-'))
153 && !rest.is_empty()
154 } else {
155 false
156 }
157}
158
159fn strip_comment(line: &str) -> &str {
161 let bytes = line.as_bytes();
162 let (mut in_s, mut in_d) = (false, false);
163 let mut i = 0;
164 while i < bytes.len() {
165 match bytes[i] {
166 b'\'' if !in_d => in_s = !in_s,
167 b'"' if !in_s => in_d = !in_d,
168 b'#' if !in_s && !in_d => return &line[..i],
169 _ => {}
170 }
171 i += 1;
172 }
173 line
174}
175
176fn parse_line(line: &str, ln: usize) -> Result<VarSpec, SchemaError> {
177 let colon = line
178 .find(':')
179 .ok_or_else(|| err(ln, "expected `NAME: type`"))?;
180 let name = line[..colon].trim().to_string();
181 if !valid_key(&name) {
182 return Err(err(ln, format!("invalid variable name `{name}`")));
183 }
184 let rhs = line[colon + 1..].trim();
185 if rhs.is_empty() {
186 return Err(err(ln, "missing type"));
187 }
188
189 let (type_part, default_part) = split_default(rhs);
190 let mut type_str = type_part.trim().to_string();
191 let mut optional = false;
192 if let Some(stripped) = type_str.strip_suffix('?') {
193 optional = true;
194 type_str = stripped.trim().to_string();
195 }
196 let ty = parse_type(&type_str).map_err(|m| err(ln, m))?;
197
198 let default = match default_part {
199 Some(d) => {
200 let value = unquote(d.trim());
201 if let Err(reason) = validate_value(&ty, &value) {
202 return Err(err(ln, format!("default value invalid: {reason}")));
203 }
204 Some(value)
205 }
206 None => None,
207 };
208
209 Ok(VarSpec {
210 name,
211 ty,
212 default,
213 optional,
214 })
215}
216
217fn split_default(s: &str) -> (&str, Option<&str>) {
220 let b = s.as_bytes();
221 let (mut in_s, mut in_d) = (false, false);
222 let mut depth = 0i32;
223 let mut i = 0;
224 while i < b.len() {
225 match b[i] {
226 b'\'' if !in_d => in_s = !in_s,
227 b'"' if !in_s => in_d = !in_d,
228 b'(' if !in_s && !in_d => depth += 1,
229 b')' if !in_s && !in_d => depth -= 1,
230 b'=' if !in_s && !in_d && depth == 0 => return (&s[..i], Some(&s[i + 1..])),
231 _ => {}
232 }
233 i += 1;
234 }
235 (s, None)
236}
237
238fn parse_type(s: &str) -> Result<VarType, String> {
239 let s = s.trim();
240 let lower = s.to_ascii_lowercase();
241 if lower.starts_with("enum(") && s.ends_with(')') {
242 let inner = &s[5..s.len() - 1];
243 let vals: Vec<String> = inner
244 .split(',')
245 .map(|v| unquote(v.trim()))
246 .filter(|v| !v.is_empty())
247 .collect();
248 if vals.is_empty() {
249 return Err("enum needs at least one value".into());
250 }
251 return Ok(VarType::Enum(vals));
252 }
253 Ok(match lower.as_str() {
254 "string" | "str" => VarType::String,
255 "int" | "integer" => VarType::Int,
256 "number" | "num" | "float" => VarType::Number,
257 "bool" | "boolean" => VarType::Bool,
258 "port" => VarType::Port,
259 "url" | "uri" => VarType::Url,
260 "secret" | "password" => VarType::Secret,
261 other => return Err(format!("unknown type `{other}`")),
262 })
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268
269 #[test]
270 fn parses_types_and_defaults() {
271 let s = Schema::parse(
272 "PORT: port = 3000\nHOST: string = \"localhost\"\nKEY: secret\nMODE: enum(a, b) = a\n",
273 )
274 .unwrap();
275 assert_eq!(s.vars.len(), 4);
276 let port = s.get("PORT").unwrap();
277 assert_eq!(port.ty, VarType::Port);
278 assert_eq!(port.default.as_deref(), Some("3000"));
279 assert!(!port.required());
280 let key = s.get("KEY").unwrap();
281 assert!(key.ty.is_secret());
282 assert!(key.required());
283 let mode = s.get("MODE").unwrap();
284 assert_eq!(mode.ty, VarType::Enum(vec!["a".into(), "b".into()]));
285 }
286
287 #[test]
288 fn optional_marker() {
289 let s = Schema::parse("DSN: url?\n").unwrap();
290 let dsn = s.get("DSN").unwrap();
291 assert!(dsn.optional);
292 assert!(!dsn.required());
293 }
294
295 #[test]
296 fn rejects_unknown_type_and_dupes() {
297 assert!(Schema::parse("X: banana\n").is_err());
298 assert!(Schema::parse("X: int\nX: int\n").is_err());
299 assert!(Schema::parse("PORT: port = 70000\n").is_err());
300 }
301
302 #[test]
303 fn value_validation() {
304 assert!(validate_value(&VarType::Port, "8080").is_ok());
305 assert!(validate_value(&VarType::Port, "0").is_err());
306 assert!(validate_value(&VarType::Bool, "YES").is_ok());
307 assert!(validate_value(&VarType::Url, "postgres://h/db").is_ok());
308 assert!(validate_value(&VarType::Url, "notaurl").is_err());
309 assert!(validate_value(&VarType::Int, "12").is_ok());
310 assert!(validate_value(&VarType::Int, "1.2").is_err());
311 assert!(validate_value(&VarType::Enum(vec!["a".into()]), "b").is_err());
312 }
313}