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