winbrew_models\shared/
identifiers.rs

1use 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    /// Canonical catalog identifier that always validates against package-id syntax.
97    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    /// Display-friendly package name used in package references and reports.
115    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    /// Strongly typed bucket name used by Scoop package ids.
129    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}