biov 0.1.11

A uv-style tool manager for bioinformatics: reproducible Docker-backed tools with digest-pinned lockfiles (installs as `bv`)
use std::path::{Path, PathBuf};

use anyhow::Context;
use tempfile::TempDir;

pub struct FetchedSource {
    pub dir: PathBuf,
    pub name_hint: String,
    pub version_hint: Option<String>,
    pub source_url: String,
    _tempdir: Option<TempDir>,
}

pub enum Source {
    LocalDir(PathBuf),
    GitHub {
        owner: String,
        repo: String,
        git_ref: Option<String>,
    },
}

impl Source {
    pub fn parse(spec: &str) -> anyhow::Result<Self> {
        if let Some(rest) = spec.strip_prefix("github:") {
            let (repo_part, git_ref) = rest
                .split_once('@')
                .map(|(rp, r)| (rp, Some(r.to_string())))
                .unwrap_or((rest, None));
            let (owner, repo) = repo_part.split_once('/').ok_or_else(|| {
                anyhow::anyhow!("github source must be 'github:owner/repo', got '{}'", spec)
            })?;
            Ok(Source::GitHub {
                owner: owner.to_string(),
                repo: repo.to_string(),
                git_ref,
            })
        } else {
            let path = PathBuf::from(spec);
            let canonical = path
                .canonicalize()
                .with_context(|| format!("'{}' does not exist", spec))?;
            if !canonical.is_dir() {
                anyhow::bail!("'{}' is not a directory", spec);
            }
            Ok(Source::LocalDir(canonical))
        }
    }

    pub fn fetch(self) -> anyhow::Result<FetchedSource> {
        match self {
            Source::LocalDir(dir) => {
                let name_hint = dir
                    .file_name()
                    .and_then(|n| n.to_str())
                    .unwrap_or("tool")
                    .to_string();
                let version_hint = git_latest_tag(&dir);
                let source_url = format!("file://{}", dir.display());
                Ok(FetchedSource {
                    dir,
                    name_hint,
                    version_hint,
                    source_url,
                    _tempdir: None,
                })
            }
            Source::GitHub {
                owner,
                repo,
                git_ref,
            } => {
                let tmp = tempfile::tempdir().context("failed to create temp dir")?;
                let dest = tmp.path().join("repo");
                let clone_url = format!("https://github.com/{}/{}", owner, repo);

                let mut cmd = std::process::Command::new("git");
                cmd.args(["clone", "--depth", "1"]);
                if let Some(ref r) = git_ref {
                    cmd.args(["--branch", r]);
                }
                cmd.arg(&clone_url).arg(&dest);

                let status = cmd
                    .status()
                    .context("'git clone' failed; is git installed?")?;
                if !status.success() {
                    anyhow::bail!("failed to clone {}", clone_url);
                }

                let version_hint = git_ref
                    .as_deref()
                    .map(|r| r.trim_start_matches('v').to_string())
                    .or_else(|| git_latest_tag(&dest));

                Ok(FetchedSource {
                    dir: dest,
                    name_hint: repo.clone(),
                    version_hint,
                    source_url: clone_url,
                    _tempdir: Some(tmp),
                })
            }
        }
    }
}

fn git_latest_tag(dir: &Path) -> Option<String> {
    let out = std::process::Command::new("git")
        .args([
            "-C",
            &dir.to_string_lossy(),
            "describe",
            "--tags",
            "--abbrev=0",
        ])
        .output()
        .ok()?;
    if out.status.success() {
        let tag = String::from_utf8_lossy(&out.stdout).trim().to_string();
        Some(tag.trim_start_matches('v').to_string())
    } else {
        None
    }
}