Skip to main content

use_db_url/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4//! Database URL and DSN primitives for `RustUse`.
5
6use 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            /// Creates a database URL text wrapper.
16            ///
17            /// # Errors
18            ///
19            /// Returns [`DatabaseUrlError`] when text is empty or contains control characters.
20            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            /// Returns the stored text.
25            #[must_use]
26            pub fn as_str(&self) -> &str {
27                &self.0
28            }
29
30            /// Consumes the wrapper and returns the stored string.
31            #[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/// A database URL scheme such as `postgresql` or `sqlite`.
64#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
65pub struct DatabaseScheme(String);
66
67impl DatabaseScheme {
68    /// Creates a scheme from a conservative URI scheme label.
69    ///
70    /// # Errors
71    ///
72    /// Returns [`DatabaseUrlError`] when the scheme is empty or malformed.
73    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    /// Returns the stored scheme.
90    #[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/// A database network port.
103#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
104pub struct DatabasePort(u16);
105
106impl DatabasePort {
107    /// Creates a nonzero database port.
108    ///
109    /// # Errors
110    ///
111    /// Returns [`DatabaseUrlError::InvalidPort`] when `port` is zero.
112    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    /// Returns the port number.
121    #[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/// A database URL path component.
134#[derive(Clone, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
135pub struct DatabasePath(String);
136
137impl DatabasePath {
138    /// Creates a path wrapper. Empty paths are allowed and represent no path metadata.
139    ///
140    /// # Errors
141    ///
142    /// Returns [`DatabaseUrlError::ControlCharacter`] when text contains control characters.
143    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    /// Returns the stored path.
153    #[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/// Lightweight parts extracted from a database URL.
166#[derive(Clone, Debug, Eq, PartialEq)]
167pub struct DatabaseUrlParts {
168    /// URL scheme.
169    pub scheme: DatabaseScheme,
170    /// Optional host.
171    pub host: Option<DatabaseHost>,
172    /// Optional port.
173    pub port: Option<DatabasePort>,
174    /// URL path.
175    pub path: DatabasePath,
176}
177
178/// Error returned by database URL primitives.
179#[derive(Clone, Copy, Debug, Eq, PartialEq)]
180pub enum DatabaseUrlError {
181    /// URL text was empty.
182    EmptyUrl,
183    /// URL scheme was empty.
184    EmptyScheme,
185    /// Host text was empty.
186    EmptyHost,
187    /// DSN text was empty.
188    EmptyDsn,
189    /// Text contained a control character.
190    ControlCharacter,
191    /// Scheme text was malformed.
192    InvalidScheme,
193    /// Port was zero or malformed.
194    InvalidPort,
195    /// URL text did not contain a scheme.
196    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    /// Parses lightweight URL parts with simple string splitting.
220    ///
221    /// # Errors
222    ///
223    /// Returns [`DatabaseUrlError`] when the URL has no valid scheme or contains invalid parts.
224    pub fn parse_basic(&self) -> Result<DatabaseUrlParts, DatabaseUrlError> {
225        parse_database_url_basic(self.as_str())
226    }
227
228    /// Returns the URL scheme when parsing succeeds.
229    #[must_use]
230    pub fn scheme(&self) -> Option<DatabaseScheme> {
231        self.parse_basic().ok().map(|parts| parts.scheme)
232    }
233}
234
235/// Parses lightweight database URL parts with dependency-free string splitting.
236///
237/// # Errors
238///
239/// Returns [`DatabaseUrlError`] when the URL has no valid scheme or contains invalid parts.
240pub 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/// Returns whether input can be parsed as a lightweight database URL.
292#[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}