dogelang 0.3.0

The Doge programming language — Python's ease, Rust's engine. Installs the `doge` CLI.
//! Turning generated Rust into a runnable native binary. Each
//! script gets its own tiny Cargo project under the cache, all sharing one
//! target dir so `doge-runtime` compiles once.
use std::path::{Path, PathBuf};
use std::process::Command;

use crate::cache::{self, CachePaths};

const RUST_MISSING: &str = "\
very rust. much missing.

  doge compiles through Rust's toolchain, but cargo wasn't found.

such fix: install Rust from https://rustup.rs";

/// How long to wait for a peer's in-progress build before assuming its lock is
/// stale (a crashed process) and taking it over. A single tiny script builds in
/// seconds, so this is deliberately far longer than any real build.
const BUILD_LOCK_STALE_AFTER: std::time::Duration = std::time::Duration::from_secs(300);

/// How often to re-check a peer's build lock while waiting for it to finish.
const BUILD_LOCK_POLL: std::time::Duration = std::time::Duration::from_millis(50);

/// Compile `source` to a cached native binary and return its path, reusing the
/// cache when the script is unchanged. Every error is a fully rendered,
/// doge-flavored message ready to print to stderr.
pub fn ensure_binary(source: &str, generated: &str) -> Result<PathBuf, String> {
    let paths = cache::resolve(source)?;
    if cache::cache_hit(&paths.entry_dir, &paths.binary, source) {
        return Ok(paths.binary);
    }
    detect_toolchain()?;

    // Serialize with any other process building this same script: two concurrent
    // `cargo build`s share one cache entry, and one relinking the binary can hit
    // the other mid-run ("os error 2"). The lock holder builds; late arrivals
    // wait, then reuse the binary it produced.
    let _lock = BuildLock::acquire(&paths.entry_dir)?;
    if cache::cache_hit(&paths.entry_dir, &paths.binary, source) {
        return Ok(paths.binary);
    }
    write_entry(&paths, source, generated)?;
    compile(&paths)?;
    Ok(paths.binary)
}

/// A held build lock: an exclusive marker file under the script's cache entry
/// that serializes concurrent builds of the same script. Removed on drop.
struct BuildLock {
    path: PathBuf,
}

impl BuildLock {
    /// Take the build lock for `entry_dir`, waiting out any peer build. Creates
    /// the entry dir first so the lock has somewhere to live.
    fn acquire(entry_dir: &Path) -> Result<BuildLock, String> {
        std::fs::create_dir_all(entry_dir).map_err(disk_err)?;
        let path = entry_dir.join("build.lock");
        loop {
            match std::fs::OpenOptions::new()
                .write(true)
                .create_new(true)
                .open(&path)
            {
                Ok(_) => return Ok(BuildLock { path }),
                Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
                    if lock_is_stale(&path) {
                        let _ = std::fs::remove_file(&path);
                        continue;
                    }
                    std::thread::sleep(BUILD_LOCK_POLL);
                }
                Err(err) => return Err(disk_err(err)),
            }
        }
    }
}

impl Drop for BuildLock {
    fn drop(&mut self) {
        let _ = std::fs::remove_file(&self.path);
    }
}

/// A lock older than `BUILD_LOCK_STALE_AFTER`, or one we can no longer stat,
/// belonged to a build that died — it is safe to steal.
fn lock_is_stale(path: &Path) -> bool {
    match std::fs::metadata(path).and_then(|meta| meta.modified()) {
        Ok(created) => created
            .elapsed()
            .map(|age| age > BUILD_LOCK_STALE_AFTER)
            .unwrap_or(false),
        Err(_) => true,
    }
}

/// Run a freshly built binary with inherited stdio and return its exit code, so
/// `doge bark` exits exactly as the script did. Trailing `args` are forwarded to
/// the program, where `env.args()` reads them back.
pub fn spawn(binary: &Path, args: &[String]) -> Result<i32, String> {
    match Command::new(binary).args(args).status() {
        Ok(status) => Ok(status.code().unwrap_or(1)),
        Err(err) => Err(format!(
            "very run. much fail.\n\n  doge built your script but could not run it: {err}"
        )),
    }
}

/// Copy the cached binary next to the user, as `./<stem>` (`.exe` on Windows).
pub fn copy_to_cwd(binary: &Path, stem: &str) -> Result<(), String> {
    let name = format!("{stem}{}", std::env::consts::EXE_SUFFIX);
    let dest = PathBuf::from(format!("./{name}"));
    std::fs::copy(binary, &dest)
        .map(|_| ())
        .map_err(|err| format!("very disk. much sad.\n\n  doge could not write ./{name}: {err}"))
}

