winbrew_models\catalog/
package.rs

1use std::collections::BTreeSet;
2
3use serde::{Deserialize, Serialize};
4
5use crate::catalog::installer_type::CatalogInstallerType;
6use crate::install::{Architecture, InstallerType};
7use crate::package::{PackageId, PackageSource};
8use crate::shared::CatalogId;
9use crate::shared::validation::{Validate, ensure_hash, ensure_http_url, ensure_non_empty};
10use crate::shared::{HashAlgorithm, ModelError, Version};
11
12/// A validated catalog package entry.
13///
14/// Catalog packages are source-aware, typed records that are ready for search,
15/// selection, and installation workflows. They preserve the source identity and
16/// descriptive fields but leave installer discovery to `CatalogInstaller`.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct CatalogPackage {
19    /// Canonical catalog id.
20    pub id: CatalogId,
21    /// Human-readable package name.
22    pub name: String,
23    /// Parsed semantic version.
24    pub version: Version,
25    /// Package source.
26    pub source: PackageSource,
27    /// Optional namespace or bucket within the source.
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub namespace: Option<String>,
30    /// Source-local identifier for the package.
31    #[serde(default, skip_serializing_if = "String::is_empty")]
32    pub source_id: String,
33    /// When the catalog row was first written.
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub created_at: Option<String>,
36    /// When the catalog row was last updated.
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub updated_at: Option<String>,
39    /// Optional package summary.
40    pub description: Option<String>,
41    /// Optional homepage URL.
42    pub homepage: Option<String>,
43    /// Optional license text.
44    pub license: Option<String>,
45    /// Optional publisher string.
46    pub publisher: Option<String>,
47    /// Optional package metadata locale.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub locale: Option<String>,
50    /// Optional package moniker or alias.
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub moniker: Option<String>,
53    /// Optional package platform metadata encoded as JSON text.
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub platform: Option<String>,
56    /// Optional package commands encoded as JSON text.
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub commands: Option<String>,
59    /// Optional package protocols encoded as JSON text.
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub protocols: Option<String>,
62    /// Optional package file extensions encoded as JSON text.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub file_extensions: Option<String>,
65    /// Optional package capabilities encoded as JSON text.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub capabilities: Option<String>,
68    /// Optional package search tags encoded as JSON text.
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub tags: Option<String>,
71    /// Optional package bin metadata encoded as JSON text.
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub bin: Option<String>,
74    /// Optional package PATH add metadata encoded as JSON text.
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub env_add_path: Option<String>,
77}
78
79/// A validated installer entry associated with a catalog package.
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct CatalogInstaller {
82    /// Package id this installer belongs to.
83    pub package_id: CatalogId,
84    /// Download URL for the installer payload.
85    pub url: String,
86    /// Expected checksum or empty string when checksumless installs are allowed.
87    pub hash: String,
88    /// Checksum algorithm used to verify the installer.
89    pub hash_algorithm: HashAlgorithm,
90    /// Normalized installer family used for catalog browsing and filtering.
91    pub installer_type: CatalogInstallerType,
92    /// Silent-install or package-manager switches when the source provides them.
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub installer_switches: Option<String>,
95    /// Optional installer platform metadata encoded as JSON text.
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub platform: Option<String>,
98    /// Optional installer commands encoded as JSON text.
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub commands: Option<String>,
101    /// Optional installer protocols encoded as JSON text.
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub protocols: Option<String>,
104    /// Optional installer file extensions encoded as JSON text.
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub file_extensions: Option<String>,
107    /// Optional installer capabilities encoded as JSON text.
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub capabilities: Option<String>,
110    /// Architecture target for the installer.
111    pub arch: Architecture,
112    /// Raw installer format used by the engine-facing model, distinct from `installer_type`.
113    pub kind: InstallerType,
114    /// Nested installer format when the installer contains an archive payload.
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub nested_kind: Option<InstallerType>,
117    /// Optional install scope reported by the source, usually `user` or `machine` for Winget.
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub scope: Option<String>,
120}
121
122/// Canonical identity for a catalog installer row.
123#[derive(Debug, Clone, PartialEq, Eq, Hash)]
124pub struct CanonicalInstallerKey {
125    /// Package id this installer belongs to.
126    pub package_id: String,
127    /// Download URL for the installer payload.
128    pub url: String,
129    /// Expected checksum or empty string when checksumless installs are allowed.
130    pub hash: String,
131    /// Checksum algorithm used to verify the installer.
132    pub hash_algorithm: String,
133    /// Normalized installer family used for catalog browsing and filtering.
134    pub installer_type: String,
135    /// Silent-install or package-manager switches when the source provides them.
136    pub installer_switches: Option<String>,
137    /// Optional install scope reported by the source.
138    pub scope: Option<String>,
139    /// Architecture target for the installer.
140    pub arch: String,
141    /// Raw installer format used by the engine-facing model, distinct from `installer_type`.
142    pub kind: String,
143    /// Nested installer format when the installer contains an archive payload.
144    pub nested_kind: Option<String>,
145}
146
147impl CatalogPackage {
148    /// Validate the package id, source, and version relationship.
149    pub fn validate(&self) -> Result<(), ModelError> {
150        self.id.validate()?;
151        ensure_non_empty("catalog_package.name", &self.name)?;
152        ensure_non_empty("catalog_package.source_id", &self.source_id)?;
153        if let Some(namespace) = self.namespace.as_deref() {
154            ensure_non_empty("catalog_package.namespace", namespace)?;
155        }
156        self.version.validate()?;
157
158        let package_id = PackageId::parse(self.id.as_ref())?;
159        let expected_source = package_id.source();
160        if self.source != expected_source {
161            return Err(ModelError::source_mismatch(
162                "catalog_package.source",
163                expected_source.as_str(),
164                self.source.as_str(),
165            ));
166        }
167
168        if self.namespace.as_deref() != package_id.namespace() {
169            return Err(ModelError::invalid_contract(
170                "catalog_package.namespace",
171                format!(
172                    "expected {:?}, got {:?}",
173                    package_id.namespace(),
174                    self.namespace.as_deref()
175                ),
176            ));
177        }
178
179        if self.source_id != package_id.source_id() {
180            return Err(ModelError::invalid_contract(
181                "catalog_package.source_id",
182                format!(
183                    "expected {}, got {}",
184                    package_id.source_id(),
185                    self.source_id
186                ),
187            ));
188        }
189
190        if let Some(created_at) = self.created_at.as_deref() {
191            ensure_non_empty("catalog_package.created_at", created_at)?;
192        }
193
194        if let Some(updated_at) = self.updated_at.as_deref() {
195            ensure_non_empty("catalog_package.updated_at", updated_at)?;
196        }
197
198        if let Some(locale) = self.locale.as_deref() {
199            ensure_non_empty("catalog_package.locale", locale)?;
200        } else if self.source == PackageSource::Winget {
201            return Err(ModelError::invalid_contract(
202                "catalog_package.locale",
203                "winget packages require a locale",
204            ));
205        }
206
207        if let Some(moniker) = self.moniker.as_deref() {
208            ensure_non_empty("catalog_package.moniker", moniker)?;
209        }
210
211        if let Some(platform) = self.platform.as_deref() {
212            ensure_non_empty("catalog_package.platform", platform)?;
213        }
214
215        if let Some(commands) = self.commands.as_deref() {
216            ensure_non_empty("catalog_package.commands", commands)?;
217        }
218
219        if let Some(protocols) = self.protocols.as_deref() {
220            ensure_non_empty("catalog_package.protocols", protocols)?;
221        }
222
223        if let Some(file_extensions) = self.file_extensions.as_deref() {
224            ensure_non_empty("catalog_package.file_extensions", file_extensions)?;
225        }
226
227        if let Some(capabilities) = self.capabilities.as_deref() {
228            ensure_non_empty("catalog_package.capabilities", capabilities)?;
229        }
230
231        if let Some(tags) = self.tags.as_deref() {
232            ensure_non_empty("catalog_package.tags", tags)?;
233        }
234
235        if let Some(bin) = self.bin.as_deref() {
236            ensure_non_empty("catalog_package.bin", bin)?;
237        }
238
239        if let Some(env_add_path) = self.env_add_path.as_deref() {
240            ensure_non_empty("catalog_package.env_add_path", env_add_path)?;
241        }
242
243        Ok(())
244    }
245}
246
247impl Validate for CatalogPackage {
248    fn validate(&self) -> Result<(), ModelError> {
249        CatalogPackage::validate(self)
250    }
251}
252
253impl CatalogInstaller {
254    /// Validate the installer URL, checksum, and ids.
255    pub fn validate(&self) -> Result<(), ModelError> {
256        self.package_id.validate()?;
257        ensure_http_url("catalog_installer.url", &self.url)?;
258
259        if !self.hash.trim().is_empty() {
260            ensure_hash("catalog_installer.hash", &self.hash)?;
261
262            if let Some(expected_algorithm) = HashAlgorithm::detect(&self.hash)
263                && expected_algorithm != self.hash_algorithm
264            {
265                return Err(ModelError::invalid_contract(
266                    "catalog_installer.hash_algorithm",
267                    format!(
268                        "expected {}, got {}",
269                        expected_algorithm.as_str(),
270                        self.hash_algorithm.as_str()
271                    ),
272                ));
273            }
274        }
275
276        if let Some(installer_switches) = self.installer_switches.as_deref() {
277            ensure_non_empty("catalog_installer.installer_switches", installer_switches)?;
278        }
279
280        if let Some(platform) = self.platform.as_deref() {
281            ensure_non_empty("catalog_installer.platform", platform)?;
282        }
283
284        if let Some(commands) = self.commands.as_deref() {
285            ensure_non_empty("catalog_installer.commands", commands)?;
286        }
287
288        if let Some(protocols) = self.protocols.as_deref() {
289            ensure_non_empty("catalog_installer.protocols", protocols)?;
290        }
291
292        if let Some(file_extensions) = self.file_extensions.as_deref() {
293            ensure_non_empty("catalog_installer.file_extensions", file_extensions)?;
294        }
295
296        if let Some(capabilities) = self.capabilities.as_deref() {
297            ensure_non_empty("catalog_installer.capabilities", capabilities)?;
298        }
299
300        if let Some(scope) = self.scope.as_deref() {
301            let normalized_scope = scope.trim().to_ascii_lowercase();
302            if !matches!(normalized_scope.as_str(), "user" | "machine") {
303                return Err(ModelError::invalid_contract(
304                    "catalog_installer.scope",
305                    format!("expected user or machine, got {scope}"),
306                ));
307            }
308        }
309
310        Ok(())
311    }
312
313    /// Return the canonical identity used to deduplicate installer rows.
314    pub fn canonical_key(&self) -> CanonicalInstallerKey {
315        CanonicalInstallerKey {
316            package_id: self.package_id.to_string(),
317            url: self.url.clone(),
318            hash: self.hash.clone(),
319            hash_algorithm: self.hash_algorithm.as_str().to_string(),
320            installer_type: self.installer_type.as_str().to_string(),
321            installer_switches: self.installer_switches.clone(),
322            scope: self.scope.clone(),
323            arch: self.arch.as_str().to_string(),
324            kind: self.kind.to_string(),
325            nested_kind: self.nested_kind.map(|kind| kind.to_string()),
326        }
327    }
328
329    /// Merge metadata-only fields from another installer that shares the same canonical key.
330    pub fn merge_metadata_from(&mut self, other: &Self) -> Result<(), ModelError> {
331        if self.canonical_key() != other.canonical_key() {
332            return Err(ModelError::invalid_contract(
333                "catalog_installer.merge",
334                "cannot merge installers with different canonical keys",
335            ));
336        }
337
338        self.platform = merge_json_text_array(
339            self.platform.take(),
340            other.platform.as_deref(),
341            "catalog_installer.platform",
342        )?;
343        self.commands = merge_json_text_array(
344            self.commands.take(),
345            other.commands.as_deref(),
346            "catalog_installer.commands",
347        )?;
348        self.protocols = merge_json_text_array(
349            self.protocols.take(),
350            other.protocols.as_deref(),
351            "catalog_installer.protocols",
352        )?;
353        self.file_extensions = merge_json_text_array(
354            self.file_extensions.take(),
355            other.file_extensions.as_deref(),
356            "catalog_installer.file_extensions",
357        )?;
358        self.capabilities = merge_json_text_array(
359            self.capabilities.take(),
360            other.capabilities.as_deref(),
361            "catalog_installer.capabilities",
362        )?;
363
364        Ok(())
365    }
366}
367
368impl Validate for CatalogInstaller {
369    fn validate(&self) -> Result<(), ModelError> {
370        CatalogInstaller::validate(self)
371    }
372}
373
374fn merge_json_text_array(
375    left: Option<String>,
376    right: Option<&str>,
377    field: &'static str,
378) -> Result<Option<String>, ModelError> {
379    let mut values = BTreeSet::new();
380
381    if let Some(value) = left.as_deref() {
382        collect_json_text_array(field, value, &mut values)?;
383    }
384
385    if let Some(value) = right {
386        collect_json_text_array(field, value, &mut values)?;
387    }
388
389    if values.is_empty() {
390        return Ok(None);
391    }
392
393    let merged: Vec<String> = values.into_iter().collect();
394    let json = serde_json::to_string(&merged)
395        .map_err(|err| ModelError::invalid_contract(field, err.to_string()))?;
396
397    Ok(Some(json))
398}
399
400fn collect_json_text_array(
401    field: &'static str,
402    json: &str,
403    values: &mut BTreeSet<String>,
404) -> Result<(), ModelError> {
405    let entries: Vec<String> = serde_json::from_str(json)
406        .map_err(|err| ModelError::invalid_contract(field, err.to_string()))?;
407
408    for entry in entries {
409        let trimmed = entry.trim();
410        if trimmed.is_empty() {
411            continue;
412        }
413
414        values.insert(trimmed.to_string());
415    }
416
417    Ok(())
418}
419
420#[cfg(test)]
421mod tests {
422    use super::{CatalogInstaller, CatalogPackage};
423    use crate::catalog::installer_type::CatalogInstallerType;
424    use crate::install::{Architecture, InstallerType};
425    use crate::package::PackageId;
426    use crate::package::PackageSource;
427    use crate::shared::CatalogId;
428    use crate::shared::{HashAlgorithm, Version};
429
430    fn catalog_installer(package_id: CatalogId, url: &str) -> CatalogInstaller {
431        CatalogInstaller {
432            package_id,
433            url: url.to_string(),
434            hash: "abc123".to_string(),
435            hash_algorithm: HashAlgorithm::Sha256,
436            installer_type: CatalogInstallerType::Unknown,
437            installer_switches: None,
438            platform: None,
439            commands: None,
440            protocols: None,
441            file_extensions: None,
442            capabilities: None,
443            arch: Architecture::X64,
444            kind: InstallerType::Exe,
445            nested_kind: None,
446            scope: None,
447        }
448    }
449
450    fn catalog_package(id: CatalogId, name: &str, version: Version) -> CatalogPackage {
451        let package_id = PackageId::parse(id.as_ref()).expect("catalog id should parse");
452
453        CatalogPackage {
454            id,
455            name: name.to_string(),
456            version,
457            source: package_id.source(),
458            namespace: package_id.namespace().map(str::to_string),
459            source_id: package_id.source_id().to_string(),
460            created_at: None,
461            updated_at: None,
462            description: None,
463            homepage: None,
464            license: None,
465            publisher: None,
466            locale: None,
467            moniker: None,
468            platform: None,
469            commands: None,
470            protocols: None,
471            file_extensions: None,
472            capabilities: None,
473            tags: None,
474            bin: None,
475            env_add_path: None,
476        }
477    }
478
479    #[test]
480    fn rejects_source_mismatch() {
481        let mut package = catalog_package(
482            "winget/Contoso.App".into(),
483            "Contoso App",
484            Version::parse("1.2.3").expect("version should parse"),
485        );
486        package.source = PackageSource::Scoop;
487
488        let err = package.validate().expect_err("source mismatch should fail");
489
490        assert!(err.to_string().contains("source mismatch"));
491    }
492
493    #[test]
494    fn validates_checksumless_catalog_installer() {
495        let installer =
496            catalog_installer("winget/Contoso.App".into(), "https://example.test/app.exe");
497        let installer = CatalogInstaller {
498            hash: "".to_string(),
499            arch: Architecture::Any,
500            kind: InstallerType::Portable,
501            ..installer
502        };
503
504        assert!(installer.validate().is_ok());
505    }
506
507    #[test]
508    fn catalog_installer_nested_kind_round_trips_through_serde() {
509        let mut installer =
510            catalog_installer("winget/Contoso.App".into(), "https://example.test/app.zip");
511        installer.arch = Architecture::Any;
512        installer.kind = InstallerType::Zip;
513        installer.nested_kind = Some(InstallerType::Msi);
514        installer.scope = Some("user".to_string());
515        installer.hash = "deadbeef".to_string();
516        installer.hash_algorithm = HashAlgorithm::Sha256;
517        installer.installer_type = CatalogInstallerType::Zip;
518        installer.installer_switches = Some("/S".to_string());
519
520        let json = serde_json::to_string(&installer).expect("installer should serialize");
521        assert!(json.contains("\"nested_kind\":\"msi\""));
522        assert!(json.contains("\"hash_algorithm\":\"sha256\""));
523        assert!(json.contains("\"installer_type\":\"zip\""));
524        assert!(json.contains("\"installer_switches\":\"/S\""));
525        assert!(json.contains("\"scope\":\"user\""));
526
527        let restored: CatalogInstaller =
528            serde_json::from_str(&json).expect("installer should deserialize");
529
530        assert_eq!(restored.nested_kind, Some(InstallerType::Msi));
531        assert_eq!(restored.hash_algorithm, HashAlgorithm::Sha256);
532        assert_eq!(restored.installer_type, CatalogInstallerType::Zip);
533        assert_eq!(restored.installer_switches.as_deref(), Some("/S"));
534        assert_eq!(restored.scope.as_deref(), Some("user"));
535    }
536
537    #[test]
538    fn catalog_installer_deserializes_without_nested_kind() {
539        let json = r#"{
540            "package_id":"winget/Contoso.App",
541            "url":"https://example.test/app.exe",
542            "hash":"sha256:deadbeef",
543            "hash_algorithm":"sha256",
544            "installer_type":"unknown",
545            "arch":"any",
546            "kind":"portable"
547        }"#;
548
549        let installer: CatalogInstaller =
550            serde_json::from_str(json).expect("installer should deserialize");
551
552        assert_eq!(installer.nested_kind, None);
553        assert_eq!(installer.hash_algorithm, HashAlgorithm::Sha256);
554        assert_eq!(installer.installer_type, CatalogInstallerType::Unknown);
555        assert_eq!(installer.installer_switches, None);
556    }
557
558    #[test]
559    fn canonical_key_distinguishes_nested_kind_presence() {
560        let mut base =
561            catalog_installer("winget/Contoso.App".into(), "https://example.test/app.zip");
562        base.hash = "sha256:deadbeef".to_string();
563        base.hash_algorithm = HashAlgorithm::Sha256;
564        base.installer_type = CatalogInstallerType::Zip;
565        base.arch = Architecture::Any;
566        base.kind = InstallerType::Zip;
567
568        let mut nested = base.clone();
569        nested.nested_kind = Some(InstallerType::Msi);
570
571        assert_ne!(base.canonical_key(), nested.canonical_key());
572    }
573
574    #[test]
575    fn merge_metadata_unions_arrays_deterministically() {
576        let mut left =
577            catalog_installer("winget/Contoso.App".into(), "https://example.test/app.zip");
578        left.hash = "sha256:deadbeef".to_string();
579        left.hash_algorithm = HashAlgorithm::Sha256;
580        left.installer_type = CatalogInstallerType::Zip;
581        left.arch = Architecture::Any;
582        left.kind = InstallerType::Zip;
583        left.nested_kind = Some(InstallerType::Msi);
584        left.platform = Some("[\"Windows.Server\", \"Windows.Desktop\"]".to_string());
585        left.commands = Some("[\"contoso\"]".to_string());
586        left.protocols = Some("[\"contoso-protocol\"]".to_string());
587        left.file_extensions = Some("[\".exe\"]".to_string());
588        left.capabilities = Some("[\"internetClient\"]".to_string());
589
590        let mut right = left.clone();
591        right.platform = Some("[\"Windows.Desktop\", \"Windows.LTSC\"]".to_string());
592        right.commands = Some("[\"contoso-server\", \"contoso\"]".to_string());
593        right.protocols = Some("[\"contoso-protocol\", \"contoso-shell\"]".to_string());
594        right.file_extensions = Some("[\".msi\", \".exe\"]".to_string());
595        right.capabilities = Some("[\"internetClient\", \"internetClientServer\"]".to_string());
596
597        left.merge_metadata_from(&right)
598            .expect("merge should succeed");
599
600        assert_eq!(
601            left.platform.as_deref(),
602            Some("[\"Windows.Desktop\",\"Windows.LTSC\",\"Windows.Server\"]")
603        );
604        assert_eq!(
605            left.commands.as_deref(),
606            Some("[\"contoso\",\"contoso-server\"]")
607        );
608        assert_eq!(
609            left.protocols.as_deref(),
610            Some("[\"contoso-protocol\",\"contoso-shell\"]")
611        );
612        assert_eq!(left.file_extensions.as_deref(), Some("[\".exe\",\".msi\"]"));
613        assert_eq!(
614            left.capabilities.as_deref(),
615            Some("[\"internetClient\",\"internetClientServer\"]")
616        );
617    }
618
619    #[test]
620    fn merge_metadata_preserves_present_side_when_other_is_missing() {
621        let mut left =
622            catalog_installer("winget/Contoso.App".into(), "https://example.test/app.zip");
623        left.hash = "sha256:deadbeef".to_string();
624        left.hash_algorithm = HashAlgorithm::Sha256;
625        left.installer_type = CatalogInstallerType::Zip;
626        left.arch = Architecture::Any;
627        left.kind = InstallerType::Zip;
628        left.nested_kind = None;
629
630        let mut right = left.clone();
631        right.platform = Some("[\"Windows.Desktop\"]".to_string());
632        right.commands = None;
633        right.protocols = Some("[\"contoso-protocol\"]".to_string());
634        right.file_extensions = None;
635        right.capabilities = None;
636
637        left.merge_metadata_from(&right)
638            .expect("merge should succeed");
639
640        assert_eq!(left.platform.as_deref(), Some("[\"Windows.Desktop\"]"));
641        assert_eq!(left.commands, None);
642        assert_eq!(left.protocols.as_deref(), Some("[\"contoso-protocol\"]"));
643        assert_eq!(left.file_extensions, None);
644        assert_eq!(left.capabilities, None);
645    }
646
647    #[test]
648    fn catalog_package_round_trips_through_serde() {
649        let mut package = catalog_package(
650            "scoop/main/Contoso.App".into(),
651            "Contoso App",
652            Version::parse("1.2.3").expect("version should parse"),
653        );
654        package.description = Some("Example package".to_string());
655        package.created_at = Some("2026-04-14 12:00:00".to_string());
656        package.updated_at = Some("2026-04-14 12:34:56".to_string());
657        package.publisher = Some("Contoso Ltd.".to_string());
658        package.locale = Some("en-US".to_string());
659        package.moniker = Some("contoso".to_string());
660        package.tags = Some("[\"utility\"]".to_string());
661        package.bin = Some("[\"tool.exe\"]".to_string());
662        package.env_add_path = Some("[\"bin\"]".to_string());
663
664        let json = serde_json::to_string(&package).expect("package should serialize");
665        let restored: CatalogPackage =
666            serde_json::from_str(&json).expect("package should deserialize");
667
668        assert_eq!(restored.id, package.id);
669        assert_eq!(restored.source, package.source);
670        assert_eq!(restored.namespace, package.namespace);
671        assert_eq!(restored.source_id, package.source_id);
672        assert_eq!(restored.created_at, package.created_at);
673        assert_eq!(restored.updated_at, package.updated_at);
674        assert_eq!(restored.version, package.version);
675        assert_eq!(restored.publisher, package.publisher);
676        assert_eq!(restored.locale, package.locale);
677        assert_eq!(restored.moniker, package.moniker);
678        assert_eq!(restored.tags, package.tags);
679        assert_eq!(restored.bin, package.bin);
680        assert_eq!(restored.env_add_path, package.env_add_path);
681    }
682
683    #[test]
684    fn winget_packages_require_locale() {
685        let package = catalog_package(
686            "winget/Contoso.App".into(),
687            "Contoso App",
688            Version::parse("1.2.3").expect("version should parse"),
689        );
690
691        let err = package
692            .validate()
693            .expect_err("winget package should require locale");
694
695        assert!(err.to_string().contains("require a locale"));
696    }
697}