1use std::fmt;
8
9use url::Url;
10
11const SUPPORTED_SCHEMES: &[&str] = &["http", "https", "file", "oxide"];
12
13#[derive(Debug, Clone, PartialEq, Eq, Hash)]
14pub struct OxideUrl {
15 inner: Url,
16}
17
18#[derive(Debug)]
19pub enum UrlError {
20 Parse(String),
21 UnsupportedScheme(String),
22 Empty,
23 RelativeRequiresBase,
24}
25
26impl fmt::Display for UrlError {
27 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
28 match self {
29 UrlError::Parse(msg) => write!(f, "URL parse error: {msg}"),
30 UrlError::UnsupportedScheme(s) => write!(f, "unsupported URL scheme: {s}"),
31 UrlError::Empty => write!(f, "empty URL"),
32 UrlError::RelativeRequiresBase => {
33 write!(f, "relative URL cannot be parsed without a base URL")
34 }
35 }
36 }
37}
38
39impl std::error::Error for UrlError {}
40
41#[allow(dead_code)]
42impl OxideUrl {
43 pub fn parse(input: &str) -> Result<Self, UrlError> {
49 let trimmed = input.trim();
50 if trimmed.is_empty() {
51 return Err(UrlError::Empty);
52 }
53
54 if (trimmed.starts_with('/') || trimmed.starts_with('.')) && !trimmed.starts_with("//") {
55 return Err(UrlError::RelativeRequiresBase);
56 }
57
58 let normalized = if trimmed.contains("://") || trimmed.starts_with("//") {
59 trimmed.to_string()
60 } else {
61 format!("https://{trimmed}")
62 };
63
64 let inner = Url::parse(&normalized).map_err(|e| UrlError::Parse(e.to_string()))?;
65
66 if !SUPPORTED_SCHEMES.contains(&inner.scheme()) {
67 return Err(UrlError::UnsupportedScheme(inner.scheme().to_string()));
68 }
69
70 Ok(Self { inner })
71 }
72
73 pub fn join(&self, reference: &str) -> Result<Self, UrlError> {
75 let inner = self
76 .inner
77 .join(reference)
78 .map_err(|e| UrlError::Parse(e.to_string()))?;
79
80 if !SUPPORTED_SCHEMES.contains(&inner.scheme()) {
81 return Err(UrlError::UnsupportedScheme(inner.scheme().to_string()));
82 }
83
84 Ok(Self { inner })
85 }
86
87 pub fn scheme(&self) -> &str {
88 self.inner.scheme()
89 }
90
91 pub fn host_str(&self) -> Option<&str> {
92 self.inner.host_str()
93 }
94
95 pub fn port(&self) -> Option<u16> {
96 self.inner.port()
97 }
98
99 pub fn path(&self) -> &str {
100 self.inner.path()
101 }
102
103 pub fn query(&self) -> Option<&str> {
104 self.inner.query()
105 }
106
107 pub fn fragment(&self) -> Option<&str> {
108 self.inner.fragment()
109 }
110
111 pub fn as_str(&self) -> &str {
112 self.inner.as_str()
113 }
114
115 pub fn is_fetchable(&self) -> bool {
117 matches!(self.scheme(), "http" | "https")
118 }
119
120 pub fn is_local_file(&self) -> bool {
122 self.scheme() == "file"
123 }
124
125 pub fn is_internal(&self) -> bool {
127 self.scheme() == "oxide"
128 }
129
130 pub fn to_file_path(&self) -> Option<std::path::PathBuf> {
132 self.inner.to_file_path().ok()
133 }
134
135 pub fn set_fragment(&mut self, fragment: Option<&str>) {
136 self.inner.set_fragment(fragment);
137 }
138
139 pub fn set_query(&mut self, query: Option<&str>) {
140 self.inner.set_query(query);
141 }
142
143 pub fn query_pairs(&self) -> Vec<(String, String)> {
144 self.inner
145 .query_pairs()
146 .map(|(k, v)| (k.to_string(), v.to_string()))
147 .collect()
148 }
149
150 pub fn origin_str(&self) -> String {
152 match self.inner.origin() {
153 url::Origin::Opaque(_) => self.scheme().to_string(),
154 url::Origin::Tuple(scheme, host, port) => {
155 format!("{scheme}://{host}:{port}")
156 }
157 }
158 }
159
160 pub fn same_origin(&self, other: &OxideUrl) -> bool {
162 self.inner.origin() == other.inner.origin()
163 }
164
165 pub fn app_origin(&self) -> String {
173 if self.is_local_file() {
174 let path = self.inner.path();
175 let dir = match path.rfind('/') {
176 Some(0) => "/",
177 Some(i) => &path[..i],
178 None => path,
179 };
180 format!("file://{dir}")
181 } else {
182 self.origin_str()
183 }
184 }
185}
186
187pub fn app_origin_of(url: &str) -> String {
189 match OxideUrl::parse(url) {
190 Ok(parsed) => parsed.app_origin(),
191 Err(_) => url.to_string(),
192 }
193}
194
195impl fmt::Display for OxideUrl {
196 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
197 write!(f, "{}", self.inner)
198 }
199}
200
201pub fn percent_encode(input: &str) -> String {
203 percent_encoding::utf8_percent_encode(input, percent_encoding::NON_ALPHANUMERIC).to_string()
204}
205
206pub fn percent_decode(input: &str) -> String {
208 percent_encoding::percent_decode_str(input)
209 .decode_utf8_lossy()
210 .to_string()
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216
217 #[test]
218 fn parse_https() {
219 let url = OxideUrl::parse("https://example.com/app.wasm").unwrap();
220 assert_eq!(url.scheme(), "https");
221 assert_eq!(url.host_str(), Some("example.com"));
222 assert_eq!(url.path(), "/app.wasm");
223 }
224
225 #[test]
226 fn bare_hostname_becomes_https() {
227 let url = OxideUrl::parse("example.com/app.wasm").unwrap();
228 assert_eq!(url.scheme(), "https");
229 assert_eq!(url.as_str(), "https://example.com/app.wasm");
230 }
231
232 #[test]
233 fn resolve_relative() {
234 let base = OxideUrl::parse("https://example.com/apps/v1/main.wasm").unwrap();
235 let resolved = base.join("../v2/new.wasm").unwrap();
236 assert_eq!(resolved.as_str(), "https://example.com/apps/v2/new.wasm");
237 }
238
239 #[test]
240 fn file_url() {
241 let url = OxideUrl::parse("file:///tmp/app.wasm").unwrap();
242 assert!(url.is_local_file());
243 assert!(!url.is_fetchable());
244 }
245
246 #[test]
247 fn oxide_internal() {
248 let url = OxideUrl::parse("oxide://home").unwrap();
249 assert!(url.is_internal());
250 }
251
252 #[test]
253 fn unsupported_scheme() {
254 assert!(OxideUrl::parse("ftp://example.com").is_err());
255 }
256
257 #[test]
258 fn query_and_fragment() {
259 let url = OxideUrl::parse("https://example.com/app.wasm?v=1#section").unwrap();
260 assert_eq!(url.query(), Some("v=1"));
261 assert_eq!(url.fragment(), Some("section"));
262 }
263
264 #[test]
265 fn percent_encoding_roundtrip() {
266 let original = "hello world";
267 let encoded = percent_encode(original);
268 let decoded = percent_decode(&encoded);
269 assert_eq!(decoded, original);
270 }
271
272 #[test]
273 fn app_origin_https_ignores_path() {
274 let a = OxideUrl::parse("https://example.com/apps/a.wasm").unwrap();
275 let b = OxideUrl::parse("https://example.com/other/b.wasm").unwrap();
276 assert_eq!(a.app_origin(), "https://example.com:443");
277 assert_eq!(a.app_origin(), b.app_origin());
278 }
279
280 #[test]
281 fn app_origin_file_is_containing_directory() {
282 let a = OxideUrl::parse("file:///tmp/apps/a.wasm").unwrap();
283 let b = OxideUrl::parse("file:///tmp/other/b.wasm").unwrap();
284 assert_eq!(a.app_origin(), "file:///tmp/apps");
285 assert_ne!(a.app_origin(), b.app_origin());
286 }
287
288 #[test]
289 fn app_origin_of_falls_back_to_input() {
290 assert_eq!(app_origin_of(""), "");
291 assert_eq!(app_origin_of("./relative.wasm"), "./relative.wasm");
292 }
293
294 #[test]
295 fn relative_path_rejected_without_base() {
296 assert!(matches!(
297 OxideUrl::parse("../other.wasm"),
298 Err(UrlError::RelativeRequiresBase)
299 ));
300 }
301}