tovuk 0.1.98

Deploy Rust workers, static frontends, and full-stack services to Tovuk.
use super::super::{
    check::first_output_line, command_policy::command_tokens, config::TovukConfig,
    project::number_field, project_kind::ProjectKind,
};
use flate2::{Compression, write::GzEncoder};
use serde_json::{Value, json};
use std::{
    fs,
    io::{self, Write},
    path::{Path, PathBuf},
    process::{Command, Stdio},
};

pub(super) fn artifact_check(
    project_dir: &Path,
    config: Option<&TovukConfig>,
    limits: &Value,
    requested: bool,
    checks_ok: bool,
) -> Value {
    if !requested {
        return json!({
            "requested": false,
            "status": "skipped",
            "agent_instruction": "Run `tovuk deploy --dry-run --build-artifact --json` after adding or upgrading Rust dependencies to build the local release binary and check compressed worker size before deploy.",
        });
    }

    if !checks_ok {
        return json!({
            "requested": true,
            "status": "skipped_failed_checks",
            "ok": false,
            "message": "Artifact build was skipped because normal dry-run checks failed first.",
            "agent_instruction": "Fix the first failed quality check, then rerun `tovuk deploy --dry-run --build-artifact --json`.",
        });
    }

    let Some(config) = config else {
        return artifact_failure(
            "missing_config",
            "tovuk.toml must pass validation before artifact size can be checked.",
            "Fix tovuk.toml, rerun `tovuk check`, then rerun `tovuk deploy --dry-run --build-artifact --json`.",
        );
    };

    let Some(target) = worker_artifact_target(project_dir, config) else {
        return json!({
            "requested": true,
            "status": "not_applicable",
            "ok": true,
            "message": "No Rust worker artifact is required for this service kind.",
        });
    };

    run_artifact_check(&target, limits)
}

fn run_artifact_check(target: &WorkerArtifactTarget, limits: &Value) -> Value {
    let build = shell_command(&target.build_command)
        .current_dir(&target.build_dir)
        .stdin(Stdio::null())
        .output();
    let output = match build {
        Ok(output) => output,
        Err(error) => {
            return artifact_failure(
                "build_command_failed",
                &format!("Could not run worker build command: {error}"),
                &format!(
                    "Install the Rust toolchain, run `{}` in `{}`, fix the build, then rerun artifact dry-run.",
                    target.build_command,
                    target.build_dir.display()
                ),
            );
        }
    };
    if !output.status.success() {
        return artifact_failure(
            "build_failed",
            &first_output_line(&output.stderr, &output.stdout, "worker build failed"),
            &format!(
                "Run `{}` in `{}`, fix the first build error, then rerun artifact dry-run.",
                target.build_command,
                target.build_dir.display()
            ),
        );
    }

    let uncompressed_bytes = match fs::metadata(&target.binary_path) {
        Ok(metadata) => metadata.len(),
        Err(error) => {
            return artifact_failure(
                "binary_missing",
                &format!("Worker binary was not found after build: {error}"),
                &format!(
                    "Ensure the worker runtime command points to the release binary built by `{}`, then rerun artifact dry-run.",
                    target.build_command
                ),
            );
        }
    };
    let compressed_bytes = match gzip_file_size(&target.binary_path) {
        Ok(size) => size,
        Err(error) => {
            return artifact_failure(
                "gzip_failed",
                &format!("Could not gzip worker binary: {error}"),
                "Check the release binary path and file permissions, then rerun artifact dry-run.",
            );
        }
    };
    let compressed_limit_bytes = worker_compressed_limit_bytes(limits);
    let ok = compressed_limit_bytes == 0 || compressed_bytes <= compressed_limit_bytes;
    json!({
        "requested": true,
        "status": if ok { "passed" } else { "failed" },
        "ok": ok,
        "kind": "rust_worker_binary",
        "buildCommand": target.build_command,
        "buildCwd": target.build_dir.display().to_string(),
        "binaryPath": target.binary_path.display().to_string(),
        "platform": "local",
        "uncompressedBytes": uncompressed_bytes,
        "compressedBytes": compressed_bytes,
        "compressedLimitBytes": compressed_limit_bytes,
        "limitSource": "usage.limits.workerCompressedSizeMib",
        "message": artifact_message(ok, compressed_bytes, compressed_limit_bytes),
        "agent_instruction": artifact_instruction(ok, compressed_bytes, compressed_limit_bytes),
    })
}

