repobin 0.1.0-alpha.1

Experimental repo-local Bazel command dispatcher; API and behavior may change without notice
Documentation
use std::env;
use std::path::{Component, Path, PathBuf};

use crate::app::RepobinError;
use crate::config::RepoConfig;
use crate::shell::{ShellFragment, bin_dir_on_path, path_update_fragment};

#[derive(Debug, Clone)]
pub struct InstallReport {
    pub bin_dir: PathBuf,
    pub installed_binary: PathBuf,
    pub installed_tools: Vec<String>,
    pub path_warning: Option<PathWarning>,
}

#[derive(Debug, Clone)]
pub struct PathWarning {
    pub bin_dir: PathBuf,
    pub fragment: ShellFragment,
}

pub fn install(
    current_executable: &Path,
    repo_config: &RepoConfig,
    bin_dir: &Path,
    path_var: Option<&std::ffi::OsStr>,
    shell_var: Option<&std::ffi::OsStr>,
    home_dir: Option<&Path>,
) -> Result<InstallReport, RepobinError> {
    std::fs::create_dir_all(bin_dir).map_err(|source| RepobinError::CreateBinDir {
        path: bin_dir.to_path_buf(),
        source,
    })?;

    let installed_binary = install_binary(current_executable, bin_dir)?;
    let mut installed_tools = Vec::new();
    for tool_name in repo_config.config.tools.keys() {
        install_tool_link(bin_dir, tool_name)?;
        installed_tools.push(tool_name.clone());
    }
    installed_tools.sort();

    let path_warning = if bin_dir_on_path(bin_dir, path_var) {
        None
    } else {
        Some(PathWarning {
            bin_dir: bin_dir.to_path_buf(),
            fragment: path_update_fragment(bin_dir, shell_var, home_dir),
        })
    };

    Ok(InstallReport {
        bin_dir: bin_dir.to_path_buf(),
        installed_binary,
        installed_tools,
        path_warning,
    })
}

pub fn resolve_bin_dir(
    requested: Option<&Path>,
    cwd: &Path,
    home_dir: Option<&Path>,
) -> Result<PathBuf, RepobinError> {
    let path = if let Some(requested) = requested {
        expand_user_path(requested, home_dir)?
    } else {
        default_bin_dir(home_dir)?
    };

    if path.is_absolute() {
        Ok(path)
    } else {
        Ok(cwd.join(path))
    }
}

fn default_bin_dir(home_dir: Option<&Path>) -> Result<PathBuf, RepobinError> {
    let Some(home_dir) = home_dir else {
        return Err(RepobinError::MissingHomeDirectory);
    };
    Ok(home_dir.join("bin"))
}

fn expand_user_path(path: &Path, home_dir: Option<&Path>) -> Result<PathBuf, RepobinError> {
    let mut components = path.components();
    let Some(first) = components.next() else {
        return Ok(path.to_path_buf());
    };
    if first != Component::Normal("~".as_ref()) {
        return Ok(path.to_path_buf());
    }

    let Some(home_dir) = home_dir else {
        return Err(RepobinError::MissingHomeDirectory);
    };

    let mut expanded = home_dir.to_path_buf();
    for component in components {
        expanded.push(component.as_os_str());
    }
    Ok(expanded)
}

fn install_binary(current_executable: &Path, bin_dir: &Path) -> Result<PathBuf, RepobinError> {
    let destination = bin_dir.join("repobin");
    let temp_destination = temporary_path(bin_dir, ".repobin");

    std::fs::copy(current_executable, &temp_destination).map_err(|source| {
        RepobinError::CopyInstalledBinary {
            from: current_executable.to_path_buf(),
            to: destination.clone(),
            source,
        }
    })?;

    let permissions = std::fs::metadata(current_executable).map_err(|source| {
        RepobinError::ReadInstalledBinary {
            path: current_executable.to_path_buf(),
            source,
        }
    })?;
    std::fs::set_permissions(&temp_destination, permissions.permissions()).map_err(|source| {
        RepobinError::WriteInstalledBinary {
            path: temp_destination.clone(),
            source,
        }
    })?;

    std::fs::rename(&temp_destination, &destination).map_err(|source| {
        RepobinError::WriteInstalledBinary {
            path: destination.clone(),
            source,
        }
    })?;

    Ok(destination)
}

