dolly-cli 0.1.3

Like apt, but for GitHub repositories — clone, build, install and update tools from source.
Documentation
use std::path::Path;

use anyhow::{Context, anyhow, bail};
use dolly_cli::{
    binary, build, git, paths,
    recipe::{Recipe, RecipeError},
    ui,
};
use owo_colors::OwoColorize;

const MAX_COMMITS_SHOWN: usize = 10;

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 repo_path = paths::repositories_dir()?.join(repo);

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

    let recipe = match Recipe::find(repo) {
        Ok(r) => Some(r),
        Err(RecipeError::NotFound(_)) => None,
        Err(e) => {
            return Err(anyhow::Error::from(e).context(format!("loading recipe for `{repo}`")));
        }
    };

    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("Updated", &format!("{repo} ({old} -> {new})"));
    let summary = collect_summary(&repo_path, &old, &new)
        .with_context(|| format!("summarizing changes in `{repo}`"))?;
    print_summary(&summary);

    let Some(recipe) = recipe else {
        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("Installed", repo);

    Ok(())
}

struct UpdateSummary {
    subjects: Vec<String>,
    files_changed: usize,
    insertions: usize,
    deletions: usize,
}

fn collect_summary(repo_path: &Path, from: &str, to: &str) -> Result<UpdateSummary, git::GitError> {
    let subjects = git::log_between(repo_path, from, to)?;
    let (files_changed, insertions, deletions) = git::diffstat(repo_path, from, to)?;
    Ok(UpdateSummary {
        subjects,
        files_changed,
        insertions,
        deletions,
    })
}

fn print_summary(summary: &UpdateSummary) {
    let n = summary.subjects.len();
    if n == 0 && summary.files_changed == 0 {
        return;
    }
    let commit_word = if n == 1 { "commit" } else { "commits" };
    let file_word = if summary.files_changed == 1 {
        "file"
    } else {
        "files"
    };
    ui::detail(&format!(
        "{n} new {commit_word}, {} {file_word} changed ({} {})",
        summary.files_changed,
        format!("+{}", summary.insertions).green(),
        format!("-{}", summary.deletions).red(),
    ));
    for subject in summary.subjects.iter().take(MAX_COMMITS_SHOWN) {
        ui::detail(subject);
    }
    if n > MAX_COMMITS_SHOWN {
        let line = format!("...and {} more", n - MAX_COMMITS_SHOWN);
        ui::detail(&line.dimmed().to_string());
    }
}

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

    if !repositories_dir.exists() {
        return Ok(());
    }

    let mut repos: Vec<String> = Vec::new();
    for entry in repositories_dir
        .read_dir()
        .with_context(|| format!("reading {}", repositories_dir.display()))?
    {
        let entry = entry?;
        if !entry.file_type()?.is_dir() {
            continue;
        }
        repos.push(entry.file_name().to_string_lossy().into_owned());
    }
    repos.sort();

    for repo in repos {
        handle_repo(&repo, dry_run)?;
    }

    Ok(())
}