1use crate::{Error, Result};
2use serde::{Deserialize, Serialize};
3use std::{
4 fmt::Display,
5 path::{Path, PathBuf},
6};
7use url::Url;
8
9#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
11#[serde(untagged)]
12pub enum Href {
13 Url(Url),
17
18 String(String),
22}
23
24#[derive(Debug)]
25pub enum RealizedHref {
26 PathBuf(PathBuf),
28
29 Url(Url),
31}
32
33pub trait SelfHref {
51 fn self_href(&self) -> Option<&Href>;
62
63 fn self_href_mut(&mut self) -> &mut Option<Href>;
74}
75
76impl Href {
77 pub fn absolute(&self, base: &Href) -> Result<Href> {
88 tracing::debug!("making href={self} absolute with base={base}");
89 match base {
90 Href::Url(url) => url.join(self.as_str()).map(Href::Url).map_err(Error::from),
91 Href::String(s) => Ok(Href::String(make_absolute(self.as_str(), s))),
92 }
93 }
94
95 pub fn relative(&self, base: &Href) -> Result<Href> {
106 tracing::debug!("making href={self} relative with base={base}");
107 match base {
108 Href::Url(base) => match self {
109 Href::Url(url) => Ok(base
110 .make_relative(url)
111 .map(Href::String)
112 .unwrap_or_else(|| self.clone())),
113 Href::String(s) => {
114 let url = s.parse()?;
115 Ok(base
116 .make_relative(&url)
117 .map(Href::String)
118 .unwrap_or_else(|| self.clone()))
119 }
120 },
121 Href::String(s) => Ok(Href::String(make_relative(self.as_str(), s))),
122 }
123 }
124
125 pub fn is_absolute(&self) -> bool {
129 match self {
130 Href::Url(_) => true,
131 Href::String(s) => s.starts_with('/'),
132 }
133 }
134
135 pub fn as_str(&self) -> &str {
137 match self {
138 Href::Url(url) => url.as_str(),
139 Href::String(s) => s.as_str(),
140 }
141 }
142
143 pub fn realize(self) -> RealizedHref {
145 match self {
146 Href::Url(url) => {
147 if url.scheme() == "file" {
148 url.to_file_path()
149 .map(RealizedHref::PathBuf)
150 .unwrap_or_else(|_| RealizedHref::Url(url))
151 } else {
152 RealizedHref::Url(url)
153 }
154 }
155 Href::String(s) => RealizedHref::PathBuf(PathBuf::from(s)),
156 }
157 }
158}
159
160impl Display for Href {
161 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
162 match self {
163 Href::Url(url) => url.fmt(f),
164 Href::String(s) => s.fmt(f),
165 }
166 }
167}
168
169impl From<&str> for Href {
170 fn from(value: &str) -> Self {
171 if let Ok(url) = Url::parse(value) {
172 Href::Url(url)
173 } else {
174 Href::String(value.to_string())
175 }
176 }
177}
178
179impl From<String> for Href {
180 fn from(value: String) -> Self {
181 if let Ok(url) = Url::parse(&value) {
182 Href::Url(url)
183 } else {
184 Href::String(value)
185 }
186 }
187}
188
189impl From<&Path> for Href {
190 fn from(value: &Path) -> Self {
191 if cfg!(target_os = "windows") {
192 if let Ok(url) = Url::from_file_path(value) {
193 Href::Url(url)
194 } else {
195 Href::String(value.to_string_lossy().into_owned())
196 }
197 } else {
198 Href::String(value.to_string_lossy().into_owned())
199 }
200 }
201}
202
203impl From<PathBuf> for Href {
204 fn from(value: PathBuf) -> Self {
205 if cfg!(target_os = "windows") {
206 if let Ok(url) = Url::from_file_path(&value) {
207 Href::Url(url)
208 } else {
209 Href::String(value.to_string_lossy().into_owned())
210 }
211 } else {
212 Href::String(value.to_string_lossy().into_owned())
213 }
214 }
215}
216
217impl TryFrom<Href> for Url {
218 type Error = Error;
219 fn try_from(value: Href) -> Result<Self> {
220 match value {
221 Href::Url(url) => Ok(url),
222 Href::String(s) => s.parse().map_err(Error::from),
223 }
224 }
225}
226
227#[cfg(feature = "reqwest")]
228impl From<reqwest::Url> for Href {
229 fn from(value: reqwest::Url) -> Self {
230 Href::Url(value)
231 }
232}
233
234#[cfg(not(feature = "reqwest"))]
235impl From<Url> for Href {
236 fn from(value: Url) -> Self {
237 Href::Url(value)
238 }
239}
240
241impl PartialEq<&str> for Href {
242 fn eq(&self, other: &&str) -> bool {
243 self.as_str().eq(*other)
244 }
245}
246
247fn make_absolute(href: &str, base: &str) -> String {
248 if href.starts_with('/') {
250 href.to_string()
251 } else {
252 let (base, _) = base.split_at(base.rfind('/').unwrap_or(0));
253 if base.is_empty() {
254 normalize_path(href)
255 } else {
256 normalize_path(&format!("{}/{}", base, href))
257 }
258 }
259}
260
261fn normalize_path(path: &str) -> String {
262 let mut parts = if path.starts_with('/') {
263 Vec::new()
264 } else {
265 vec![""]
266 };
267 for part in path.split('/') {
268 match part {
269 "." => {}
270 ".." => {
271 let _ = parts.pop();
272 }
273 s => parts.push(s),
274 }
275 }
276 parts.join("/")
277}
278
279fn make_relative(href: &str, base: &str) -> String {
280 let mut relative = String::new();
282
283 fn extract_path_filename(s: &str) -> (&str, &str) {
284 let last_slash_idx = s.rfind('/').unwrap_or(0);
285 let (path, filename) = s.split_at(last_slash_idx);
286 if filename.is_empty() {
287 (path, "")
288 } else {
289 (path, &filename[1..])
290 }
291 }
292
293 let (base_path, base_filename) = extract_path_filename(base);
294 let (href_path, href_filename) = extract_path_filename(href);
295
296 let mut base_path = base_path.split('/').peekable();
297 let mut href_path = href_path.split('/').peekable();
298
299 while base_path.peek().is_some() && base_path.peek() == href_path.peek() {
300 let _ = base_path.next();
301 let _ = href_path.next();
302 }
303
304 for base_path_segment in base_path {
305 if base_path_segment.is_empty() {
306 break;
307 }
308
309 if !relative.is_empty() {
310 relative.push('/');
311 }
312
313 relative.push_str("..");
314 }
315
316 for href_path_segment in href_path {
317 if relative.is_empty() {
318 relative.push_str("./");
319 } else {
320 relative.push('/');
321 }
322
323 relative.push_str(href_path_segment);
324 }
325
326 if !relative.is_empty() || base_filename != href_filename {
327 if href_filename.is_empty() {
328 relative.push('/');
329 } else {
330 if relative.is_empty() {
331 relative.push_str("./");
332 } else {
333 relative.push('/');
334 }
335 relative.push_str(href_filename);
336 }
337 }
338
339 relative
340}