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::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode};

use clap::Parser;
use thiserror::Error;

use crate::bazel::RealBazel;
use crate::cli::{Cli, Command as CliCommand};
use crate::config::{CONFIG_FILE_NAME, load_repo_config};
use crate::dispatch::{DispatchPlan, prepare_dispatch};
use crate::install::{current_home_dir, install, resolve_bin_dir};

const REPOBIN_BINARY_NAME: &str = "repobin";

#[derive(Debug, Error)]
pub enum RepobinError {
    #[error("no {CONFIG_FILE_NAME} found from `{}` upward", start_dir.display())]
    ConfigNotFound { start_dir: PathBuf },
    #[error("failed to read config `{}`", path.display())]
    ReadConfig {
        path: PathBuf,
        #[source]
        source: std::io::Error,
    },
    #[error("failed to parse config `{}`", path.display())]
    ParseConfig {
        path: PathBuf,
        #[source]
        source: toml::de::Error,
    },
    #[error("unsupported {CONFIG_FILE_NAME} version `{version}`")]
    UnsupportedConfigVersion { version: u32 },
    #[error("{0}")]
    InvalidConfig(String),
    #[error("tool `{tool}` is not configured in `{}`", config_path.display())]
    ToolNotConfigured { tool: String, config_path: PathBuf },
    #[error("HOME is not set and no --bin-dir override was provided")]
    MissingHomeDirectory,
    #[error("failed to create bin directory `{}`", path.display())]
    CreateBinDir {
        path: PathBuf,
        #[source]
        source: std::io::Error,
    },
    #[error("failed to read repobin binary `{}`", path.display())]
    ReadInstalledBinary {
        path: PathBuf,
        #[source]
        source: std::io::Error,
    },
    #[error("failed to copy repobin binary from `{}` to `{}`", from.display(), to.display())]
    CopyInstalledBinary {
        from: PathBuf,
        to: PathBuf,
        #[source]
        source: std::io::Error,
    },
    #[error("failed to write installed repobin binary `{}`", path.display())]
    WriteInstalledBinary {
        path: PathBuf,
        #[source]
        source: std::io::Error,
    },
    #[error("failed to create tool symlink `{}`", path.display())]
    CreateToolSymlink {
        path: PathBuf,
        #[source]
        source: std::io::Error,
    },
    #[error("failed to start bazel {action}")]
    SpawnBazel {
        action: String,
        #[source]
        source: std::io::Error,
    },
    #[error("failed while waiting for bazel {action}")]
    WaitBazel {
        action: String,
        #[source]
        source: std::io::Error,
    },
    #[error("failed while reading bazel {action} output")]
    ReadBazelOutput {
        action: String,
        #[source]
        source: std::io::Error,
    },
    #[error(
        "bazel build failed for `{target}`{}",
        status
            .map(|code| format!(" with exit code {code}"))
            .unwrap_or_default()
    )]
    BazelBuildFailed { target: String, status: Option<i32> },
    #[error("failed to resolve executable path for `{target}`: {stderr}")]
    BazelQueryFailed { target: String, stderr: String },
    #[error("configured target `{target}` is not executable")]
    TargetNotExecutable { target: String },
    #[error("failed to exec `{}`", path.display())]
    ExecTool {
        path: PathBuf,
        #[source]
        source: std::io::Error,
    },
    #[error(transparent)]
    Io(#[from] std::io::Error),
}

impl RepobinError {
    pub fn exit_code(&self) -> ExitCode {
        match self {
            Self::ConfigNotFound { .. }
            | Self::ParseConfig { .. }
            | Self::UnsupportedConfigVersion { .. }
            | Self::InvalidConfig(_)
            | Self::ToolNotConfigured { .. }
            | Self::MissingHomeDirectory => ExitCode::from(2),
            _ => ExitCode::FAILURE,
        }
    }
}

pub fn run_from_env() -> Result<ExitCode, RepobinError> {
    let args = env::args_os().collect::<Vec<_>>();
    let argv0 = args
        .first()
        .cloned()
        .unwrap_or_else(|| OsString::from(REPOBIN_BINARY_NAME));
    let invocation_name = invocation_name(&argv0);
    let cwd = env::current_dir()?;

    if invocation_name != REPOBIN_BINARY_NAME {
        let forwarded_args = args.get(1..).unwrap_or(&[]).to_vec();
        dispatch_tool(&cwd, &invocation_name, &forwarded_args)?;
        return Ok(ExitCode::SUCCESS);
    }

    let cli = Cli::parse_from(args);
    let current_executable = env::current_exe()?;
    run_cli(&cwd, &current_executable, cli)
}

