forgex 0.8.0

CLI and runtime for the Forge full-stack framework
use std::env;
use std::fs;
use std::path::{Path, PathBuf};

fn main() {
    println!("cargo:rerun-if-changed=build.rs");

    let manifest_dir =
        PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR must be set"));
    let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR must be set"));

    let embedded_examples_dir = out_dir.join("examples");

    if embedded_examples_dir.exists() {
        fs::remove_dir_all(&embedded_examples_dir)
            .expect("failed to clear generated embedded examples");
    }
    fs::create_dir_all(&embedded_examples_dir)
        .expect("failed to create generated embedded examples");

    if let Some(examples_dir) = find_examples_dir(&manifest_dir) {
        build_bundle_from_examples(&examples_dir, &embedded_examples_dir);
        return;
    }

    let archive_path = manifest_dir.join("generated/examples.tar");
    println!("cargo:rerun-if-changed={}", archive_path.display());

    if archive_path.exists() {
        let archive =
            fs::File::open(&archive_path).expect("failed to open generated examples archive");
        let mut archive = tar::Archive::new(archive);
        archive
            .unpack(&embedded_examples_dir)
            .expect("failed to unpack generated examples archive");
        return;
    }

    unreachable!("could not find examples directory or generated examples archive");
}

fn build_bundle_from_examples(examples_dir: &Path, bundle_dir: &Path) {
    for framework_dir in fs::read_dir(examples_dir).expect("failed to read examples directory") {
        let framework_dir = framework_dir.expect("failed to read framework entry");
        let framework_path = framework_dir.path();
        if !framework_path.is_dir() {
            continue;
        }

        let framework_name = framework_path
            .file_name()
            .and_then(|name| name.to_str())
            .expect("framework directory must have utf-8 name");

        if !framework_name.starts_with("with-") {
            continue;
        }

        for template_dir in fs::read_dir(&framework_path).expect("failed to read framework dir") {
            let template_dir = template_dir.expect("failed to read template entry");
            let template_path = template_dir.path();
            if !template_path.is_dir() {
                continue;
            }

            copy_template_tree(
                &template_path,
                &bundle_dir.join(framework_name).join(
                    template_path
                        .file_name()
                        .expect("template directory must have a name"),
                ),
            );
        }
    }
}

fn find_examples_dir(manifest_dir: &Path) -> Option<PathBuf> {
    let candidates = [
        manifest_dir.join("../../examples"),
        manifest_dir.join("examples"),
    ];

    for candidate in candidates {
        if candidate.is_dir() {
            register_rerun_paths(&candidate);
            return Some(candidate);
        }
    }

    None
}

fn register_rerun_paths(root: &Path) {
    println!("cargo:rerun-if-changed={}", root.display());

    if let Ok(entries) = fs::read_dir(root) {
        for entry in entries.flatten() {
            let path = entry.path();
            println!("cargo:rerun-if-changed={}", path.display());
            if path.is_dir() {
                register_rerun_paths(&path);
            }
        }
    }
}

fn copy_template_tree(src: &Path, dest: &Path) {
    fs::create_dir_all(dest).expect("failed to create template directory");
    copy_dir_contents(src, dest, Path::new(""));
}

fn copy_dir_contents(src: &Path, dest: &Path, relative: &Path) {
    let entries = fs::read_dir(src).expect("failed to read template source directory");

    for entry in entries {
        let entry = entry.expect("failed to read template source entry");
        let entry_path = entry.path();
        let entry_name = entry.file_name();
        let relative_path = relative.join(&entry_name);

        if should_exclude(&relative_path) {
            continue;
        }

        let dest_path = dest.join(&entry_name);
        if entry_path.is_dir() {
            fs::create_dir_all(&dest_path).expect("failed to create bundled directory");
            copy_dir_contents(&entry_path, &dest_path, &relative_path);
        } else {
            if let Some(parent) = dest_path.parent() {
                fs::create_dir_all(parent).expect("failed to create bundled file parent");
            }
            fs::copy(&entry_path, &dest_path).expect("failed to copy template file");
        }
    }
}

fn should_exclude(relative_path: &Path) -> bool {
    const EXACT_FILES: &[&str] = &[
        ".forge-dev-integration.log",
        "package-lock.json",
        "bun.lock",
        "Cargo.lock",
    ];
    const PATH_COMPONENTS: &[&str] = &[
        ".git",
        "pg_data",
        "target",
        "node_modules",
        ".svelte-kit",
        "build",
        "dist",
        "playwright-report",
        "test-results",
        "skills",
    ];

    if let Some(file_name) = relative_path.file_name().and_then(|name| name.to_str())
        && EXACT_FILES.contains(&file_name)
    {
        return true;
    }

    relative_path.components().any(|component| {
        let name = component.as_os_str().to_string_lossy();
        PATH_COMPONENTS.iter().any(|excluded| *excluded == name)
    })
}