tovuk 0.1.99

Deploy Rust workers, static frontends, and full-stack services to Tovuk.
use super::super::constants::{
    DEFAULT_BUN_FRONTEND_CHECK_COMMAND, DEFAULT_NPM_FRONTEND_CHECK_COMMAND,
};
use crate::cli::project::read_package_json;
use serde_json::Value;
use std::{fs, path::Path};

const NEXT_CONFIG_FILES: &[&str] = &[
    "next.config.js",
    "next.config.mjs",
    "next.config.ts",
    "next.config.mts",
];
const PACKAGE_DEPENDENCY_SECTIONS: &[&str] = &[
    "dependencies",
    "devDependencies",
    "optionalDependencies",
    "peerDependencies",
];

pub(super) fn frontend_lockfile_exists(project_dir: &Path) -> bool {
    [
        "package-lock.json",
        "npm-shrinkwrap.json",
        "pnpm-lock.yaml",
        "yarn.lock",
        "bun.lock",
        "bun.lockb",
    ]
    .iter()
    .any(|file| project_dir.join(file).exists())
}

pub(crate) fn is_plain_static_frontend(project_dir: &Path) -> bool {
    !project_dir.join("package.json").exists() && project_dir.join("index.html").exists()
}

pub(crate) fn is_next_frontend(project_dir: &Path) -> bool {
    next_config_file(project_dir).is_some()
        || read_package_json(project_dir).is_some_and(|manifest| package_uses_next(&manifest))
}

pub(super) fn next_config_file(project_dir: &Path) -> Option<&'static str> {
    NEXT_CONFIG_FILES
        .iter()
        .copied()
        .find(|file| project_dir.join(file).exists())
}

pub(super) fn next_static_export_enabled(project_dir: &Path) -> bool {
    let Some(config_file) = next_config_file(project_dir) else {
        return false;
    };
    let Ok(source) = fs::read_to_string(project_dir.join(config_file)) else {
        return false;
    };
    let compact = compact_javascript_config(&source);
    compact.contains("output:\"export\"") || compact.contains("output:'export'")
}

pub(crate) fn frontend_package_manager(project_dir: &Path) -> &'static str {
    if project_dir.join("bun.lock").exists() || project_dir.join("bun.lockb").exists() {
        "bun"
    } else {
        "npm"
    }
}

pub(crate) fn frontend_check_command(project_dir: &Path) -> String {
    if is_plain_static_frontend(project_dir) {
        ":".to_owned()
    } else if frontend_package_manager(project_dir) == "bun" {
        DEFAULT_BUN_FRONTEND_CHECK_COMMAND.to_owned()
    } else {
        DEFAULT_NPM_FRONTEND_CHECK_COMMAND.to_owned()
    }
}

pub(crate) fn frontend_build_command(project_dir: &Path) -> String {
    if is_plain_static_frontend(project_dir) {
        ":".to_owned()
    } else if frontend_package_manager(project_dir) == "bun" {
        "bun run build".to_owned()
    } else {
        "npm run build".to_owned()
    }
}

fn package_uses_next(manifest: &Value) -> bool {
    PACKAGE_DEPENDENCY_SECTIONS.iter().any(|section| {
        manifest
            .get(section)
            .and_then(Value::as_object)
            .is_some_and(|dependencies| dependencies.contains_key("next"))
    })
}

fn compact_javascript_config(source: &str) -> String {
    source
        .chars()
        .filter(|character| !character.is_ascii_whitespace())
        .collect()
}

#[cfg(test)]
mod tests {
    use super::{is_next_frontend, next_static_export_enabled};
    use std::{fs, path::PathBuf};

    #[test]
    fn detects_next_from_package_dependencies() -> Result<(), Box<dyn std::error::Error>> {
        let project_dir = temp_project("next-package")?;
        fs::write(
            project_dir.join("package.json"),
            r#"{"dependencies":{"next":"16.0.0"}}"#,
        )?;

        require(
            is_next_frontend(&project_dir),
            "Next dependency was not detected",
        )?;
        require(
            !next_static_export_enabled(&project_dir),
            "missing Next config unexpectedly enabled static export",
        )?;

        cleanup(project_dir)
    }

    #[test]
    fn accepts_next_static_export_config() -> Result<(), Box<dyn std::error::Error>> {
        let project_dir = temp_project("next-static-export")?;
        fs::write(project_dir.join("package.json"), r#"{"dependencies":{}}"#)?;
        fs::write(
            project_dir.join("next.config.mjs"),
            "const nextConfig = {\n  output: \"export\",\n};\nexport default nextConfig;\n",
        )?;

        require(
            is_next_frontend(&project_dir),
            "Next config was not detected",
        )?;
        require(
            next_static_export_enabled(&project_dir),
            "static export config was not accepted",
        )?;

        cleanup(project_dir)
    }

    #[test]
    fn rejects_next_config_without_static_export() -> Result<(), Box<dyn std::error::Error>> {
        let project_dir = temp_project("next-missing-export")?;
        fs::write(project_dir.join("next.config.mjs"), "export default {};\n")?;

        require(
            is_next_frontend(&project_dir),
            "Next config was not detected",
        )?;
        require(
            !next_static_export_enabled(&project_dir),
            "config without static export was accepted",
        )?;

        cleanup(project_dir)
    }

    fn temp_project(name: &str) -> Result<PathBuf, Box<dyn std::error::Error>> {
        let project_dir = std::env::temp_dir().join(format!("tovuk-{name}-{}", std::process::id()));
        let _ = fs::remove_dir_all(&project_dir);
        fs::create_dir_all(&project_dir)?;
        Ok(project_dir)
    }

    fn cleanup(project_dir: PathBuf) -> Result<(), Box<dyn std::error::Error>> {
        fs::remove_dir_all(project_dir)?;
        Ok(())
    }

    fn require(condition: bool, message: &str) -> Result<(), Box<dyn std::error::Error>> {
        if condition {
            Ok(())
        } else {
            Err(message.to_owned().into())
        }
    }
}