Skip to main content

soar_registry/
package.rs

1//! Remote package metadata structures.
2//!
3//! This module defines the [`RemotePackage`] struct which represents package
4//! metadata as received from a repository. It handles various serialization
5//! quirks in the metadata format, including flexible boolean parsing and
6//! optional number fields.
7
8use std::fmt;
9
10use serde::{
11    de::{self, Visitor},
12    Deserialize, Deserializer, Serialize,
13};
14
15/// Internal enum for deserializing boolean values that may be strings.
16#[derive(Deserialize)]
17#[serde(untagged)]
18enum FlexiBool {
19    Bool(bool),
20    String(String),
21}
22
23fn empty_is_none<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
24where
25    D: Deserializer<'de>,
26{
27    let s: Option<String> = Option::deserialize(deserializer)?;
28    Ok(s.filter(|s| !s.is_empty()))
29}
30
31fn optional_number<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
32where
33    D: Deserializer<'de>,
34{
35    struct OptU64Visitor;
36
37    impl<'de> Visitor<'de> for OptU64Visitor {
38        type Value = Option<u64>;
39
40        fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
41            f.write_str("a positive integer, string, or null")
42        }
43
44        fn visit_none<E>(self) -> Result<Self::Value, E> {
45            Ok(None)
46        }
47
48        fn visit_unit<E>(self) -> Result<Self::Value, E> {
49            Ok(None)
50        }
51
52        fn visit_u64<E>(self, v: u64) -> Result<Self::Value, E> {
53            Ok(Some(v))
54        }
55
56        fn visit_i64<E>(self, v: i64) -> Result<Self::Value, E>
57        where
58            E: de::Error,
59        {
60            Ok((v >= 0).then_some(v as u64))
61        }
62
63        fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
64        where
65            E: de::Error,
66        {
67            if v.is_empty() {
68                return Ok(None);
69            }
70
71            v.parse::<i64>()
72                .ok()
73                .filter(|&n| n >= 0)
74                .map(|n| n as u64)
75                .ok_or_else(|| E::custom("invalid number"))
76                .map(Some)
77                .or(Ok(None))
78        }
79    }
80
81    deserializer.deserialize_any(OptU64Visitor)
82}
83
84fn flexible_bool<'de, D>(deserializer: D) -> Result<Option<bool>, D::Error>
85where
86    D: Deserializer<'de>,
87{
88    match Option::<FlexiBool>::deserialize(deserializer)? {
89        Some(FlexiBool::Bool(b)) => Ok(Some(b)),
90        Some(FlexiBool::String(s)) => {
91            match s.to_lowercase().as_str() {
92                "true" | "yes" | "1" => Ok(Some(true)),
93                "false" | "no" | "0" => Ok(Some(false)),
94                "" => Ok(None),
95                _ => {
96                    Err(de::Error::invalid_value(
97                        de::Unexpected::Str(&s),
98                        &"a valid boolean (true/false, yes/no, 1/0)",
99                    ))
100                }
101            }
102        }
103        None => Ok(None),
104    }
105}
106
107/// Package metadata as received from a remote repository.
108///
109/// This struct represents the complete metadata for a package available in a
110/// repository. It handles various quirks in the serialization format:
111///
112/// - Boolean fields accept both actual booleans and string representations
113///   (`"true"`, `"false"`, `"yes"`, `"no"`, `"1"`, `"0"`)
114/// - Numeric fields accept string representations of numbers
115/// - Empty strings are normalized to `None`
116/// - Various field aliases are supported for backward compatibility
117///
118/// # Required Fields
119///
120/// - `pkg_name` - Human-readable package name
121/// - `description` - Package description
122/// - `version` - Package version string
123/// - `download_url` - URL to download the package
124#[derive(Debug, Default, Clone, Deserialize, Serialize)]
125pub struct RemotePackage {
126    #[serde(default, deserialize_with = "flexible_bool", alias = "_disabled")]
127    pub disabled: Option<bool>,
128
129    #[serde(alias = "_disabled_reason")]
130    pub disabled_reason: Option<serde_json::Value>,
131
132    /// Optional. It exists to disambiguate identically-named packages within
133    /// one repository; where names are already unique a repository can omit
134    /// it, and the name is used instead.
135    #[serde(default, deserialize_with = "empty_is_none")]
136    pub pkg_id: Option<String>,
137    pub pkg_name: String,
138
139    #[serde(default, deserialize_with = "empty_is_none")]
140    pub pkg_family: Option<String>,
141
142    #[serde(default, deserialize_with = "empty_is_none")]
143    pub pkg_type: Option<String>,
144
145    pub description: String,
146    pub version: String,
147
148    pub download_url: String,
149
150    /// Bytes, as the older format publishes them.
151    #[serde(default, deserialize_with = "optional_number")]
152    pub size_raw: Option<u64>,
153
154    /// Bytes, as the port format publishes them. The older format uses this
155    /// name for a human-readable string, which parses as no value, so the two
156    /// can coexist in one index without either being mistaken for the other.
157    #[serde(default, deserialize_with = "optional_number")]
158    pub size: Option<u64>,
159
160    #[serde(default, deserialize_with = "empty_is_none")]
161    pub ghcr_pkg: Option<String>,
162
163    #[serde(default, deserialize_with = "optional_number")]
164    pub ghcr_size_raw: Option<u64>,
165
166    pub ghcr_files: Option<Vec<String>>,
167
168    #[serde(default, deserialize_with = "empty_is_none")]
169    pub ghcr_blob: Option<String>,
170
171    #[serde(default, deserialize_with = "empty_is_none")]
172    pub ghcr_url: Option<String>,
173
174    #[serde(alias = "src_url")]
175    pub src_urls: Option<Vec<String>>,
176
177    #[serde(alias = "homepage")]
178    pub homepages: Option<Vec<String>>,
179
180    #[serde(alias = "license")]
181    pub licenses: Option<Vec<String>>,
182
183    #[serde(alias = "maintainer")]
184    pub maintainers: Option<Vec<String>>,
185
186    #[serde(alias = "note")]
187    pub notes: Option<Vec<String>>,
188
189    #[serde(default, deserialize_with = "empty_is_none")]
190    pub bsum: Option<String>,
191
192    #[serde(default, deserialize_with = "empty_is_none")]
193    pub build_id: Option<String>,
194
195    #[serde(default, deserialize_with = "empty_is_none", alias = "date")]
196    pub build_date: Option<String>,
197
198    #[serde(default, deserialize_with = "empty_is_none", alias = "build_gha")]
199    pub build_action: Option<String>,
200
201    #[serde(default, deserialize_with = "empty_is_none")]
202    pub build_script: Option<String>,
203
204    #[serde(default, deserialize_with = "empty_is_none")]
205    pub build_log: Option<String>,
206
207    #[serde(alias = "category")]
208    pub categories: Option<Vec<String>>,
209
210    pub provides: Option<Vec<String>>,
211
212    #[serde(default, deserialize_with = "empty_is_none")]
213    pub icon: Option<String>,
214
215    #[serde(default, deserialize_with = "empty_is_none")]
216    pub desktop: Option<String>,
217
218    #[serde(default, deserialize_with = "empty_is_none")]
219    pub appstream: Option<String>,
220
221    #[serde(default, deserialize_with = "empty_is_none")]
222    pub app_id: Option<String>,
223
224    #[serde(default, deserialize_with = "flexible_bool")]
225    pub soar_syms: Option<bool>,
226
227    #[serde(default, deserialize_with = "flexible_bool")]
228    pub deprecated: Option<bool>,
229
230    #[serde(default, deserialize_with = "flexible_bool")]
231    pub desktop_integration: Option<bool>,
232
233    #[serde(default, deserialize_with = "flexible_bool")]
234    pub portable: Option<bool>,
235
236    pub repology: Option<Vec<String>>,
237    pub snapshots: Option<Vec<String>>,
238    pub replaces: Option<Vec<String>>,
239    /// Executables inside the artifact, as source path -> installed name.
240    /// Pinned side files to install alongside the artifact.
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub extra: Option<Vec<RemoteExtra>>,
243    /// What the package takes out of its artifact. Absent means the whole
244    /// artifact is the package, which is how the older format always behaved.
245    #[serde(default, skip_serializing_if = "Option::is_none")]
246    pub files: Option<Vec<RemoteFile>>,
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    #[test]
254    fn test_remote_package_deserialization() {
255        let json = r#"{
256            "pkg_id": "test-pkg",
257            "pkg_name": "test",
258            "description": "A test package",
259            "version": "1.0.0",
260            "download_url": "https://example.com/test.tar.gz"
261        }"#;
262
263        let pkg: RemotePackage = serde_json::from_str(json).unwrap();
264        assert_eq!(pkg.pkg_id.as_deref(), Some("test-pkg"));
265        assert_eq!(pkg.pkg_name, "test");
266        assert_eq!(pkg.version, "1.0.0");
267    }
268
269    #[test]
270    fn test_pkg_id_is_optional() {
271        let json = r#"{
272            "pkg_name": "test",
273            "description": "A test package",
274            "version": "1.0.0",
275            "download_url": "https://example.com/test.tar.gz"
276        }"#;
277
278        let pkg: RemotePackage = serde_json::from_str(json).unwrap();
279        assert_eq!(pkg.pkg_id, None);
280        assert_eq!(pkg.pkg_name, "test");
281    }
282
283    #[test]
284    fn test_flexible_bool() {
285        let json = r#"{
286            "pkg_id": "test",
287            "pkg_name": "test",
288            "description": "test",
289            "version": "1.0.0",
290            "download_url": "https://example.com",
291            "disabled": "true"
292        }"#;
293
294        let pkg: RemotePackage = serde_json::from_str(json).unwrap();
295        assert_eq!(pkg.disabled, Some(true));
296    }
297}
298
299/// One file the package installs, as published in the index.
300///
301/// `to` is a path inside the package directory, so the directory it lands in
302/// says what it is: `bin/` is a command, `share/man/` a manual page. An empty
303/// `source` means the artifact is itself the file.
304#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
305pub struct RemoteFile {
306    #[serde(default)]
307    pub source: String,
308    pub to: String,
309    /// Extra paths, relative to the package directory, resolving to this file.
310    #[serde(default, skip_serializing_if = "Vec::is_empty")]
311    pub alias: Vec<String>,
312}
313
314/// A pinned side file as published in the index.
315#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
316pub struct RemoteExtra {
317    pub url: String,
318    pub to: String,
319    #[serde(default, skip_serializing_if = "Option::is_none")]
320    pub blake3: Option<String>,
321    #[serde(default, skip_serializing_if = "Option::is_none")]
322    pub sha256: Option<String>,
323}