winbrew_models\package/
model.rs

1//! Package aggregate types and source/kind classification.
2//!
3//! This file owns the canonical typed package representation used by catalog,
4//! search, and install orchestration. The aggregate keeps the source metadata,
5//! version, optional descriptive fields, installer candidates, and dependency
6//! list together so callers do not need to reconstruct a package from multiple
7//! sources.
8
9use core::str::FromStr;
10use serde::{Deserialize, Serialize};
11
12use crate::install::Installer;
13use crate::shared::validation::{Validate, ensure_non_empty};
14use crate::shared::{ModelError, Version};
15
16use super::dependency::Dependency;
17
18/// The upstream source that produced a package record.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "lowercase")]
21pub enum PackageSource {
22    /// A package sourced from the Winget catalog.
23    Winget,
24    /// A package sourced from a Scoop bucket.
25    Scoop,
26    /// A package sourced from Chocolatey.
27    Chocolatey,
28    /// A package sourced from the WinBrew catalog.
29    Winbrew,
30}
31
32/// The lifecycle classification of a package record.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "lowercase")]
35pub enum PackageKind {
36    /// A catalog record that can be installed.
37    Catalog,
38    /// A record that represents an installed package snapshot.
39    Installed,
40}
41
42/// Canonical package aggregate used by catalog, search, and install flows.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct Package {
45    /// Stable package identifier in canonical catalog id form.
46    pub id: String,
47    /// Human-readable display name.
48    pub name: String,
49    /// Parsed semantic version.
50    pub version: Version,
51    /// The source that produced the record.
52    pub source: PackageSource,
53    /// Whether the record represents a catalog item or installed snapshot.
54    pub kind: PackageKind,
55    /// Short summary or description text, when available.
56    pub description: Option<String>,
57    /// Product homepage URL, when provided.
58    pub homepage: Option<String>,
59    /// License string reported by the source.
60    pub license: Option<String>,
61    /// Publisher or maintainer string.
62    pub publisher: Option<String>,
63    /// Resolved installer candidates associated with the package.
64    pub installers: Vec<Installer>,
65    /// Declared dependencies for the package.
66    pub dependencies: Vec<Dependency>,
67}
68
69impl Package {
70    /// Validate the package and all nested installer/dependency records.
71    pub fn validate(&self) -> Result<(), ModelError> {
72        ensure_non_empty("package.id", &self.id)?;
73        ensure_non_empty("package.name", &self.name)?;
74        self.version.validate()?;
75
76        for installer in &self.installers {
77            installer.validate()?;
78        }
79
80        for dependency in &self.dependencies {
81            dependency.validate()?;
82        }
83
84        Ok(())
85    }
86}
87
88impl PackageSource {
89    pub fn as_str(self) -> &'static str {
90        match self {
91            Self::Winget => "winget",
92            Self::Scoop => "scoop",
93            Self::Chocolatey => "chocolatey",
94            Self::Winbrew => "winbrew",
95        }
96    }
97}
98
99impl FromStr for PackageSource {
100    type Err = ModelError;
101
102    fn from_str(s: &str) -> Result<Self, Self::Err> {
103        match s.trim().to_ascii_lowercase().as_str() {
104            "winget" => Ok(Self::Winget),
105            "scoop" => Ok(Self::Scoop),
106            "chocolatey" => Ok(Self::Chocolatey),
107            "winbrew" => Ok(Self::Winbrew),
108            other => Err(ModelError::invalid_enum_value("package.source", other)),
109        }
110    }
111}
112
113impl core::fmt::Display for PackageSource {
114    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
115        f.write_str(self.as_str())
116    }
117}
118
119impl From<PackageSource> for String {
120    fn from(value: PackageSource) -> Self {
121        value.to_string()
122    }
123}
124
125impl AsRef<str> for PackageSource {
126    fn as_ref(&self) -> &str {
127        self.as_str()
128    }
129}
130
131impl PackageKind {
132    pub fn as_str(self) -> &'static str {
133        match self {
134            Self::Catalog => "catalog",
135            Self::Installed => "installed",
136        }
137    }
138}
139
140impl FromStr for PackageKind {
141    type Err = ModelError;
142
143    fn from_str(s: &str) -> Result<Self, Self::Err> {
144        match s.trim().to_ascii_lowercase().as_str() {
145            "catalog" => Ok(Self::Catalog),
146            "installed" => Ok(Self::Installed),
147            other => Err(ModelError::invalid_enum_value("package.kind", other)),
148        }
149    }
150}
151
152impl core::fmt::Display for PackageKind {
153    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
154        f.write_str(self.as_str())
155    }
156}
157
158impl From<PackageKind> for String {
159    fn from(value: PackageKind) -> Self {
160        value.to_string()
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::{Package, PackageKind, PackageSource};
167    use crate::install::{Architecture, Installer, InstallerType};
168    use crate::shared::Version;
169    use core::str::FromStr;
170
171    #[test]
172    fn validates_package() {
173        let package = Package {
174            id: "winget/Contoso.App".to_string(),
175            name: "Contoso App".to_string(),
176            version: Version::parse("1.2.3").expect("version should parse"),
177            source: PackageSource::Winget,
178            kind: PackageKind::Catalog,
179            description: None,
180            homepage: None,
181            license: None,
182            publisher: None,
183            installers: vec![Installer {
184                url: "https://example.test/app.exe".to_string(),
185                hash: "sha256:deadbeef".to_string(),
186                architecture: Architecture::X64,
187                kind: InstallerType::Exe,
188            }],
189            dependencies: vec![],
190        };
191
192        assert!(package.validate().is_ok());
193    }
194
195    #[test]
196    fn parses_package_source_and_kind() {
197        assert_eq!(
198            PackageSource::from_str("winget").unwrap(),
199            PackageSource::Winget
200        );
201        assert_eq!(
202            PackageSource::from_str("scoop").unwrap(),
203            PackageSource::Scoop
204        );
205        assert_eq!(
206            PackageSource::from_str("chocolatey").unwrap(),
207            PackageSource::Chocolatey
208        );
209        assert_eq!(
210            PackageSource::from_str("winbrew").unwrap(),
211            PackageSource::Winbrew
212        );
213        assert_eq!(
214            PackageKind::from_str("catalog").unwrap(),
215            PackageKind::Catalog
216        );
217        assert_eq!(
218            PackageKind::from_str("installed").unwrap(),
219            PackageKind::Installed
220        );
221    }
222}