winbrew_models\shared/
identifiers.rs1use core::ops::Deref;
2use core::str::FromStr;
3
4use serde::{Deserialize, Serialize};
5
6use super::ModelError;
7use super::validation::{Validate, ensure_non_empty};
8use crate::package::PackageId;
9
10macro_rules! define_string_newtype {
11 (
12 $(#[$meta:meta])*
13 $vis:vis struct $name:ident;
14 field = $field:literal;
15 parse = $parse:expr;
16 validate = $validate:expr;
17 ) => {
18 $(#[$meta])*
19 #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
20 #[serde(transparent)]
21 $vis struct $name(String);
22
23 impl $name {
24 pub fn parse(input: &str) -> Result<Self, ModelError> {
25 let trimmed = input.trim();
26 let value = ($parse)(trimmed)?;
27 Ok(Self(value))
28 }
29
30 pub fn as_str(&self) -> &str {
31 &self.0
32 }
33 }
34
35 impl Validate for $name {
36 fn validate(&self) -> Result<(), ModelError> {
37 ($validate)(&self.0)
38 }
39 }
40
41 impl Deref for $name {
42 type Target = str;
43
44 fn deref(&self) -> &Self::Target {
45 self.as_str()
46 }
47 }
48
49 impl core::fmt::Display for $name {
50 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
51 f.write_str(&self.0)
52 }
53 }
54
55 impl AsRef<str> for $name {
56 fn as_ref(&self) -> &str {
57 self.as_str()
58 }
59 }
60
61 impl FromStr for $name {
62 type Err = ModelError;
63
64 fn from_str(s: &str) -> Result<Self, Self::Err> {
65 Self::parse(s)
66 }
67 }
68
69 impl From<String> for $name {
70 fn from(value: String) -> Self {
71 Self(value)
72 }
73 }
74
75 impl From<&str> for $name {
76 fn from(value: &str) -> Self {
77 Self(value.to_string())
78 }
79 }
80
81 impl PartialEq<&str> for $name {
82 fn eq(&self, other: &&str) -> bool {
83 self.0 == *other
84 }
85 }
86
87 impl PartialEq<String> for $name {
88 fn eq(&self, other: &String) -> bool {
89 &self.0 == other
90 }
91 }
92 };
93}
94
95define_string_newtype! {
96 pub struct CatalogId;
98 field = "catalog_id";
99 parse = |trimmed: &str| {
100 if trimmed.is_empty() {
101 return Err(ModelError::empty("catalog_id"));
102 }
103
104 PackageId::parse(trimmed)?;
105 Ok(trimmed.to_string())
106 };
107 validate = |value: &str| {
108 ensure_non_empty("catalog_id", value)?;
109 PackageId::parse(value).map(|_| ())
110 };
111}
112
113define_string_newtype! {
114 pub struct PackageName;
116 field = "package_ref.name";
117 parse = |trimmed: &str| {
118 if trimmed.is_empty() {
119 return Err(ModelError::empty("package_ref.name"));
120 }
121
122 Ok(trimmed.to_string())
123 };
124 validate = |value: &str| ensure_non_empty("package_ref.name", value);
125}
126
127define_string_newtype! {
128 pub struct BucketName;
130 field = "package_id.bucket";
131 parse = |trimmed: &str| {
132 if trimmed.is_empty() {
133 return Err(ModelError::empty("package_id.bucket"));
134 }
135
136 if trimmed.contains('/') {
137 return Err(ModelError::invalid_package_id(
138 trimmed,
139 "bucket names cannot contain '/'",
140 ));
141 }
142
143 Ok(trimmed.to_string())
144 };
145 validate = |value: &str| {
146 ensure_non_empty("package_id.bucket", value)?;
147
148 if value.contains('/') {
149 return Err(ModelError::invalid_package_id(
150 value,
151 "bucket names cannot contain '/'",
152 ));
153 }
154
155 Ok(())
156 };
157}
158
159#[cfg(test)]
160mod tests {
161 use super::{BucketName, CatalogId, PackageName};
162
163 #[test]
164 fn parses_catalog_ids() {
165 let id = CatalogId::parse("winget/Contoso.App").expect("catalog id should parse");
166
167 assert_eq!(id.as_str(), "winget/Contoso.App");
168 }
169
170 #[test]
171 fn parses_non_empty_package_name() {
172 let name = PackageName::parse("Contoso App").expect("package name should parse");
173
174 assert_eq!(name.as_str(), "Contoso App");
175 }
176
177 #[test]
178 fn parses_bucket_name() {
179 let bucket = BucketName::parse("main").expect("bucket should parse");
180
181 assert_eq!(bucket.as_str(), "main");
182 }
183
184 #[test]
185 fn rejects_invalid_values() {
186 assert!(CatalogId::parse("invalid").is_err());
187 assert!(PackageName::parse(" ").is_err());
188 assert!(BucketName::parse("main/tools").is_err());
189 }
190}