1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
7use std::error::Error;
8
9macro_rules! database_url_text_type {
10 ($type_name:ident, $empty_error:expr) => {
11 #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
12 pub struct $type_name(String);
13
14 impl $type_name {
15 pub fn new(input: impl AsRef<str>) -> Result<Self, DatabaseUrlError> {
21 validate_text(input.as_ref(), $empty_error).map(|value| Self(value.to_owned()))
22 }
23
24 #[must_use]
26 pub fn as_str(&self) -> &str {
27 &self.0
28 }
29
30 #[must_use]
32 pub fn into_string(self) -> String {
33 self.0
34 }
35 }
36
37 impl AsRef<str> for $type_name {
38 fn as_ref(&self) -> &str {
39 self.as_str()
40 }
41 }
42
43 impl fmt::Display for $type_name {
44 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
45 formatter.write_str(self.as_str())
46 }
47 }
48
49 impl FromStr for $type_name {
50 type Err = DatabaseUrlError;
51
52 fn from_str(input: &str) -> Result<Self, Self::Err> {
53 Self::new(input)
54 }
55 }
56 };
57}
58
59database_url_text_type!(DatabaseUrl, DatabaseUrlError::EmptyUrl);
60database_url_text_type!(DatabaseHost, DatabaseUrlError::EmptyHost);
61database_url_text_type!(DatabaseDsn, DatabaseUrlError::EmptyDsn);
62
63#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
65pub struct DatabaseScheme(String);
66
67impl DatabaseScheme {
68 pub fn new(input: impl AsRef<str>) -> Result<Self, DatabaseUrlError> {
74 let trimmed = validate_text(input.as_ref(), DatabaseUrlError::EmptyScheme)?;
75 let mut characters = trimmed.chars();
76 let Some(first) = characters.next() else {
77 return Err(DatabaseUrlError::EmptyScheme);
78 };
79 if !first.is_ascii_alphabetic()
80 || !characters.all(|character| {
81 character.is_ascii_alphanumeric() || matches!(character, '+' | '-' | '.')
82 })
83 {
84 return Err(DatabaseUrlError::InvalidScheme);
85 }
86 Ok(Self(trimmed.to_ascii_lowercase()))
87 }
88
89 #[must_use]
91 pub fn as_str(&self) -> &str {
92 &self.0
93 }
94}
95
96impl fmt::Display for DatabaseScheme {
97 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
98 formatter.write_str(self.as_str())
99 }
100}
101
102#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
104pub struct DatabasePort(u16);
105
106impl DatabasePort {
107 pub const fn new(port: u16) -> Result<Self, DatabaseUrlError> {
113 if port == 0 {
114 Err(DatabaseUrlError::InvalidPort)
115 } else {
116 Ok(Self(port))
117 }
118 }
119
120 #[must_use]
122 pub const fn value(self) -> u16 {
123 self.0
124 }
125}
126
127impl fmt::Display for DatabasePort {
128 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
129 write!(formatter, "{}", self.0)
130 }
131}
132
133#[derive(Clone, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
135pub struct DatabasePath(String);
136
137impl DatabasePath {
138 pub fn new(input: impl AsRef<str>) -> Result<Self, DatabaseUrlError> {
144 let input = input.as_ref();
145 if input.chars().any(char::is_control) {
146 return Err(DatabaseUrlError::ControlCharacter);
147 }
148 let text = input.trim();
149 Ok(Self(text.to_owned()))
150 }
151
152 #[must_use]
154 pub fn as_str(&self) -> &str {
155 &self.0
156 }
157}
158
159impl fmt::Display for DatabasePath {
160 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
161 formatter.write_str(self.as_str())
162 }
163}
164
165#[derive(Clone, Debug, Eq, PartialEq)]
167pub struct DatabaseUrlParts {
168 pub scheme: DatabaseScheme,
170 pub host: Option<DatabaseHost>,
172 pub port: Option<DatabasePort>,
174 pub path: DatabasePath,
176}
177
178#[derive(Clone, Copy, Debug, Eq, PartialEq)]
180pub enum DatabaseUrlError {
181 EmptyUrl,
183 EmptyScheme,
185 EmptyHost,
187 EmptyDsn,
189 ControlCharacter,
191 InvalidScheme,
193 InvalidPort,
195 MissingScheme,
197}
198
199impl fmt::Display for DatabaseUrlError {
200 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
201 match self {
202 Self::EmptyUrl => formatter.write_str("database URL cannot be empty"),
203 Self::EmptyScheme => formatter.write_str("database URL scheme cannot be empty"),
204 Self::EmptyHost => formatter.write_str("database URL host cannot be empty"),
205 Self::EmptyDsn => formatter.write_str("database DSN cannot be empty"),
206 Self::ControlCharacter => {
207 formatter.write_str("database URL text cannot contain control characters")
208 },
209 Self::InvalidScheme => formatter.write_str("database URL scheme is invalid"),
210 Self::InvalidPort => formatter.write_str("database URL port is invalid"),
211 Self::MissingScheme => formatter.write_str("database URL is missing a scheme"),
212 }
213 }
214}
215
216impl Error for DatabaseUrlError {}
217
218impl DatabaseUrl {
219 pub fn parse_basic(&self) -> Result<DatabaseUrlParts, DatabaseUrlError> {
225 parse_database_url_basic(self.as_str())
226 }
227
228 #[must_use]
230 pub fn scheme(&self) -> Option<DatabaseScheme> {
231 self.parse_basic().ok().map(|parts| parts.scheme)
232 }
233}
234
235pub fn parse_database_url_basic(input: &str) -> Result<DatabaseUrlParts, DatabaseUrlError> {
241 let trimmed = validate_text(input, DatabaseUrlError::EmptyUrl)?;
242 let scheme_end = trimmed.find(':').ok_or(DatabaseUrlError::MissingScheme)?;
243 let scheme = DatabaseScheme::new(&trimmed[..scheme_end])?;
244 let mut remainder = &trimmed[scheme_end + 1..];
245 let mut host = None;
246 let mut port = None;
247
248 if let Some(after_slashes) = remainder.strip_prefix("//") {
249 let authority_end = after_slashes
250 .find(['/', '?', '#'])
251 .unwrap_or(after_slashes.len());
252 let authority = &after_slashes[..authority_end];
253 remainder = &after_slashes[authority_end..];
254
255 if !authority.is_empty() {
256 let host_port = authority
257 .rsplit_once('@')
258 .map_or(authority, |(_, tail)| tail);
259 if let Some((host_text, port_text)) = host_port.rsplit_once(':') {
260 if !host_text.is_empty()
261 && port_text
262 .chars()
263 .all(|character| character.is_ascii_digit())
264 {
265 host = Some(DatabaseHost::new(host_text)?);
266 port = Some(DatabasePort::new(
267 port_text
268 .parse()
269 .map_err(|_| DatabaseUrlError::InvalidPort)?,
270 )?);
271 } else {
272 host = Some(DatabaseHost::new(host_port)?);
273 }
274 } else {
275 host = Some(DatabaseHost::new(host_port)?);
276 }
277 }
278 }
279
280 let suffix_end = remainder.find(['?', '#']).unwrap_or(remainder.len());
281 let path = DatabasePath::new(&remainder[..suffix_end])?;
282
283 Ok(DatabaseUrlParts {
284 scheme,
285 host,
286 port,
287 path,
288 })
289}
290
291#[must_use]
293pub fn looks_like_database_url(input: &str) -> bool {
294 parse_database_url_basic(input).is_ok()
295}
296
297fn validate_text(input: &str, empty_error: DatabaseUrlError) -> Result<&str, DatabaseUrlError> {
298 if input.chars().any(char::is_control) {
299 return Err(DatabaseUrlError::ControlCharacter);
300 }
301 let trimmed = input.trim();
302 if trimmed.is_empty() {
303 return Err(empty_error);
304 }
305 Ok(trimmed)
306}
307
308#[cfg(test)]
309mod tests {
310 use super::{
311 DatabasePort, DatabaseScheme, DatabaseUrl, DatabaseUrlError, looks_like_database_url,
312 };
313
314 #[test]
315 fn parses_database_urls() -> Result<(), DatabaseUrlError> {
316 let url = DatabaseUrl::new("postgresql://localhost:5432/app")?;
317 let parts = url.parse_basic()?;
318
319 assert_eq!(parts.scheme.as_str(), "postgresql");
320 assert_eq!(parts.host.expect("host").as_str(), "localhost");
321 assert_eq!(parts.port.expect("port").value(), 5432);
322 assert_eq!(parts.path.as_str(), "/app");
323 assert!(looks_like_database_url("sqlite:///tmp/app.db"));
324 Ok(())
325 }
326
327 #[test]
328 fn validates_scheme_and_port() {
329 assert_eq!(
330 DatabaseScheme::new("1bad"),
331 Err(DatabaseUrlError::InvalidScheme)
332 );
333 assert_eq!(DatabasePort::new(0), Err(DatabaseUrlError::InvalidPort));
334 assert_eq!(DatabaseUrl::new(""), Err(DatabaseUrlError::EmptyUrl));
335 }
336}