use crate::output::Finding;
use anyhow::{Context, bail};
use serde::Deserialize;
use std::{collections::BTreeMap, fs, path::Path};
#[derive(Debug, Clone, Deserialize)]
pub struct Schema {
pub variables: BTreeMap<String, Variable>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Variable {
#[serde(rename = "type", default = "default_type")]
pub kind: String,
#[serde(default)]
pub values: Vec<String>,
#[serde(default)]
pub default: Option<String>,
#[serde(default)]
pub required: bool,
#[serde(default)]
pub description: Option<String>,
#[serde(default = "default_secret")]
pub secret: bool,
#[serde(default)]
pub environments: Vec<String>,
}
fn default_type() -> String {
"string".into()
}
fn default_secret() -> bool {
true
}
pub fn load(path: &Path) -> anyhow::Result<Schema> {
let text =
fs::read_to_string(path).with_context(|| format!("read schema {}", path.display()))?;
let schema: Schema = serde_yaml::from_str(&text).context("parse schema YAML")?;
if schema.variables.is_empty() {
bail!("schema contains no variables")
}
Ok(schema)
}
pub fn parse_env(text: &str) -> BTreeMap<String, String> {
text.lines()
.filter_map(|line| {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
return None;
}
let (key, value) = line.split_once('=')?;
Some((
key.trim().to_string(),
value.trim().trim_matches('"').to_string(),
))
})
.collect()
}
pub fn parse_env_strict(text: &str) -> anyhow::Result<BTreeMap<String, String>> {
let mut values = BTreeMap::new();
for (line_number, line) in text.lines().enumerate() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((key, value)) = line.split_once('=') else {
bail!("invalid dotenv line {}", line_number + 1)
};
let key = key.trim();
if key.is_empty() || values.contains_key(key) {
bail!(
"invalid or duplicate dotenv variable on line {}",
line_number + 1
)
}
values.insert(key.to_owned(), value.trim().trim_matches('"').to_owned());
}
Ok(values)
}
pub fn validate(
schema: &Schema,
values: &BTreeMap<String, String>,
environment: &str,
) -> Vec<Finding> {
let mut findings = Vec::new();
for (name, rule) in &schema.variables {
if !rule.environments.is_empty() && !rule.environments.iter().any(|e| e == environment) {
continue;
}
let Some(value) = values.get(name) else {
if rule.required && rule.default.is_none() {
findings.push(Finding {
variable: name.clone(),
kind: "missing".into(),
message: "required variable is missing".into(),
});
}
continue;
};
let valid = match rule.kind.as_str() {
"integer" => value.parse::<i64>().is_ok(),
"boolean" => matches!(value.as_str(), "true" | "false"),
"url" => value.starts_with("http://") || value.starts_with("https://"),
"enum" => rule.values.iter().any(|v| v == value),
"duration" => {
!value.is_empty()
&& value
.chars()
.all(|c| c.is_ascii_digit() || matches!(c, 's' | 'm' | 'h' | 'd'))
}
"string" => true,
_ => false,
};
if !valid {
findings.push(Finding {
variable: name.clone(),
kind: "invalid".into(),
message: format!("invalid {} value", rule.kind),
});
}
}
for name in values.keys() {
if !schema.variables.contains_key(name) {
findings.push(Finding {
variable: name.clone(),
kind: "extra".into(),
message: "variable is not defined in the schema".into(),
});
}
}
findings
}
pub fn example(schema: &Schema) -> String {
schema
.variables
.iter()
.map(|(name, rule)| {
let description = rule.description.as_deref().unwrap_or("value");
let value = if rule.secret {
""
} else {
rule.default.as_deref().unwrap_or("")
};
format!("# {description}\n{name}={value}\n")
})
.collect()
}