use crate::config::{Config, Secret};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct IrField {
pub name: String,
pub optional: bool,
pub as_path: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct IrProfile {
pub name: String,
pub fields: Vec<IrField>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CodegenIr {
pub project: String,
pub profiles: Vec<String>,
pub union: Vec<IrField>,
pub profile_fields: Vec<IrProfile>,
}
fn is_secret_optional(secret: &Secret) -> bool {
secret.required != Some(true)
}
fn build_union(config: &Config) -> Vec<IrField> {
let total_profiles = config.profiles.len();
let mut sorted_profiles: Vec<&String> = config.profiles.keys().collect();
sorted_profiles.sort();
struct Acc {
required_count: usize,
as_path: bool,
description: Option<String>,
}
let mut acc: BTreeMap<String, Acc> = BTreeMap::new();
for profile_name in sorted_profiles {
for (name, secret) in &config.profiles[profile_name].secrets {
let entry = acc.entry(name.clone()).or_insert(Acc {
required_count: 0,
as_path: false,
description: None,
});
if !is_secret_optional(secret) {
entry.required_count += 1;
}
if secret.as_path == Some(true) {
entry.as_path = true;
}
if entry.description.is_none() {
entry.description = secret.description.clone();
}
}
}
acc.into_iter()
.map(|(name, a)| IrField {
name,
optional: a.required_count != total_profiles,
as_path: a.as_path,
description: a.description,
})
.collect()
}
pub fn capitalize(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
}
}
fn build_profile_fields(secrets: &HashMap<String, Secret>) -> Vec<IrField> {
let mut fields: Vec<IrField> = secrets
.iter()
.map(|(name, secret)| IrField {
name: name.clone(),
optional: is_secret_optional(secret),
as_path: secret.as_path.unwrap_or(false),
description: secret.description.clone(),
})
.collect();
fields.sort_by(|a, b| a.name.cmp(&b.name));
fields
}
pub fn build_ir(config: &Config) -> CodegenIr {
let union = build_union(config);
let profile_fields = if config.profiles.is_empty() {
vec![IrProfile {
name: "default".to_string(),
fields: union.clone(),
}]
} else {
let mut names: Vec<&String> = config.profiles.keys().collect();
names.sort();
names
.into_iter()
.map(|name| IrProfile {
name: name.clone(),
fields: build_profile_fields(&config.profiles[name].secrets),
})
.collect()
};
let profiles = profile_fields.iter().map(|p| p.name.clone()).collect();
CodegenIr {
project: config.project.name.clone(),
profiles,
union,
profile_fields,
}
}
pub mod schema {
use super::{CodegenIr, IrField, capitalize};
use serde_json::{Map, Value, json};
fn property_type(field: &IrField) -> Value {
if field.optional {
json!({ "type": ["string", "null"] })
} else {
json!({ "type": "string" })
}
}
fn object_schema(title: &str, fields: &[IrField], additional_properties: bool) -> Value {
let mut properties = Map::new();
let mut required = Vec::new();
for field in fields {
properties.insert(field.name.clone(), property_type(field));
if !field.optional {
required.push(Value::String(field.name.clone()));
}
}
json!({
"$schema": "http://json-schema.org/draft-06/schema#",
"type": "object",
"additionalProperties": additional_properties,
"title": title,
"properties": Value::Object(properties),
"required": required,
})
}
pub fn emit(ir: &CodegenIr, profile: Option<&str>) -> Result<String, String> {
let schema = match profile {
None => object_schema("SecretSpec", &ir.union, false),
Some(name) => {
let found = ir
.profile_fields
.iter()
.find(|p| p.name == name)
.ok_or_else(|| {
format!(
"unknown profile '{name}'; available: {}",
ir.profiles.join(", ")
)
})?;
object_schema(&format!("{}Secrets", capitalize(name)), &found.fields, true)
}
};
Ok(format!(
"{}\n",
serde_json::to_string_pretty(&schema).unwrap()
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{Profile, Project};
fn secret(required: Option<bool>, as_path: Option<bool>, desc: Option<&str>) -> Secret {
Secret {
description: desc.map(String::from),
required,
as_path,
..Default::default()
}
}
fn config_with(profiles: Vec<(&str, Vec<(&str, Secret)>)>) -> Config {
let mut map = HashMap::new();
for (name, secrets) in profiles {
let mut secret_map = HashMap::new();
for (sname, s) in secrets {
secret_map.insert(sname.to_string(), s);
}
map.insert(
name.to_string(),
Profile {
defaults: None,
secrets: secret_map,
},
);
}
Config {
project: Project {
name: "ir-test".to_string(),
..Default::default()
},
profiles: map,
providers: None,
}
}
fn union_field<'a>(ir: &'a CodegenIr, name: &str) -> &'a IrField {
ir.union.iter().find(|f| f.name == name).unwrap()
}
#[test]
fn union_optional_if_optional_or_missing_in_any_profile() {
let ir = build_ir(&config_with(vec![
(
"development",
vec![
("DATABASE_URL", secret(Some(true), None, None)),
("API_KEY", secret(Some(false), None, None)),
],
),
(
"production",
vec![
("DATABASE_URL", secret(Some(true), None, None)),
("API_KEY", secret(Some(true), None, None)),
("REDIS_URL", secret(Some(true), None, None)),
],
),
]));
assert!(!union_field(&ir, "DATABASE_URL").optional);
assert!(union_field(&ir, "API_KEY").optional);
assert!(union_field(&ir, "REDIS_URL").optional);
let names: Vec<&str> = ir.union.iter().map(|f| f.name.as_str()).collect();
assert_eq!(names, vec!["API_KEY", "DATABASE_URL", "REDIS_URL"]);
}
#[test]
fn union_as_path_if_any_profile_marks_it() {
let ir = build_ir(&config_with(vec![
(
"development",
vec![("CERT", secret(Some(true), None, None))],
),
(
"production",
vec![("CERT", secret(Some(true), Some(true), None))],
),
]));
assert!(union_field(&ir, "CERT").as_path);
}
#[test]
fn per_profile_fields_are_raw_and_exact() {
let ir = build_ir(&config_with(vec![
(
"development",
vec![
("DATABASE_URL", secret(Some(true), None, Some("dev db"))),
("API_KEY", secret(Some(false), None, None)),
],
),
(
"production",
vec![("DATABASE_URL", secret(Some(true), Some(true), None))],
),
]));
assert_eq!(ir.profiles, vec!["development", "production"]);
let dev = ir
.profile_fields
.iter()
.find(|p| p.name == "development")
.unwrap();
let dev_names: Vec<&str> = dev.fields.iter().map(|f| f.name.as_str()).collect();
assert_eq!(dev_names, vec!["API_KEY", "DATABASE_URL"]);
assert_eq!(dev.fields[1].description.as_deref(), Some("dev db"));
let prod = ir
.profile_fields
.iter()
.find(|p| p.name == "production")
.unwrap();
assert_eq!(prod.fields.len(), 1);
assert!(prod.fields[0].as_path);
assert!(!prod.fields[0].optional);
}
#[test]
fn unspecified_required_is_optional_matching_derive() {
let ir = build_ir(&config_with(vec![(
"default",
vec![("TOKEN", secret(None, None, None))],
)]));
assert!(union_field(&ir, "TOKEN").optional);
}
#[test]
fn schema_emits_types_and_nullability_for_quicktype() {
let ir = build_ir(&config_with(vec![
(
"development",
vec![
("DATABASE_URL", secret(Some(true), None, None)),
("API_KEY", secret(Some(false), None, None)),
],
),
(
"production",
vec![("DATABASE_URL", secret(Some(true), None, None))],
),
]));
let union: serde_json::Value =
serde_json::from_str(&schema::emit(&ir, None).unwrap()).unwrap();
assert_eq!(union["type"], "object");
assert_eq!(union["title"], "SecretSpec");
assert_eq!(union["additionalProperties"], false);
assert_eq!(union["properties"]["DATABASE_URL"]["type"], "string");
assert_eq!(
union["properties"]["API_KEY"]["type"],
serde_json::json!(["string", "null"])
);
let required: Vec<&str> = union["required"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert!(required.contains(&"DATABASE_URL"));
assert!(!required.contains(&"API_KEY"));
let prod: serde_json::Value =
serde_json::from_str(&schema::emit(&ir, Some("production")).unwrap()).unwrap();
assert_eq!(prod["title"], "ProductionSecrets");
assert!(prod["properties"]["DATABASE_URL"].is_object());
assert!(prod["properties"]["API_KEY"].is_null()); assert_eq!(prod["additionalProperties"], true);
assert!(schema::emit(&ir, Some("nope")).is_err());
}
#[test]
fn empty_profiles_yield_single_default_with_union_fields() {
let mut config = config_with(vec![]);
config.profiles.clear();
let ir = build_ir(&config);
assert_eq!(ir.profiles, vec!["default"]);
assert_eq!(ir.profile_fields.len(), 1);
assert_eq!(ir.profile_fields[0].name, "default");
assert!(ir.union.is_empty());
}
}