fission-command-server 0.6.1

Fission CLI server shell command implementation
Documentation
use anyhow::{bail, Context, Result};
use std::net::TcpListener;
use std::path::Path;
use std::process::Command;

pub fn check(project_dir: &Path, release: bool) -> Result<()> {
    ensure_server_entry_configured(project_dir)?;
    artifacts(project_dir, release, true).context("failed to build server browser artifacts")?;
    run_server_builder(project_dir, release, "check", &[])
}

pub fn build(project_dir: &Path, release: bool) -> Result<()> {
    ensure_server_entry_configured(project_dir)?;
    artifacts(project_dir, release, true).context("failed to build server browser artifacts")?;
    build_server_binary(project_dir, release)
}

pub fn routes(project_dir: &Path) -> Result<()> {
    ensure_server_entry_configured(project_dir)?;
    run_server_builder(project_dir, false, "routes", &[])
}

pub fn serve(project_dir: &Path, release: bool, host: String, port: u16) -> Result<()> {
    ensure_server_entry_configured(project_dir)?;
    ensure_server_address_available(&host, port)?;
    artifacts(project_dir, release, true).context("failed to build server browser artifacts")?;
    let port = port.to_string();
    run_server_builder(
        project_dir,
        release,
        "serve",
        &["--host", host.as_str(), "--port", port.as_str()],
    )
}

fn ensure_server_address_available(host: &str, port: u16) -> Result<()> {
    let address = format!("{host}:{port}");
    let listener = TcpListener::bind(&address).with_context(|| {
        format!(
            "server address {address} is already in use; stop the existing process or choose another port with --port"
        )
    })?;
    drop(listener);
    Ok(())
}

pub fn artifacts(project_dir: &Path, release: bool, compile: bool) -> Result<()> {
    ensure_server_entry_configured(project_dir)?;
    let package_name = package_name(project_dir)?;
    let features = package_features(project_dir)?;
    let mut args = vec!["--package-name", package_name.as_str()];
    if features.iter().any(|feature| feature == "browser") {
        args.push("--package-no-default-features");
        args.push("--package-feature");
        args.push("browser");
    }
    if !compile {
        args.push("--no-compile");
    }
    run_server_builder(project_dir, release, "artifacts", &args)
}

fn ensure_server_entry_configured(project_dir: &Path) -> Result<()> {
    let path = project_dir.join("fission.toml");
    let data = std::fs::read_to_string(&path)
        .with_context(|| format!("failed to read {}", path.display()))?;
    let value: toml::Value =
        toml::from_str(&data).with_context(|| format!("failed to parse {}", path.display()))?;
    if value
        .get("server")
        .and_then(|server| server.get("entry"))
        .and_then(|entry| entry.as_str())
        .is_some()
    {
        Ok(())
    } else {
        bail!("fission.toml is missing [server].entry")
    }
}

fn package_name(project_dir: &Path) -> Result<String> {
    let path = project_dir.join("Cargo.toml");
    let data = std::fs::read_to_string(&path)
        .with_context(|| format!("failed to read {}", path.display()))?;
    let value: toml::Value =
        toml::from_str(&data).with_context(|| format!("failed to parse {}", path.display()))?;
    value
        .get("package")
        .and_then(|package| package.get("name"))
        .and_then(|name| name.as_str())
        .map(ToString::to_string)
        .ok_or_else(|| anyhow::anyhow!("{} is missing [package].name", path.display()))
}

fn package_features(project_dir: &Path) -> Result<Vec<String>> {
    let path = project_dir.join("Cargo.toml");
    let data = std::fs::read_to_string(&path)
        .with_context(|| format!("failed to read {}", path.display()))?;
    let value: toml::Value =
        toml::from_str(&data).with_context(|| format!("failed to parse {}", path.display()))?;
    Ok(value
        .get("features")
        .and_then(|features| features.as_table())
        .map(|features| features.keys().cloned().collect())
        .unwrap_or_default())
}

fn run_server_builder(
    project_dir: &Path,
    release: bool,
    command_name: &str,
    extra_args: &[&str],
) -> Result<()> {
    let manifest_path = project_dir.join("Cargo.toml");
    if !manifest_path.exists() {
        bail!(
            "server entry is configured but {} is missing",
            manifest_path.display()
        );
    }
    let manifest_path = manifest_path
        .canonicalize()
        .with_context(|| format!("failed to resolve {}", manifest_path.display()))?;
    let mut command = Command::new("cargo");
    command.current_dir(project_dir);
    command
        .arg("run")
        .arg("--manifest-path")
        .arg(&manifest_path);
    if release {
        command.arg("--release");
    }
    command.arg("--").arg(command_name);
    for arg in extra_args {
        command.arg(arg);
    }
    let status = command.status().context("failed to run server app")?;
    if !status.success() {
        bail!("server app failed with {status}");
    }
    Ok(())
}

fn build_server_binary(project_dir: &Path, release: bool) -> Result<()> {
    let manifest_path = project_dir.join("Cargo.toml");
    if !manifest_path.exists() {
        bail!(
            "server entry is configured but {} is missing",
            manifest_path.display()
        );
    }
    let manifest_path = manifest_path
        .canonicalize()
        .with_context(|| format!("failed to resolve {}", manifest_path.display()))?;
    let mut command = Command::new("cargo");
    command.current_dir(project_dir);
    command
        .arg("build")
        .arg("--manifest-path")
        .arg(&manifest_path);
    if release {
        command.arg("--release");
    }
    let status = command.status().context("failed to build server app")?;
    if !status.success() {
        bail!("server app build failed with {status}");
    }
    Ok(())
}

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

    fn temp_project(name: &str) -> std::path::PathBuf {
        let dir = std::env::temp_dir().join(format!("{name}-{}", std::process::id()));
        let _ = fs::remove_dir_all(&dir);
        fs::create_dir_all(&dir).unwrap();
        dir
    }

    #[test]
    fn server_entry_configuration_is_required() {
        let dir = temp_project("fission-server-config-missing");
        fs::write(dir.join("fission.toml"), "[app]\nname = \"Test\"\n").unwrap();

        let error = ensure_server_entry_configured(&dir).unwrap_err();
        assert!(error.to_string().contains("[server].entry"));

        let _ = fs::remove_dir_all(&dir);
    }

    #[test]
    fn reads_package_name_and_browser_feature_for_artifact_shims() {
        let dir = temp_project("fission-server-config-package");
        fs::write(
            dir.join("Cargo.toml"),
            r#"[package]
name = "server-app"
version = "0.1.0"
edition = "2021"

[features]
default = ["server"]
server = []
browser = []
"#,
        )
        .unwrap();

        assert_eq!(package_name(&dir).unwrap(), "server-app");
        assert!(package_features(&dir)
            .unwrap()
            .iter()
            .any(|feature| feature == "browser"));

        let _ = fs::remove_dir_all(&dir);
    }

    #[test]
    fn serve_preflight_reports_busy_port_before_building_artifacts() {
        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
        let port = listener.local_addr().unwrap().port();

        let error = ensure_server_address_available("127.0.0.1", port).unwrap_err();
        assert!(error.to_string().contains("already in use"));
    }
}