Skip to main content

upstream_rs/storage/database/
api.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::{Result, anyhow};
4
5use crate::models::upstream::Package;
6
7use super::packages::PackageConnection;
8
9#[derive(Debug, Clone)]
10pub struct PackageDatabase {
11    database_file: PathBuf,
12}
13
14impl PackageDatabase {
15    pub fn open(package_database_path: &Path) -> Result<Self> {
16        let database_file = Self::database_path_for(package_database_path);
17        PackageConnection::open(&database_file)?;
18        Ok(Self { database_file })
19    }
20
21    pub fn database_path_for(package_database_path: &Path) -> PathBuf {
22        match package_database_path
23            .extension()
24            .and_then(|extension| extension.to_str())
25        {
26            Some("db") => package_database_path.to_path_buf(),
27            _ => package_database_path.with_extension("db"),
28        }
29    }
30
31    pub fn schema_version(&self) -> Result<u32> {
32        self.connection()?.schema_version()
33    }
34
35    pub fn package_exists(&self, name: &str) -> Result<bool> {
36        self.connection()?.package_exists(name)
37    }
38
39    pub fn get_package(&self, name: &str) -> Result<Option<Package>> {
40        self.connection()?.get_package(name)
41    }
42
43    pub fn list_packages(&self) -> Result<Vec<Package>> {
44        self.connection()?.list_packages()
45    }
46
47    pub fn upsert_package(&mut self, package: &Package) -> Result<()> {
48        self.connection()?.upsert_package(package)
49    }
50
51    pub fn replace_all_packages(&mut self, packages: &[Package]) -> Result<()> {
52        self.connection()?.replace_all_packages(packages)
53    }
54
55    pub fn remove_package(&mut self, name: &str) -> Result<bool> {
56        self.connection()?.remove_package(name)
57    }
58
59    pub fn update_package<F>(&mut self, name: &str, update: F) -> Result<bool>
60    where
61        F: FnOnce(&mut Package) -> Result<bool>,
62    {
63        let mut package = self
64            .get_package(name)?
65            .ok_or_else(|| anyhow!("Package '{}' not found", name))?;
66        let changed = update(&mut package)?;
67        if !changed {
68            return Ok(false);
69        }
70        if package.name != name {
71            return Err(anyhow!(
72                "Package update changed '{}' to '{}'; use rename_package for package renames",
73                name,
74                package.name
75            ));
76        }
77
78        self.upsert_package(&package)?;
79        Ok(true)
80    }
81
82    pub fn rename_package(&mut self, old_name: &str, new_name: &str) -> Result<()> {
83        if self.package_exists(new_name)? {
84            return Err(anyhow!("Package '{}' already exists", new_name));
85        }
86
87        self.connection()?.update_package(old_name, |package| {
88            package.name = new_name.to_string();
89            Ok(())
90        })
91    }
92
93    fn connection(&self) -> Result<PackageConnection> {
94        PackageConnection::open(&self.database_file)
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::PackageDatabase;
101    use crate::models::common::enums::{Channel, Filetype, Provider};
102    use crate::models::upstream::Package;
103    use std::path::{Path, PathBuf};
104    use std::time::{SystemTime, UNIX_EPOCH};
105    use std::{fs, io};
106
107    fn temp_database_path(name: &str) -> PathBuf {
108        let nanos = SystemTime::now()
109            .duration_since(UNIX_EPOCH)
110            .map(|d| d.as_nanos())
111            .unwrap_or(0);
112        std::env::temp_dir()
113            .join(format!("upstream-packages-test-{name}-{nanos}"))
114            .join("packages.db")
115    }
116
117    fn test_package(name: &str) -> Package {
118        Package::with_defaults(
119            name.to_string(),
120            format!("owner/{name}"),
121            Filetype::Binary,
122            None,
123            None,
124            Channel::Stable,
125            Provider::Github,
126            None,
127        )
128    }
129
130    fn legacy_packages_file(database_path: &Path) -> PathBuf {
131        database_path.with_extension("json")
132    }
133
134    fn cleanup(path: &Path) -> io::Result<()> {
135        if let Some(parent) = path.parent() {
136            fs::remove_dir_all(parent)?;
137        }
138        Ok(())
139    }
140
141    #[test]
142    fn open_starts_empty_when_file_missing() {
143        let path = temp_database_path("missing");
144        let db = PackageDatabase::open(&path).expect("open database");
145        assert!(db.list_packages().expect("list packages").is_empty());
146        assert!(path.exists());
147        cleanup(&path).expect("cleanup");
148    }
149
150    #[test]
151    fn open_ignores_adjacent_legacy_json() {
152        let path = temp_database_path("legacy-json-ignored");
153        let legacy_path = legacy_packages_file(&path);
154        if let Some(parent) = legacy_path.parent() {
155            fs::create_dir_all(parent).expect("create parent");
156        }
157        fs::write(&legacy_path, "{not-json").expect("write invalid legacy json");
158
159        let db = PackageDatabase::open(&path).expect("open database");
160
161        assert!(path.exists());
162        assert!(db.list_packages().expect("list packages").is_empty());
163
164        cleanup(&path).expect("cleanup");
165    }
166
167    #[test]
168    fn upsert_replaces_existing_package_name() {
169        let path = temp_database_path("update");
170        if let Some(parent) = path.parent() {
171            fs::create_dir_all(parent).expect("create parent");
172        }
173        let mut db = PackageDatabase::open(&path).expect("open database");
174
175        let mut first = test_package("tool");
176        first.version.major = 1;
177        db.upsert_package(&first).expect("store first");
178
179        let mut second = first.clone();
180        second.version.major = 2;
181        second.repo_slug = "owner/renamed-repo".to_string();
182        db.upsert_package(&second).expect("store update");
183
184        let package = db
185            .get_package("tool")
186            .expect("load package")
187            .expect("stored package");
188        assert_eq!(package.version.major, 2);
189        assert_eq!(package.repo_slug, "owner/renamed-repo");
190
191        cleanup(&path).expect("cleanup");
192    }
193
194    #[test]
195    fn update_package_upserts_changed_package() {
196        let path = temp_database_path("update-package");
197        if let Some(parent) = path.parent() {
198            fs::create_dir_all(parent).expect("create parent");
199        }
200        let mut db = PackageDatabase::open(&path).expect("open database");
201        db.upsert_package(&test_package("tool"))
202            .expect("store package");
203
204        let changed = db
205            .update_package("tool", |package| {
206                package.version.major = 3;
207                Ok(true)
208            })
209            .expect("update package");
210
211        assert!(changed);
212        let reloaded = PackageDatabase::open(&path).expect("reload database");
213        assert_eq!(
214            reloaded
215                .get_package("tool")
216                .expect("load package")
217                .expect("updated package")
218                .version
219                .major,
220            3
221        );
222
223        cleanup(&path).expect("cleanup");
224    }
225
226    #[test]
227    fn update_package_skips_unchanged_package() {
228        let path = temp_database_path("unchanged-package");
229        if let Some(parent) = path.parent() {
230            fs::create_dir_all(parent).expect("create parent");
231        }
232        let mut db = PackageDatabase::open(&path).expect("open database");
233        db.upsert_package(&test_package("tool"))
234            .expect("store package");
235
236        let changed = db
237            .update_package("tool", |_package| Ok(false))
238            .expect("update package");
239
240        assert!(!changed);
241        cleanup(&path).expect("cleanup");
242    }
243
244    #[test]
245    fn rename_package_updates_database_primary_key() {
246        let path = temp_database_path("rename-package");
247        if let Some(parent) = path.parent() {
248            fs::create_dir_all(parent).expect("create parent");
249        }
250        let mut db = PackageDatabase::open(&path).expect("open database");
251        db.upsert_package(&test_package("old"))
252            .expect("store package");
253
254        db.rename_package("old", "new").expect("rename package");
255
256        let reloaded = PackageDatabase::open(&path).expect("reload database");
257        assert!(reloaded.get_package("old").expect("load old").is_none());
258        assert!(reloaded.get_package("new").expect("load new").is_some());
259
260        cleanup(&path).expect("cleanup");
261    }
262
263    #[test]
264    fn remove_package_returns_expected_status() {
265        let path = temp_database_path("remove");
266        if let Some(parent) = path.parent() {
267            fs::create_dir_all(parent).expect("create parent");
268        }
269        let mut db = PackageDatabase::open(&path).expect("open database");
270        db.upsert_package(&test_package("one"))
271            .expect("store package");
272
273        assert!(db.remove_package("one").expect("remove"));
274        assert!(!db.remove_package("one").expect("second remove"));
275
276        cleanup(&path).expect("cleanup");
277    }
278
279    #[test]
280    fn upsert_creates_missing_parent_dirs() {
281        let path = temp_database_path("missing-parent");
282        let mut db = PackageDatabase::open(&path).expect("open database");
283        db.upsert_package(&test_package("tool"))
284            .expect("save package");
285
286        assert!(path.exists());
287        cleanup(&path).expect("cleanup");
288    }
289
290    #[test]
291    fn upsert_writes_database_and_can_reload() {
292        let path = temp_database_path("reload");
293        let mut db = PackageDatabase::open(&path).expect("open database");
294        db.upsert_package(&test_package("tool"))
295            .expect("save package");
296
297        let reloaded = PackageDatabase::open(&path).expect("reload database");
298        assert_eq!(reloaded.list_packages().expect("list packages").len(), 1);
299        assert!(
300            reloaded
301                .get_package("tool")
302                .expect("load package")
303                .is_some()
304        );
305
306        cleanup(&path).expect("cleanup");
307    }
308
309    #[test]
310    fn upsert_overwrites_visible_result() {
311        let path = temp_database_path("overwrite");
312        let mut db = PackageDatabase::open(&path).expect("open database");
313
314        let mut first = test_package("tool");
315        first.version.major = 1;
316        db.upsert_package(&first).expect("save first");
317
318        let mut second = test_package("tool");
319        second.version.major = 2;
320        db.upsert_package(&second).expect("save second");
321
322        let reloaded = PackageDatabase::open(&path).expect("reload database");
323        let packages = reloaded.list_packages().expect("list packages");
324        assert_eq!(packages.len(), 1);
325        assert_eq!(packages[0].version.major, 2);
326
327        cleanup(&path).expect("cleanup");
328    }
329}