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
166impl fmt::Display for OxideUrl {
167 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
168 write!(f, "{}", self.inner)
169 }
170}
171
172pub fn percent_encode(input: &str) -> String {
174 percent_encoding::utf8_percent_encode(input, percent_encoding::NON_ALPHANUMERIC).to_string()
175}
176
177pub fn percent_decode(input: &str) -> String {
179 percent_encoding::percent_decode_str(input)
180 .decode_utf8_lossy()
181 .to_string()
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187
188 #[test]
189 fn parse_https() {
190 let url = OxideUrl::parse("https://example.com/app.wasm").unwrap();
191 assert_eq!(url.scheme(), "https");
192 assert_eq!(url.host_str(), Some("example.com"));
193 assert_eq!(url.path(), "/app.wasm");
194 }
195
196 #[test]
197 fn bare_hostname_becomes_https() {
198 let url = OxideUrl::parse("example.com/app.wasm").unwrap();
199 assert_eq!(url.scheme(), "https");
200 assert_eq!(url.as_str(), "https://example.com/app.wasm");
201 }
202
203 #[test]
204 fn resolve_relative() {
205 let base = OxideUrl::parse("https://example.com/apps/v1/main.wasm").unwrap();
206 let resolved = base.join("../v2/new.wasm").unwrap();
207 assert_eq!(resolved.as_str(), "https://example.com/apps/v2/new.wasm");
208 }
209
210 #[test]
211 fn file_url() {
212 let url = OxideUrl::parse("file:///tmp/app.wasm").unwrap();
213 assert!(url.is_local_file());
214 assert!(!url.is_fetchable());
215 }
216
217 #[test]
218 fn oxide_internal() {
219 let url = OxideUrl::parse("oxide://home").unwrap();
220 assert!(url.is_internal());
221 }
222
223 #[test]
224 fn unsupported_scheme() {
225 assert!(OxideUrl::parse("ftp://example.com").is_err());
226 }
227
228 #[test]
229 fn query_and_fragment() {
230 let url = OxideUrl::parse("https://example.com/app.wasm?v=1#section").unwrap();
231 assert_eq!(url.query(), Some("v=1"));
232 assert_eq!(url.fragment(), Some("section"));
233 }
234
235 #[test]
236 fn percent_encoding_roundtrip() {
237 let original = "hello world";
238 let encoded = percent_encode(original);
239 let decoded = percent_decode(&encoded);
240 assert_eq!(decoded, original);
241 }
242
243 #[test]
244 fn relative_path_rejected_without_base() {
245 assert!(matches!(
246 OxideUrl::parse("../other.wasm"),
247 Err(UrlError::RelativeRequiresBase)
248 ));
249 }
250}