bio_tools 0.1.1

Install, run, and inspect computational biology and chemistry tools, e.g. AlphaFold, Boltz, RFdiffusion, and ProteinMPNN
Documentation
//! Optional standalone CLI application; a thin wrapper around the library API which can be launched
//! without using it in a rust or python program

use std::{env, error::Error, ffi::OsString, path::PathBuf, process, str::FromStr};

use bio_tools::{
    install::{InstallEvent, Installer, StatusKind},
    run::run,
    tool_definitions::{
        Tool,
        catalog::{self, CatalogEntry},
    },
};

fn format_status(status: &bio_tools::install::ToolStatus) -> String {
    match status.result {
        StatusKind::Pass => match &status.device {
            Some(device) => format!("Pass, {} {device}", status.detail),
            None => format!("Pass, {}", status.detail),
        },
        StatusKind::NotFound => "Not installed".to_owned(),
        StatusKind::Error => format!("Error: {}", status.detail),
    }
}

const USAGE: &str = "Usage:\n  bio_tools [--root <directory>] install <tool>\n  bio_tools [--root <directory>] uninstall <tool>\n  bio_tools [--root <directory>] status-quick <tool>\n  bio_tools [--root <directory>] status-full <tool>\n  bio_tools [--root <directory>] run <tool> [-- <tool arguments...>]\n  bio_tools [--root <directory>] list-quick\n  bio_tools [--root <directory>] list-full\n  bio_tools metadata <tool>\n\n`status` and `list` remain aliases for their full variants. The installation root defaults to $BIO_TOOLS_ROOT, or ./.bio_tools when unset.";

fn main() {
    if let Err(error) = real_main() {
        eprintln!("bio_tools: {error}");
        process::exit(1);
    }
}

fn real_main() -> Result<(), Box<dyn Error>> {
    let mut args = env::args_os().skip(1).collect::<Vec<_>>();
    if args.is_empty() || matches!(args[0].to_str(), Some("-h" | "--help" | "help")) {
        println!("{USAGE}");
        return Ok(());
    }
    let root = take_root(&mut args)?;
    let command = args
        .first()
        .and_then(|value| value.to_str())
        .ok_or("missing command")?
        .to_owned();
    args.remove(0);
    if matches!(command.as_str(), "list" | "list-full" | "list-quick") {
        if !args.is_empty() {
            return Err(format!("{command} does not accept arguments").into());
        }
        let installer = Installer::from_environment(root)?;
        for tool in Tool::ALL {
            let status = if command == "list-quick" {
                installer.status_quick(tool)
            } else {
                installer.status_full(tool)
            };
            println!("{}: {}", tool.name(), format_status(&status));
        }
        return Ok(());
    }
    if command == "metadata" {
        let slug = args
            .first()
            .and_then(|value| value.to_str())
            .ok_or("missing tool name")?
            .to_owned();
        args.remove(0);
        require_empty(&args, "metadata")?;
        let entry = catalog::by_slug(&slug).ok_or_else(|| {
            let slugs = catalog::ALL
                .iter()
                .map(|entry| entry.slug())
                .collect::<Vec<_>>()
                .join(", ");
            format!("unknown tool {slug:?}; known tools: {slugs}")
        })?;
        print_metadata(entry);
        return Ok(());
    }
    let tool = Tool::from_str(
        args.first()
            .and_then(|value| value.to_str())
            .ok_or("missing tool name")?,
    )?;
    args.remove(0);
    let mut installer = Installer::from_environment(root)?.with_reporter(progress);
    match command.as_str() {
        "install" => {
            require_empty(&args, "install")?;
            installer.install(tool)?;
        }
        "uninstall" => {
            require_empty(&args, "uninstall")?;
            let report = installer.uninstall(tool)?;
            if report.removed.is_empty() {
                println!("{} was already absent.", tool.name());
            }
            for path in report.removed {
                println!("Removed {}", path.display());
            }
            for note in report.kept {
                println!("Kept: {note}");
            }
        }
        "status" | "status-full" | "status_full" | "status-quick" | "status_quick" => {
            require_empty(&args, &command)?;
            let status = if matches!(command.as_str(), "status-quick" | "status_quick") {
                installer.status_quick(tool)
            } else {
                installer.status_full(tool)
            };
            println!("{}: {:?}\n{}", tool.name(), status.result, status.detail);
            if let Some(device) = status.device {
                println!("Device: {device}");
            }
            if status.result != StatusKind::Pass {
                process::exit(2);
            }
        }
        "run" => run_tool(&installer, tool, args)?,
        _ => return Err(format!("unknown command {command:?}\n\n{USAGE}").into()),
    }
    Ok(())
}

