forte-cli 0.3.23

CLI for the Forte fullstack web framework
use crate::cli::fe_runtime;
use anyhow::{Context, Result};
use std::fs;
use std::hash::{DefaultHasher, Hash, Hasher};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::process::Stdio;

#[derive(Debug)]
pub struct BuildOptions {
    pub project_dir: PathBuf,
}

pub async fn run_build(options: BuildOptions) -> Result<()> {
    let project_dir = options.project_dir.canonicalize()?;

    if !project_dir.join("Forte.toml").exists() {
        anyhow::bail!("Not a Forte project (Forte.toml not found)");
    }

    println!("Building Forte project for production...");
    println!("Project directory: {}", project_dir.display());

    run_codegen(&project_dir).await?;
    build_backend(&project_dir)?;
    build_frontend(&project_dir)?;

    let dist_dir = project_dir.join("dist");
    create_dist(&project_dir, &dist_dir)?;

    println!();
    println!("Build complete!");
    println!("Output: {}", dist_dir.display());

    Ok(())
}

const FORTE_RS_TO_TS_VERSION: &str = "0.1.7";

async fn ensure_forte_rs_to_ts() -> Result<PathBuf> {
    let url = crate::tools::fn0_release_url("forte-rs-to-ts", FORTE_RS_TO_TS_VERSION)?;
    crate::tools::ensure_github_tool_with_libs(
        "forte-rs-to-ts",
        FORTE_RS_TO_TS_VERSION,
        &url,
        "forte-rs-to-ts",
    )
    .await
}

async fn run_codegen(project_dir: &Path) -> Result<()> {
    let rs_dir = project_dir.join("rs");
    if !rs_dir.exists() {
        fe_runtime::ensure(project_dir)?;
        generate_frontend_routes(project_dir)?;
        return Ok(());
    }

    let binary = ensure_forte_rs_to_ts().await?;

    println!("[codegen] Running forte-rs-to-ts...");
    let status = Command::new(&binary)
        .arg(project_dir)
        .stdout(Stdio::null())
        .status()
        .context("Failed to run forte-rs-to-ts")?;

    if !status.success() {
        anyhow::bail!("forte-rs-to-ts failed with status: {}", status);
    }

    fe_runtime::ensure(project_dir)?;
    generate_frontend_routes(project_dir)?;

    Ok(())
}

fn generate_frontend_routes(project_dir: &Path) -> Result<()> {
    let pages_dir = project_dir.join("rs/src/pages");

    if !pages_dir.exists() {
        return Ok(());
    }

    println!("[codegen] Generating frontend routes...");

    let prefix = fe_runtime::page_import_prefix(project_dir);
    let mut routes = Vec::new();
    scan_pages_dir(&pages_dir, &pages_dir, prefix, &mut routes)?;

    routes.sort_by(|a, b| {
        let a_dynamic = a.0.contains(':');
        let b_dynamic = b.0.contains(':');
        if a_dynamic != b_dynamic {
            return a_dynamic.cmp(&b_dynamic);
        }
        a.0.cmp(&b.0)
    });

    let mut output = String::new();
    output.push_str("// Auto-generated by forte build\n\n");
    output.push_str("export const routes: Array<{ path: string; component: () => Promise<{ default: (props: any) => any }>; schema: () => Promise<{ PropsSchema: any }> }> = [\n");

    for (path, fe_page_path) in &routes {
        let fe_props_path = fe_page_path
            .strip_suffix("/page")
            .map(|s| format!("{}/.props", s))
            .unwrap_or_else(|| fe_page_path.clone());
        output.push_str(&format!(
            "  {{ path: \"{}\", component: () => import(\"{}\"), schema: () => import(\"{}\") }},\n",
            path, fe_page_path, fe_props_path
        ));
    }

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

    let output_path = fe_runtime::routes_generated(project_dir);
    if let Some(parent) = output_path.parent() {
        fs::create_dir_all(parent)?;
    }
    fs::write(output_path, output)?;

    println!("[codegen] Generated {} route(s)", routes.len());

    Ok(())
}

fn scan_pages_dir(
    base_dir: &Path,
    current_dir: &Path,
    page_import_prefix: &str,
    routes: &mut Vec<(String, String)>,
) -> Result<()> {
    for entry in fs::read_dir(current_dir)? {
        let entry = entry?;
        let path = entry.path();

        if path.is_dir() {
            scan_pages_dir(base_dir, &path, page_import_prefix, routes)?;
        } else if path.extension().is_some_and(|ext| ext == "rs")
            && has_handler_function(&path)?
            && let Some((route_path, fe_page_path)) =
                path_to_route(base_dir, &path, page_import_prefix)
        {
            routes.push((route_path, fe_page_path));
        }
    }
    Ok(())
}

fn has_handler_function(path: &Path) -> Result<bool> {
    let content = fs::read_to_string(path)?;
    if content.contains("type Props = Redirect") {
        return Ok(false);
    }
    Ok(content.contains("pub async fn handler"))
}

