upstream-rs 2.6.0

Fetch package updates directly from the source.
Documentation
use std::path::{Path, PathBuf};

use anyhow::{Result, anyhow};

use crate::models::upstream::Package;

use super::packages::PackageConnection;

#[derive(Debug, Clone)]
pub struct PackageDatabase {
    database_file: PathBuf,
}

impl PackageDatabase {
    pub fn open(package_database_path: &Path) -> Result<Self> {
        let database_file = Self::database_path_for(package_database_path);
        PackageConnection::open(&database_file)?;
        Ok(Self { database_file })
    }

    pub fn database_path_for(package_database_path: &Path) -> PathBuf {
        match package_database_path
            .extension()
            .and_then(|extension| extension.to_str())
        {
            Some("db") => package_database_path.to_path_buf(),
            _ => package_database_path.with_extension("db"),
        }
    }

    pub fn schema_version(&self) -> Result<u32> {
        self.connection()?.schema_version()
    }

    pub fn package_exists(&self, name: &str) -> Result<bool> {
        self.connection()?.package_exists(name)
    }

    pub fn get_package(&self, name: &str) -> Result<Option<Package>> {
        self.connection()?.get_package(name)
    }

    pub fn list_packages(&self) -> Result<Vec<Package>> {
        self.connection()?.list_packages()
    }

    pub fn upsert_package(&mut self, package: &Package) -> Result<()> {
        self.connection()?.upsert_package(package)
    }

    pub fn replace_all_packages(&mut self, packages: &[Package]) -> Result<()> {
        self.connection()?.replace_all_packages(packages)
    }

    pub fn remove_package(&mut self, name: &str) -> Result<bool> {
        self.connection()?.remove_package(name)
    }

    pub fn update_package<F>(&mut self, name: &str, update: F) -> Result<bool>
    where
        F: FnOnce(&mut Package) -> Result<bool>,
    {
        let mut package = self
            .get_package(name)?
            .ok_or_else(|| anyhow!("Package '{}' not found", name))?;
        let changed = update(&mut package)?;
        if !changed {
            return Ok(false);
        }
        if package.name != name {
            return Err(anyhow!(
                "Package update changed '{}' to '{}'; use rename_package for package renames",
                name,
                package.name
            ));
        }

        self.upsert_package(&package)?;
        Ok(true)
    }

    pub fn rename_package(&mut self, old_name: &str, new_name: &str) -> Result<()> {
        if self.package_exists(new_name)? {
            return Err(anyhow!("Package '{}' already exists", new_name));
        }

        self.connection()?.update_package(old_name, |package| {
            package.name = new_name.to_string();
            Ok(())
        })
    }

    fn connection(&self) -> Result<PackageConnection> {
        PackageConnection::open(&self.database_file)
    }
}

#[cfg(test)]
mod tests {
    use super::PackageDatabase;
    use crate::models::common::enums::{Channel, Filetype, Provider};
    use crate::models::upstream::Package;
    use std::path::{Path, PathBuf};
    use std::time::{SystemTime, UNIX_EPOCH};
    use std::{fs, io};

    fn temp_database_path(name: &str) -> PathBuf {
        let nanos = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map(|d| d.as_nanos())
            .unwrap_or(0);
        std::env::temp_dir()
            .join(format!("upstream-packages-test-{name}-{nanos}"))
            .join("packages.db")
    }

    fn test_package(name: &str) -> Package {
        Package::with_defaults(
            name.to_string(),
            format!("owner/{name}"),
            Filetype::Binary,
            None,
            None,
            Channel::Stable,
            Provider::Github,
            None,
        )
    }

    fn legacy_packages_file(database_path: &Path) -> PathBuf {
        database_path.with_extension("json")
    }

    fn cleanup(path: &Path) -> io::Result<()> {
        if let Some(parent) = path.parent() {
            fs::remove_dir_all(parent)?;
        }
        Ok(())
    }

    #[test]
    fn open_starts_empty_when_file_missing() {
        let path = temp_database_path("missing");
        let db = PackageDatabase::open(&path).expect("open database");
        assert!(db.list_packages().expect("list packages").is_empty());
        assert!(path.exists());
        cleanup(&path).expect("cleanup");
    }

    #[test]
    fn open_ignores_adjacent_legacy_json() {
        let path = temp_database_path("legacy-json-ignored");
        let legacy_path = legacy_packages_file(&path);
        if let Some(parent) = legacy_path.parent() {
            fs::create_dir_all(parent).expect("create parent");
        }
        fs::write(&legacy_path, "{not-json").expect("write invalid legacy json");

        let db = PackageDatabase::open(&path).expect("open database");

        assert!(path.exists());
        assert!(db.list_packages().expect("list packages").is_empty());

        cleanup(&path).expect("cleanup");
    }