fn detect_toolchain() -> Result<(), String> {
    match Command::new("cargo").arg("--version").output() {
        Ok(output) if output.status.success() => Ok(()),
        _ => Err(RUST_MISSING.to_string()),
    }
}

/// The `doge-runtime` crate as an absolute path when this is a dev checkout, or
/// `None` once installed (the source tree beside the compiler no longer exists).
fn runtime_path() -> Option<PathBuf> {
    let raw = concat!(env!("CARGO_MANIFEST_DIR"), "/../doge-runtime");
    std::fs::canonicalize(raw).ok()
}

/// How the generated crate depends on `doge-runtime`: a `path` dependency into the
/// dev checkout when it exists, else the version-pinned crates.io release that an
/// installed `doge` was published alongside. This is what lets a `cargo install`ed
/// compiler build scripts without the source tree.
fn runtime_dependency(runtime: Option<&Path>) -> String {
    match runtime {
        Some(path) => format!("doge-runtime = {{ path = {:?} }}", path.to_string_lossy()),
        None => format!("doge-runtime = \"={}\"", env!("CARGO_PKG_VERSION")),
    }
}

/// The generated crate's `Cargo.toml`. The empty `[workspace]` makes it its own
/// workspace root, so it never attaches to a repo workspace when the cache happens
/// to live inside one.
fn cargo_manifest(package: &str, runtime: Option<&Path>) -> String {
    format!(
        "[package]\n\
         name = \"{package}\"\n\
         version = \"0.0.0\"\n\
         edition = \"2021\"\n\
         \n\
         [workspace]\n\
         \n\
         [dependencies]\n\
         {dependency}\n\
         \n\
         [[bin]]\n\
         name = \"{package}\"\n\
         path = \"src/main.rs\"\n",
        dependency = runtime_dependency(runtime),
    )
}

fn write_entry(paths: &CachePaths, source: &str, generated: &str) -> Result<(), String> {
    let src_dir = paths.entry_dir.join("src");
    std::fs::create_dir_all(&src_dir).map_err(disk_err)?;

    let cargo_toml = cargo_manifest(&paths.package, runtime_path().as_deref());
    std::fs::write(paths.entry_dir.join("Cargo.toml"), cargo_toml).map_err(disk_err)?;
    std::fs::write(src_dir.join("main.rs"), generated).map_err(disk_err)?;
    // Written LAST: source.doge is the validity marker `cache_hit` checks, so it
    // must not exist until Cargo.toml and main.rs are safely on disk.
    std::fs::write(paths.entry_dir.join("source.doge"), source).map_err(disk_err)?;
    Ok(())
}

fn compile(paths: &CachePaths) -> Result<(), String> {
    let output = Command::new("cargo")
        .arg("build")
        .arg("--release")
        .arg("--quiet")
        .current_dir(&paths.entry_dir)
        .env("CARGO_TARGET_DIR", &paths.target_dir)
        .output()
        .map_err(|_| RUST_MISSING.to_string())?;
    if output.status.success() {
        return Ok(());
    }

    // A rustc rejection of generated code is a doge bug (Hard Rule 11): capture
    // the output into build.log and report it as one — never spill it on screen.
    let log = paths.entry_dir.join("build.log");
    let mut captured = output.stdout;
    captured.extend_from_slice(&output.stderr);
    let _ = std::fs::write(&log, &captured);
    Err(format!(
        "very bug. much sorry.\n\n\
         the Rust that doge generated failed to build — this is a doge bug, not your script.\n\
         pls report it and attach: {}",
        log.display()
    ))
}

fn disk_err(err: std::io::Error) -> String {
    format!("very disk. much sad.\n\n  doge could not write its build cache: {err}")
}

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

    #[test]
    fn installed_build_uses_the_versioned_runtime() {
        // With no dev checkout, the generated crate must depend on the published
        // runtime by version so an installed `doge` can build without the source.
        let manifest = cargo_manifest("doge_script_abc", None);
        assert!(
            manifest.contains(&format!(
                "doge-runtime = \"={}\"",
                env!("CARGO_PKG_VERSION")
            )),
            "expected a version-pinned runtime dep, got:\n{manifest}"
        );
        assert!(
            !manifest.contains("doge-runtime = { path"),
            "no path runtime dep when installed, got:\n{manifest}"
        );
    }

    #[test]
    fn dev_build_uses_the_path_runtime() {
        let runtime = PathBuf::from("/checkout/doge-runtime");
        let manifest = cargo_manifest("doge_script_abc", Some(&runtime));
        assert!(
            manifest.contains("doge-runtime = { path = "),
            "expected a path runtime dep, got:\n{manifest}"
        );
    }
}