zebflow 0.6.1

Reactive web automation platform — pipelines, templates, and real-time collaboration in a single binary.
Documentation
use std::fs;
use std::path::Path;

fn main() {
    generate_version();
    generate_platform_template_assets();
    generate_help_index();
}

fn generate_version() {
    let cargo_version = std::env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "0.0.0".to_string());

    // Rerun if CI passes a new BUILD_VERSION.
    println!("cargo:rerun-if-env-changed=BUILD_VERSION");

    // Use BUILD_VERSION from CI (passed as Docker build-arg) so the binary version
    // matches the Docker Hub tag. Fall back to a local timestamp when building outside CI.
    let app_version = match std::env::var("BUILD_VERSION") {
        Ok(v) if !v.trim().is_empty() => v.trim().to_string(),
        _ => {
            let now = std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .unwrap_or_default()
                .as_secs();
            format!("{cargo_version}.{}", format_build_timestamp(now))
        }
    };

    let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR not set");
    let dest = std::path::Path::new(&out_dir).join("version_gen.rs");
    fs::write(
        &dest,
        format!("pub const APP_VERSION: &str = \"{app_version}\";\n"),
    )
    .expect("failed writing version_gen.rs");

    println!("cargo:warning=🔖 Build version: {app_version}");
    println!("cargo:rerun-if-changed=Cargo.toml");
}

/// Format unix timestamp as YYYYMMDDHHmm (UTC, no external deps).
fn format_build_timestamp(secs: u64) -> String {
    // Days since Unix epoch
    let mut days = secs / 86400;
    let time_of_day = secs % 86400;
    let hour = time_of_day / 3600;
    let minute = (time_of_day % 3600) / 60;

    // Gregorian calendar calculation
    let mut year = 1970u64;
    loop {
        let leap = is_leap(year);
        let days_in_year = if leap { 366 } else { 365 };
        if days < days_in_year {
            break;
        }
        days -= days_in_year;
        year += 1;
    }
    let leap = is_leap(year);
    let month_days: [u64; 12] = [
        31,
        if leap { 29 } else { 28 },
        31,
        30,
        31,
        30,
        31,
        31,
        30,
        31,
        30,
        31,
    ];
    let mut month = 1u64;
    for &md in &month_days {
        if days < md {
            break;
        }
        days -= md;
        month += 1;
    }
    let day = days + 1;
    format!("{year}{month:02}{day:02}{hour:02}{minute:02}")
}

