run-what 0.94.0

HTML-first web framework powered by Rust. No JavaScript frameworks, no build steps—just HTML.
//! Bundle command: package a wwwhat app as a standalone executable project.
//!
//! Generates a Rust project that embeds all project files via `include_bytes!()`.
//! The resulting binary extracts files at runtime, starts the server, and opens a browser.

use anyhow::{Context, Result, bail};
use flate2::Compression;
use flate2::write::GzEncoder;
use std::fs;
use std::path::Path;
use std::process::Command;
use walkdir::WalkDir;

/// Directories to include in the bundle archive.
const BUNDLE_DIRS: &[&str] = &[
    "site",
    "pages", // legacy fallback
    "components",
    "static",
    "layouts",
    "partials",
    "emails",
];

/// Create a standalone bundle from a wwwhat project.
///
/// Generates a Rust project at `output/` containing:
/// - `project.tar.gz`: compressed archive of all project files
/// - `Cargo.toml`: depends on `wwwhat-core` from crates.io
/// - `src/main.rs`: extracts archive, starts server, opens browser
///
/// If `compile` is true, also runs `cargo build --release` and copies the binary.
pub fn create_bundle(path: &Path, output: &Path, compile: bool) -> Result<()> {
    let project_path = fs::canonicalize(path)
        .with_context(|| format!("Project directory not found: {}", path.display()))?;

    // Verify it's a wwwhat project
    if !project_path.join("wwwhat.toml").exists() {
        bail!(
            "No wwwhat.toml found in {}. Is this a wwwhat project?",
            project_path.display()
        );
    }

    println!("  Bundling project: {}", project_path.display());

    // Create output directory
    fs::create_dir_all(output)
        .with_context(|| format!("Could not create output directory: {}", output.display()))?;
    fs::create_dir_all(output.join("src"))?;

    // Step 1: Create tar.gz archive of project files
    let archive_path = output.join("project.tar.gz");
    let file_count = create_archive(&project_path, &archive_path)?;
    println!("  Archived {} files into project.tar.gz", file_count);

    // Step 2: Generate Cargo.toml
    let cargo_toml = generate_cargo_toml();
    fs::write(output.join("Cargo.toml"), cargo_toml)?;

    // Step 3: Generate src/main.rs
    let main_rs = generate_main_rs();
    fs::write(output.join("src").join("main.rs"), main_rs)?;

    println!("  Generated standalone project at {}", output.display());

    if compile {
        println!("  Compiling release binary...");
        let cargo_toml_path = output.join("Cargo.toml");
        let original_manifest = fs::read_to_string(&cargo_toml_path)?;

        if let Some(local_manifest) = local_compile_manifest(&original_manifest) {
            fs::write(&cargo_toml_path, local_manifest)?;
        }

        let status = Command::new("cargo")
            .arg("build")
            .arg("--release")
            .current_dir(output)
            .status()
            .context("Failed to run cargo build")?;

        fs::write(&cargo_toml_path, original_manifest)?;

        if !status.success() {
            bail!("Compilation failed");
        }

        // Find the binary name from the output directory
        let binary_name = "wwwhat-app";
        let binary_src = output.join("target").join("release").join(binary_name);
        if binary_src.exists() {
            let binary_dst = output.join(binary_name);
            fs::copy(&binary_src, &binary_dst)?;
            println!("  Binary ready: {}", binary_dst.display());
        }
    } else {
        println!();
        println!("  To compile:");
        println!("    cd {} && cargo build --release", output.display());
        println!();
        println!(
            "  The binary will be at {}/target/release/wwwhat-app",
            output.display()
        );
    }

    Ok(())
}

/// Create a tar.gz archive of all project files.
fn create_archive(project_path: &Path, archive_path: &Path) -> Result<usize> {
    let file = fs::File::create(archive_path)?;
    let enc = GzEncoder::new(file, Compression::default());
    let mut tar = tar::Builder::new(enc);
    let mut count = 0;

    // Add wwwhat.toml
    let config_path = project_path.join("wwwhat.toml");
    if config_path.exists() {
        tar.append_path_with_name(&config_path, "wwwhat.toml")?;
        count += 1;
    }

    // Add each directory that exists
    for dir_name in BUNDLE_DIRS {
        let dir_path = project_path.join(dir_name);
        if dir_path.is_dir() {
            for entry in WalkDir::new(&dir_path).into_iter().filter_map(|e| e.ok()) {
                let entry_path = entry.path();
                if entry_path.is_file() {
                    let relative = entry_path.strip_prefix(project_path)?;
                    tar.append_path_with_name(entry_path, relative)?;
                    count += 1;
                }
            }
        }
    }

    // Add static directory
    let static_path = project_path.join("static");
    if static_path.is_dir() {
        // Already handled above via BUNDLE_DIRS
    }

    tar.into_inner()?.finish()?;
    Ok(count)
}

