1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4#[derive(Debug, Clone, PartialEq, Eq)]
6pub struct UrlParts {
7 pub scheme: String,
8 pub host: Option<String>,
9 pub port: Option<u16>,
10 pub path: String,
11 pub query: Option<String>,
12 pub fragment: Option<String>,
13}
14
15#[must_use]
17pub fn looks_like_url(input: &str) -> bool {
18 parse_url_basic(input).is_some()
19}
20
21#[must_use]
23pub fn is_http_url(input: &str) -> bool {
24 matches!(parse_url_basic(input), Some(UrlParts { scheme, .. }) if scheme == "http")
25}
26
27#[must_use]
29pub fn is_https_url(input: &str) -> bool {
30 matches!(parse_url_basic(input), Some(UrlParts { scheme, .. }) if scheme == "https")
31}
32
33#[must_use]
35pub fn parse_url_basic(input: &str) -> Option<UrlParts> {
36 let trimmed = input.trim();
37 let scheme = extract_scheme(trimmed)?;
38 let remainder = &trimmed[scheme.len() + 1..];
39 let authority_input = remainder.strip_prefix("//")?;
40 let authority_end = authority_input
41 .find(['/', '?', '#'])
42 .unwrap_or(authority_input.len());
43 let authority = &authority_input[..authority_end];
44 if authority.is_empty() {
45 return None;
46 }
47
48 let (host, port) = parse_authority(authority);
49 let suffix = &authority_input[authority_end..];
50 let fragment_index = suffix.find('#');
51 let without_fragment = &suffix[..fragment_index.unwrap_or(suffix.len())];
52 let query_index = without_fragment.find('?');
53 let path = without_fragment[..query_index.unwrap_or(without_fragment.len())].to_string();
54 let path = if path.is_empty() {
55 String::from("/")
56 } else {
57 path
58 };
59
60 Some(UrlParts {
61 scheme,
62 host,
63 port,
64 path,
65 query: query_index.map(|index| without_fragment[index + 1..].to_string()),
66 fragment: fragment_index.map(|index| suffix[index + 1..].to_string()),
67 })
68}
69
70#[must_use]
72pub fn extract_host(input: &str) -> Option<String> {
73 parse_url_basic(input).and_then(|parts| parts.host)
74}
75
76#[must_use]
78pub fn extract_port(input: &str) -> Option<u16> {
79 parse_url_basic(input).and_then(|parts| parts.port)
80}
81
82#[must_use]
84pub fn extract_path(input: &str) -> Option<String> {
85 parse_url_basic(input).map(|parts| parts.path)
86}
87
88#[must_use]
90pub fn ensure_trailing_slash(input: &str) -> String {
91 let (base, suffix) = split_suffix(input.trim());
92 if base.is_empty() {
93 return format!("/{suffix}");
94 }
95 if base.ends_with('/') {
96 input.trim().to_string()
97 } else {
98 format!("{base}/{suffix}")
99 }
100}
101
102#[must_use]
104pub fn strip_trailing_slash(input: &str) -> String {
105 let (base, suffix) = split_suffix(input.trim());
106 let trimmed = if base == "/" {
107 "/"
108 } else {
109 base.trim_end_matches('/')
110 };
111
112 format!("{trimmed}{suffix}")
113}
114
115#[must_use]
117pub fn remove_fragment(input: &str) -> String {
118 match input.find('#') {
119 Some(index) => input[..index].to_string(),
120 None => input.to_string(),
121 }
122}
123
124#[must_use]
126pub fn remove_query(input: &str) -> String {
127 match input.find('?') {
128 Some(query_index) => {
129 let fragment = input[query_index..]
130 .find('#')
131 .map(|offset| &input[query_index + offset..])
132 .unwrap_or("");
133 format!("{}{fragment}", &input[..query_index])
134 }
135 None => input.to_string(),
136 }
137}
138
139fn extract_scheme(input: &str) -> Option<String> {
140 let colon_index = input.find(':')?;
141 let candidate = &input[..colon_index];
142 if candidate.is_empty() {
143 return None;
144 }
145 let mut characters = candidate.chars();
146 let first = characters.next()?;
147 if !first.is_ascii_alphabetic() {
148 return None;
149 }
150 if characters
151 .all(|character| character.is_ascii_alphanumeric() || matches!(character, '+' | '-' | '.'))
152 {
153 Some(candidate.to_ascii_lowercase())
154 } else {
155 None
156 }
157}
158
159fn parse_authority(authority: &str) -> (Option<String>, Option<u16>) {
160 let host_port = authority
161 .rsplit_once('@')
162 .map_or(authority, |(_, tail)| tail);
163
164 if let Some(after_bracket) = host_port.strip_prefix('[') {
165 if let Some(end) = after_bracket.find(']') {
166 let host = &after_bracket[..end];
167 let tail = &after_bracket[end + 1..];
168 let port = tail.strip_prefix(':').and_then(|value| value.parse().ok());
169 return ((!host.is_empty()).then(|| host.to_string()), port);
170 }
171 return (None, None);
172 }
173
174 if let Some((host, port_text)) = host_port.rsplit_once(':') {
175 if !host.is_empty()
176 && !port_text.is_empty()
177 && port_text
178 .chars()
179 .all(|character| character.is_ascii_digit())
180 {
181 return (Some(host.to_string()), port_text.parse().ok());
182 }
183 }
184
185 ((!host_port.is_empty()).then(|| host_port.to_string()), None)
186}
187
188fn split_suffix(input: &str) -> (&str, &str) {
189 let index = input.find(['?', '#']).unwrap_or(input.len());
190 (&input[..index], &input[index..])
191}