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;
const BUNDLE_DIRS: &[&str] = &[
"site",
"pages", "components",
"static",
"layouts",
"partials",
"emails",
];
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()))?;
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());
fs::create_dir_all(output)
.with_context(|| format!("Could not create output directory: {}", output.display()))?;
fs::create_dir_all(output.join("src"))?;
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);
let cargo_toml = generate_cargo_toml();
fs::write(output.join("Cargo.toml"), cargo_toml)?;
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");
}
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(())
}
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;
let config_path = project_path.join("wwwhat.toml");
if config_path.exists() {
tar.append_path_with_name(&config_path, "wwwhat.toml")?;
count += 1;
}
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;
}
}
}
}
let static_path = project_path.join("static");
if static_path.is_dir() {
}
tar.into_inner()?.finish()?;
Ok(count)
}
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
))
}
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");
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();
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());
}
}