winbrew_models\package/
reference.rs1use 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16pub enum PackageRef {
17 ByName(PackageName),
19 ById(PackageId),
21}
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub enum PackageId {
26 Winget { id: String },
28 Scoop { bucket: BucketName, id: String },
30 Chocolatey { id: String },
32 Winbrew { id: String },
34}
35
36impl PackageRef {
37 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 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 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 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 pub fn namespace(&self) -> Option<&str> {
160 match self {
161 Self::Scoop { bucket, .. } => Some(bucket.as_str()),
162 _ => None,
163 }
164 }
165
166 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}