winbrew_models\package/
reference.rs

1use core::fmt;
2use core::str::FromStr;
3
4use serde::{Deserialize, Serialize};
5
6use crate::shared::ModelError;
7use crate::shared::validation::{Validate, ensure_non_empty};
8use crate::shared::{BucketName, PackageName};
9
10/// A package reference provided by callers or CLI commands.
11///
12/// Package references can either name a package directly or point to an
13/// explicit catalog id via `@winget/<id>`, `@scoop/<bucket>/<id>`,
14/// `@chocolatey/<id>`, or `@winbrew/<id>` syntax.
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16pub enum PackageRef {
17    /// Reference by display name or package name.
18    ByName(PackageName),
19    /// Reference by explicit package id.
20    ById(PackageId),
21}
22
23/// The canonical package id syntax used by catalog and query code.
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub enum PackageId {
26    /// A Winget catalog id.
27    Winget { id: String },
28    /// A Scoop bucket/id pair.
29    Scoop { bucket: BucketName, id: String },
30    /// A Chocolatey package id.
31    Chocolatey { id: String },
32    /// A WinBrew package id.
33    Winbrew { id: String },
34}
35
36impl PackageRef {
37    /// Parse a package reference from `name` or an explicit catalog id.
38    pub fn parse(input: &str) -> Result<Self, ModelError> {
39        let trimmed = input.trim();
40
41        if let Some(rest) = trimmed.strip_prefix('@') {
42            Ok(Self::ById(PackageId::parse(rest)?))
43        } else if trimmed.is_empty() {
44            Err(ModelError::empty("package_ref"))
45        } else if trimmed.contains('/') {
46            Err(invalid_package_id(trimmed))
47        } else {
48            Ok(Self::ByName(PackageName::parse(trimmed)?))
49        }
50    }
51}
52
53impl Validate for PackageRef {
54    fn validate(&self) -> Result<(), ModelError> {
55        match self {
56            Self::ByName(name) => name.validate(),
57            Self::ById(package_id) => package_id.validate(),
58        }
59    }
60}
61
62impl PackageId {
63    /// Parse a canonical catalog id from `winget/<id>`, `scoop/<bucket>/<id>`,
64    /// `chocolatey/<id>`, or `winbrew/<id>` syntax.
65    pub fn parse(input: &str) -> Result<Self, ModelError> {
66        let trimmed = input.trim();
67        let mut parts = trimmed.split('/');
68
69        let source = parts
70            .next()
71            .filter(|value| !value.is_empty())
72            .ok_or_else(|| invalid_package_id(trimmed))?;
73
74        let package_id = match source {
75            "winget" => {
76                let id = parts
77                    .next()
78                    .filter(|value| !value.is_empty())
79                    .ok_or_else(|| invalid_package_id(trimmed))?;
80
81                if parts.next().is_some() {
82                    return Err(invalid_package_id(trimmed));
83                }
84
85                Self::Winget { id: id.to_string() }
86            }
87            "scoop" => {
88                let bucket = BucketName::parse(
89                    parts
90                        .next()
91                        .filter(|value| !value.is_empty())
92                        .ok_or_else(|| invalid_package_id(trimmed))?,
93                )?;
94                let id = parts
95                    .next()
96                    .filter(|value| !value.is_empty())
97                    .ok_or_else(|| invalid_package_id(trimmed))?;
98
99                if parts.next().is_some() {
100                    return Err(invalid_package_id(trimmed));
101                }
102
103                Self::Scoop {
104                    bucket,
105                    id: id.to_string(),
106                }
107            }
108            "chocolatey" => {
109                let id = parts
110                    .next()
111                    .filter(|value| !value.is_empty())
112                    .ok_or_else(|| invalid_package_id(trimmed))?;
113
114                if parts.next().is_some() {
115                    return Err(invalid_package_id(trimmed));
116                }
117
118                Self::Chocolatey { id: id.to_string() }
119            }
120            "winbrew" => {
121                let id = parts
122                    .next()
123                    .filter(|value| !value.is_empty())
124                    .ok_or_else(|| invalid_package_id(trimmed))?;
125
126                if parts.next().is_some() {
127                    return Err(invalid_package_id(trimmed));
128                }
129
130                Self::Winbrew { id: id.to_string() }
131            }
132            _ => return Err(invalid_package_id(trimmed)),
133        };
134
135        Ok(package_id)
136    }
137
138    /// Return the canonical `source/id` display form for this package id.
139    pub fn catalog_id(&self) -> String {
140        match self {
141            Self::Winget { id } => format!("winget/{id}"),
142            Self::Scoop { bucket, id } => format!("scoop/{}/{id}", bucket.as_str()),
143            Self::Chocolatey { id } => format!("chocolatey/{id}"),
144            Self::Winbrew { id } => format!("winbrew/{id}"),
145        }
146    }
147
148    /// Return the upstream source associated with this package id.
149    pub fn source(&self) -> crate::package::PackageSource {
150        match self {
151            Self::Winget { .. } => crate::package::PackageSource::Winget,
152            Self::Scoop { .. } => crate::package::PackageSource::Scoop,
153            Self::Chocolatey { .. } => crate::package::PackageSource::Chocolatey,
154            Self::Winbrew { .. } => crate::package::PackageSource::Winbrew,
155        }
156    }
157
158    /// Return the optional namespace segment encoded in this package id.
159    pub fn namespace(&self) -> Option<&str> {
160        match self {
161            Self::Scoop { bucket, .. } => Some(bucket.as_str()),
162            _ => None,
163        }
164    }
165
166    /// Return the source-local package id segment.
167    pub fn source_id(&self) -> &str {
168        match self {
169            Self::Winget { id }
170            | Self::Scoop { id, .. }
171            | Self::Chocolatey { id }
172            | Self::Winbrew { id } => id,
173        }
174    }
175}
176
177impl Validate for PackageId {
178    fn validate(&self) -> Result<(), ModelError> {
179        match self {
180            Self::Winget { id } => ensure_non_empty("package_id.id", id),
181            Self::Scoop { bucket, id } => {
182                bucket.validate()?;
183                ensure_non_empty("package_id.id", id)
184            }
185            Self::Chocolatey { id } | Self::Winbrew { id } => ensure_non_empty("package_id.id", id),
186        }
187    }
188}
189
190impl fmt::Display for PackageId {
191    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
192        f.write_str(&self.catalog_id())
193    }
194}
195
196impl FromStr for PackageRef {
197    type Err = ModelError;
198
199    fn from_str(s: &str) -> Result<Self, Self::Err> {
200        Self::parse(s)
201    }
202}
203
204impl FromStr for PackageId {
205    type Err = ModelError;
206
207    fn from_str(s: &str) -> Result<Self, Self::Err> {
208        Self::parse(s)
209    }
210}
211
212fn invalid_package_id(input: &str) -> ModelError {
213    ModelError::invalid_package_id(
214        input,
215        "expected @winget/<id>, @scoop/<bucket>/<id>, @chocolatey/<id>, or @winbrew/<id>",
216    )
217}
218
219#[cfg(test)]
220mod tests {
221    use super::{BucketName, PackageId, PackageRef};
222    use crate::shared::PackageName;
223
224    #[test]
225    fn parses_package_name() {
226        assert_eq!(
227            PackageRef::parse("git").unwrap(),
228            PackageRef::ByName(PackageName::parse("git").unwrap())
229        );
230    }
231
232    #[test]
233    fn parses_winget_id() {
234        assert_eq!(
235            PackageRef::parse("@winget/Google.Chrome").unwrap(),
236            PackageRef::ById(PackageId::Winget {
237                id: "Google.Chrome".to_string(),
238            })
239        );
240    }
241
242    #[test]
243    fn parses_chocolatey_id() {
244        assert_eq!(
245            PackageRef::parse("@chocolatey/git").unwrap(),
246            PackageRef::ById(PackageId::Chocolatey {
247                id: "git".to_string(),
248            })
249        );
250    }
251
252    #[test]
253    fn parses_winbrew_id() {
254        assert_eq!(
255            PackageRef::parse("@winbrew/git").unwrap(),
256            PackageRef::ById(PackageId::Winbrew {
257                id: "git".to_string(),
258            })
259        );
260    }
261
262    #[test]
263    fn parses_scoop_id() {
264        assert_eq!(
265            PackageRef::parse("@scoop/main/7zip").unwrap(),
266            PackageRef::ById(PackageId::Scoop {
267                bucket: BucketName::parse("main").unwrap(),
268                id: "7zip".to_string(),
269            })
270        );
271    }
272
273    #[test]
274    fn rejects_bucket_names_with_slashes() {
275        let err = BucketName::parse("main/tools").unwrap_err();
276
277        assert!(err.to_string().contains("bucket names cannot contain '/'"));
278    }
279
280    #[test]
281    fn invalid_package_id_has_helpful_error() {
282        let err = PackageRef::parse("@invalid").unwrap_err();
283
284        assert!(err.to_string().contains(
285            "expected @winget/<id>, @scoop/<bucket>/<id>, @chocolatey/<id>, or @winbrew/<id>"
286        ));
287    }
288
289    #[test]
290    fn rejects_bare_package_ids_with_slashes() {
291        let err = PackageRef::parse("scoop/main/curl").unwrap_err();
292
293        assert!(err.to_string().contains(
294            "expected @winget/<id>, @scoop/<bucket>/<id>, @chocolatey/<id>, or @winbrew/<id>"
295        ));
296    }
297}