use crate::error::{DomainError, DomainErrorKind};
use stillwater::refined::{And, Predicate, Refined};
use url::Url as UrlParser;
#[derive(Debug, Clone, Copy, Default)]
pub struct ValidUrl;
impl Predicate<String> for ValidUrl {
type Error = DomainError;
fn check(value: &String) -> Result<(), Self::Error> {
UrlParser::parse(value)
.map(|_| ())
.map_err(|_| DomainError {
format_name: "URL",
value: value.clone(),
reason: DomainErrorKind::InvalidFormat {
expected: "scheme://host/path",
},
example: "https://example.com",
})
}
fn description() -> &'static str {
"RFC 3986 URL"
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct HttpScheme;
impl Predicate<String> for HttpScheme {
type Error = DomainError;
fn check(value: &String) -> Result<(), Self::Error> {
let parsed = UrlParser::parse(value).map_err(|_| DomainError {
format_name: "HTTP URL",
value: value.clone(),
reason: DomainErrorKind::InvalidFormat {
expected: "valid URL",
},
example: "https://example.com",
})?;
match parsed.scheme() {
"http" | "https" => Ok(()),
scheme => Err(DomainError {
format_name: "HTTP URL",
value: value.clone(),
reason: DomainErrorKind::InvalidComponent {
component: "scheme",
reason: format!("expected http or https, got {}", scheme),
},
example: "https://example.com",
}),
}
}
fn description() -> &'static str {
"HTTP or HTTPS scheme"
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct HttpsOnly;
impl Predicate<String> for HttpsOnly {
type Error = DomainError;
fn check(value: &String) -> Result<(), Self::Error> {
let parsed = UrlParser::parse(value).map_err(|_| DomainError {
format_name: "HTTPS URL",
value: value.clone(),
reason: DomainErrorKind::InvalidFormat {
expected: "valid URL",
},
example: "https://example.com",
})?;
if parsed.scheme() == "https" {
Ok(())
} else {
Err(DomainError {
format_name: "HTTPS URL",
value: value.clone(),
reason: DomainErrorKind::InvalidComponent {
component: "scheme",
reason: format!("expected https, got {}", parsed.scheme()),
},
example: "https://example.com",
})
}
}
fn description() -> &'static str {
"HTTPS scheme only"
}
}
pub type Url = Refined<String, ValidUrl>;
pub type HttpUrl = Refined<String, And<ValidUrl, HttpScheme>>;
pub type SecureUrl = Refined<String, And<ValidUrl, HttpsOnly>>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_https_url() {
assert!(Url::new("https://example.com".to_string()).is_ok());
}
#[test]
fn valid_http_url() {
assert!(Url::new("http://example.com".to_string()).is_ok());
}
#[test]
fn valid_with_path() {
assert!(Url::new("https://example.com/path/to/resource".to_string()).is_ok());
}
#[test]
fn valid_with_query() {
assert!(Url::new("https://example.com?foo=bar&baz=qux".to_string()).is_ok());
}
#[test]
fn valid_with_fragment() {
assert!(Url::new("https://example.com#section".to_string()).is_ok());
}
#[test]
fn valid_with_port() {
assert!(Url::new("https://example.com:8080".to_string()).is_ok());
}
#[test]
fn valid_ftp_url() {
assert!(Url::new("ftp://files.example.com".to_string()).is_ok());
}
#[test]
fn invalid_missing_scheme() {
assert!(Url::new("example.com".to_string()).is_err());
}
#[test]
fn invalid_malformed() {
assert!(Url::new("not a url at all".to_string()).is_err());
}
#[test]
fn valid_url_description() {
assert_eq!(ValidUrl::description(), "RFC 3986 URL");
}
#[test]
fn http_url_accepts_http() {
assert!(HttpUrl::new("http://example.com".to_string()).is_ok());
}
#[test]
fn http_url_accepts_https() {
assert!(HttpUrl::new("https://example.com".to_string()).is_ok());
}
#[test]
fn http_url_rejects_ftp() {
let result = HttpUrl::new("ftp://example.com".to_string());
assert!(result.is_err());
let err = result.unwrap_err();
match err {
stillwater::refined::AndError::Second(domain_err) => {
assert!(matches!(
domain_err.reason,
DomainErrorKind::InvalidComponent { .. }
));
}
_ => panic!("Expected AndError::Second for scheme rejection"),
}
}
#[test]
fn http_url_rejects_file() {
assert!(HttpUrl::new("file:///path/to/file".to_string()).is_err());
}
#[test]
fn http_scheme_description() {
assert_eq!(HttpScheme::description(), "HTTP or HTTPS scheme");
}
#[test]
fn secure_url_accepts_https() {
assert!(SecureUrl::new("https://example.com".to_string()).is_ok());
}
#[test]
fn secure_url_rejects_http() {
let result = SecureUrl::new("http://example.com".to_string());
assert!(result.is_err());
let err = result.unwrap_err();
match err {
stillwater::refined::AndError::Second(domain_err) => {
assert!(matches!(
domain_err.reason,
DomainErrorKind::InvalidComponent { .. }
));
}
_ => panic!("Expected AndError::Second for scheme rejection"),
}
}
#[test]
fn https_only_description() {
assert_eq!(HttpsOnly::description(), "HTTPS scheme only");
}
#[test]
fn https_only_standalone_rejects_malformed() {
let result = HttpsOnly::check(&"not a url".to_string());
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.format_name, "HTTPS URL");
assert!(matches!(err.reason, DomainErrorKind::InvalidFormat { .. }));
}
#[test]
fn http_scheme_standalone_rejects_malformed() {
let result = HttpScheme::check(&"invalid".to_string());
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.format_name, "HTTP URL");
}
#[test]
fn and_combinator_validates_both_predicates() {
assert!(HttpUrl::new("not a url".to_string()).is_err());
assert!(HttpUrl::new("ftp://example.com".to_string()).is_err());
assert!(HttpUrl::new("https://example.com".to_string()).is_ok());
}
#[test]
fn invalid_url_error_includes_format_name() {
let result = Url::new("invalid".to_string());
let err = result.unwrap_err();
assert_eq!(err.format_name, "URL");
}
#[test]
fn invalid_url_error_includes_example() {
let result = Url::new("invalid".to_string());
let err = result.unwrap_err();
assert_eq!(err.example, "https://example.com");
}
#[test]
fn scheme_error_is_invalid_component() {
let result = SecureUrl::new("http://example.com".to_string());
let err = result.unwrap_err();
match err {
stillwater::refined::AndError::Second(domain_err) => match domain_err.reason {
DomainErrorKind::InvalidComponent { component, reason } => {
assert_eq!(component, "scheme");
assert!(reason.contains("https"));
}
_ => panic!("Expected InvalidComponent error"),
},
_ => panic!("Expected AndError::Second"),
}
}
}