pcdl-build 0.1.0

Build-script helper for opening the local PCDL app
Documentation
use std::env;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};

const PCDL_APP_VERSION: &str = "0.1.0";
const RUST_MIR2_WRAPPER_VERSION: &str = "0.1.2";
const RUST_MIR2_VERSION: &str = "0.1.2";
const MIR2PCDL_VERSION: &str = "0.1.2";

pub fn maybe_open_editor() {
    println!("cargo:rerun-if-env-changed=PCDL_OPEN");
    println!("cargo:rerun-if-env-changed=PCDL_APP_BIN");
    println!("cargo:rerun-if-env-changed=PCDL_RUST_MIR2_BIN");
    println!("cargo:rerun-if-env-changed=RUST_MIR2_WRAPPER_BIN");
    println!("cargo:rerun-if-env-changed=PCDL_PACKAGE_NAME");
    println!("cargo:rerun-if-env-changed=PCDL_2R_DIR");
    println!("cargo:rerun-if-env-changed=PCDL_APP_PORT");
    println!("cargo:rerun-if-env-changed=PCDL_FRONTEND_PORT");

    if env::var("PCDL_OPEN").ok().as_deref() != Some("1") {
        return;
    }

    if let Err(error) = install_published_tools() {
        warn(&format!("failed to install published PCDL tools: {error}"));
        return;
    }

    let cargo_bin_dir = cargo_bin_dir();
    let rust_mir2_bin = env::var_os("PCDL_RUST_MIR2_BIN")
        .map(PathBuf::from)
        .unwrap_or_else(|| binary_path(&cargo_bin_dir, "rust_mir2"));
    let wrapper_bin = env::var_os("RUST_MIR2_WRAPPER_BIN")
        .map(PathBuf::from)
        .unwrap_or_else(|| binary_path(&cargo_bin_dir, "rust_mir2-wrapper"));
    let app_bin = env::var_os("PCDL_APP_BIN")
        .map(PathBuf::from)
        .unwrap_or_else(|| binary_path(&cargo_bin_dir, "pcdl-app"));

    let Some(manifest_dir) = env::var_os("CARGO_MANIFEST_DIR").map(PathBuf::from) else {
        warn("missing CARGO_MANIFEST_DIR");
        return;
    };
    let project_root = workspace_or_manifest_root(&manifest_dir);
    let file_config = PcdlBuildConfig::load(&project_root.join("pcdl.toml")).unwrap_or_default();
    let source_project = env::var("PCDL_SOURCE_PROJECT")
        .map(PathBuf::from)
        .ok()
        .or(file_config.source_project.map(PathBuf::from))
        .map(|path| resolve_config_path(&project_root, path))
        .unwrap_or_else(|| project_root.clone());
    let package_name = env::var("PCDL_PACKAGE_NAME").unwrap_or_else(|_| {
        file_config
            .package_name
            .unwrap_or_else(|| env::var("CARGO_PKG_NAME").unwrap_or_else(|_| "unknown".to_string()))
    });
    let two_r_dir = env::var("PCDL_2R_DIR")
        .map(PathBuf::from)
        .ok()
        .or(file_config.two_r_dir.map(PathBuf::from))
        .map(|path| resolve_config_path(&project_root, path))
        .map(|path| path.display().to_string())
        .unwrap_or_else(|| project_root.join(".pcdl/2r").display().to_string());
    let backend_port = env::var("PCDL_APP_PORT")
        .ok()
        .or(file_config.backend_port)
        .unwrap_or_else(|| "8787".to_string());
    let frontend_port = env::var("PCDL_FRONTEND_PORT")
        .ok()
        .or(file_config.frontend_port)
        .unwrap_or_else(|| "3000".to_string());
    let log_dir = project_root.join(".pcdl");
    let log_path = log_dir.join("pcdl_app.log");

    if let Err(error) = std::fs::create_dir_all(&log_dir) {
        warn(&format!("failed to create PCDL log dir: {error}"));
        return;
    }

    if is_port_open(&backend_port) {
        println!("cargo:warning=PCDL editor already appears to be running on port {backend_port}");
        return;
    }

    let mut command = Command::new(app_bin);
    command
        .arg("open")
        .arg("--project")
        .arg(source_project)
        .arg("--package")
        .arg(package_name)
        .arg("--2r-dir")
        .arg(two_r_dir)
        .arg("--backend-port")
        .arg(&backend_port)
        .arg("--frontend-port")
        .arg(frontend_port)
        .arg("--open-browser")
        .env("PCDL_RUST_MIR2_BIN", &rust_mir2_bin)
        .env("RUST_MIR2_WRAPPER_BIN", &wrapper_bin)
        .current_dir(&project_root)
        .stdin(Stdio::null())
        .stdout(log_file(&log_path))
        .stderr(log_file(&log_path));

    match command.spawn() {
        Ok(_) => println!(
            "cargo:warning=PCDL editor launch requested; log: {}",
            log_path.display()
        ),
        Err(error) => warn(&format!(
            "failed to launch pcdl-app; install it or set PCDL_APP_BIN: {error}"
        )),
    }
}

