rustploy 0.1.39

Rustploy PaaS daemon — lightweight self-hosted deployment platform
//! Gera, em tempo de compilação, o catálogo estático de templates a partir dos
//! blueprints escritos à mão em `templates/blueprints/<id>/`.
//!
//! Cada blueprint tem:
//!   - `template.toml`      — `[variables]`, `[config.env]`, `[[config.domains]]`,
//!     `[[config.mounts]]` (formato Dokploy).
//!   - `docker-compose.yml` — o compose de fato (embutido via `include_str!`).
//!   - um logo (svg/png/…)  — só o nome do arquivo é registrado.
//!
//! O resultado é um `static TEMPLATES: &[Template]` em `$OUT_DIR/templates_catalog.rs`,
//! incluído por `src/templates/mod.rs`. Zero I/O em runtime, tudo `&'static`.

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 {
    /// `env` aparece como array de `"KEY=VALUE"` OU como tabela `KEY = "VALUE"`.
    #[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,
            // Alguns blueprints importados têm TOML inválido (ex.: escapes `\{`
            // em strings `"""`). Pula em vez de quebrar o build inteiro.
            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);

        // ── variables ──
        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();
        }

        // ── env (array de "K=V" ou tabela) ──
        let mut env = String::new();
        for (k, v) in normalize_env(&tpl.config.env) {
            writeln!(env, "        Kv {{ key: {}, raw: {} }},", lit(&k), lit(&v)).unwrap();
        }

        // ── domains ──
        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();
        }

        // ── mounts: só os "file mounts" (filePath + content). Volume mounts
        //    (name + mountPath) já são declarados no próprio compose. ──
        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();
}

/// `meta-entry.json` (só alguns blueprints têm) traz description/logo melhores.
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);
    };
    // Parse mínimo por serde_json seria ideal, mas evitamos a build-dep: busca
    // ingênua pelos campos "description" e "logo".
    let desc = json_string_field(&s, "description").unwrap_or_default();
    let logo = json_string_field(&s, "logo").unwrap_or(default_logo);
    (desc, logo)
}

/// Os arquivos de logo NÃO vivem neste crate (pesavam 14 MB e estouravam o limite
/// do crates.io): moraram no repo do `rustploy-gui`, o único que os exibe. Aqui
/// fica só o mapa `<id>\t<arquivo>` em `templates/logos.txt`, para o catálogo
/// continuar sabendo o nome do logo de cada template. Um blueprint novo que traga
/// o logo na própria pasta continua valendo (ver [`find_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)
        })
}

/// `gitea_mysql` → "Gitea Mysql"; `open-webui` → "Open Webui".
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(" ")
}

/// Normaliza `config.env` (array `"K=V"` ou tabela) para pares `(K, V)`.
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(),
    }
}

/// Literal Rust `&str` válido (escapado) para `s`.
fn lit(s: &str) -> String {
    format!("{s:?}")
}