1use crate::output::Finding;
2use anyhow::{Context, bail};
3use serde::Deserialize;
4use std::{collections::BTreeMap, fs, path::Path};
5
6#[derive(Debug, Clone, Deserialize)]
7pub struct Schema {
8 pub variables: BTreeMap<String, Variable>,
9}
10#[derive(Debug, Clone, Deserialize)]
11pub struct Variable {
12 #[serde(rename = "type", default = "default_type")]
13 pub kind: String,
14 #[serde(default)]
15 pub values: Vec<String>,
16 #[serde(default)]
17 pub default: Option<String>,
18 #[serde(default)]
19 pub required: bool,
20 #[serde(default)]
21 pub description: Option<String>,
22 #[serde(default = "default_secret")]
23 pub secret: bool,
24 #[serde(default)]
25 pub environments: Vec<String>,
26}
27fn default_type() -> String {
28 "string".into()
29}
30fn default_secret() -> bool {
31 true
32}
33
34pub fn load(path: &Path) -> anyhow::Result<Schema> {
35 let text =
36 fs::read_to_string(path).with_context(|| format!("read schema {}", path.display()))?;
37 let schema: Schema = serde_yaml::from_str(&text).context("parse schema YAML")?;
38 if schema.variables.is_empty() {
39 bail!("schema contains no variables")
40 }
41 Ok(schema)
42}
43
44pub fn parse_env(text: &str) -> BTreeMap<String, String> {
45 text.lines()
46 .filter_map(|line| {
47 let line = line.trim();
48 if line.is_empty() || line.starts_with('#') {
49 return None;
50 }
51 let (key, value) = line.split_once('=')?;
52 Some((
53 key.trim().to_string(),
54 value.trim().trim_matches('"').to_string(),
55 ))
56 })
57 .collect()
58}
59
60pub fn parse_env_strict(text: &str) -> anyhow::Result<BTreeMap<String, String>> {
61 let mut values = BTreeMap::new();
62 for (line_number, line) in text.lines().enumerate() {
63 let line = line.trim();
64 if line.is_empty() || line.starts_with('#') {
65 continue;
66 }
67 let Some((key, value)) = line.split_once('=') else {
68 bail!("invalid dotenv line {}", line_number + 1)
69 };
70 let key = key.trim();
71 if key.is_empty() || values.contains_key(key) {
72 bail!(
73 "invalid or duplicate dotenv variable on line {}",
74 line_number + 1
75 )
76 }
77 values.insert(key.to_owned(), value.trim().trim_matches('"').to_owned());
78 }
79 Ok(values)
80}
81
82pub fn validate(
83 schema: &Schema,
84 values: &BTreeMap<String, String>,
85 environment: &str,
86) -> Vec<Finding> {
87 let mut findings = Vec::new();
88 for (name, rule) in &schema.variables {
89 if !rule.environments.is_empty() && !rule.environments.iter().any(|e| e == environment) {
90 continue;
91 }
92 let Some(value) = values.get(name) else {
93 if rule.required && rule.default.is_none() {
94 findings.push(Finding {
95 variable: name.clone(),
96 kind: "missing".into(),
97 message: "required variable is missing".into(),
98 });
99 }
100 continue;
101 };
102 let valid = match rule.kind.as_str() {
103 "integer" => value.parse::<i64>().is_ok(),
104 "boolean" => matches!(value.as_str(), "true" | "false"),
105 "url" => value.starts_with("http://") || value.starts_with("https://"),
106 "enum" => rule.values.iter().any(|v| v == value),
107 "duration" => {
108 !value.is_empty()
109 && value
110 .chars()
111 .all(|c| c.is_ascii_digit() || matches!(c, 's' | 'm' | 'h' | 'd'))
112 }
113 "string" => true,
114 _ => false,
115 };
116 if !valid {
117 findings.push(Finding {
118 variable: name.clone(),
119 kind: "invalid".into(),
120 message: format!("invalid {} value", rule.kind),
121 });
122 }
123 }
124 for name in values.keys() {
125 if !schema.variables.contains_key(name) {
126 findings.push(Finding {
127 variable: name.clone(),
128 kind: "extra".into(),
129 message: "variable is not defined in the schema".into(),
130 });
131 }
132 }
133 findings
134}
135
136pub fn example(schema: &Schema) -> String {
137 schema
138 .variables
139 .iter()
140 .map(|(name, rule)| {
141 let description = rule.description.as_deref().unwrap_or("value");
142 let value = if rule.secret {
143 ""
144 } else {
145 rule.default.as_deref().unwrap_or("")
146 };
147 format!("# {description}\n{name}={value}\n")
148 })
149 .collect()
150}