use std::collections::BTreeMap;
use std::fmt::Write as _;
use std::path::Path;
use serde::Deserialize;
#[derive(Deserialize, Default)]
struct RawTemplate {
#[serde(default)]
variables: BTreeMap<String, toml::Value>,
#[serde(default)]
config: RawConfig,
}
#[derive(Deserialize, Default)]
struct RawConfig {
#[serde(default)]
env: Option<toml::Value>,
#[serde(default)]
domains: Vec<RawDomain>,
#[serde(default)]
mounts: Vec<toml::Value>,
}
#[derive(Deserialize)]
struct RawDomain {
#[serde(rename = "serviceName")]
service_name: String,
#[serde(default)]
port: i64,
#[serde(default)]
host: String,
#[serde(default)]
path: Option<String>,
}
pub fn generate() {
let manifest = std::env::var("CARGO_MANIFEST_DIR").unwrap();
let blueprints = Path::new(&manifest).join("templates/blueprints");
println!("cargo:rerun-if-changed={}", blueprints.display());
let mut dirs: Vec<_> = std::fs::read_dir(&blueprints)
.unwrap_or_else(|e| panic!("lendo {}: {e}", blueprints.display()))
.filter_map(|e| e.ok())
.filter(|e| e.path().is_dir())
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
dirs.sort();
let mut out = String::new();
out.push_str("// @generated by build.rs — não editar à mão.\n");
out.push_str("pub static TEMPLATES: &[Template] = &[\n");
for id in &dirs {
let dir = blueprints.join(id);
let toml_path = dir.join("template.toml");
let compose_path = dir.join("docker-compose.yml");
if !toml_path.exists() || !compose_path.exists() {
continue;
}
println!("cargo:rerun-if-changed={}", toml_path.display());
println!("cargo:rerun-if-changed={}", compose_path.display());
let raw_str = std::fs::read_to_string(&toml_path).unwrap();
let tpl: RawTemplate = match toml::from_str(&raw_str) {
Ok(t) => t,
Err(e) => {
println!(
"cargo:warning=template {id} ignorado (TOML inválido): {}",
e.to_string().lines().next().unwrap_or("")
);
continue;
}
};
let name = prettify(id);
let (description, logo) = read_meta(&dir, id);
let default_port = tpl
.config
.domains
.first()
.map(|d| d.port as u16)
.unwrap_or(80);
let mut vars = String::new();
for (k, v) in &tpl.variables {
let raw = v
.as_str()
.map(str::to_owned)
.unwrap_or_else(|| v.to_string());
writeln!(
vars,
" Var {{ key: {}, raw: {} }},",
lit(k),
lit(&raw)
)
.unwrap();
}
let mut env = String::new();
for (k, v) in normalize_env(&tpl.config.env) {
writeln!(env, " Kv {{ key: {}, raw: {} }},", lit(&k), lit(&v)).unwrap();
}
let mut doms = String::new();
for d in &tpl.config.domains {
writeln!(
doms,
" Domain {{ service_name: {}, port: {}, host: {}, path: {} }},",
lit(&d.service_name),
d.port as u16,
lit(&d.host),
lit(d.path.as_deref().unwrap_or("/")),
)
.unwrap();
}
let mut mounts = String::new();
for m in &tpl.config.mounts {
let Some(t) = m.as_table() else { continue };
let (Some(fp), Some(content)) = (
t.get("filePath").and_then(|v| v.as_str()),
t.get("content").and_then(|v| v.as_str()),
) else {
continue;
};
writeln!(
mounts,
" Mount {{ file_path: {}, content: {} }},",
lit(fp),
lit(content)
)
.unwrap();
}
let compose_include = format!(
"include_str!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/templates/blueprints/{id}/docker-compose.yml\"))"
);
writeln!(out, " Template {{").unwrap();
writeln!(out, " id: {},", lit(id)).unwrap();
writeln!(out, " name: {},", lit(&name)).unwrap();
writeln!(out, " description: {},", lit(&description)).unwrap();
writeln!(out, " logo: {},", lit(&logo)).unwrap();
writeln!(out, " default_port: {default_port},").unwrap();
writeln!(out, " compose: {compose_include},").unwrap();
writeln!(out, " variables: &[\n{vars} ],").unwrap();
writeln!(out, " env: &[\n{env} ],").unwrap();
writeln!(out, " domains: &[\n{doms} ],").unwrap();
writeln!(out, " mounts: &[\n{mounts} ],").unwrap();
writeln!(out, " }},").unwrap();
}
out.push_str("];\n");
let dest = Path::new(&std::env::var("OUT_DIR").unwrap()).join("templates_catalog.rs");
std::fs::write(&dest, out).unwrap();
}
fn read_meta(dir: &Path, id: &str) -> (String, String) {
let default_logo = find_logo(dir)
.or_else(|| logo_from_list(id))
.unwrap_or_default();
let meta_path = dir.join("meta-entry.json");
let Ok(s) = std::fs::read_to_string(&meta_path) else {
return (String::new(), default_logo);
};
let desc = json_string_field(&s, "description").unwrap_or_default();
let logo = json_string_field(&s, "logo").unwrap_or(default_logo);
(desc, logo)
}
fn logo_from_list(id: &str) -> Option<String> {
let manifest = std::env::var("CARGO_MANIFEST_DIR").ok()?;
let list = Path::new(&manifest).join("templates/logos.txt");
println!("cargo:rerun-if-changed={}", list.display());
std::fs::read_to_string(list)
.ok()?
.lines()
.filter_map(|l| l.split_once('\t'))
.find(|(i, _)| *i == id)
.map(|(_, f)| f.to_string())
}
fn json_string_field(s: &str, field: &str) -> Option<String> {
let key = format!("\"{field}\"");
let i = s.find(&key)?;
let rest = &s[i + key.len()..];
let colon = rest.find(':')?;
let after = rest[colon + 1..].trim_start();
let after = after.strip_prefix('"')?;
let end = after.find('"')?;
Some(after[..end].to_string())
}
fn find_logo(dir: &Path) -> Option<String> {
let exts = ["svg", "png", "jpg", "jpeg", "webp", "ico", "gif"];
std::fs::read_dir(dir)
.ok()?
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().into_owned())
.find(|n| {
Path::new(n)
.extension()
.and_then(|e| e.to_str())
.map(|e| exts.contains(&e.to_lowercase().as_str()))
.unwrap_or(false)
})
}
fn prettify(id: &str) -> String {
id.split(['-', '_'])
.filter(|w| !w.is_empty())
.map(|w| {
let mut c = w.chars();
match c.next() {
Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
None => String::new(),
}
})
.collect::<Vec<_>>()
.join(" ")
}
fn normalize_env(env: &Option<toml::Value>) -> Vec<(String, String)> {
match env {
Some(toml::Value::Array(items)) => items
.iter()
.filter_map(|v| v.as_str())
.filter_map(|s| s.split_once('='))
.map(|(k, v)| (k.trim().to_string(), v.to_string()))
.collect(),
Some(toml::Value::Table(t)) => t
.iter()
.map(|(k, v)| {
let val = v
.as_str()
.map(str::to_owned)
.unwrap_or_else(|| v.to_string());
(k.clone(), val)
})
.collect(),
_ => Vec::new(),
}
}
fn lit(s: &str) -> String {
format!("{s:?}")
}