fn path_to_route(
    base_dir: &Path,
    file_path: &Path,
    page_import_prefix: &str,
) -> Option<(String, String)> {
    let relative = file_path.strip_prefix(base_dir).ok()?;
    let relative_str = relative.to_string_lossy();

    let mut route_path = relative_str
        .trim_end_matches(".rs")
        .trim_end_matches("/mod")
        .replace('\\', "/");

    if route_path == "index" || route_path.is_empty() {
        route_path = "/".to_string();
    } else {
        route_path = route_path
            .replace("/index", "")
            .replace("[", ":")
            .replace("]", "");
        if !route_path.starts_with('/') {
            route_path = format!("/{}", route_path);
        }
    }

    if route_path.starts_with("/api/") {
        return None;
    }

    let fe_page_path = build_fe_page_path(&route_path, page_import_prefix);

    Some((route_path, fe_page_path))
}

fn build_fe_page_path(route_path: &str, prefix: &str) -> String {
    if route_path == "/" {
        format!("{}/pages/index/page", prefix)
    } else {
        let path = route_path
            .replace(":", "[")
            .split('/')
            .filter(|s| !s.is_empty())
            .map(|s| {
                if s.starts_with('[') {
                    format!("{}]", s)
                } else {
                    s.to_string()
                }
            })
            .collect::<Vec<_>>()
            .join("/");
        format!("{}/pages/{}/page", prefix, path)
    }
}

fn build_backend(project_dir: &Path) -> Result<()> {
    println!("[build] Building backend (release)...");
    let status = Command::new("cargo")
        .arg("build")
        .arg("--release")
        .arg("--target")
        .arg("wasm32-wasip2")
        .current_dir(project_dir.join("rs"))
        .status()
        .context("Failed to run cargo build")?;

    if !status.success() {
        anyhow::bail!("cargo build failed with status: {}", status);
    }

    Ok(())
}

fn find_wasm_binary(release_dir: &Path) -> Result<PathBuf> {
    for entry in fs::read_dir(release_dir)? {
        let path = entry?.path();
        if path.extension().is_some_and(|ext| ext == "wasm") {
            return Ok(path);
        }
    }
    anyhow::bail!("No .wasm file found in {}", release_dir.display())
}

fn build_frontend(project_dir: &Path) -> Result<()> {
    let fe_dir = project_dir.join("fe");

    println!("[build] Building frontend...");

    if let Some(config_path) = fe_runtime::vite_config(project_dir) {
        let server_entry = fe_runtime::ssr_entry(project_dir);

        let status = Command::new("npx")
            .args(["vite", "build", "--config"])
            .arg(&config_path)
            .current_dir(&fe_dir)
            .status()
            .context("Failed to run vite build (client)")?;
        if !status.success() {
            anyhow::bail!("vite client build failed with status: {}", status);
        }

        let status = Command::new("npx")
            .args(["vite", "build", "--ssr"])
            .arg(&server_entry)
            .args(["--config"])
            .arg(&config_path)
            .current_dir(&fe_dir)
            .status()
            .context("Failed to run vite build --ssr")?;
        if !status.success() {
            anyhow::bail!("vite ssr build failed with status: {}", status);
        }
    } else {
        let status = Command::new("npm")
            .arg("run")
            .arg("build")
            .current_dir(&fe_dir)
            .status()
            .context("Failed to run npm run build")?;
        if !status.success() {
            anyhow::bail!("npm run build failed with status: {}", status);
        }
    }

    Ok(())
}

fn create_dist(project_dir: &Path, dist_dir: &Path) -> Result<()> {
    println!("[dist] Creating distribution package...");

    if dist_dir.exists() {
        fs::remove_dir_all(dist_dir)?;
    }
    fs::create_dir_all(dist_dir)?;

    let backend_wasm = find_wasm_binary(&project_dir.join("rs/target/wasm32-wasip2/release"))?;
    let frontend_js = project_dir.join("fe/dist/ssr/server.js");
    let client_js = project_dir.join("fe/dist/client.js");
    let public_dir = project_dir.join("fe/public");

    fs::copy(&backend_wasm, dist_dir.join("backend.wasm"))?;
    println!("[dist] Copied backend.wasm");

    let dist_public = dist_dir.join("public");
    fs::create_dir_all(&dist_public)?;

    if public_dir.exists() {
        copy_dir_recursive(&public_dir, &dist_public)?;
        println!("[dist] Copied public/");
    }

    let client_content = fs::read(&client_js)?;
    let hash = compute_hash(&client_content);
    let hashed_filename = format!("client.{}.js", hash);

    fs::write(dist_public.join(&hashed_filename), &client_content)?;
    println!("[dist] Copied {} (hashed)", hashed_filename);

    let server_content = fs::read_to_string(&frontend_js)?;
    let updated_content =
        server_content.replace("/public/client.js", &format!("/public/{}", hashed_filename));
    fs::write(dist_dir.join("server.js"), updated_content)?;
    println!("[dist] Copied server.js (updated client path)");

    let manifest = format!(r#"{{"client.js":"{}"}}"#, hashed_filename);
    fs::write(dist_public.join("manifest.json"), manifest)?;
    println!("[dist] Generated manifest.json");

    Ok(())
}

fn compute_hash(content: &[u8]) -> String {
    let mut hasher = DefaultHasher::new();
    content.hash(&mut hasher);
    let hash = hasher.finish();
    format!("{:08x}", hash as u32)
}

fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
    fs::create_dir_all(dst)?;

    for entry in fs::read_dir(src)? {
        let entry = entry?;
        let src_path = entry.path();
        let dst_path = dst.join(entry.file_name());

        if src_path.is_dir() {
            copy_dir_recursive(&src_path, &dst_path)?;
        } else {
            fs::copy(&src_path, &dst_path)?;
        }
    }

    Ok(())
}