    #[test]
    fn upsert_replaces_existing_package_name() {
        let path = temp_database_path("update");
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).expect("create parent");
        }
        let mut db = PackageDatabase::open(&path).expect("open database");

        let mut first = test_package("tool");
        first.version.major = 1;
        db.upsert_package(&first).expect("store first");

        let mut second = first.clone();
        second.version.major = 2;
        second.repo_slug = "owner/renamed-repo".to_string();
        db.upsert_package(&second).expect("store update");

        let package = db
            .get_package("tool")
            .expect("load package")
            .expect("stored package");
        assert_eq!(package.version.major, 2);
        assert_eq!(package.repo_slug, "owner/renamed-repo");

        cleanup(&path).expect("cleanup");
    }

    #[test]
    fn update_package_upserts_changed_package() {
        let path = temp_database_path("update-package");
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).expect("create parent");
        }
        let mut db = PackageDatabase::open(&path).expect("open database");
        db.upsert_package(&test_package("tool"))
            .expect("store package");

        let changed = db
            .update_package("tool", |package| {
                package.version.major = 3;
                Ok(true)
            })
            .expect("update package");

        assert!(changed);
        let reloaded = PackageDatabase::open(&path).expect("reload database");
        assert_eq!(
            reloaded
                .get_package("tool")
                .expect("load package")
                .expect("updated package")
                .version
                .major,
            3
        );

        cleanup(&path).expect("cleanup");
    }

    #[test]
    fn update_package_skips_unchanged_package() {
        let path = temp_database_path("unchanged-package");
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).expect("create parent");
        }
        let mut db = PackageDatabase::open(&path).expect("open database");
        db.upsert_package(&test_package("tool"))
            .expect("store package");

        let changed = db
            .update_package("tool", |_package| Ok(false))
            .expect("update package");

        assert!(!changed);
        cleanup(&path).expect("cleanup");
    }

    #[test]
    fn rename_package_updates_database_primary_key() {
        let path = temp_database_path("rename-package");
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).expect("create parent");
        }
        let mut db = PackageDatabase::open(&path).expect("open database");
        db.upsert_package(&test_package("old"))
            .expect("store package");

        db.rename_package("old", "new").expect("rename package");

        let reloaded = PackageDatabase::open(&path).expect("reload database");
        assert!(reloaded.get_package("old").expect("load old").is_none());
        assert!(reloaded.get_package("new").expect("load new").is_some());

        cleanup(&path).expect("cleanup");
    }

    #[test]
    fn remove_package_returns_expected_status() {
        let path = temp_database_path("remove");
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).expect("create parent");
        }
        let mut db = PackageDatabase::open(&path).expect("open database");
        db.upsert_package(&test_package("one"))
            .expect("store package");

        assert!(db.remove_package("one").expect("remove"));
        assert!(!db.remove_package("one").expect("second remove"));

        cleanup(&path).expect("cleanup");
    }

    #[test]
    fn upsert_creates_missing_parent_dirs() {
        let path = temp_database_path("missing-parent");
        let mut db = PackageDatabase::open(&path).expect("open database");
        db.upsert_package(&test_package("tool"))
            .expect("save package");

        assert!(path.exists());
        cleanup(&path).expect("cleanup");
    }

    #[test]
    fn upsert_writes_database_and_can_reload() {
        let path = temp_database_path("reload");
        let mut db = PackageDatabase::open(&path).expect("open database");
        db.upsert_package(&test_package("tool"))
            .expect("save package");

        let reloaded = PackageDatabase::open(&path).expect("reload database");
        assert_eq!(reloaded.list_packages().expect("list packages").len(), 1);
        assert!(
            reloaded
                .get_package("tool")
                .expect("load package")
                .is_some()
        );

        cleanup(&path).expect("cleanup");
    }

    #[test]
    fn upsert_overwrites_visible_result() {
        let path = temp_database_path("overwrite");
        let mut db = PackageDatabase::open(&path).expect("open database");

        let mut first = test_package("tool");
        first.version.major = 1;
        db.upsert_package(&first).expect("save first");

        let mut second = test_package("tool");
        second.version.major = 2;
        db.upsert_package(&second).expect("save second");

        let reloaded = PackageDatabase::open(&path).expect("reload database");
        let packages = reloaded.list_packages().expect("list packages");
        assert_eq!(packages.len(), 1);
        assert_eq!(packages[0].version.major, 2);

        cleanup(&path).expect("cleanup");
    }
}