use anyhow::{Context, Result};
use dialoguer::{Confirm, Input, Select, theme::ColorfulTheme};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::process::Command;
use crate::prerender;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DeployConfig {
pub ssh: Option<SshConfig>,
pub docker: Option<DockerConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SshConfig {
pub host: String,
pub user: String,
pub remote_dir: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DockerConfig {
pub image: String,
pub registry: Option<String>,
}
pub async fn deploy_ssh(
project_path: &Path,
host: Option<&str>,
user: Option<&str>,
remote_dir: Option<&str>,
dry_run: bool,
yes: bool,
) -> Result<()> {
let toml_path = project_path.join("wwwhat.toml");
let saved = load_deploy_config(&toml_path);
let host = resolve_or_prompt(
host,
saved
.as_ref()
.and_then(|c| c.ssh.as_ref().map(|s| s.host.as_str())),
"SSH host (e.g., 77.42.22.50)",
)?;
let user = resolve_or_prompt(
user,
saved
.as_ref()
.and_then(|c| c.ssh.as_ref().map(|s| s.user.as_str())),
"SSH user",
)?;
let remote_dir = resolve_or_prompt(
remote_dir,
saved
.as_ref()
.and_then(|c| c.ssh.as_ref().map(|s| s.remote_dir.as_str())),
"Remote directory (e.g., /opt/wwwhat)",
)?;
save_deploy_config(
&toml_path,
&DeployConfig {
ssh: Some(SshConfig {
host: host.clone(),
user: user.clone(),
remote_dir: remote_dir.clone(),
}),
docker: saved.and_then(|c| c.docker),
},
)?;
println!();
println!(" Deploy to {}@{}:{}", user, host, remote_dir);
println!();
if !yes && !dry_run {
if !Confirm::with_theme(&ColorfulTheme::default())
.with_prompt(" Proceed with deployment?")
.default(true)
.interact()?
{
println!(" Deployment cancelled.");
return Ok(());
}
}
println!(" Building release binary...");
let build_cmd = format!("cargo build --release --bin run-what");
run_cmd(&build_cmd, dry_run)?;
println!(" Syncing project files...");
let project_str = project_path.to_string_lossy();
let rsync_cmd = format!(
"rsync -avz --exclude target --exclude .git --exclude '*.db' --exclude '*.db-journal' -e ssh {src}/ {user}@{host}:{dir}/project/",
src = project_str,
user = user,
host = host,
dir = remote_dir,
);
run_cmd(&rsync_cmd, dry_run)?;
println!(" Copying binary...");
let scp_cmd = format!(
"scp target/release/run-what {}@{}:{}/run-what",
user, host, remote_dir,
);
run_cmd(&scp_cmd, dry_run)?;
println!(" Installing systemd service...");
let service = format!(
r#"[Unit]
Description=wwwhat web server
After=network.target
[Service]
ExecStart={dir}/run-what dev --path {dir}/project --host 0.0.0.0 --port 8085
Restart=always
WorkingDirectory={dir}
[Install]
WantedBy=multi-user.target"#,
dir = remote_dir,
);
let install_cmd = format!(
r#"ssh {}@{} "echo '{}' > /etc/systemd/system/wwwhat.service && systemctl daemon-reload && systemctl restart wwwhat""#,
user,
host,
service.replace('\n', "\\n"),
);
run_cmd(&install_cmd, dry_run)?;
println!();
println!(" Deployment complete!");
println!();
Ok(())
}
pub async fn deploy_docker(
project_path: &Path,
image: Option<&str>,
registry: Option<&str>,
dry_run: bool,
yes: bool,
) -> Result<()> {
let toml_path = project_path.join("wwwhat.toml");
let saved = load_deploy_config(&toml_path);
let image = resolve_or_prompt(
image,
saved
.as_ref()
.and_then(|c| c.docker.as_ref().map(|s| s.image.as_str())),
"Docker image name (e.g., wwwhat-app)",
)?;
save_deploy_config(
&toml_path,
&DeployConfig {
ssh: saved.as_ref().and_then(|c| c.ssh.clone()),
docker: Some(DockerConfig {
image: image.clone(),
registry: registry.map(String::from),
}),
},
)?;
let dockerfile_path = project_path.join("Dockerfile");
if !dockerfile_path.exists() {
println!(" Generating Dockerfile...");
let dockerfile = generate_dockerfile();
std::fs::write(&dockerfile_path, &dockerfile)?;
println!(" Created {}", dockerfile_path.display());
}
let dockerignore_path = project_path.join(".dockerignore");
if !dockerignore_path.exists() {
let dockerignore = generate_dockerignore();
std::fs::write(&dockerignore_path, &dockerignore)?;
println!(" Created {}", dockerignore_path.display());
}
println!();
println!(" Building Docker image: {}", image);
if !yes && !dry_run {
if !Confirm::with_theme(&ColorfulTheme::default())
.with_prompt(" Proceed with Docker build?")
.default(true)
.interact()?
{
println!(" Cancelled.");
return Ok(());
}
}
let build_cmd = format!("docker build -t {} {}", image, project_path.display());
run_cmd(&build_cmd, dry_run)?;
if let Some(reg) = registry {
let full_tag = format!("{}/{}", reg, image);
println!(" Tagging and pushing to {}...", reg);
run_cmd(&format!("docker tag {} {}", image, full_tag), dry_run)?;
run_cmd(&format!("docker push {}", full_tag), dry_run)?;
}
println!();
println!(" Docker build complete!");
println!();
Ok(())
}
pub async fn deploy_static(project_path: &Path, output: &Path, dry_run: bool) -> Result<()> {
if dry_run {
println!(
" [dry-run] Would pre-render {} to {}",
project_path.display(),
output.display()
);
return Ok(());
}
println!();
println!(" Building static site...");
println!();
let result = prerender::prerender(prerender::PreRenderConfig {
project_path: project_path.to_path_buf(),
output_path: output.to_path_buf(),
minify: true,
})
.await?;
println!();
println!(" Static site ready!");
println!(" Pages: {}", result.pages_rendered);
println!(" Size: {}", crate::format_bytes(result.total_bytes));
println!(" Output: {}", output.display());
if !result.pages_skipped.is_empty() {
println!(" Skipped:");
for page in &result.pages_skipped {
println!(" - {}", page);
}
}
println!();
println!(" Deploy to any static host:");
println!(
" Cloudflare Pages: npx wrangler pages deploy {}",
output.display()
);
println!(
" Netlify: netlify deploy --dir {}",
output.display()
);
println!(" Vercel: vercel --prod {}", output.display());
println!();
Ok(())
}
pub fn choose_deploy_target() -> Result<String> {
let choices = &[
"static - Pre-render to HTML files (Cloudflare Pages, Netlify, etc.)",
"ssh - Deploy to a VPS via SSH (Hetzner, DigitalOcean, etc.)",
"docker - Build a Docker image",
];
println!();
let selection = Select::with_theme(&ColorfulTheme::default())
.with_prompt(" Choose deploy target")
.items(choices)
.default(0)
.interact()?;
Ok(match selection {
0 => "static".to_string(),
1 => "ssh".to_string(),
2 => "docker".to_string(),
_ => unreachable!(),
})
}
fn resolve_or_prompt(
cli_value: Option<&str>,
saved_value: Option<&str>,
prompt: &str,
) -> Result<String> {
if let Some(v) = cli_value {
return Ok(v.to_string());
}
if let Some(v) = saved_value {
return Ok(v.to_string());
}
let value: String = Input::with_theme(&ColorfulTheme::default())
.with_prompt(format!(" {}", prompt))
.interact_text()?;
Ok(value)
}
fn run_cmd(cmd: &str, dry_run: bool) -> Result<()> {
if dry_run {
println!(" [dry-run] {}", cmd);
return Ok(());
}
let status = Command::new("sh")
.arg("-c")
.arg(cmd)
.status()
.with_context(|| format!("Failed to execute: {}", cmd))?;
if !status.success() {
anyhow::bail!(
"Command failed with exit code {}: {}",
status.code().unwrap_or(-1),
cmd
);
}
Ok(())
}
fn generate_dockerfile() -> String {
r#"# Multi-stage build with cargo-chef for fast rebuilds
FROM lukemathwalker/cargo-chef:latest-rust-1.85 AS chef
WORKDIR /app
FROM chef AS planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json
FROM chef AS builder
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json
COPY . .
RUN cargo build --release --bin run-what
FROM debian:bookworm-slim AS runtime
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
RUN useradd -m -s /bin/bash wwwhat
WORKDIR /app
COPY --from=builder /app/target/release/run-what /usr/local/bin/run-what
COPY . /app/project
USER wwwhat
EXPOSE 8085
CMD ["run-what", "dev", "--path", "/app/project", "--host", "0.0.0.0", "--port", "8085"]
"#
.to_string()
}
fn generate_dockerignore() -> String {
r#"target/
.git/
*.db
*.db-journal
*.db-shm
*.db-wal
"#
.to_string()
}
fn load_deploy_config(toml_path: &Path) -> Option<DeployConfig> {
let content = std::fs::read_to_string(toml_path).ok()?;
let doc = content.parse::<toml_edit::DocumentMut>().ok()?;
let deploy = doc.get("deploy")?;
let deploy_str = deploy.to_string();
toml::from_str::<DeployConfig>(&deploy_str).ok()
}
fn save_deploy_config(toml_path: &Path, config: &DeployConfig) -> Result<()> {
let content = std::fs::read_to_string(toml_path).unwrap_or_default();
let mut doc = content
.parse::<toml_edit::DocumentMut>()
.unwrap_or_else(|_| toml_edit::DocumentMut::new());
let deploy_str = toml::to_string(config)?;
let deploy_doc = deploy_str.parse::<toml_edit::DocumentMut>()?;
doc["deploy"] = deploy_doc.as_item().clone();
std::fs::write(toml_path, doc.to_string())?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_dockerfile() {
let df = generate_dockerfile();
assert!(df.contains("cargo-chef"));
assert!(df.contains("run-what"));
assert!(df.contains("bookworm-slim"));
assert!(df.contains("EXPOSE 8085"));
}
#[test]
fn test_deploy_config_roundtrip() {
let config = DeployConfig {
ssh: Some(SshConfig {
host: "1.2.3.4".to_string(),
user: "root".to_string(),
remote_dir: "/opt/wwwhat".to_string(),
}),
docker: Some(DockerConfig {
image: "my-app".to_string(),
registry: Some("ghcr.io/user".to_string()),
}),
};
let serialized = toml::to_string(&config).unwrap();
let deserialized: DeployConfig = toml::from_str(&serialized).unwrap();
assert_eq!(deserialized.ssh.as_ref().unwrap().host, "1.2.3.4");
assert_eq!(deserialized.docker.as_ref().unwrap().image, "my-app");
}
}