/// Generate the Cargo.toml for the standalone project.
fn generate_cargo_toml() -> String {
    let version = env!("CARGO_PKG_VERSION");
    format!(
        r#"[package]
name = "wwwhat-app"
version = "0.1.0"
edition = "2021"

[workspace]

[[bin]]
name = "wwwhat-app"
path = "src/main.rs"

[dependencies]
wwwhat-core = "{version}"
tokio = {{ version = "1", features = ["full"] }}
flate2 = "1"
tar = "0.4"
open = "5"
anyhow = "1"
tracing = "0.1"
tracing-subscriber = {{ version = "0.3", features = ["env-filter"] }}
"#
    )
}

fn local_compile_manifest(manifest: &str) -> Option<String> {
    let cli_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
    let core_dir = cli_dir.parent()?.join("wwwhat-core");

    if !core_dir.join("Cargo.toml").exists() {
        return None;
    }

    Some(format!(
        "{manifest}\n[patch.crates-io]\nwwwhat-core = {{ path = {:?} }}\n",
        core_dir
    ))
}

/// Generate the main.rs for the standalone project.
fn generate_main_rs() -> String {
    r#"//! Standalone wwwhat application.
//! Generated by `run-what bundle`. Edit freely.

use anyhow::Result;
use flate2::read::GzDecoder;
use std::path::PathBuf;
use tar::Archive;
use tracing::warn;

const PROJECT_DATA: &[u8] = include_bytes!("../project.tar.gz");

fn extract_project() -> Result<PathBuf> {
    // Use a stable temp directory based on data hash
    let hash = {
        let mut h: u64 = 5381;
        for &b in &PROJECT_DATA[..PROJECT_DATA.len().min(4096)] {
            h = h.wrapping_mul(33).wrapping_add(b as u64);
        }
        h
    };
    let extract_dir = std::env::temp_dir().join(format!("wwwhat-bundle-{:x}", hash));

    // Skip extraction if already done
    if extract_dir.join("wwwhat.toml").exists() {
        return Ok(extract_dir);
    }

    std::fs::create_dir_all(&extract_dir)?;

    let decoder = GzDecoder::new(PROJECT_DATA);
    let mut archive = Archive::new(decoder);
    archive.unpack(&extract_dir)?;

    Ok(extract_dir)
}

fn setup_persistence(extract_dir: &std::path::Path) -> Result<()> {
    let cwd = std::env::current_dir()?;

    // Ensure data/ and uploads/ directories exist in CWD for persistence
    for dir_name in &["data", "uploads"] {
        let cwd_dir = cwd.join(dir_name);
        let extract_target = extract_dir.join(dir_name);

        // Create the directory in CWD if it doesn't exist
        if !cwd_dir.exists() {
            std::fs::create_dir_all(&cwd_dir)?;
        }

        // Remove any existing dir in extract path and symlink to CWD
        if extract_target.exists() {
            std::fs::remove_dir_all(&extract_target).ok();
        }

        #[cfg(unix)]
        std::os::unix::fs::symlink(&cwd_dir, &extract_target)?;

        #[cfg(windows)]
        std::os::windows::fs::symlink_dir(&cwd_dir, &extract_target)?;
    }

    Ok(())
}

#[tokio::main]
async fn main() -> Result<()> {
    tracing_subscriber::fmt()
        .with_env_filter(tracing_subscriber::EnvFilter::new("info"))
        .with_target(false)
        .compact()
        .init();

    let extract_dir = extract_project()?;
    setup_persistence(&extract_dir)?;

    let config_path = extract_dir.join("wwwhat.toml");
    let config = if config_path.exists() {
        wwwhat_core::Config::load(&config_path)?
    } else {
        wwwhat_core::Config::default()
    };

    let host = config.server.host.clone();
    let port = config.server.port;

    let state = wwwhat_core::server::AppState::with_dev_mode(config, extract_dir, false)?;

    if let Err(e) = state.init_datasources().await {
        eprintln!("Failed to initialize datasources: {}", e);
        std::process::exit(1);
    }

    let url = format!("http://{}:{}", host, port);

    println!();
    println!("  What app running!");
    println!();
    println!("  Local:   {}", url);
    println!();
    println!("  Data stored in: {}/data/", std::env::current_dir()?.display());
    println!("  Press Ctrl+C to stop");
    println!();

    // Open browser after a short delay
    let open_url = url.clone();
    tokio::spawn(async move {
        tokio::time::sleep(std::time::Duration::from_millis(500)).await;
        if let Err(e) = open::that(&open_url) {
            warn!("Could not open browser: {}", e);
        }
    });

    let shutdown = async {
        tokio::signal::ctrl_c().await.ok();
    };

    wwwhat_core::server::serve_with_shutdown(state, shutdown).await?;

    Ok(())
}
"#
    .to_string()
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;

    #[test]
    fn test_generate_cargo_toml_pins_current_version() {
        let toml = generate_cargo_toml();
        let version = env!("CARGO_PKG_VERSION");
        assert!(toml.contains(&format!("wwwhat-core = \"{}\"", version)));
        assert!(toml.contains("name = \"wwwhat-app\""));
    }

    #[test]
    fn test_generate_main_rs_contains_key_elements() {
        let main = generate_main_rs();
        assert!(main.contains("include_bytes!"));
        assert!(main.contains("extract_project"));
        assert!(main.contains("setup_persistence"));
        assert!(main.contains("serve_with_shutdown"));
        assert!(main.contains("open::that"));
    }

    #[test]
    fn test_create_archive_requires_wwwhat_toml() {
        let dir = tempfile::tempdir().unwrap();
        let archive_path = dir.path().join("test.tar.gz");
        // No wwwhat.toml, but create_archive just archives what exists
        let result = create_archive(dir.path(), &archive_path);
        assert!(result.is_ok());
        assert_eq!(result.unwrap(), 0);
    }

    #[test]
    fn test_create_archive_includes_config() {
        let dir = tempfile::tempdir().unwrap();
        fs::write(dir.path().join("wwwhat.toml"), "[server]\nport = 8085").unwrap();
        let archive_path = dir.path().join("test.tar.gz");
        let count = create_archive(dir.path(), &archive_path).unwrap();
        assert_eq!(count, 1);
        assert!(archive_path.exists());
    }

    #[test]
    fn test_create_archive_includes_site_dir() {
        let dir = tempfile::tempdir().unwrap();
        fs::write(dir.path().join("wwwhat.toml"), "[server]").unwrap();
        let site_dir = dir.path().join("site");
        fs::create_dir_all(&site_dir).unwrap();
        fs::write(site_dir.join("index.html"), "<h1>Hello</h1>").unwrap();
        fs::write(site_dir.join("about.html"), "<h1>About</h1>").unwrap();

        let archive_path = dir.path().join("test.tar.gz");
        let count = create_archive(dir.path(), &archive_path).unwrap();
        // wwwhat.toml + 2 html files
        assert_eq!(count, 3);
    }

    #[test]
    fn test_bundle_rejects_non_project_dir() {
        let dir = tempfile::tempdir().unwrap();
        let output = dir.path().join("bundle");
        let result = create_bundle(dir.path(), &output, false);
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("wwwhat.toml"));
    }

    #[test]
    fn test_bundle_creates_complete_project() {
        let dir = tempfile::tempdir().unwrap();
        fs::write(dir.path().join("wwwhat.toml"), "[server]\nport = 8085").unwrap();
        let site_dir = dir.path().join("site");
        fs::create_dir_all(&site_dir).unwrap();
        fs::write(site_dir.join("index.html"), "<h1>Hello</h1>").unwrap();

        let output = dir.path().join("bundle");
        create_bundle(dir.path(), &output, false).unwrap();

        assert!(output.join("Cargo.toml").exists());
        assert!(output.join("src").join("main.rs").exists());
        assert!(output.join("project.tar.gz").exists());
    }
}