oreutils 0.0.3

Installer for various Rust reimaginations of coreutils. These are not drop-in replacements.
use regex::Regex;
use semver::Version;
use std::process::Command;
use structopt::StructOpt;

mod fetch;

#[derive(Debug, StructOpt)]
#[structopt(
    name = "oreutils",
    about = "Installation manager for various CLI utilities reimagined in Rust",
    rename_all = "kebab-case"
)]
enum Opt {
    #[structopt(about = "Install the basic utilities: ripgrep, exa, bat, fd")]
    Install {
        #[structopt(help = "Specific tool to install. Omit to install all.")]
        tool: Option<String>,
    },
    #[structopt(
        about = "Upgrade any installed tools. Use `oreutils install` to install missing ones."
    )]
    Upgrade {
        #[structopt(help = "Specific tool to upgrade. Omit to upgrade all.")]
        tool: Option<String>,
    },
    #[structopt(about = "Uninstall all oreutils tools")]
    Uninstall,
}

#[derive(Clone, Copy)]
struct Tool {
    name: &'static str,
    package: &'static str,
    cli: &'static str,
}

impl Tool {
    fn equals(&self, other: &str) -> bool {
        self.name == other || self.package == other || self.cli == other
    }
}

const TOOLS: &[Tool] = &[
    Tool {
        name: "ripgrep",
        package: "ripgrep",
        cli: "rg",
    },
    Tool {
        name: "exa",
        package: "exa",
        cli: "exa",
    },
    Tool {
        name: "bat",
        package: "bat",
        cli: "bat",
    },
    Tool {
        name: "fd",
        package: "fd-find",
        cli: "fd",
    },
];

fn main() {
    let opt = Opt::from_args();

    match opt {
        Opt::Install {tool} => install(tool),
        Opt::Upgrade {tool} => upgrade(tool),
        Opt::Uninstall => uninstall(),
    }
}

fn install(tool: Option<String>) {
    for_each_tool(tool, |tool| {
        let exists = which::which(tool.cli);
        if exists.is_ok() {
            println!(
                "Tool {:?} already installed, use `oreutils upgrade` to upgrade",
                tool.name
            );
            return;
        }
        cargo_install(tool.package, false);
    });
}


fn for_each_tool<F: Fn(&Tool)>(tool: Option<String>, f: F) {
    if let Some(tool) = tool {
        for tool in TOOLS.iter().filter(|x| x.equals(&tool)) {
            f(tool)
        }
    } else {
        for tool in TOOLS.iter()  {
            f(tool)
        }
    };
}

fn upgrade(tool: Option<String>) {
    for_each_tool(tool, |tool| {
        let res = upgrade_tool(tool);
        match res {
            Ok(vers) => println!("Tool {} updated to version {}", tool.name, vers),
            Err(Error::NotFound) => println!(
                "Tool {} not installed, use `oreutils install` to install",
                tool.name
            ),
            Err(Error::VersionBroken(None)) => {
                println!("`{} --version` didn't produce expected output", tool.cli)
            }
            Err(Error::VersionBroken(Some(v))) => println!(
                "`{} --version` didn't produce expected output: could not parse {}",
                tool.cli, v
            ),
            Err(Error::AlreadyUpdated(v)) => println!("Tool {} is already up to date at version {}", tool.name, v),
            Err(Error::CratesFetchError(e)) => println!(
                "Failed to fetch information for crate {} from crates.io: {}",
                tool.name, e
            ),
        }
    });
}

enum Error {
    NotFound,
    VersionBroken(Option<String>),
    CratesFetchError(fetch::FetchError),
    AlreadyUpdated(Version)
}

fn upgrade_tool(tool: &Tool) -> Result<Version, Error> {
    let output = Command::new(tool.cli)
        .args(&["--version"])
        .output()
        .map_err(|_| Error::NotFound)?;
    let output = String::from_utf8(output.stdout).map_err(|_| Error::VersionBroken(None))?;
    let output = output.lines().next().ok_or(Error::VersionBroken(None))?;
    let re = Regex::new(r"\d+\.\d+\.\d+").unwrap();
    let vers = re
        .find(output)
        .ok_or(Error::VersionBroken(Some(output.into())))?;
    let vers = vers.as_str();
    let vers = Version::parse(vers).map_err(|_| Error::VersionBroken(Some(vers.into())))?;
    let latest_vers =
        fetch::get_latest_version(tool.package).map_err(|e| Error::CratesFetchError(e))?;

    if vers < latest_vers {
        cargo_install(tool.package, true);
        Ok(latest_vers)
    } else {
        Err(Error::AlreadyUpdated(vers))
    }
}

fn uninstall() {
    unimplemented!()
}

fn cargo_install(pkg: &str, force: bool) {
    let mut cmd = Command::new("cargo");
    if force {
        cmd.args(&["install", "-f", pkg]);
    } else {
        cmd.args(&["install", pkg]);
    }
    cmd.env("RUSTFLAGS", "-Ctarget-cpu=native");
    let res = cmd.spawn();
    match res {
        Ok(mut child) => {
            let status = child.wait().expect("Command wasn't running");
            if !status.success() {
                eprintln!("Installing {:?} failed", pkg);
            }
        }
        Err(_) => eprintln!("Cargo didn't start"),
    }
}