fn print_metadata(entry: &CatalogEntry) {
    let categories = entry
        .categories
        .iter()
        .map(ToString::to_string)
        .collect::<Vec<_>>()
        .join(", ");
    println!("{} ({})\n{}", entry.name(), entry.slug(), "=".repeat(56));
    println!("Categories: {categories}");
    println!("Launch type: {}", entry.launch_type);
    println!("License type: {}", entry.license_type);
    println!("Expense: {}", entry.expense);
    println!("Top choice: {}", entry.top_choice);
    println!(
        "Install recipe: {}",
        match entry.identity.tool() {
            Some(tool) => tool.slug(),
            None => "none (not installable by bio_tools)",
        }
    );

    let spec = &entry.spec;
    println!("\nSpec\n{}", "-".repeat(56));
    println!("Summary: {}", spec.summary);
    println!("Description: {}", spec.description);
    println!("Availability: {}", spec.availability);
    println!("License: {}", spec.license);
    println!("License details: {}", spec.license_details);
    for (label, url) in [
        (
            "License URL",
            spec.license.official_url().or(spec.license_url),
        ),
        ("Repo URL", spec.repo_url),
        ("Home URL", spec.home_url),
        ("Docs URL", spec.docs_url),
        ("Paper URL", spec.paper_url),
    ] {
        if let Some(url) = url {
            println!("{label}: {url}");
        }
    }
}

fn take_root(args: &mut Vec<OsString>) -> Result<PathBuf, Box<dyn Error>> {
    if args.first().is_some_and(|arg| arg == "--root") {
        if args.len() < 2 {
            return Err("--root needs a directory".into());
        }
        args.remove(0);
        return Ok(PathBuf::from(args.remove(0)));
    }
    Ok(env::var_os("BIO_TOOLS_ROOT")
        .map(PathBuf::from)
        .unwrap_or_else(|| PathBuf::from(".bio_tools")))
}

fn run_tool(
    installer: &Installer,
    tool: Tool,
    mut args: Vec<OsString>,
) -> Result<(), Box<dyn Error>> {
    if args.first().is_some_and(|arg| arg == "--") {
        args.remove(0);
    }
    let executable = installer.executable_path(tool);
    if !executable.is_file() {
        return Err(format!("{} has no installed console entry point at {}; install it first, or use the library API for a tool-specific invocation", tool.name(), executable.display()).into());
    }
    let output = run(&installer.tool_command(tool).args(args).timeout(None))?;
    print!("{}", output.stdout_lossy());
    eprint!("{}", output.stderr_lossy());
    Ok(())
}

fn progress(event: InstallEvent) {
    match event {
        InstallEvent::ToolStarted(tool) => println!("\n{tool}\n{}", "=".repeat(56)),
        InstallEvent::Step { description, .. }
        | InstallEvent::Note {
            message: description,
            ..
        } => println!("  {description}"),
        InstallEvent::ToolFinished(_) => {}
    }
}
fn require_empty(args: &[OsString], command: &str) -> Result<(), Box<dyn Error>> {
    if args.is_empty() {
        Ok(())
    } else {
        Err(format!("{command} accepts only one tool name").into())
    }
}