1use crate::error::{DomainError, DomainErrorKind};
32use stillwater::refined::{And, Predicate, Refined};
33use url::Url as UrlParser;
34
35#[derive(Debug, Clone, Copy, Default)]
51pub struct ValidUrl;
52
53impl Predicate<String> for ValidUrl {
54 type Error = DomainError;
55
56 fn check(value: &String) -> Result<(), Self::Error> {
57 UrlParser::parse(value)
58 .map(|_| ())
59 .map_err(|_| DomainError {
60 format_name: "URL",
61 value: value.clone(),
62 reason: DomainErrorKind::InvalidFormat {
63 expected: "scheme://host/path",
64 },
65 example: "https://example.com",
66 })
67 }
68
69 fn description() -> &'static str {
70 "RFC 3986 URL"
71 }
72}
73
74#[derive(Debug, Clone, Copy, Default)]
91pub struct HttpScheme;
92
93impl Predicate<String> for HttpScheme {
94 type Error = DomainError;
95
96 fn check(value: &String) -> Result<(), Self::Error> {
97 let parsed = UrlParser::parse(value).map_err(|_| DomainError {
98 format_name: "HTTP URL",
99 value: value.clone(),
100 reason: DomainErrorKind::InvalidFormat {
101 expected: "valid URL",
102 },
103 example: "https://example.com",
104 })?;
105
106 match parsed.scheme() {
107 "http" | "https" => Ok(()),
108 scheme => Err(DomainError {
109 format_name: "HTTP URL",
110 value: value.clone(),
111 reason: DomainErrorKind::InvalidComponent {
112 component: "scheme",
113 reason: format!("expected http or https, got {}", scheme),
114 },
115 example: "https://example.com",
116 }),
117 }
118 }
119
120 fn description() -> &'static str {
121 "HTTP or HTTPS scheme"
122 }
123}
124
125#[derive(Debug, Clone, Copy, Default)]
142pub struct HttpsOnly;
143
144impl Predicate<String> for HttpsOnly {
145 type Error = DomainError;
146
147 fn check(value: &String) -> Result<(), Self::Error> {
148 let parsed = UrlParser::parse(value).map_err(|_| DomainError {
149 format_name: "HTTPS URL",
150 value: value.clone(),
151 reason: DomainErrorKind::InvalidFormat {
152 expected: "valid URL",
153 },
154 example: "https://example.com",
155 })?;
156
157 if parsed.scheme() == "https" {
158 Ok(())
159 } else {
160 Err(DomainError {
161 format_name: "HTTPS URL",
162 value: value.clone(),
163 reason: DomainErrorKind::InvalidComponent {
164 component: "scheme",
165 reason: format!("expected https, got {}", parsed.scheme()),
166 },
167 example: "https://example.com",
168 })
169 }
170 }
171
172 fn description() -> &'static str {
173 "HTTPS scheme only"
174 }
175}
176
177pub type Url = Refined<String, ValidUrl>;
191
192pub type HttpUrl = Refined<String, And<ValidUrl, HttpScheme>>;
210
211pub type SecureUrl = Refined<String, And<ValidUrl, HttpsOnly>>;
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232
233 #[test]
235 fn valid_https_url() {
236 assert!(Url::new("https://example.com".to_string()).is_ok());
237 }
238
239 #[test]
240 fn valid_http_url() {
241 assert!(Url::new("http://example.com".to_string()).is_ok());
242 }
243
244 #[test]
245 fn valid_with_path() {
246 assert!(Url::new("https://example.com/path/to/resource".to_string()).is_ok());
247 }
248
249 #[test]
250 fn valid_with_query() {
251 assert!(Url::new("https://example.com?foo=bar&baz=qux".to_string()).is_ok());
252 }
253
254 #[test]
255 fn valid_with_fragment() {
256 assert!(Url::new("https://example.com#section".to_string()).is_ok());
257 }
258
259 #[test]
260 fn valid_with_port() {
261 assert!(Url::new("https://example.com:8080".to_string()).is_ok());
262 }
263
264 #[test]
265 fn valid_ftp_url() {
266 assert!(Url::new("ftp://files.example.com".to_string()).is_ok());
268 }
269
270 #[test]
271 fn invalid_missing_scheme() {
272 assert!(Url::new("example.com".to_string()).is_err());
273 }
274
275 #[test]
276 fn invalid_malformed() {
277 assert!(Url::new("not a url at all".to_string()).is_err());
278 }
279
280 #[test]
281 fn valid_url_description() {
282 assert_eq!(ValidUrl::description(), "RFC 3986 URL");
283 }
284
285 #[test]
287 fn http_url_accepts_http() {
288 assert!(HttpUrl::new("http://example.com".to_string()).is_ok());
289 }
290
291 #[test]
292 fn http_url_accepts_https() {
293 assert!(HttpUrl::new("https://example.com".to_string()).is_ok());
294 }
295
296 #[test]
297 fn http_url_rejects_ftp() {
298 let result = HttpUrl::new("ftp://example.com".to_string());
299 assert!(result.is_err());
300 let err = result.unwrap_err();
303 match err {
304 stillwater::refined::AndError::Second(domain_err) => {
305 assert!(matches!(
306 domain_err.reason,
307 DomainErrorKind::InvalidComponent { .. }
308 ));
309 }
310 _ => panic!("Expected AndError::Second for scheme rejection"),
311 }
312 }
313
314 #[test]
315 fn http_url_rejects_file() {
316 assert!(HttpUrl::new("file:///path/to/file".to_string()).is_err());
317 }
318
319 #[test]
320 fn http_scheme_description() {
321 assert_eq!(HttpScheme::description(), "HTTP or HTTPS scheme");
322 }
323
324 #[test]
326 fn secure_url_accepts_https() {
327 assert!(SecureUrl::new("https://example.com".to_string()).is_ok());
328 }
329
330 #[test]
331 fn secure_url_rejects_http() {
332 let result = SecureUrl::new("http://example.com".to_string());
333 assert!(result.is_err());
334 let err = result.unwrap_err();
337 match err {
338 stillwater::refined::AndError::Second(domain_err) => {
339 assert!(matches!(
340 domain_err.reason,
341 DomainErrorKind::InvalidComponent { .. }
342 ));
343 }
344 _ => panic!("Expected AndError::Second for scheme rejection"),
345 }
346 }
347
348 #[test]
349 fn https_only_description() {
350 assert_eq!(HttpsOnly::description(), "HTTPS scheme only");
351 }
352
353 #[test]
356 fn https_only_standalone_rejects_malformed() {
357 let result = HttpsOnly::check(&"not a url".to_string());
358 assert!(result.is_err());
359 let err = result.unwrap_err();
360 assert_eq!(err.format_name, "HTTPS URL");
361 assert!(matches!(err.reason, DomainErrorKind::InvalidFormat { .. }));
362 }
363
364 #[test]
365 fn http_scheme_standalone_rejects_malformed() {
366 let result = HttpScheme::check(&"invalid".to_string());
367 assert!(result.is_err());
368 let err = result.unwrap_err();
369 assert_eq!(err.format_name, "HTTP URL");
370 }
371
372 #[test]
374 fn and_combinator_validates_both_predicates() {
375 assert!(HttpUrl::new("not a url".to_string()).is_err());
377
378 assert!(HttpUrl::new("ftp://example.com".to_string()).is_err());
380
381 assert!(HttpUrl::new("https://example.com".to_string()).is_ok());
383 }
384
385 #[test]
387 fn invalid_url_error_includes_format_name() {
388 let result = Url::new("invalid".to_string());
389 let err = result.unwrap_err();
390 assert_eq!(err.format_name, "URL");
391 }
392
393 #[test]
394 fn invalid_url_error_includes_example() {
395 let result = Url::new("invalid".to_string());
396 let err = result.unwrap_err();
397 assert_eq!(err.example, "https://example.com");
398 }
399
400 #[test]
401 fn scheme_error_is_invalid_component() {
402 let result = SecureUrl::new("http://example.com".to_string());
403 let err = result.unwrap_err();
404 match err {
406 stillwater::refined::AndError::Second(domain_err) => match domain_err.reason {
407 DomainErrorKind::InvalidComponent { component, reason } => {
408 assert_eq!(component, "scheme");
409 assert!(reason.contains("https"));
410 }
411 _ => panic!("Expected InvalidComponent error"),
412 },
413 _ => panic!("Expected AndError::Second"),
414 }
415 }
416}