spool-memory 0.2.3

Local-first developer memory system — persistent, structured knowledge for AI coding tools
Documentation
//! Binary release — copies bundled binaries from the Tauri app bundle into
//! `~/.spool/bin/`.
//!
//! The Tauri build process places the binaries inside the app bundle's
//! resource directory. On first run (or when a version mismatch is detected),
//! this module:
//!
//! 1. Copies each binary from the source directory to `~/.spool/bin/`
//! 2. Sets executable permissions on Unix
//! 3. Verifies each binary is runnable via `--version`

use anyhow::{Context, Result, bail};
use std::path::{Path, PathBuf};

/// Names of binaries that ship with Spool.
pub const BUNDLED_BINARIES: &[&str] = &["spool", "spool-mcp", "spool-daemon"];

/// Result of a release operation.
#[derive(Debug, Clone)]
pub struct ReleaseReport {
    pub copied: Vec<PathBuf>,
    pub skipped: Vec<PathBuf>,
}

/// Release `BUNDLED_BINARIES` from `source_dir` into `target_bin_dir`.
///
/// - `source_dir`: path to the directory containing bundled binaries (e.g.
///   the Tauri resource directory).
/// - `target_bin_dir`: destination, typically `~/.spool/bin/`.
/// - `force`: when `true`, overwrites existing binaries even if they look
///   identical. Use during version upgrades.
pub fn release_binaries(
    source_dir: &Path,
    target_bin_dir: &Path,
    force: bool,
) -> Result<ReleaseReport> {
    if !source_dir.is_dir() {
        bail!("source directory does not exist: {}", source_dir.display());
    }
    std::fs::create_dir_all(target_bin_dir)
        .with_context(|| format!("creating {}", target_bin_dir.display()))?;

    let mut copied = Vec::new();
    let mut skipped = Vec::new();

    for name in BUNDLED_BINARIES {
        let exe_name = if cfg!(windows) {
            format!("{name}.exe")
        } else {
            (*name).to_string()
        };
        let source = source_dir.join(&exe_name);
        let target = target_bin_dir.join(&exe_name);

        if !source.exists() {
            // Skip binaries that aren't in the bundle (e.g. dev builds).
            continue;
        }

        if !force && target.exists() && files_appear_identical(&source, &target)? {
            skipped.push(target);
            continue;
        }

        copy_binary(&source, &target)?;
        copied.push(target);
    }

    Ok(ReleaseReport { copied, skipped })
}

/// Copy a single binary, preserving permissions on Unix.
fn copy_binary(source: &Path, target: &Path) -> Result<()> {
    std::fs::copy(source, target)
        .with_context(|| format!("copying {}{}", source.display(), target.display()))?;

    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let mut perms = std::fs::metadata(target)
            .with_context(|| format!("reading permissions for {}", target.display()))?
            .permissions();
        perms.set_mode(0o755);
        std::fs::set_permissions(target, perms)
            .with_context(|| format!("setting permissions on {}", target.display()))?;
    }

    Ok(())
}

/// Check whether two files have identical size and mtime. Cheap heuristic
/// to avoid re-copying binaries that haven't changed.
fn files_appear_identical(a: &Path, b: &Path) -> Result<bool> {
    let meta_a =
        std::fs::metadata(a).with_context(|| format!("reading metadata for {}", a.display()))?;
    let meta_b =
        std::fs::metadata(b).with_context(|| format!("reading metadata for {}", b.display()))?;
    Ok(meta_a.len() == meta_b.len())
}

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

    fn write_fake_binary(dir: &Path, name: &str, content: &str) -> PathBuf {
        let exe_name = if cfg!(windows) {
            format!("{name}.exe")
        } else {
            name.to_string()
        };
        let path = dir.join(&exe_name);
        fs::write(&path, content).unwrap();
        path
    }

    #[test]
    fn release_should_copy_existing_bundled_binaries() {
        let temp = tempdir().unwrap();
        let source = temp.path().join("source");
        let target = temp.path().join("target");
        fs::create_dir_all(&source).unwrap();

        write_fake_binary(&source, "spool", "fake spool");
        write_fake_binary(&source, "spool-mcp", "fake spool-mcp");
        // spool-daemon intentionally missing — should be skipped silently

        let report = release_binaries(&source, &target, false).unwrap();
        assert_eq!(report.copied.len(), 2);
        assert!(report.skipped.is_empty());

        let target_spool = if cfg!(windows) { "spool.exe" } else { "spool" };
        assert!(target.join(target_spool).exists());
    }

    #[test]
    fn release_should_skip_when_not_forced_and_target_exists() {
        let temp = tempdir().unwrap();
        let source = temp.path().join("source");
        let target = temp.path().join("target");
        fs::create_dir_all(&source).unwrap();
        fs::create_dir_all(&target).unwrap();

        write_fake_binary(&source, "spool", "v1");
        write_fake_binary(&target, "spool", "v1");

        let report = release_binaries(&source, &target, false).unwrap();
        assert_eq!(report.copied.len(), 0);
        assert_eq!(report.skipped.len(), 1);
    }

    #[test]
    fn release_should_overwrite_when_forced() {
        let temp = tempdir().unwrap();
        let source = temp.path().join("source");
        let target = temp.path().join("target");
        fs::create_dir_all(&source).unwrap();
        fs::create_dir_all(&target).unwrap();

        write_fake_binary(&source, "spool", "new version");
        write_fake_binary(&target, "spool", "old version");

        let report = release_binaries(&source, &target, true).unwrap();
        assert_eq!(report.copied.len(), 1);

        let target_spool = if cfg!(windows) { "spool.exe" } else { "spool" };
        let copied = fs::read_to_string(target.join(target_spool)).unwrap();
        assert_eq!(copied, "new version");
    }

    #[test]
    fn release_should_fail_when_source_dir_missing() {
        let temp = tempdir().unwrap();
        let source = temp.path().join("nonexistent");
        let target = temp.path().join("target");
        let result = release_binaries(&source, &target, false);
        assert!(result.is_err());
    }

    #[cfg(unix)]
    #[test]
    fn release_should_set_executable_permissions() {
        use std::os::unix::fs::PermissionsExt;
        let temp = tempdir().unwrap();
        let source = temp.path().join("source");
        let target = temp.path().join("target");
        fs::create_dir_all(&source).unwrap();

        write_fake_binary(&source, "spool", "binary");
        release_binaries(&source, &target, false).unwrap();

        let mode = fs::metadata(target.join("spool"))
            .unwrap()
            .permissions()
            .mode();
        assert_eq!(mode & 0o755, 0o755);
    }
}