pkg-utils 0.1.0

A reimplementation of some pacman functionality in pure rust. WIP
Documentation
//! This crate implements a _subset_ of libalpm functionality,
//! basically what is required to read databases and parse things.
//!
//! The goal is not to replace libalpm for doing actual system
//! package management, but to be able to access metadata about an
//! Arch or Arch-based system without needing to use the cumbersome
//! C API. Generally this is an attempt to write a more flexible API
//! for a lot of the stuff that libalpm does, in order to facilitate
//! writing things like AUR helpers.

#[macro_use]
extern crate display_derive;
extern crate failure;
extern crate flate2;
extern crate itertools;
#[macro_use]
extern crate log;
extern crate rayon;
extern crate tar;
extern crate version_compare;
extern crate xz2;

pub mod db;
mod error;
pub mod package;

use std::fmt::{self, Formatter, Display};
use std::path::Path;

use version_compare::Version;

use db::Db;
use error::Result;
use package::MetaPackage;

/// Little helper struct that logically groups a system's
/// databases together, and makes construction and passing around
/// a reference easier.
//TODO: System configuration associated with this
pub struct Alpm {
    pub local_db: Db,
    pub sync_dbs: Vec<Db>
}

impl Alpm {
    /// Create an Alpm instance using multiple threads (WIP: Threading is hard)
    pub fn new(location: impl AsRef<Path> + Sync) -> Result<Alpm> {
        let (local, sync) = rayon::join(
            || Db::local_db(&location),
            || Db::sync_dbs(&location)
        );
        Ok(Alpm {
            local_db: local?,
            sync_dbs: sync?
        })
    }
    
    /// Easy iterator over all packages on the system that are installed and are
    /// not found in the local database.
    pub fn foreign_pkgs<'a>(&'a self) -> impl Iterator<Item = &'a MetaPackage> {
        debug!("computing foreign packages");
        self.local_db.packages.iter()
            // We want all the pkgs that are foreign, so filter the ones that aren't
            .filter(move |package| !is_pkg_foreign(&self.sync_dbs, &package.name) )
    }
}

fn is_pkg_foreign(sync_dbs: &[Db], pkg_name: &str) -> bool {
    sync_dbs.iter()
        .any(|db| db.pkg(&pkg_name).is_some() )
}

/// This is some package info that can be gained just from parsing file names, not
/// tucking into the tar entries that contain more extensive metadata.
#[derive(Debug)]
pub struct PkgSpec {
    pub name: String,
    version: String,
    release: String,
    pub arch: Option<String>
}

impl PkgSpec {
    /// Parse a package specifier. `None` is returned if the provided
    /// string is not a valid specifier. Any of the following are valid:
    /// `pacman-5.1.1-2`, `pacman-5.1.1-2/`, or `pacman-5.1.1-2/desc`
    /// (trailing paths are removed).
    //TODO: Make this more legible
    //TODO: Merge with split_pkgname
    pub fn split_specifier(specifier: &str) -> Option<PkgSpec> {
        let specifier = if let Some(indx) = specifier.find('/') {
            &specifier[..indx]
        } else {
            specifier
        };
        let mut parts = specifier.rsplit('-');
        let rel = parts.next();
        let version = parts.next();
        
        let name: Vec<&str> = parts.rev().collect();
        let name = name.join("-");
        
        if let None = rel {
            None
        } else if let None = version {
            None
        } else {
            Some(PkgSpec {
                name: name,
                version: version.unwrap().to_string(),
                release: rel.unwrap().to_string(),
                arch: None
            })
        }
    }
    
    /// Parse a full package name, such as `pacman-5.1.1-2-x86_64`
    /// This is similar to [split_specifier](struct.PkgSpec.html#method.split_specifier),
    /// except it does not accept trailing paths, and includes an
    /// architecture specifier on the backend.
    pub fn split_pkgname(name: &str) -> Option<PkgSpec> {
        if let Some(indx) = name.rfind('-') {
            let pkgspec = &name[..indx];
            if let Some(mut pkgspec) = PkgSpec::split_specifier(pkgspec) {
                pkgspec.arch = Some(name[indx + 1..].to_string());
                Some(pkgspec)
            } else {
                None
            }
        } else {
            None
        }
    }
    
