upstream-rs 1.17.1

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

use anyhow::{Context, Result, anyhow};
use serde::{Deserialize, Serialize};

use crate::models::upstream::PackageMetadata;
use crate::utils::filesystem::atomic_ops::write_atomic;

const METADATA_STORAGE_VERSION: u32 = 1;

#[derive(Debug, Clone, Serialize, Deserialize)]
struct PackageMetadataFile {
    version: u32,
    packages: HashMap<String, PackageMetadata>,
}

impl Default for PackageMetadataFile {
    fn default() -> Self {
        Self {
            version: METADATA_STORAGE_VERSION,
            packages: HashMap::new(),
        }
    }
}

pub struct MetadataStorage {
    file: PackageMetadataFile,
    metadata_file: PathBuf,
}

impl MetadataStorage {
    pub fn new(metadata_file: &Path) -> Result<Self> {
        let mut storage = Self {
            file: PackageMetadataFile::default(),
            metadata_file: metadata_file.to_path_buf(),
        };
        storage.load()?;
        Ok(storage)
    }

    pub fn load(&mut self) -> Result<()> {
        if !self.metadata_file.exists() {
            self.file = PackageMetadataFile::default();
            return Ok(());
        }

        match fs::read_to_string(&self.metadata_file) {
            Ok(json) => {
                if json.trim().is_empty() {
                    self.file = PackageMetadataFile::default();
                    return Ok(());
                }
                self.file = serde_json::from_str(&json).with_context(|| {
                    format!(
                        "Failed to parse metadata storage '{}'",
                        self.metadata_file.display()
                    )
                })?;
                Ok(())
            }
            Err(e) => Err(anyhow!("Warning: Failed to load metadata storage: {}", e)),
        }
    }

    pub fn save(&self) -> Result<()> {
        let json = serde_json::to_string_pretty(&self.file)
            .context("Failed to serialize metadata storage")?;
        write_atomic(&self.metadata_file, json.as_bytes()).with_context(|| {
            format!(
                "Failed to write metadata storage to '{}'",
                self.metadata_file.display()
            )
        })
    }

    pub fn set_pin_reason(&mut self, name: &str, reason: String) -> Result<()> {
        let entry = self.file.packages.entry(name.to_string()).or_default();
        entry.pin_reason = Some(reason);
        self.save()
    }

    pub fn clear_pin_reason(&mut self, name: &str) -> Result<()> {
        if let Some(entry) = self.file.packages.get_mut(name) {
            entry.pin_reason = None;
            if is_empty_entry(entry) {
                self.file.packages.remove(name);
            }
            self.save()?;
        }
        Ok(())
    }

    pub fn remove_package(&mut self, name: &str) -> Result<()> {
        if self.file.packages.remove(name).is_some() {
            self.save()?;
        }
        Ok(())
    }

    pub fn rename_package(&mut self, old_name: &str, new_name: &str) -> Result<()> {
        if old_name == new_name {
            return Ok(());
        }
        if let Some(entry) = self.file.packages.remove(old_name) {
            self.file.packages.insert(new_name.to_string(), entry);
            self.save()?;
        }
        Ok(())
    }

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

fn is_empty_entry(entry: &PackageMetadata) -> bool {
    entry.pin_reason.is_none()
}

#[cfg(test)]
mod tests {
    use super::MetadataStorage;
    use std::path::{Path, PathBuf};
    use std::time::{SystemTime, UNIX_EPOCH};
    use std::{fs, io};

    fn temp_metadata_file(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-meta-storage-test-{name}-{nanos}"))
            .join("metadata.json")
    }

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

    #[test]
    fn set_and_clear_pin_reason_round_trips() {
        let path = temp_metadata_file("set-clear");
        let mut storage = MetadataStorage::new(&path).expect("create storage");
        storage
            .set_pin_reason("rg", "pin for scripts".to_string())
            .expect("set reason");
        let storage = MetadataStorage::new(&path).expect("reload");
        assert_eq!(
            storage
                .get_package("rg")
                .and_then(|m| m.pin_reason.as_deref()),
            Some("pin for scripts")
        );

        let mut storage = MetadataStorage::new(&path).expect("reload mutable");
        storage.clear_pin_reason("rg").expect("clear reason");
        let storage = MetadataStorage::new(&path).expect("reload after clear");
        assert!(storage.get_package("rg").is_none());
        cleanup(&path).expect("cleanup");
    }

    #[test]
    fn rename_migrates_entry() {
        let path = temp_metadata_file("rename");
        let mut storage = MetadataStorage::new(&path).expect("create storage");
        storage
            .set_pin_reason("old", "why".to_string())
            .expect("set reason");
        storage.rename_package("old", "new").expect("rename");
        let storage = MetadataStorage::new(&path).expect("reload");
        assert!(storage.get_package("old").is_none());
        assert_eq!(
            storage
                .get_package("new")
                .and_then(|m| m.pin_reason.as_deref()),
            Some("why")
        );
        cleanup(&path).expect("cleanup");
    }
}