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