forgex 0.0.1-alpha

CLI and runtime for the Forge full-stack framework
Documentation
//! Runtime Generator
//!
//! Generates the `.forge/svelte/` package directory containing the @forge/svelte runtime.
//! This runtime is regenerated by `forge generate` to keep it in sync with the CLI version.

use anyhow::Result;
use std::fs;
use std::path::Path;

use super::template::render;
use crate::template_vars;

/// Current FORGE CLI version (used for version tracking)
pub const FORGE_VERSION: &str = env!("CARGO_PKG_VERSION");

// Runtime templates
const PACKAGE_JSON: &str = include_str!("../../templates/runtime/package.json.tmpl");
const TYPES_TS: &str = include_str!("../../templates/runtime/types.ts.tmpl");
const CLIENT_TS: &str = include_str!("../../templates/runtime/client.ts.tmpl");
const CONTEXT_TS: &str = include_str!("../../templates/runtime/context.ts.tmpl");
const STORES_TS: &str = include_str!("../../templates/runtime/stores.ts.tmpl");
const API_TS: &str = include_str!("../../templates/runtime/api.ts.tmpl");
const FORGE_PROVIDER: &str = include_str!("../../templates/runtime/ForgeProvider.svelte.tmpl");
const INDEX_TS: &str = include_str!("../../templates/runtime/index.ts.tmpl");

/// Check if the project has a legacy runtime structure (embedded in src/lib/forge/runtime/)
pub fn has_legacy_runtime(frontend_dir: &Path) -> bool {
    frontend_dir.join("src/lib/forge/runtime/index.ts").exists()
}

/// Get the version of the runtime currently installed in the project
pub fn get_installed_version(frontend_dir: &Path) -> Option<String> {
    let version_file = frontend_dir.join(".forge/version");
    fs::read_to_string(version_file)
        .ok()
        .map(|s| s.trim().to_string())
}

/// Check if a runtime update is needed
pub fn needs_update(frontend_dir: &Path) -> bool {
    match get_installed_version(frontend_dir) {
        Some(installed) => installed != FORGE_VERSION,
        None => true, // No version file means we need to generate
    }
}

/// Generate the .forge/svelte/ package in the frontend directory.
///
/// This creates:
/// - `.forge/svelte/package.json` - NPM package manifest
/// - `.forge/svelte/index.ts` - Main exports
/// - `.forge/svelte/types.ts` - Type definitions
/// - `.forge/svelte/client.ts` - ForgeClient class
/// - `.forge/svelte/context.ts` - Svelte context utilities
/// - `.forge/svelte/stores.ts` - Svelte store functions
/// - `.forge/svelte/api.ts` - API helper functions
/// - `.forge/svelte/ForgeProvider.svelte` - Root provider component
/// - `.forge/version` - Version tracking file
pub fn generate_runtime(frontend_dir: &Path) -> Result<()> {
    let forge_dir = frontend_dir.join(".forge");
    let svelte_dir = forge_dir.join("svelte");

    // Create directories
    fs::create_dir_all(&svelte_dir)?;

    // Write version file
    fs::write(forge_dir.join("version"), FORGE_VERSION)?;

    // Template variables
    let vars = template_vars!("version" => FORGE_VERSION);

    // Generate all runtime files
    fs::write(svelte_dir.join("package.json"), render(PACKAGE_JSON, &vars))?;
    fs::write(svelte_dir.join("types.ts"), render(TYPES_TS, &vars))?;
    fs::write(svelte_dir.join("client.ts"), render(CLIENT_TS, &vars))?;
    fs::write(svelte_dir.join("context.ts"), render(CONTEXT_TS, &vars))?;
    fs::write(svelte_dir.join("stores.ts"), render(STORES_TS, &vars))?;
    fs::write(svelte_dir.join("api.ts"), render(API_TS, &vars))?;
    fs::write(
        svelte_dir.join("ForgeProvider.svelte"),
        render(FORGE_PROVIDER, &vars),
    )?;
    fs::write(svelte_dir.join("index.ts"), render(INDEX_TS, &vars))?;

    Ok(())
}

