Skip to main content

envo/
schema.rs

1//! The `.envo` schema: a tiny typed declaration language for environment
2//! variables. Each non-empty, non-comment line is `NAME: type [= default]`,
3//! with an optional trailing `?` to mark the variable optional.
4
5use 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    /// A variable is required when it is neither optional nor has a default.
62    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
95/// Validate that a concrete string value conforms to a declared type.
96/// Returns a human-readable reason on failure.
97pub 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
159/// Strip a trailing `#` comment that is not inside quotes.
160fn 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
217/// Split `type [= default]` at the first top-level `=` (ignoring `=` inside
218/// quotes or `enum(...)` parentheses).
219fn 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}