use std::collections::BTreeMap;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
fn main() {
println!("cargo:rerun-if-changed=schemas/");
println!("cargo:rerun-if-changed=schemas/platform/");
println!("cargo:rerun-if-changed=signing/openlatch-provider.pub");
println!("cargo:rerun-if-changed=build.rs");
forward_env("OPENLATCH_PROVIDER_POSTHOG_KEY");
forward_env("OPENLATCH_PROVIDER_SENTRY_DSN");
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap());
let schemas_dir = manifest_dir.join("schemas");
generate_typify_types(&manifest_dir, &schemas_dir);
}
fn forward_env(name: &str) {
if let Ok(v) = env::var(name) {
println!("cargo:rustc-env={name}={v}");
} else {
println!("cargo:rustc-env={name}=");
}
}
fn generate_typify_types(manifest_dir: &Path, schemas_dir: &Path) {
let out_path = manifest_dir.join("src").join("generated").join("types.rs");
let header = "// AUTO-GENERATED by build.rs from schemas/. DO NOT EDIT.\n\
// Regenerate: cargo build (touches `schemas/` to invalidate).\n\
#![allow(clippy::all, dead_code, unused_imports)]\n\n";
let bootstrap = env::var("OPENLATCH_PROVIDER_BOOTSTRAP").is_ok();
let body = if !schemas_dir.is_dir() {
if bootstrap {
String::new()
} else {
panic!(
"schemas/ directory missing at {}. Set OPENLATCH_PROVIDER_BOOTSTRAP=1 to bypass.",
schemas_dir.display()
);
}
} else {
match generate_typify(schemas_dir) {
Ok(s) if !s.is_empty() => s,
Ok(_) => {
if bootstrap {
String::new()
} else {
panic!(
"schemas/ contains no *.schema.json files. \
Set OPENLATCH_PROVIDER_BOOTSTRAP=1 to bypass."
);
}
}
Err(e) => panic!("typify codegen failed: {e}"),
}
};
let new = format!("{header}{body}\n");
let existing = fs::read_to_string(&out_path).unwrap_or_default();
if existing != new {
fs::create_dir_all(out_path.parent().unwrap()).ok();
fs::write(&out_path, new).expect("failed to write src/generated/types.rs");
let _ =
std::process::Command::new(env::var("RUSTFMT").unwrap_or_else(|_| "rustfmt".into()))
.arg("--edition")
.arg("2021")
.arg(&out_path)
.status();
}
}
fn generate_typify(schemas_dir: &Path) -> Result<String, String> {
use schemars::schema::RootSchema;
use serde_json::Value;
use std::collections::BTreeSet;
use typify::{TypeSpace, TypeSpaceSettings};
let mut paths = BTreeSet::new();
for entry in fs::read_dir(schemas_dir).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
let p = entry.path();
if p.is_file()
&& p.extension().and_then(|e| e.to_str()) == Some("json")
&& p.file_name()
.and_then(|n| n.to_str())
.map(|n| n.ends_with(".schema.json"))
.unwrap_or(false)
{
paths.insert(p);
}
}
if paths.is_empty() {
return Ok(String::new());
}
let mut files: BTreeMap<String, Value> = BTreeMap::new();
for path in &paths {
let stem = path
.file_name()
.and_then(|n| n.to_str())
.map(|n| n.trim_end_matches(".schema.json").to_string())
.ok_or_else(|| format!("invalid filename: {}", path.display()))?;
let raw = fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
let mut value: Value =
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", path.display()))?;
strip_keys_recursively(&mut value, &["default", "examples"]);
files.insert(stem, value);
}
let mut combined_defs = serde_json::Map::new();
for (stem, schema) in &files {
if let Some(Value::Object(defs)) = schema.get("$defs") {
for (k, v) in defs {
if combined_defs.contains_key(k) {
return Err(format!(
"duplicate $defs key `{}` while bundling schemas (last seen in `{}`)",
k, stem
));
}
let mut clone = v.clone();
rewrite_refs(&mut clone, &files)?;
combined_defs.insert(k.clone(), clone);
}
}
let alias_key = pascal(stem);
let mut whole = schema.clone();
if let Some(obj) = whole.as_object_mut() {
obj.remove("$defs");
obj.remove("$schema");
obj.remove("$id");
}
rewrite_refs(&mut whole, &files)?;
combined_defs.insert(alias_key, whole);
}
let bisect = env::var("OPENLATCH_PROVIDER_DEBUG_TYPIFY").is_ok();
let mut space = TypeSpace::new(TypeSpaceSettings::default().with_struct_builder(true));
if bisect {
for (name, def) in &combined_defs {
let one = serde_json::json!({
"title": "OpenLatchProviderBundleSingle",
"type": "object",
"definitions": { name.clone(): def.clone() },
});
let root: RootSchema = serde_json::from_value(one)
.map_err(|e| format!("bundle parse for `{name}`: {e}"))?;
space
.add_root_schema(root)
.map_err(|e| format!("typify add_root_schema for `{name}`: {e}"))?;
}
} else {
let combined = serde_json::json!({
"title": "OpenLatchProviderBundle",
"type": "object",
"definitions": Value::Object(combined_defs),
});
if let Ok(out_dir) = env::var("OUT_DIR") {
let _ = fs::write(
std::path::Path::new(&out_dir).join("typify-bundle.json"),
serde_json::to_string_pretty(&combined).unwrap_or_default(),
);
}
let root: RootSchema = serde_json::from_value(combined)
.map_err(|e| format!("bundle root schema parse: {e}"))?;
space
.add_root_schema(root)
.map_err(|e| format!("typify add_root_schema: {e}"))?;
}
let stream = space.to_stream();
let parsed: syn::File = syn::parse2(stream).map_err(|e| e.to_string())?;
Ok(prettyplease::unparse(&parsed))
}
fn rewrite_refs(
value: &mut serde_json::Value,
files: &BTreeMap<String, serde_json::Value>,
) -> Result<(), String> {
use serde_json::Value;
match value {
Value::Object(map) => {
if let Some(Value::String(r)) = map.get_mut("$ref") {
if let Some(rewritten) = rewrite_ref_string(r, files)? {
*r = rewritten;
}
}
for (_k, v) in map.iter_mut() {
rewrite_refs(v, files)?;
}
}
Value::Array(arr) => {
for v in arr.iter_mut() {
rewrite_refs(v, files)?;
}
}
_ => {}
}
Ok(())
}
fn rewrite_ref_string(
r: &str,
files: &BTreeMap<String, serde_json::Value>,
) -> Result<Option<String>, String> {
if r.starts_with('#') {
return Ok(None);
}
let (file_part, fragment) = match r.split_once('#') {
Some((a, b)) => (a, Some(b)),
None => (r, None),
};
if !file_part.ends_with(".schema.json") {
return Ok(None);
}
let stem = file_part.trim_end_matches(".schema.json");
if !files.contains_key(stem) {
return Err(format!(
"cross-file $ref `{}` points at unknown schema `{}`",
r, file_part
));
}
let new = match fragment {
Some(frag) => {
if let Some(name) = frag.strip_prefix("/$defs/") {
format!("#/definitions/{name}")
} else {
format!("#/definitions/{}{frag}", pascal(stem))
}
}
None => format!("#/definitions/{}", pascal(stem)),
};
Ok(Some(new))
}
fn strip_keys_recursively(value: &mut serde_json::Value, keys: &[&str]) {
use serde_json::Value;
match value {
Value::Object(map) => {
for k in keys {
map.remove(*k);
}
for (_, v) in map.iter_mut() {
strip_keys_recursively(v, keys);
}
}
Value::Array(arr) => {
for v in arr.iter_mut() {
strip_keys_recursively(v, keys);
}
}
_ => {}
}
}
fn pascal(stem: &str) -> String {
let mut out = String::with_capacity(stem.len());
let mut upper = true;
for ch in stem.chars() {
if ch == '-' || ch == '_' || ch == '.' {
upper = true;
continue;
}
if upper {
out.extend(ch.to_uppercase());
upper = false;
} else {
out.push(ch);
}
}
out
}