/// Update the frontend package.json to include the @forge/svelte dependency
pub fn update_frontend_package_json(frontend_dir: &Path) -> Result<()> {
    let package_json_path = frontend_dir.join("package.json");

    if !package_json_path.exists() {
        return Ok(()); // No package.json to update
    }

    let content = fs::read_to_string(&package_json_path)?;

    // Check if already has the dependency
    if content.contains("\"@forge/svelte\"") {
        return Ok(());
    }

    // Parse and update the JSON
    let mut json: serde_json::Value = serde_json::from_str(&content)?;

    if let Some(deps) = json.get_mut("dependencies") {
        if let Some(obj) = deps.as_object_mut() {
            obj.insert(
                "@forge/svelte".to_string(),
                serde_json::Value::String("file:./.forge/svelte".to_string()),
            );
        }
    } else {
        // Create dependencies object if it doesn't exist
        let mut deps = serde_json::Map::new();
        deps.insert(
            "@forge/svelte".to_string(),
            serde_json::Value::String("file:./.forge/svelte".to_string()),
        );
        json["dependencies"] = serde_json::Value::Object(deps);
    }

    // Write back with pretty formatting
    let formatted = serde_json::to_string_pretty(&json)?;
    fs::write(&package_json_path, formatted)?;

    Ok(())
}

/// Remove legacy runtime directory (src/lib/forge/runtime/)
pub fn remove_legacy_runtime(frontend_dir: &Path) -> Result<()> {
    let legacy_dir = frontend_dir.join("src/lib/forge/runtime");
    if legacy_dir.exists() {
        fs::remove_dir_all(legacy_dir)?;
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::tempdir;

    #[test]
    fn test_generate_runtime() {
        let dir = tempdir().unwrap();
        generate_runtime(dir.path()).unwrap();

        assert!(dir.path().join(".forge/version").exists());
        assert!(dir.path().join(".forge/svelte/package.json").exists());
        assert!(dir.path().join(".forge/svelte/index.ts").exists());
        assert!(dir.path().join(".forge/svelte/types.ts").exists());
        assert!(dir.path().join(".forge/svelte/client.ts").exists());
        assert!(dir.path().join(".forge/svelte/context.ts").exists());
        assert!(dir.path().join(".forge/svelte/stores.ts").exists());
        assert!(dir.path().join(".forge/svelte/api.ts").exists());
        assert!(dir
            .path()
            .join(".forge/svelte/ForgeProvider.svelte")
            .exists());
    }

    #[test]
    fn test_version_file() {
        let dir = tempdir().unwrap();
        generate_runtime(dir.path()).unwrap();

        let version = fs::read_to_string(dir.path().join(".forge/version")).unwrap();
        assert_eq!(version.trim(), FORGE_VERSION);
    }

    #[test]
    fn test_needs_update() {
        let dir = tempdir().unwrap();

        // No version file = needs update
        assert!(needs_update(dir.path()));

        // Generate runtime
        generate_runtime(dir.path()).unwrap();

        // Same version = no update needed
        assert!(!needs_update(dir.path()));

        // Different version = needs update
        fs::write(dir.path().join(".forge/version"), "0.0.0").unwrap();
        assert!(needs_update(dir.path()));
    }

    #[test]
    fn test_has_legacy_runtime() {
        let dir = tempdir().unwrap();

        // No legacy runtime
        assert!(!has_legacy_runtime(dir.path()));

        // Create legacy structure
        let legacy_dir = dir.path().join("src/lib/forge/runtime");
        fs::create_dir_all(&legacy_dir).unwrap();
        fs::write(legacy_dir.join("index.ts"), "// legacy").unwrap();

        assert!(has_legacy_runtime(dir.path()));
    }
}