    // Ugly as hell for API
    pub fn version_str(&self) -> String {
        format!("{}-{}", self.version, self.release)
    }
}

#[derive(Clone, Debug, Hash, Eq, PartialEq)]
enum Comp {
    Eq,
    Lt,
    Le,
    Ge,
    Gt,
}

impl Comp {
    fn make_inclusive(self) -> Comp {
        match self {
            Comp::Lt => Comp::Le,
            Comp::Gt => Comp::Ge,
            _ => self
        }
    }
}

impl Display for Comp {
    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
        write!(f, "{}", match self {
            Comp::Eq => "=",
            Comp::Lt => "<",
            Comp::Le => "<=",
            Comp::Ge => ">=",
            Comp::Gt => ">"
        })
    }
}

/// This either represents a virtual package, or an actual
/// package in a databse. It can be used to query a db.
#[derive(Clone, Debug, Hash, Eq, PartialEq)]
pub struct Provide {
    pub name: String,
    version: Option<String>,
    comp: Comp,
}

impl Provide {
    /// This will only return None if `data == ""`
    /// Valid Provides are a package name, or a package name
    /// and version, delimited by an equals sign (`pacman=5.1.1`),
    ///  or one of the comparison operators (as detailed on the
    /// [archwiki](https://wiki.archlinux.org/index.php/PKGBUILD#Dependencies)).
    // Currently clones the string it's given, API suggestions welcome
    // TODO: Parse other equality operators too
    pub fn parse(data: impl AsRef<str>) -> Option<Provide> {
        let data = data.as_ref();
        let mut comp = Comp::Eq;
        
        let mut data = data.splitn(2, |c| {
            if c == '<' {
                comp = Comp::Lt;
                true
            } else if c == '>' {
                comp = Comp::Gt;
                true
            } else if c == '=' {
                comp = Comp::Eq;
                true
            } else {
                false
            }
        });
        
        let name = data.next()?.to_string();
        
        let version = if let Some(version) = data.next() {
            if version.chars().nth(0) == Some('=') {
                comp = comp.make_inclusive();
                Some(version[1..].to_string())
            } else {
                Some(version.to_string())
            }
        } else { None };
        
        Some(Provide { name, version, comp })
    }
    
    /// If the two Provides have the same name, and `self.version()` is
    /// acceptable by the terms of the `comp` field of both provides.
    // It really feels like I'm just doing stuff here and
    pub fn satisfies(&self, other: &Provide) -> bool {
        self.name == other.name && if let Some(ref other_ver) = other.version() {
            if let Some(ref ver) = self.version() {
                // ver >= other_ver
                match other.comp {
                    Comp::Eq => ver >= other_ver,
                    Comp::Lt => ver < other_ver,
                    Comp::Le => ver <= other_ver,
                    Comp::Ge => ver >= other_ver,
                    Comp::Gt => ver > other_ver,
                }
            } else {
                // This maybe should fail instead of just giving false
                //   At least give a clue what is going on
                false
            }
        } else {
            //`None` accepts any version
            true
        }
    }
    
    pub fn version(&self) -> Option<Version> {
        self.version.as_ref().and_then(|ver| {
            Version::from(ver)
        })
    }
}

impl Display for Provide {
    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
        match &self.version {
            Some(version) => write!(f, "{}{}{}", self.name, self.comp, version),
            None => write!(f, "{}", self.name)
        }
    }
}

// Tests require curl
#[cfg(test)]
mod tests {
    use std::fs::create_dir_all;
    use std::path::Path;
    use std::process::Command;
    
