forte-cli 0.3.30

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

#[derive(Debug)]
pub struct BuildOptions {
    pub project_dir: PathBuf,
    pub static_base_url: Option<String>,
}

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, options.static_base_url.as_deref())?;

    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, project_dir: &Path) -> Result<PathBuf> {
    let cargo_toml = project_dir.join("rs/Cargo.toml");
    let content = fs::read_to_string(&cargo_toml)
        .with_context(|| format!("read {}", cargo_toml.display()))?;

    #[derive(serde::Deserialize)]
    struct CargoToml {
        package: CargoPackage,
    }
    #[derive(serde::Deserialize)]
    struct CargoPackage {
        name: String,
    }

    let parsed: CargoToml =
        toml::from_str(&content).with_context(|| format!("parse {}", cargo_toml.display()))?;

    let wasm_name = format!("{}.wasm", parsed.package.name.replace('-', "_"));
    let wasm_path = release_dir.join(&wasm_name);

    if !wasm_path.exists() {
        anyhow::bail!(
            "expected wasm '{}' not found in {}",
            wasm_name,
            release_dir.display()
        );
    }
    Ok(wasm_path)
}

fn build_frontend(project_dir: &Path, static_base_url: Option<&str>) -> 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 mut client_cmd = Command::new("npx");
        client_cmd
            .args(["vite", "build", "--config"])
            .arg(&config_path)
            .current_dir(&fe_dir);
        if let Some(base) = static_base_url {
            client_cmd.env("VITE_PUBLIC_URL", base);
        }
        let status = client_cmd
            .status()
            .context("Failed to run vite build (client)")?;
        if !status.success() {
            anyhow::bail!("vite client build failed with status: {}", status);
        }

        let mut ssr_cmd = Command::new("npx");
        ssr_cmd
            .args(["vite", "build", "--ssr"])
            .arg(&server_entry)
            .args(["--config"])
            .arg(&config_path)
            .current_dir(&fe_dir);
        if let Some(base) = static_base_url {
            ssr_cmd.env("VITE_PUBLIC_URL", base);
        }
        let status = ssr_cmd.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"),
        project_dir,
    )?;
    let frontend_js = project_dir.join("fe/dist/ssr/server.js");

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

    let server_content = fs::read(&frontend_js)?;
    fs::write(dist_dir.join("server.js"), server_content)?;
    println!("[dist] Copied server.js");

    Ok(())
}