fn run_cli(cwd: &Path, current_executable: &Path, cli: Cli) -> Result<ExitCode, RepobinError> {
    match cli.command {
        CliCommand::Install(args) => {
            let repo_config = load_repo_config(cwd)?;
            let home_dir = current_home_dir();
            let bin_dir =
                resolve_bin_dir(args.bin_dir.bin_dir.as_deref(), cwd, home_dir.as_deref())?;
            let report = install(
                current_executable,
                &repo_config,
                &bin_dir,
                env::var_os("PATH").as_deref(),
                env::var_os("SHELL").as_deref(),
                home_dir.as_deref(),
            )?;

            println!("Installed repobin to {}", report.installed_binary.display());
            for tool in &report.installed_tools {
                println!(
                    "Installed {} -> repobin",
                    report.bin_dir.join(tool).display()
                );
            }

            if let Some(warning) = report.path_warning {
                eprintln!("warning: `{}` is not on PATH", warning.bin_dir.display());
                if let Some(config_hint) = warning.fragment.config_hint {
                    eprintln!("Add this to {config_hint}:");
                } else {
                    eprintln!("Add this to your shell config:");
                }
                eprintln!();
                eprintln!("{}", warning.fragment.fragment);
            }

            Ok(ExitCode::SUCCESS)
        }
        CliCommand::Doctor(args) => {
            let repo_config = load_repo_config(cwd)?;
            let home_dir = current_home_dir();
            let bin_dir =
                resolve_bin_dir(args.bin_dir.bin_dir.as_deref(), cwd, home_dir.as_deref())?;
            let on_path = crate::shell::bin_dir_on_path(&bin_dir, env::var_os("PATH").as_deref());

            println!("Repo root: {}", repo_config.repo_root.display());
            println!("Config: {}", repo_config.config_path.display());
            println!("Version: {}", repo_config.config.version);
            println!("Bin dir: {}", bin_dir.display());
            println!("On PATH: {}", if on_path { "yes" } else { "no" });
            println!("Tools:");
            for (name, tool) in &repo_config.config.tools {
                println!("  {name} -> {}", tool.target);
            }

            if !on_path {
                let fragment = crate::shell::path_update_fragment(
                    &bin_dir,
                    env::var_os("SHELL").as_deref(),
                    home_dir.as_deref(),
                );
                println!("Suggested PATH fragment:");
                println!("{}", fragment.fragment);
            }

            Ok(ExitCode::SUCCESS)
        }
        CliCommand::List => {
            let repo_config = load_repo_config(cwd)?;
            for (name, tool) in &repo_config.config.tools {
                println!("{name} -> {}", tool.target);
            }
            Ok(ExitCode::SUCCESS)
        }
        CliCommand::Exec(args) => {
            dispatch_tool(cwd, &args.tool, &args.args)?;
            Ok(ExitCode::SUCCESS)
        }
    }
}

fn dispatch_tool(
    cwd: &Path,
    tool_name: &str,
    forwarded_args: &[OsString],
) -> Result<(), RepobinError> {
    let bazel = RealBazel::new(env::var_os("REPOBIN_VERBOSE").is_some());
    let plan = prepare_dispatch(&bazel, cwd, tool_name, forwarded_args)?;
    exec_dispatch(plan)
}

fn exec_dispatch(plan: DispatchPlan) -> Result<(), RepobinError> {
    use std::os::unix::process::CommandExt;

    let error = Command::new(&plan.executable_path)
        .arg0(&plan.tool_name)
        .args(&plan.forwarded_args)
        .current_dir(&plan.original_cwd)
        .exec();
    Err(RepobinError::ExecTool {
        path: plan.executable_path,
        source: error,
    })
}

fn invocation_name(argv0: &OsString) -> String {
    Path::new(argv0)
        .file_name()
        .map(|value| value.to_string_lossy().to_string())
        .filter(|value| !value.is_empty())
        .unwrap_or_else(|| REPOBIN_BINARY_NAME.to_string())
}

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

    use super::invocation_name;

    #[test]
    fn invocation_name_uses_basename() {
        assert_eq!(
            invocation_name(&OsString::from("/Users/test/bin/boss")),
            "boss"
        );
        assert_eq!(invocation_name(&OsString::from("repobin")), "repobin");
        assert_eq!(
            invocation_name(&OsString::from(Path::new("").as_os_str())),
            "repobin"
        );
    }
}