dolly-cli 0.1.0

Like apt, but for GitHub repositories - clone, build, install and update tools from source.
Documentation
use anyhow::{Context, anyhow, bail};
use dolly_cli::{binary, build, git, paths, recipe::Recipe, ui};

pub fn handle(repo: Option<&str>, dry_run: bool) -> anyhow::Result<()> {
    match repo {
        Some(r) => handle_repo(r, dry_run),
        None => handle_all(dry_run),
    }
}

fn handle_repo(repo: &str, dry_run: bool) -> anyhow::Result<()> {
    let repos_dir = paths::repos_dir()?;

    let recipe = Recipe::find(repo).with_context(|| format!("loading recipe for `{repo}`"))?;
    let repo_path = repos_dir.join(repo);

    if !repo_path.exists() {
        bail!("package `{repo}` is not installed")
    }

    let old = git::head_commit(&repo_path).with_context(|| format!("reading HEAD for `{repo}`"))?;

    if dry_run {
        git::fetch(&repo_path).with_context(|| format!("fetching `{repo}`"))?;
        let upstream = git::upstream_commit(&repo_path)
            .with_context(|| format!("reading upstream HEAD for `{repo}`"))?;
        if upstream != old {
            ui::status("Would update", &format!("{repo} ({old} -> {upstream})"));
        } else {
            ui::status("Up-to-date", repo);
        }
        return Ok(());
    }

    ui::status("Pulling", repo);
    git::pull(&repo_path).with_context(|| format!("pulling `{repo}`"))?;
    let new = git::head_commit(&repo_path).with_context(|| format!("reading HEAD for `{repo}`"))?;

    if new == old {
        ui::status("Up-to-date", repo);
        return Ok(());
    }

    ui::status("Building", repo);
    build::run(&recipe.build.steps, &repo_path).with_context(|| format!("building `{repo}`"))?;

    let binary_path = repo_path.join(&recipe.build.output);
    let bin_name = binary_path
        .file_name()
        .ok_or_else(|| anyhow!("`build.output` has no filename"))?;
    let install_path = paths::default_install_dir()?.join(bin_name);
    binary::place(&binary_path, &install_path).with_context(|| format!("installing `{repo}`"))?;
    binary::make_executable(&install_path)
        .with_context(|| format!("setting executable bit on {}", install_path.display()))?;

    ui::status("Updated", &format!("{repo} ({old}{new})"));

    Ok(())
}

fn handle_all(dry_run: bool) -> anyhow::Result<()> {
    let repos_dir = paths::repos_dir()?;
    let recipes_dir = paths::recipes_dir()?;

    for entry in repos_dir
        .read_dir()
        .with_context(|| format!("reading {}", repos_dir.display()))?
    {
        let entry = entry?;

        if !entry.file_type()?.is_dir() {
            continue;
        }

        let repo = entry.file_name().to_string_lossy().to_string();
        if recipes_dir.join(format!("{repo}.toml")).exists() {
            handle_repo(&repo, dry_run)?;
        }
    }

    Ok(())
}