fn workspace_or_manifest_root(manifest_dir: &Path) -> PathBuf {
    let mut current = Some(manifest_dir);

    while let Some(dir) = current {
        if dir.join("Cargo.toml").is_file() && has_workspace_manifest(&dir.join("Cargo.toml")) {
            return dir.to_path_buf();
        }

        current = dir.parent();
    }

    manifest_dir.to_path_buf()
}

fn install_published_tools() -> Result<(), String> {
    install_published_tool("pcdl-app", PCDL_APP_VERSION, None)?;
    install_published_tool(
        "rust_mir2_wrapper",
        RUST_MIR2_WRAPPER_VERSION,
        Some("nightly"),
    )?;
    install_published_tool("rust_mir2", RUST_MIR2_VERSION, None)?;
    install_published_tool("mir2pcdl", MIR2PCDL_VERSION, None)?;
    Ok(())
}

fn install_published_tool(
    crate_name: &str,
    version: &str,
    toolchain: Option<&str>,
) -> Result<(), String> {
    let cargo = env::var("CARGO").unwrap_or_else(|_| "cargo".to_string());
    let mut command = Command::new(cargo);
    if let Some(toolchain) = toolchain {
        command.arg(format!("+{toolchain}"));
    }
    command
        .arg("install")
        .arg(crate_name)
        .arg("--version")
        .arg(version);

    let status = command
        .status()
        .map_err(|error| format!("failed to run cargo install for {crate_name}: {error}"))?;

    if !status.success() {
        return Err(format!(
            "cargo install {crate_name} --version {version} failed with status {status}"
        ));
    }

    Ok(())
}

fn cargo_bin_dir() -> PathBuf {
    env::var_os("CARGO_HOME")
        .map(PathBuf::from)
        .or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".cargo")))
        .unwrap_or_else(|| PathBuf::from(".cargo"))
        .join("bin")
}

fn binary_path(dir: &Path, name: &str) -> PathBuf {
    if cfg!(target_os = "windows") {
        dir.join(format!("{name}.exe"))
    } else {
        dir.join(name)
    }
}

fn resolve_config_path(base: &Path, path: PathBuf) -> PathBuf {
    if path.is_absolute() {
        path
    } else {
        base.join(path)
    }
}

fn has_workspace_manifest(path: &Path) -> bool {
    std::fs::read_to_string(path)
        .map(|text| text.contains("[workspace]"))
        .unwrap_or(false)
}

fn is_port_open(port: &str) -> bool {
    let Ok(port) = port.parse::<u16>() else {
        return false;
    };

    std::net::TcpStream::connect_timeout(
        &std::net::SocketAddr::from(([127, 0, 0, 1], port)),
        std::time::Duration::from_millis(150),
    )
    .is_ok()
}

#[derive(Default)]
struct PcdlBuildConfig {
    source_project: Option<String>,
    package_name: Option<String>,
    two_r_dir: Option<String>,
    backend_port: Option<String>,
    frontend_port: Option<String>,
}

impl PcdlBuildConfig {
    fn load(path: &Path) -> Option<Self> {
        let text = std::fs::read_to_string(path).ok()?;
        let mut config = Self::default();

        for line in text.lines() {
            let line = line.split('#').next().unwrap_or("").trim();
            if line.is_empty() {
                continue;
            }

            let Some((key, value)) = line.split_once('=') else {
                continue;
            };
            let key = key.trim();
            let value = value
                .trim()
                .trim_matches('"')
                .trim_matches('\'')
                .to_string();

            match key {
                "source_project" => config.source_project = Some(value),
                "package_name" => config.package_name = Some(value),
                "two_r_dir" => config.two_r_dir = Some(value),
                "backend_port" => config.backend_port = Some(value),
                "frontend_port" => config.frontend_port = Some(value),
                _ => {}
            }
        }

        Some(config)
    }
}

fn warn(message: &str) {
    println!("cargo:warning={message}");
}

fn log_file(path: &Path) -> Stdio {
    std::fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(path)
        .map(Stdio::from)
        .unwrap_or_else(|_| Stdio::null())
}