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