knott 0.0.1

Fast Rust package manager helper for Arch Linux repos and the AUR
Documentation
use crate::dep::{dependency_satisfied, parse_dependency};
use anyhow::{Context, Result};
use std::collections::HashMap;
use std::env;
use std::fs;
use std::path::Path;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LocalPackage {
    pub name: String,
    pub version: String,
    pub desc: Option<String>,
    pub provides: Vec<String>,
    pub reason: Option<u8>,
}

#[derive(Debug, Clone, Default)]
pub struct LocalDb {
    packages: HashMap<String, LocalPackage>,
    provides: HashMap<String, Vec<LocalProvide>>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct LocalProvide {
    package: String,
    version: Option<String>,
}

impl LocalDb {
    pub fn load_default() -> Result<Self> {
        let path = env::var("KNOTT_DBPATH")
            .map(|path| Path::new(&path).join("local"))
            .unwrap_or_else(|_| Path::new("/var/lib/pacman/local").to_path_buf());

        Self::load_from_path(&path)
            .with_context(|| format!("read local pacman db at {}", path.display()))
    }

    pub fn empty() -> Self {
        Self::default()
    }

    pub fn load_from_path(path: &Path) -> Result<Self> {
        let mut db = Self::default();

        for entry in fs::read_dir(path)? {
            let entry = entry?;
            let desc = entry.path().join("desc");
            if !desc.is_file() {
                continue;
            }

            let data = fs::read_to_string(&desc)
                .with_context(|| format!("read local package desc {}", desc.display()))?;
            if let Some(pkg) = parse_desc(&data) {
                for provide in &pkg.provides {
                    let dep = parse_dependency(provide);
                    db.provides.entry(dep.name).or_default().push(LocalProvide {
                        package: pkg.name.clone(),
                        version: dep.version,
                    });
                }
                db.packages.insert(pkg.name.clone(), pkg);
            }
        }

        Ok(db)
    }

    pub fn get(&self, name: &str) -> Option<&LocalPackage> {
        self.packages.get(name)
    }

    pub fn packages(&self) -> impl Iterator<Item = &LocalPackage> {
        self.packages.values()
    }

    pub fn satisfies(&self, raw_dep: &str) -> bool {
        let dep = parse_dependency(raw_dep);

        if let Some(pkg) = self.packages.get(&dep.name) {
            return dependency_satisfied(&pkg.version, &dep);
        }

        if let Some(providers) = self.provides.get(&dep.name) {
            return providers.iter().any(|provider| {
                if dep.op.is_none() {
                    return true;
                }

                if let Some(version) = provider.version.as_deref() {
                    return dependency_satisfied(version, &dep);
                }

                self.packages
                    .get(&provider.package)
                    .is_some_and(|pkg| dep.op.is_none() || dependency_satisfied(&pkg.version, &dep))
            });
        }

        false
    }
}

pub fn parse_desc(data: &str) -> Option<LocalPackage> {
    let mut section = "";
    let mut name = None;
    let mut version = None;
    let mut desc = None;
    let mut provides = Vec::new();
    let mut reason = None;

    for line in data.lines() {
        if line.starts_with('%') && line.ends_with('%') && line.len() > 2 {
            section = &line[1..line.len() - 1];
            continue;
        }

        if line.is_empty() {
            continue;
        }

        match section {
            "NAME" => name = Some(line.to_string()),
            "VERSION" => version = Some(line.to_string()),
            "DESC" => desc = Some(line.to_string()),
            "PROVIDES" => provides.push(line.to_string()),
            "REASON" => reason = line.parse().ok(),
            _ => {}
        }
    }

    Some(LocalPackage {
        name: name?,
        version: version?,
        desc,
        provides,
        reason,
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_desc_file() {
        let data = "%NAME%\nfoo\n\n%VERSION%\n1.0-1\n\n%DESC%\nA package\n\n%PROVIDES%\nbar=1.0\n\n%REASON%\n0\n";
        let pkg = parse_desc(data).unwrap();
        assert_eq!(pkg.name, "foo");
        assert_eq!(pkg.version, "1.0-1");
        assert_eq!(pkg.provides, ["bar=1.0"]);
        assert_eq!(pkg.reason, Some(0));
    }

    #[test]
    fn checks_provided_dependencies() {
        let mut db = LocalDb::default();
        let pkg = LocalPackage {
            name: "foo".into(),
            version: "1.2-1".into(),
            desc: None,
            provides: vec!["bar=1.2".into()],
            reason: None,
        };
        db.provides.insert(
            "bar".into(),
            vec![LocalProvide {
                package: "foo".into(),
                version: Some("1.2".into()),
            }],
        );
        db.packages.insert("foo".into(), pkg);
        assert!(db.satisfies("bar>=1.0"));
        assert!(!db.satisfies("bar>=2.0"));
    }
}