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#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct CatalogPackage {
19 pub id: CatalogId,
21 pub name: String,
23 pub version: Version,
25 pub source: PackageSource,
27 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub namespace: Option<String>,
30 #[serde(default, skip_serializing_if = "String::is_empty")]
32 pub source_id: String,
33 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub created_at: Option<String>,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub updated_at: Option<String>,
39 pub description: Option<String>,
41 pub homepage: Option<String>,
43 pub license: Option<String>,
45 pub publisher: Option<String>,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub locale: Option<String>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub moniker: Option<String>,
53 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub platform: Option<String>,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub commands: Option<String>,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub protocols: Option<String>,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub file_extensions: Option<String>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub capabilities: Option<String>,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub tags: Option<String>,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub bin: Option<String>,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub env_add_path: Option<String>,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct CatalogInstaller {
82 pub package_id: CatalogId,
84 pub url: String,
86 pub hash: String,
88 pub hash_algorithm: HashAlgorithm,
90 pub installer_type: CatalogInstallerType,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub installer_switches: Option<String>,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub platform: Option<String>,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub commands: Option<String>,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
103 pub protocols: Option<String>,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub file_extensions: Option<String>,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub capabilities: Option<String>,
110 pub arch: Architecture,
112 pub kind: InstallerType,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub nested_kind: Option<InstallerType>,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub scope: Option<String>,
120}
121
122#[derive(Debug, Clone, PartialEq, Eq, Hash)]
124pub struct CanonicalInstallerKey {
125 pub package_id: String,
127 pub url: String,
129 pub hash: String,
131 pub hash_algorithm: String,
133 pub installer_type: String,
135 pub installer_switches: Option<String>,
137 pub scope: Option<String>,
139 pub arch: String,
141 pub kind: String,
143 pub nested_kind: Option<String>,
145}
146
147impl CatalogPackage {
148 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 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 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 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}