fn is_leap(year: u64) -> bool {
    (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
}

fn generate_platform_template_assets() {
    let templates_dir = "src/platform/web/templates";
    let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR not set");
    let dest = Path::new(&out_dir).join("platform_templates_gen.rs");
    println!("cargo:rerun-if-changed=build.rs");

    let mut rel_paths: Vec<String> = Vec::new();
    collect_files(Path::new(templates_dir), templates_dir, &mut rel_paths);
    rel_paths.sort();

    let mut code = String::from("pub const PLATFORM_TEMPLATE_ASSETS: &[EmbeddedAsset] = &[\n");
    for rel in &rel_paths {
        code.push_str(&format!(
            "    EmbeddedAsset {{ \
                path: {rel:?}, \
                bytes: include_bytes!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/src/platform/web/templates/{rel}\")) \
            }},\n"
        ));
    }
    code.push_str("];\n");

    fs::write(&dest, code).expect("failed writing platform_templates_gen.rs");

    // Re-run whenever any template file changes.
    println!("cargo:rerun-if-changed={templates_dir}");
}

// ── Help index generation ─────────────────────────────────────────────────────

fn generate_help_index() {
    let help_root = "src/platform/help";
    let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR not set");
    let dest = Path::new(&out_dir).join("help_generated.rs");

    // Always rerun when build.rs itself changes (covers cargo-chef stub → real build.rs swap,
    // and ensures a previously-empty cached HELP is regenerated after help files appear).
    println!("cargo:rerun-if-changed=build.rs");

    // Collect all files relative to the manifest root (e.g. "src/platform/help/web/hooks.md")
    let mut all_files: Vec<String> = Vec::new();
    collect_files(Path::new(help_root), help_root, &mut all_files);

    // Only .md files (excludes mod.rs, generated.rs, etc.)
    all_files.retain(|p| p.ends_with(".md"));
    all_files.sort();

    let mut code = String::from(
        "/// Auto-generated help node array. Do not edit — regenerated by build.rs.\n\
         pub static HELP: &[HelpNode] = &[\n",
    );

    for rel_from_help_root in &all_files {
        // Full path relative to manifest root
        let manifest_rel = format!("{}/{}", help_root, rel_from_help_root);

        // Help path: strip .md extension (e.g. "web/hooks")
        let help_path = rel_from_help_root.trim_end_matches(".md");

        // Read content for metadata extraction
        let content = match fs::read_to_string(&manifest_rel) {
            Ok(c) => c,
            Err(e) => {
                eprintln!("cargo:warning=Could not read {}: {}", manifest_rel, e);
                continue;
            }
        };

        let title = parse_title(&content);
        let excerpt = parse_excerpt(&content);
        let last_updated = git_last_updated(&manifest_rel);

        // include_str! path component: absolute via CARGO_MANIFEST_DIR
        let include_path = format!("/{}", manifest_rel);

        code.push_str(&format!(
            "    HelpNode {{\n\
                     path: {help_path:?},\n\
                     title: {title:?},\n\
                     excerpt: {excerpt:?},\n\
                     last_updated: {last_updated:?},\n\
                     content: include_str!(concat!(env!(\"CARGO_MANIFEST_DIR\"), {include_path:?})),\n\
                 }},\n",
        ));

        // Tell Cargo to rerun if this file changes
        println!("cargo:rerun-if-changed={}", manifest_rel);
    }

    code.push_str("];\n");

    fs::write(&dest, &code).expect("failed writing help_generated.rs");

    // Also rerun if new files are added to the help directory
    println!("cargo:rerun-if-changed={}", help_root);
}

/// Parse the first `# Heading` from markdown content.
fn parse_title(content: &str) -> String {
    for line in content.lines() {
        let trimmed = line.trim();
        if let Some(title) = trimmed.strip_prefix("# ") {
            return title.trim().to_string();
        }
    }
    "Untitled".to_string()
}

/// Parse first non-heading paragraph, ≤150 chars, stripped of inline markdown.
fn parse_excerpt(content: &str) -> String {
    let mut past_title = false;
    let mut paragraph_lines: Vec<&str> = Vec::new();

    for line in content.lines() {
        let trimmed = line.trim();

        if !past_title {
            if trimmed.starts_with("# ") {
                past_title = true;
            }
            continue;
        }

        // End of paragraph
        if trimmed.is_empty() {
            if !paragraph_lines.is_empty() {
                break;
            }
            continue;
        }

        // Skip sub-headings, horizontal rules, code blocks
        if trimmed.starts_with('#') || trimmed == "---" || trimmed.starts_with("```") {
            if !paragraph_lines.is_empty() {
                break;
            }
            continue;
        }

        paragraph_lines.push(trimmed);
    }

    let raw = paragraph_lines.join(" ");
    // Strip inline markdown: bold, italic, backticks
    let cleaned = raw
        .replace("**", "")
        .replace('*', "")
        .replace('`', "")
        .replace("__", "");
    let cleaned = cleaned.trim().to_string();

    // Truncate to 150 chars at word boundary
    if cleaned.len() <= 150 {
        cleaned
    } else {
        let truncated = &cleaned[..150];
        let end = truncated.rfind(' ').unwrap_or(150);
        format!("{}…", cleaned[..end].trim())
    }
}

/// Get last git commit date for a file (YYYY-MM-DD), or "unknown".
fn git_last_updated(manifest_rel: &str) -> String {
    let output = std::process::Command::new("git")
        .args(["log", "--format=%as", "-1", "--", manifest_rel])
        .output();

    match output {
        Ok(out) if out.status.success() => {
            let date = String::from_utf8_lossy(&out.stdout).trim().to_string();
            if date.is_empty() {
                "unknown".to_string()
            } else {
                date
            }
        }
        _ => "unknown".to_string(),
    }
}

fn collect_files(dir: &Path, root: &str, out: &mut Vec<String>) {
    let entries = match fs::read_dir(dir) {
        Ok(e) => e,
        Err(_) => return,
    };
    for entry in entries.flatten() {
        let path = entry.path();
        if path.is_dir() {
            collect_files(&path, root, out);
        } else if let Ok(rel) = path.strip_prefix(root) {
            let rel_str = rel.to_string_lossy().replace('\\', "/");
            out.push(rel_str);
        }
    }
}