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 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 Self::try_new(value).expect("ValidatedUrl validation failed")
106 }
107
108 #[must_use]
109 pub fn as_str(&self) -> &str {
110 &self.0
111 }
112
113 #[must_use]
114 pub fn scheme(&self) -> &str {
115 self.0.split("://").next().unwrap_or("")
116 }
117
118 #[must_use]
119 pub fn is_https(&self) -> bool {
120 self.scheme().eq_ignore_ascii_case("https")
121 }
122
123 #[must_use]
124 pub fn is_http(&self) -> bool {
125 let scheme = self.scheme().to_ascii_lowercase();
126 scheme == "http" || scheme == "https"
127 }
128}
129
130impl fmt::Display for ValidatedUrl {
131 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
132 write!(f, "{}", self.0)
133 }
134}
135
136impl TryFrom<String> for ValidatedUrl {
137 type Error = IdValidationError;
138
139 fn try_from(s: String) -> Result<Self, Self::Error> {
140 Self::try_new(s)
141 }
142}
143
144impl TryFrom<&str> for ValidatedUrl {
145 type Error = IdValidationError;
146
147 fn try_from(s: &str) -> Result<Self, Self::Error> {
148 Self::try_new(s)
149 }
150}
151
152impl std::str::FromStr for ValidatedUrl {
153 type Err = IdValidationError;
154
155 fn from_str(s: &str) -> Result<Self, Self::Err> {
156 Self::try_new(s)
157 }
158}
159
160impl AsRef<str> for ValidatedUrl {
161 fn as_ref(&self) -> &str {
162 &self.0
163 }
164}
165
166impl<'de> Deserialize<'de> for ValidatedUrl {
167 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
168 where
169 D: serde::Deserializer<'de>,
170 {
171 let s = String::deserialize(deserializer)?;
172 Self::try_new(s).map_err(serde::de::Error::custom)
173 }
174}
175
176impl ToDbValue for ValidatedUrl {
177 fn to_db_value(&self) -> DbValue {
178 DbValue::String(self.0.clone())
179 }
180}
181
182impl ToDbValue for &ValidatedUrl {
183 fn to_db_value(&self) -> DbValue {
184 DbValue::String(self.0.clone())
185 }
186}