fn install_tool_link(bin_dir: &Path, tool_name: &str) -> Result<(), RepobinError> {
    let destination = bin_dir.join(tool_name);
    let temp_destination = temporary_path(bin_dir, &format!(".{tool_name}"));

    let _ = std::fs::remove_file(&temp_destination);
    std::os::unix::fs::symlink("repobin", &temp_destination).map_err(|source| {
        RepobinError::CreateToolSymlink {
            path: destination.clone(),
            source,
        }
    })?;

    std::fs::rename(&temp_destination, &destination).map_err(|source| {
        RepobinError::CreateToolSymlink {
            path: destination.clone(),
            source,
        }
    })?;

    Ok(())
}

fn temporary_path(bin_dir: &Path, prefix: &str) -> PathBuf {
    bin_dir.join(format!("{prefix}.{}.tmp", std::process::id()))
}

pub fn current_home_dir() -> Option<PathBuf> {
    env::var_os("HOME").map(PathBuf::from)
}

#[cfg(test)]
mod tests {
    use std::ffi::OsString;
    use std::fs;
    use std::os::unix::fs::PermissionsExt;
    use std::path::Path;

    use tempfile::TempDir;

    use crate::config::{Config, RepoConfig, ToolConfig};

    use super::{current_home_dir, install, resolve_bin_dir};

    fn sample_repo_config(root: &std::path::Path) -> RepoConfig {
        RepoConfig {
            repo_root: root.to_path_buf(),
            config_path: root.join("REPOBIN.toml"),
            config: Config {
                version: 1,
                tools: std::collections::BTreeMap::from([
                    (
                        "boss".to_string(),
                        ToolConfig {
                            target: "//tools/boss/cli:boss".to_string(),
                        },
                    ),
                    (
                        "cube".to_string(),
                        ToolConfig {
                            target: "//tools/cube:cube".to_string(),
                        },
                    ),
                ]),
            },
        }
    }

    #[test]
    fn resolve_bin_dir_defaults_to_home_bin() {
        let cwd = Path::new("/repo");
        let resolved = resolve_bin_dir(None, cwd, Some(Path::new("/Users/test"))).expect("bin dir");
        assert_eq!(resolved, Path::new("/Users/test/bin"));
    }

    #[test]
    fn resolve_bin_dir_expands_tilde_and_relative_paths() {
        let cwd = Path::new("/repo");
        let tilde = resolve_bin_dir(
            Some(Path::new("~/custom/bin")),
            cwd,
            Some(Path::new("/Users/test")),
        )
        .expect("tilde path");
        assert_eq!(tilde, Path::new("/Users/test/custom/bin"));

        let relative =
            resolve_bin_dir(Some(Path::new(".bin")), cwd, Some(Path::new("/Users/test")))
                .expect("relative path");
        assert_eq!(relative, Path::new("/repo/.bin"));
    }

    #[test]
    fn install_copies_binary_and_creates_tool_links() {
        let temp = TempDir::new().expect("tempdir");
        let repo = sample_repo_config(temp.path());
        let source_binary = temp.path().join("repobin-source");
        fs::write(&source_binary, b"#!/bin/sh\nexit 0\n").expect("write source binary");
        fs::set_permissions(&source_binary, fs::Permissions::from_mode(0o755))
            .expect("chmod source binary");

        let bin_dir = temp.path().join("bin");
        let path_var = OsString::from("/usr/bin");
        let shell_var = OsString::from("/bin/zsh");
        let report = install(
            &source_binary,
            &repo,
            &bin_dir,
            Some(path_var.as_os_str()),
            Some(shell_var.as_os_str()),
            Some(Path::new("/Users/test")),
        )
        .expect("install");

        assert_eq!(
            report.installed_tools,
            vec!["boss".to_string(), "cube".to_string()]
        );
        assert_eq!(
            fs::read(bin_dir.join("repobin")).expect("read installed binary"),
            b"#!/bin/sh\nexit 0\n"
        );
        assert_eq!(
            std::fs::read_link(bin_dir.join("boss")).expect("boss symlink"),
            Path::new("repobin")
        );
        assert!(report.path_warning.is_some());
    }

    #[test]
    fn current_home_dir_matches_environment_when_present() {
        if let Some(home) = current_home_dir() {
            assert!(!home.as_os_str().is_empty());
        }
    }
}