fn artifact_message(ok: bool, compressed_bytes: u64, compressed_limit_bytes: u64) -> String {
    if compressed_limit_bytes == 0 {
        return format!("Local worker artifact gzip size is {compressed_bytes} bytes.");
    }
    if ok {
        return format!(
            "Local worker artifact gzip size is {compressed_bytes} bytes, below the {compressed_limit_bytes} byte account limit."
        );
    }
    format!(
        "Local worker artifact gzip size is {compressed_bytes} bytes, above the {compressed_limit_bytes} byte account limit."
    )
}

fn artifact_instruction(
    ok: bool,
    compressed_bytes: u64,
    compressed_limit_bytes: u64,
) -> Option<String> {
    if ok {
        return None;
    }
    Some(format!(
        "Reduce Rust artifact size before deploy. Add or tighten [profile.release] with strip=true, lto=\"fat\", codegen-units=1, panic=\"abort\", and opt-level=\"z\", remove heavyweight dependencies, then rerun `tovuk deploy --dry-run --build-artifact --json`. Current gzip size is {compressed_bytes} bytes; limit is {compressed_limit_bytes} bytes."
    ))
}

fn artifact_failure(code: &str, message: &str, instruction: &str) -> Value {
    json!({
        "requested": true,
        "status": "failed",
        "ok": false,
        "code": code,
        "message": message,
        "agent_instruction": instruction,
    })
}

fn worker_artifact_target(
    project_dir: &Path,
    config: &TovukConfig,
) -> Option<WorkerArtifactTarget> {
    match config.kind {
        ProjectKind::Fullstack => {
            let worker_root = config.backend.root.as_deref().unwrap_or(".");
            let build_dir = project_dir.join(worker_root);
            let build_command = config.backend.build.as_ref()?;
            let run_command = config.backend.command.as_ref()?;
            Some(WorkerArtifactTarget::new(
                &build_dir,
                build_command,
                run_command,
            ))
        }
        ProjectKind::RustWorker => {
            let run_command = config.run.command.as_ref()?;
            Some(WorkerArtifactTarget::new(
                project_dir,
                &config.build.command,
                run_command,
            ))
        }
        ProjectKind::StaticFrontend => None,
    }
}

struct WorkerArtifactTarget {
    binary_path: PathBuf,
    build_command: String,
    build_dir: PathBuf,
}

impl WorkerArtifactTarget {
    fn new(build_dir: &Path, build_command: &str, run_command: &str) -> Self {
        let binary = worker_binary_token(run_command).unwrap_or_else(|| run_command.to_owned());
        Self {
            binary_path: build_dir.join(binary),
            build_command: build_command.to_owned(),
            build_dir: build_dir.to_path_buf(),
        }
    }
}

fn worker_binary_token(run_command: &str) -> Option<String> {
    command_tokens(run_command)
        .into_iter()
        .find(|token| token.contains("target/release/"))
}

fn gzip_file_size(path: &Path) -> io::Result<u64> {
    let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
    let bytes = fs::read(path)?;
    encoder.write_all(&bytes)?;
    let compressed = encoder.finish()?;
    Ok(compressed.len() as u64)
}

fn worker_compressed_limit_bytes(limits: &Value) -> u64 {
    number_field(limits, "workerCompressedSizeMib") * 1024 * 1024
}

fn shell_command(source: &str) -> Command {
    if cfg!(target_os = "windows") {
        let mut command = Command::new("cmd");
        command.args(["/C", source]);
        command
    } else {
        let mut command = Command::new("sh");
        command.args(["-c", source]);
        command
    }
}

#[cfg(test)]
mod tests {
    use super::{worker_binary_token, worker_compressed_limit_bytes};
    use serde_json::json;

    #[test]
    fn worker_compressed_limit_uses_usage_limits() {
        assert_eq!(
            worker_compressed_limit_bytes(&json!({ "workerCompressedSizeMib": 3 })),
            3 * 1024 * 1024
        );
    }

    #[test]
    fn worker_binary_token_reads_release_binary() {
        assert_eq!(
            worker_binary_token("RUST_LOG=info ./target/release/api --serve").as_deref(),
            Some("./target/release/api")
        );
    }
}