    use {Db, package::Package, PkgSpec, Provide};
    
    #[test]
    fn test_pkgload() {
        let test_dir = Path::join(Path::new(env!("CARGO_MANIFEST_DIR")), "tests");
        create_dir_all(&test_dir).unwrap();  // Took me a year to realize this wasn't being created...
        let wget = Command::new("curl")
            .arg("-O")
            // Should come up with a more robust way of doing this
            .arg("https://sgp.mirror.pkgbuild.com/community/os/x86_64/ascii-3.18-1-x86_64.pkg.tar.xz")
            .arg("--output")
            .arg("ascii-3.18-1-x86_64.pkg.tar.xz")
            .current_dir(&test_dir)
            .spawn().unwrap()
            .wait().unwrap();
        
        if wget.success() {
            let filename = Path::join(&test_dir, "ascii-3.18-1-x86_64.pkg.tar.xz");
            let pkg = Package::load(filename).unwrap();
            
            assert_eq!(pkg.meta.name, "ascii".to_string());
            assert_eq!(pkg.meta.version().unwrap().as_str(), "3.18-1");
        }
    }
    
    #[test]
    fn test_split_specifier() {
        let pkgspec = PkgSpec::split_specifier("pacman-5.1.1-2").unwrap();
        assert_eq!(&pkgspec.name, "pacman");
        assert_eq!(&pkgspec.version, "5.1.1");
        assert_eq!(pkgspec.release, 2.to_string());
        assert!(pkgspec.arch.is_none());
        
        let pkgspec = PkgSpec::split_specifier("pithos-git-1.4.1-1/").unwrap();
        assert_eq!(&pkgspec.name, "pithos-git");
        assert_eq!(&pkgspec.version, "1.4.1");
        assert_eq!(pkgspec.release, 1.to_string());
        assert!(pkgspec.arch.is_none());
        
        let pkgspec = PkgSpec::split_specifier("acorn-5.7.2-1/desc").unwrap();
        assert_eq!(&pkgspec.name, "acorn");
        assert_eq!(&pkgspec.version, "5.7.2");
        assert_eq!(pkgspec.release, 1.to_string());
        assert!(pkgspec.arch.is_none());
        
        // split_specifier is _supposed_ to return None if it gets an arch too,
        //   this is related to the todos above
        /*
        let pkgspec = PkgSpec::split_specifier("pacman-5.1.1-2-x86_64");
        println!("{:?}", pkgspec);
        assert!(pkgspec.is_none());
        */
    }
    
    #[test]
    fn test_split_pkgname() {
        let pkgspec = PkgSpec::split_pkgname("pacman-5.1.1-2-x86_64").unwrap();
        assert_eq!(&pkgspec.name, "pacman");
        assert_eq!(&pkgspec.version, "5.1.1");
        assert_eq!(pkgspec.release, 2.to_string());
        assert_eq!(&pkgspec.arch.unwrap(), "x86_64");
        
        let pkgspec = PkgSpec::split_pkgname("pithos-git-1.4.1-1-any").unwrap();
        assert_eq!(&pkgspec.name, "pithos-git");
        assert_eq!(&pkgspec.version, "1.4.1");
        assert_eq!(pkgspec.release, 1.to_string());
        assert_eq!(&pkgspec.arch.unwrap(), "any");
    }
    
    #[test]
    fn test_localdb_parse() {
        let _db = Db::local_db("/var/lib/pacman").unwrap();
    }
    
    #[test]
    fn test_syncdb_parse() {
        let _dbs = Db::sync_dbs("/var/lib/pacman").unwrap();
    }
    
    // Requires that java-environment=8 is provided
    // This works on my system at the time of writing
    #[test]
    #[ignore]
    fn test_is_package_provided() {
        let db = Db::local_db("/var/lib/pacman").unwrap();
        assert!(db.provides(&Provide { name: "java-environment".to_string(), version: Some("8".to_string()) }));
    }
}