upstream_rs/services/storage/
metadata_storage.rs1use 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}