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 validate(
61 schema: &Schema,
62 values: &BTreeMap<String, String>,
63 environment: &str,
64) -> Vec<Finding> {
65 let mut findings = Vec::new();
66 for (name, rule) in &schema.variables {
67 if !rule.environments.is_empty() && !rule.environments.iter().any(|e| e == environment) {
68 continue;
69 }
70 let Some(value) = values.get(name) else {
71 if rule.required && rule.default.is_none() {
72 findings.push(Finding {
73 variable: name.clone(),
74 kind: "missing".into(),
75 message: "required variable is missing".into(),
76 });
77 }
78 continue;
79 };
80 let valid = match rule.kind.as_str() {
81 "integer" => value.parse::<i64>().is_ok(),
82 "boolean" => matches!(value.as_str(), "true" | "false"),
83 "url" => value.starts_with("http://") || value.starts_with("https://"),
84 "enum" => rule.values.iter().any(|v| v == value),
85 "duration" => {
86 !value.is_empty()
87 && value
88 .chars()
89 .all(|c| c.is_ascii_digit() || matches!(c, 's' | 'm' | 'h' | 'd'))
90 }
91 "string" => true,
92 _ => false,
93 };
94 if !valid {
95 findings.push(Finding {
96 variable: name.clone(),
97 kind: "invalid".into(),
98 message: format!("invalid {} value", rule.kind),
99 });
100 }
101 }
102 findings
103}
104
105pub fn example(schema: &Schema) -> String {
106 schema
107 .variables
108 .iter()
109 .map(|(name, rule)| {
110 let description = rule.description.as_deref().unwrap_or("value");
111 let value = if rule.secret {
112 ""
113 } else {
114 rule.default.as_deref().unwrap_or("")
115 };
116 format!("# {description}\n{name}={value}\n")
117 })
118 .collect()
119}