Skip to main content

soar_core/database/
models.rs

1//! Database models for soar-core.
2
3use std::fmt::Display;
4
5use serde::{Deserialize, Serialize};
6use soar_db::{models::types::PackageProvide, repository::core::InstalledPackageWithPortable};
7use soar_package::PackageExt;
8
9/// Package maintainer information.
10#[derive(Debug, Clone, Deserialize, Serialize)]
11pub struct Maintainer {
12    pub name: String,
13    pub contact: String,
14}
15
16impl Display for Maintainer {
17    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
18        write!(f, "{} ({})", self.name, self.contact)
19    }
20}
21
22/// Remote package metadata from repository.
23#[derive(Debug, Clone, Default)]
24pub struct Package {
25    pub id: u64,
26    pub repo_name: String,
27    pub disabled: Option<bool>,
28    pub disabled_reason: Option<String>,
29    pub pkg_id: String,
30    pub pkg_name: String,
31    pub pkg_family: Option<String>,
32    pub pkg_type: Option<String>,
33    pub pkg_webpage: Option<String>,
34    pub app_id: Option<String>,
35    pub description: String,
36    pub version: String,
37    pub licenses: Option<Vec<String>>,
38    pub download_url: String,
39    pub size: Option<u64>,
40    pub ghcr_pkg: Option<String>,
41    pub ghcr_size: Option<u64>,
42    pub ghcr_files: Option<Vec<String>>,
43    pub ghcr_blob: Option<String>,
44    pub ghcr_url: Option<String>,
45    pub bsum: Option<String>,
46    pub homepages: Option<Vec<String>>,
47    pub notes: Option<Vec<String>>,
48    pub source_urls: Option<Vec<String>>,
49    pub tags: Option<Vec<String>>,
50    pub categories: Option<Vec<String>>,
51    pub icon: Option<String>,
52    pub desktop: Option<String>,
53    pub appstream: Option<String>,
54    pub build_id: Option<String>,
55    pub build_date: Option<String>,
56    pub build_action: Option<String>,
57    pub build_script: Option<String>,
58    pub build_log: Option<String>,
59    pub provides: Option<Vec<PackageProvide>>,
60    pub snapshots: Option<Vec<String>>,
61    pub repology: Option<Vec<String>>,
62    pub maintainers: Option<Vec<Maintainer>>,
63    pub replaces: Option<Vec<String>>,
64    pub soar_syms: bool,
65    pub deprecated: bool,
66    pub desktop_integration: Option<bool>,
67    pub portable: Option<bool>,
68}
69
70impl PackageExt for Package {
71    fn pkg_name(&self) -> &str {
72        &self.pkg_name
73    }
74
75    fn pkg_id(&self) -> &str {
76        &self.pkg_id
77    }
78
79    fn version(&self) -> &str {
80        &self.version
81    }
82
83    fn repo_name(&self) -> &str {
84        &self.repo_name
85    }
86}
87
88/// Replace `{{version}}` placeholder in a string with the actual version.
89fn resolve_version_placeholder(s: &str, version: &str) -> String {
90    s.replace("{{version}}", version)
91}
92
93/// Replace `{{version}}` placeholder in an optional string.
94fn resolve_version_placeholder_opt(s: Option<&str>, version: &str) -> Option<String> {
95    s.map(|s| resolve_version_placeholder(s, version))
96}
97
98impl Package {
99    /// Check if a version is available for this package.
100    ///
101    /// Returns true if the version matches the package's current version
102    /// or is present in the snapshots array.
103    pub fn has_version(&self, version: &str) -> bool {
104        if self.version == version {
105            return true;
106        }
107        self.snapshots
108            .as_ref()
109            .is_some_and(|s| s.iter().any(|v| v == version))
110    }
111
112    /// Create a copy of this package with all `{{version}}` placeholders resolved.
113    ///
114    /// If `version` is provided, uses that version; otherwise uses the package's version.
115    /// This is useful when installing a specific snapshot version.
116    pub fn resolve(&self, version: Option<&str>) -> Self {
117        let ver = version.unwrap_or(&self.version);
118        let mut pkg = self.clone();
119        pkg.download_url = resolve_version_placeholder(&self.download_url, ver);
120        pkg.ghcr_pkg = resolve_version_placeholder_opt(self.ghcr_pkg.as_deref(), ver);
121        pkg.ghcr_blob = resolve_version_placeholder_opt(self.ghcr_blob.as_deref(), ver);
122        pkg.ghcr_url = resolve_version_placeholder_opt(self.ghcr_url.as_deref(), ver);
123        if version.is_some() {
124            pkg.version = ver.to_string();
125        }
126        pkg
127    }
128}
129
130/// Installed package record.
131#[derive(Debug, Clone)]
132pub struct InstalledPackage {
133    pub id: u64,
134    pub repo_name: String,
135    pub pkg_id: String,
136    pub pkg_name: String,
137    pub pkg_type: Option<String>,
138    pub version: String,
139    pub size: u64,
140    pub checksum: Option<String>,
141    pub installed_path: String,
142    pub installed_date: String,
143    pub profile: String,
144    pub pinned: bool,
145    pub is_installed: bool,
146    pub detached: bool,
147    pub unlinked: bool,
148    pub provides: Option<Vec<PackageProvide>>,
149    pub portable_path: Option<String>,
150    pub portable_home: Option<String>,
151    pub portable_config: Option<String>,
152    pub portable_share: Option<String>,
153    pub portable_cache: Option<String>,
154    pub install_patterns: Option<Vec<String>>,
155}
156
157impl PackageExt for InstalledPackage {
158    fn pkg_name(&self) -> &str {
159        &self.pkg_name
160    }
161
162    fn pkg_id(&self) -> &str {
163        &self.pkg_id
164    }
165
166    fn version(&self) -> &str {
167        &self.version
168    }
169
170    fn repo_name(&self) -> &str {
171        &self.repo_name
172    }
173}
174
175/// Conversion from soar-db InstalledPackageWithPortable to soar-core InstalledPackage.
176impl From<InstalledPackageWithPortable> for InstalledPackage {
177    fn from(pkg: InstalledPackageWithPortable) -> Self {
178        Self {
179            id: pkg.id as u64,
180            repo_name: pkg.repo_name,
181            pkg_id: pkg.pkg_id,
182            pkg_name: pkg.pkg_name,
183            pkg_type: pkg.pkg_type,
184            version: pkg.version,
185            size: pkg.size as u64,
186            checksum: pkg.checksum,
187            installed_path: pkg.installed_path,
188            installed_date: pkg.installed_date,
189            profile: pkg.profile,
190            pinned: pkg.pinned,
191            is_installed: pkg.is_installed,
192            detached: pkg.detached,
193            unlinked: pkg.unlinked,
194            provides: pkg.provides,
195            portable_path: pkg.portable_path,
196            portable_home: pkg.portable_home,
197            portable_config: pkg.portable_config,
198            portable_share: pkg.portable_share,
199            portable_cache: pkg.portable_cache,
200            install_patterns: pkg.install_patterns,
201        }
202    }
203}
204
205/// Conversion from soar-db core Package to soar-core InstalledPackage.
206impl From<soar_db::repository::core::InstalledPackage> for InstalledPackage {
207    fn from(pkg: soar_db::repository::core::InstalledPackage) -> Self {
208        Self {
209            id: pkg.id as u64,
210            repo_name: pkg.repo_name,
211            pkg_id: pkg.pkg_id,
212            pkg_name: pkg.pkg_name,
213            pkg_type: pkg.pkg_type,
214            version: pkg.version,
215            size: pkg.size as u64,
216            checksum: pkg.checksum,
217            installed_path: pkg.installed_path,
218            installed_date: pkg.installed_date,
219            profile: pkg.profile,
220            pinned: pkg.pinned,
221            is_installed: pkg.is_installed,
222            detached: pkg.detached,
223            unlinked: pkg.unlinked,
224            provides: pkg.provides,
225            portable_path: None,
226            portable_home: None,
227            portable_config: None,
228            portable_share: None,
229            portable_cache: None,
230            install_patterns: pkg.install_patterns,
231        }
232    }
233}
234
235/// Conversion from soar-db metadata Package to soar-core Package.
236impl From<soar_db::models::metadata::Package> for Package {
237    fn from(pkg: soar_db::models::metadata::Package) -> Self {
238        Self {
239            id: pkg.id as u64,
240            repo_name: String::new(), // Set by caller
241            disabled: None,
242            disabled_reason: None,
243            pkg_id: pkg.pkg_id,
244            pkg_name: pkg.pkg_name,
245            pkg_family: pkg.pkg_family,
246            pkg_type: pkg.pkg_type,
247            pkg_webpage: pkg.pkg_webpage,
248            app_id: pkg.app_id,
249            description: pkg.description.unwrap_or_default(),
250            version: pkg.version,
251            licenses: pkg.licenses,
252            download_url: pkg.download_url,
253            size: pkg.size.map(|s| s as u64),
254            ghcr_pkg: pkg.ghcr_pkg,
255            ghcr_size: pkg.ghcr_size.map(|s| s as u64),
256            ghcr_files: None,
257            ghcr_blob: pkg.ghcr_blob,
258            ghcr_url: pkg.ghcr_url,
259            bsum: pkg.bsum,
260            homepages: pkg.homepages,
261            notes: pkg.notes,
262            source_urls: pkg.source_urls,
263            tags: pkg.tags,
264            categories: pkg.categories,
265            icon: pkg.icon,
266            desktop: pkg.desktop,
267            appstream: pkg.appstream,
268            build_id: pkg.build_id,
269            build_date: pkg.build_date,
270            build_action: pkg.build_action,
271            build_script: pkg.build_script,
272            build_log: pkg.build_log,
273            provides: pkg.provides,
274            snapshots: pkg.snapshots,
275            repology: None,
276            maintainers: None,
277            replaces: pkg.replaces,
278            soar_syms: pkg.soar_syms,
279            deprecated: false,
280            desktop_integration: pkg.desktop_integration,
281            portable: pkg.portable,
282        }
283    }
284}
285
286/// Conversion from soar-db PackageWithRepo to soar-core Package.
287impl From<soar_db::models::metadata::PackageWithRepo> for Package {
288    fn from(pkg_with_repo: soar_db::models::metadata::PackageWithRepo) -> Self {
289        let mut pkg: Package = pkg_with_repo.package.into();
290        pkg.repo_name = pkg_with_repo.repo_name;
291        pkg
292    }
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    #[test]
300    fn test_resolve_version_placeholder() {
301        assert_eq!(
302            resolve_version_placeholder("https://example.com/pkg?tag={{version}}-x86_64", "v1.0.0"),
303            "https://example.com/pkg?tag=v1.0.0-x86_64"
304        );
305    }
306
307    #[test]
308    fn test_resolve_version_placeholder_multiple() {
309        assert_eq!(
310            resolve_version_placeholder("ghcr.io/user/pkg:{{version}}-{{version}}", "v2.0.0"),
311            "ghcr.io/user/pkg:v2.0.0-v2.0.0"
312        );
313    }
314
315    #[test]
316    fn test_resolve_version_placeholder_none() {
317        assert_eq!(
318            resolve_version_placeholder("https://example.com/static-url", "v1.0.0"),
319            "https://example.com/static-url"
320        );
321    }
322
323    #[test]
324    fn test_resolve_version_placeholder_opt() {
325        assert_eq!(
326            resolve_version_placeholder_opt(Some("ghcr.io/pkg:{{version}}"), "v1.0.0"),
327            Some("ghcr.io/pkg:v1.0.0".to_string())
328        );
329        assert_eq!(resolve_version_placeholder_opt(None, "v1.0.0"), None);
330    }
331
332    #[test]
333    fn test_package_resolve() {
334        let pkg = Package {
335            version: "v1.0.0".to_string(),
336            download_url: "https://example.com/pkg?tag={{version}}".to_string(),
337            ghcr_pkg: Some("ghcr.io/pkg:{{version}}".to_string()),
338            ..Default::default()
339        };
340
341        // Resolve with default version
342        let resolved = pkg.resolve(None);
343        assert_eq!(resolved.download_url, "https://example.com/pkg?tag=v1.0.0");
344        assert_eq!(resolved.ghcr_pkg, Some("ghcr.io/pkg:v1.0.0".to_string()));
345        assert_eq!(resolved.version, "v1.0.0");
346
347        // Resolve with specific version (snapshot)
348        let resolved = pkg.resolve(Some("v0.5.0"));
349        assert_eq!(resolved.download_url, "https://example.com/pkg?tag=v0.5.0");
350        assert_eq!(resolved.ghcr_pkg, Some("ghcr.io/pkg:v0.5.0".to_string()));
351        assert_eq!(resolved.version, "v0.5.0");
352    }
353
354    #[test]
355    fn test_package_resolve_no_placeholder() {
356        let pkg = Package {
357            version: "v2.0.0".to_string(),
358            download_url: "https://api.example.com/pkg/static-url".to_string(),
359            ..Default::default()
360        };
361
362        // No placeholder - URL unchanged
363        let resolved = pkg.resolve(None);
364        assert_eq!(
365            resolved.download_url,
366            "https://api.example.com/pkg/static-url"
367        );
368    }
369
370    #[test]
371    fn test_has_version_current() {
372        let pkg = Package {
373            version: "v1.0.0".to_string(),
374            ..Default::default()
375        };
376
377        assert!(pkg.has_version("v1.0.0"));
378        assert!(!pkg.has_version("v0.9.0"));
379    }
380
381    #[test]
382    fn test_has_version_snapshot() {
383        let pkg = Package {
384            version: "v1.0.0".to_string(),
385            snapshots: Some(vec![
386                "v0.9.0".to_string(),
387                "v0.8.0".to_string(),
388                "v0.7.0".to_string(),
389            ]),
390            ..Default::default()
391        };
392
393        assert!(pkg.has_version("v1.0.0")); // current version
394        assert!(pkg.has_version("v0.9.0")); // in snapshots
395        assert!(pkg.has_version("v0.8.0")); // in snapshots
396        assert!(!pkg.has_version("v0.6.0")); // not available
397    }
398
399    #[test]
400    fn test_has_version_no_snapshots() {
401        let pkg = Package {
402            version: "v1.0.0".to_string(),
403            snapshots: None,
404            ..Default::default()
405        };
406
407        assert!(pkg.has_version("v1.0.0"));
408        assert!(!pkg.has_version("v0.9.0"));
409    }
410}