Skip to main content

upstream_rs/services/storage/
metadata_storage.rs

1use std::collections::HashMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result, anyhow};
6use serde::{Deserialize, Serialize};
7
8use crate::models::upstream::PackageMetadata;
9use crate::utils::filesystem::atomic_ops::write_atomic;
10
11const METADATA_STORAGE_VERSION: u32 = 1;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
14struct PackageMetadataFile {
15    version: u32,
16    packages: HashMap<String, PackageMetadata>,
17}
18
19impl Default for PackageMetadataFile {
20    fn default() -> Self {
21        Self {
22            version: METADATA_STORAGE_VERSION,
23            packages: HashMap::new(),
24        }
25    }
26}
27
28pub struct MetadataStorage {
29    file: PackageMetadataFile,
30    metadata_file: PathBuf,
31}
32
33impl MetadataStorage {
34    pub fn new(metadata_file: &Path) -> Result<Self> {
35        let mut storage = Self {
36            file: PackageMetadataFile::default(),
37            metadata_file: metadata_file.to_path_buf(),
38        };
39        storage.load()?;
40        Ok(storage)
41    }
42
43    pub fn load(&mut self) -> Result<()> {
44        if !self.metadata_file.exists() {
45            self.file = PackageMetadataFile::default();
46            return Ok(());
47        }
48
49        match fs::read_to_string(&self.metadata_file) {
50            Ok(json) => {
51                if json.trim().is_empty() {
52                    self.file = PackageMetadataFile::default();
53                    return Ok(());
54                }
55                self.file = serde_json::from_str(&json).with_context(|| {
56                    format!(
57                        "Failed to parse metadata storage '{}'",
58                        self.metadata_file.display()
59                    )
60                })?;
61                Ok(())
62            }
63            Err(e) => Err(anyhow!("Warning: Failed to load metadata storage: {}", e)),
64        }
65    }
66
67    pub fn save(&self) -> Result<()> {
68        let json = serde_json::to_string_pretty(&self.file)
69            .context("Failed to serialize metadata storage")?;
70        write_atomic(&self.metadata_file, json.as_bytes()).with_context(|| {
71            format!(
72                "Failed to write metadata storage to '{}'",
73                self.metadata_file.display()
74            )
75        })
76    }
77
78    pub fn set_pin_reason(&mut self, name: &str, reason: String) -> Result<()> {
79        let entry = self.file.packages.entry(name.to_string()).or_default();
80        entry.pin_reason = Some(reason);
81        self.save()
82    }
83
84    pub fn clear_pin_reason(&mut self, name: &str) -> Result<()> {
85        if let Some(entry) = self.file.packages.get_mut(name) {
86            entry.pin_reason = None;
87            if is_empty_entry(entry) {
88                self.file.packages.remove(name);
89            }
90            self.save()?;
91        }
92        Ok(())
93    }
94
95    pub fn remove_package(&mut self, name: &str) -> Result<()> {
96        if self.file.packages.remove(name).is_some() {
97            self.save()?;
98        }
99        Ok(())
100    }
101
102    pub fn rename_package(&mut self, old_name: &str, new_name: &str) -> Result<()> {
103        if old_name == new_name {
104            return Ok(());
105        }
106        if let Some(entry) = self.file.packages.remove(old_name) {
107            self.file.packages.insert(new_name.to_string(), entry);
108            self.save()?;
109        }
110        Ok(())
111    }
112
113    pub fn get_package(&self, name: &str) -> Option<&PackageMetadata> {
114        self.file.packages.get(name)
115    }
116}
117
118fn is_empty_entry(entry: &PackageMetadata) -> bool {
119    entry.pin_reason.is_none()
120}
121
122#[cfg(test)]
123mod tests {
124    use super::MetadataStorage;
125    use std::path::{Path, PathBuf};
126    use std::time::{SystemTime, UNIX_EPOCH};
127    use std::{fs, io};
128
129    fn temp_metadata_file(name: &str) -> PathBuf {
130        let nanos = SystemTime::now()
131            .duration_since(UNIX_EPOCH)
132            .map(|d| d.as_nanos())
133            .unwrap_or(0);
134        std::env::temp_dir()
135            .join(format!("upstream-meta-storage-test-{name}-{nanos}"))
136            .join("metadata.json")
137    }
138
139    fn cleanup(path: &Path) -> io::Result<()> {
140        if let Some(parent) = path.parent() {
141            fs::remove_dir_all(parent)?;
142        }
143        Ok(())
144    }
145
146    #[test]
147    fn set_and_clear_pin_reason_round_trips() {
148        let path = temp_metadata_file("set-clear");
149        let mut storage = MetadataStorage::new(&path).expect("create storage");
150        storage
151            .set_pin_reason("rg", "pin for scripts".to_string())
152            .expect("set reason");
153        let storage = MetadataStorage::new(&path).expect("reload");
154        assert_eq!(
155            storage
156                .get_package("rg")
157                .and_then(|m| m.pin_reason.as_deref()),
158            Some("pin for scripts")
159        );
160
161        let mut storage = MetadataStorage::new(&path).expect("reload mutable");
162        storage.clear_pin_reason("rg").expect("clear reason");
163        let storage = MetadataStorage::new(&path).expect("reload after clear");
164        assert!(storage.get_package("rg").is_none());
165        cleanup(&path).expect("cleanup");
166    }
167
168    #[test]
169    fn rename_migrates_entry() {
170        let path = temp_metadata_file("rename");
171        let mut storage = MetadataStorage::new(&path).expect("create storage");
172        storage
173            .set_pin_reason("old", "why".to_string())
174            .expect("set reason");
175        storage.rename_package("old", "new").expect("rename");
176        let storage = MetadataStorage::new(&path).expect("reload");
177        assert!(storage.get_package("old").is_none());
178        assert_eq!(
179            storage
180                .get_package("new")
181                .and_then(|m| m.pin_reason.as_deref()),
182            Some("why")
183        );
184        cleanup(&path).expect("cleanup");
185    }
186}