Skip to main content

systemprompt_identifiers/
url.rs

1//! Validated URL type.
2
3use crate::error::IdValidationError;
4use crate::{DbValue, ToDbValue};
5use serde::{Deserialize, Serialize};
6use std::fmt;
7
8#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
9#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
10#[cfg_attr(feature = "sqlx", sqlx(transparent))]
11#[serde(transparent)]
12pub struct ValidatedUrl(String);
13
14impl ValidatedUrl {
15    pub fn try_new(value: impl Into<String>) -> Result<Self, IdValidationError> {
16        let value = value.into();
17        if value.is_empty() {
18            return Err(IdValidationError::empty("ValidatedUrl"));
19        }
20        let scheme_end = value.find("://").ok_or_else(|| {
21            IdValidationError::invalid("ValidatedUrl", "must have a scheme (e.g., 'https://')")
22        })?;
23        let scheme = &value[..scheme_end];
24        if scheme.is_empty() {
25            return Err(IdValidationError::invalid(
26                "ValidatedUrl",
27                "scheme cannot be empty",
28            ));
29        }
30        if !scheme
31            .chars()
32            .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.')
33        {
34            return Err(IdValidationError::invalid(
35                "ValidatedUrl",
36                "scheme contains invalid characters",
37            ));
38        }
39        if !scheme.starts_with(|c: char| c.is_ascii_alphabetic()) {
40            return Err(IdValidationError::invalid(
41                "ValidatedUrl",
42                "scheme must start with a letter",
43            ));
44        }
45        let after_scheme = &value[scheme_end + 3..];
46        if after_scheme.is_empty() {
47            return Err(IdValidationError::invalid(
48                "ValidatedUrl",
49                "URL must have a host component",
50            ));
51        }
52        let host_end = after_scheme.find('/').unwrap_or(after_scheme.len());
53        let authority = &after_scheme[..host_end];
54        let host_part = authority
55            .rfind('@')
56            .map_or(authority, |i| &authority[i + 1..]);
57
58        let host = if host_part.starts_with('[') {
59            let bracket_end = host_part.find(']').ok_or_else(|| {
60                IdValidationError::invalid("ValidatedUrl", "IPv6 address missing closing bracket")
61            })?;
62            &host_part[..=bracket_end]
63        } else {
64            host_part.split(':').next().unwrap_or(host_part)
65        };
66
67        if host.starts_with('[') && host.ends_with(']') {
68            let ipv6_content = &host[1..host.len() - 1];
69            if ipv6_content.is_empty() {
70                return Err(IdValidationError::invalid(
71                    "ValidatedUrl",
72                    "IPv6 address cannot be empty",
73                ));
74            }
75        }
76
77        if host_part.contains("]:") || (!host_part.starts_with('[') && host_part.contains(':')) {
78            let port_part = if host_part.starts_with('[') {
79                host_part.rsplit("]:").next()
80            } else {
81                host_part.split(':').nth(1)
82            };
83            if let Some(port) = port_part {
84                if port.is_empty() || port.starts_with('/') {
85                    return Err(IdValidationError::invalid(
86                        "ValidatedUrl",
87                        "port cannot be empty when ':' is present",
88                    ));
89                }
90            }
91        }
92
93        if host.is_empty() && !scheme.eq_ignore_ascii_case("file") {
94            return Err(IdValidationError::invalid(
95                "ValidatedUrl",
96                "host cannot be empty",
97            ));
98        }
99        Ok(Self(value))
100    }
101
102    #[must_use]
103    #[allow(clippy::expect_used)]
104    pub fn new(value: impl Into<String>) -> Self {
105        // SAFETY: `new` is the infallible constructor reserved for inputs the caller
106        // has already validated (compile-time literals, values that
107        // round-tripped through `try_new` at a boundary). Untrusted input must
108        // go through `try_new`.
109        Self::try_new(value).expect("ValidatedUrl validation failed")
110    }
111
112    #[must_use]
113    pub fn as_str(&self) -> &str {
114        &self.0
115    }
116
117    #[must_use]
118    pub fn scheme(&self) -> &str {
119        self.0.split("://").next().unwrap_or("")
120    }
121
122    #[must_use]
123    pub fn is_https(&self) -> bool {
124        self.scheme().eq_ignore_ascii_case("https")
125    }
126
127    #[must_use]
128    pub fn is_http(&self) -> bool {
129        let scheme = self.scheme().to_ascii_lowercase();
130        scheme == "http" || scheme == "https"
131    }
132}
133
134impl fmt::Display for ValidatedUrl {
135    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
136        write!(f, "{}", self.0)
137    }
138}
139
140impl TryFrom<String> for ValidatedUrl {
141    type Error = IdValidationError;
142
143    fn try_from(s: String) -> Result<Self, Self::Error> {
144        Self::try_new(s)
145    }
146}
147
148impl TryFrom<&str> for ValidatedUrl {
149    type Error = IdValidationError;
150
151    fn try_from(s: &str) -> Result<Self, Self::Error> {
152        Self::try_new(s)
153    }
154}
155
156impl std::str::FromStr for ValidatedUrl {
157    type Err = IdValidationError;
158
159    fn from_str(s: &str) -> Result<Self, Self::Err> {
160        Self::try_new(s)
161    }
162}
163
164impl AsRef<str> for ValidatedUrl {
165    fn as_ref(&self) -> &str {
166        &self.0
167    }
168}
169
170impl<'de> Deserialize<'de> for ValidatedUrl {
171    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
172    where
173        D: serde::Deserializer<'de>,
174    {
175        let s = String::deserialize(deserializer)?;
176        Self::try_new(s).map_err(serde::de::Error::custom)
177    }
178}
179
180impl ToDbValue for ValidatedUrl {
181    fn to_db_value(&self) -> DbValue {
182        DbValue::String(self.0.clone())
183    }
184}
185
186impl ToDbValue for &ValidatedUrl {
187    fn to_db_value(&self) -> DbValue {
188        DbValue::String(self.0.clone())
189    }
190}