gmi/url.rs
1//! A URL Library made specifically for Gemini clients
2//!
3//! This is a subset of [RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986#section-3.2)
4//!
5//! This URL library will NOT interpret anything more than
6//! what is required for a Gemini client. These things are as
7//! shown:
8//!
9//! Components that are required:
10//! - Authority
11//! - Host
12//! - !userinfo
13//!
14//! Additional Information:
15//! - Spaces should be percent encoded to %20
16//!
17//! Personal limitations:
18//! - IPv6 hosts are not allowed
19
20/// The main holder of a URL.
21/// This consts of 4 parts:
22/// - An optinal scheme (gemini://),
23/// - An authority (example.com:1234),
24/// - A path (hello/world.gmi)
25/// - A query (?user=23)
26///
27/// An easy way to construct the Url struct is using `try_from()`
28#[derive(Debug, Clone)]
29pub struct Url {
30 /// The scheme of the URL
31 pub scheme: Option<String>,
32 /// The authority of the URL
33 pub authority: Authority,
34 /// The path of the URL
35 pub path: Option<Path>,
36 /// The query portion of the URL
37 pub query: Option<Query>,
38}
39
40impl core::fmt::Display for Url {
41 fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result {
42 // Print the scheme
43 match &self.scheme {
44 Some(s) => write!(f, "{}://", s)?,
45 None => (),
46 }
47 // Then the authority
48 write!(f, "{}", self.authority)?;
49 // Then the path
50 match &self.path {
51 Some(p) => write!(f, "{}", p)?,
52 None => (),
53 }
54 // Then the query
55 match &self.query {
56 Some(q) => write!(f, "{}", q)?,
57 None => (),
58 }
59 Ok(())
60 }
61}
62
63#[derive(Debug, PartialEq, Eq, Clone, Copy)]
64/// An error during the parsing of a URL
65pub enum UrlParseError {
66 /// An error occured during the parsing of the authority
67 /// part of the URL.
68 AuthorityParseError(AuthorityParseError),
69 /// The URL was empty
70 EmptyURL,
71}
72
73impl core::fmt::Display for UrlParseError {
74 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
75 match self {
76 UrlParseError::AuthorityParseError(e) => write!(f, "URL parse error: {}", e),
77 UrlParseError::EmptyURL => write!(f, "URL Parse error: Empty URL"),
78 }
79 }
80}
81
82impl std::error::Error for UrlParseError {
83 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
84 match self {
85 UrlParseError::AuthorityParseError(e) => Some(e),
86 UrlParseError::EmptyURL => None,
87 }
88 }
89}
90
91impl core::convert::TryFrom<&str> for Url {
92 type Error = UrlParseError;
93 /// Parse a URL from a [`&str`] object.
94 ///
95 /// # Example:
96 /// ```
97 /// # use gmi::url::Url;
98 /// # use gmi::url::UrlParseError;
99 /// # fn main() -> Result<(), UrlParseError> {
100 /// use std::convert::TryFrom;
101 /// let url = Url::try_from("gemini://example.com")?;
102 /// assert_eq!(url.scheme, Some(String::from("gemini")));
103 /// assert_eq!(url.authority.host, "example.com");
104 /// assert_eq!(url.path, None);
105 /// assert_eq!(url.query, None);
106 /// # Ok(())
107 /// # }
108 /// ```
109 fn try_from(raw_url: &str) -> Result<Self, UrlParseError> {
110 if raw_url.trim().is_empty() {
111 return Err(UrlParseError::EmptyURL);
112 }
113 let raw_url = raw_url.trim();
114 let mut scheme_ret: Option<String> = None;
115 // Check if there's a scheme
116 let remainder = match raw_url.split_once("://") {
117 Some((s, u)) => {
118 scheme_ret = Some(s.to_string());
119 u
120 }
121 None => raw_url,
122 };
123
124 // Split off the path if there is any
125 let (auth, path) = match remainder.split_once('/') {
126 Some((a, p)) => (a, Some(p)),
127 None => (remainder, None),
128 };
129
130 // Now we get the authority
131 let authority = match Authority::try_from(auth) {
132 Ok(a) => a,
133 Err(e) => return Err(UrlParseError::AuthorityParseError(e)),
134 };
135
136 // Now we see if there's a path
137 if path.is_none() {
138 // There is none, and with no path, there also is no query,
139 // so we'll just exit here
140 return Ok(Self {
141 scheme: scheme_ret,
142 authority,
143 path: None,
144 query: None,
145 });
146 }
147
148 let path = path.unwrap();
149
150 // At this point there is a path, so we'll split off a query
151 let (parsed_path, query_str) = match path.split_once('?') {
152 Some((p, q)) => (Path::from(("/".to_string() + &p).as_ref()), Some(q)),
153 None => (Path::from(("/".to_string() + &path).as_ref()), None),
154 };
155
156 // And now we get the query part, if it exists
157 let query = match query_str {
158 None => None,
159 Some(q) => Some(Query::from(q)),
160 };
161
162 Ok(Self {
163 scheme: scheme_ret,
164 authority,
165 path: Some(parsed_path),
166 query,
167 })
168 }
169}
170
171/// An authority of a URL
172///
173/// An authority of a URL is essentially the
174/// host of the URL. Think "example.com" or "127.0.0.1"
175///
176/// This can also optionally include a port number separated by a colon (:)
177///
178/// For more info see [section 3.2 of the
179/// RFC](https://datatracker.ietf.org/doc/html/rfc3986#section-3.2)
180///
181/// # Constructing the struct
182/// An easy way to construct this struct is to use `try_from()`
183///
184/// ## Example
185/// ```
186/// # use gmi::url::Authority;
187/// # fn main() -> Result<(), gmi::url::AuthorityParseError> {
188/// use std::convert::TryFrom;
189/// let auth = Authority::try_from("example.com:1963")?;
190/// assert_eq!(auth.port, Some(1963));
191/// assert_eq!(auth.host, "example.com");
192/// # Ok(())
193/// # }
194/// ```
195#[derive(Debug, Eq, PartialEq, Clone)]
196pub struct Authority {
197 /// The host portion of the authority
198 pub host: String,
199 /// The optinal port of the authority
200 pub port: Option<u16>,
201}
202
203impl core::fmt::Display for Authority {
204 fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result {
205 write!(
206 f,
207 "{}{}",
208 self.host,
209 match self.port {
210 Some(p) => String::from(":") + &p.to_string(),
211 None => String::from(""),
212 }
213 )
214 }
215}
216
217#[derive(Debug, Eq, PartialEq, Clone, Copy)]
218/// A parsing error for the Authority
219///
220/// This enum contains various possible errors that can occur while
221/// parsing an authority
222pub enum AuthorityParseError {
223 /// Occurs when a port cannot be parsed
224 InvalidPort,
225 /// Occurs when the authority is completely
226 /// missing a host in the first place
227 MissingHost,
228}
229
230impl core::fmt::Display for AuthorityParseError {
231 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
232 match self {
233 AuthorityParseError::InvalidPort => write!(f, "Error parsing authority: Invalid port"),
234 AuthorityParseError::MissingHost => write!(f, "Error parsing authority: Missing host"),
235 }
236 }
237}
238
239impl std::error::Error for AuthorityParseError {}
240
241impl core::convert::TryFrom<&str> for Authority {
242 type Error = AuthorityParseError;
243 fn try_from(s: &str) -> Result<Self, AuthorityParseError> {
244 let host;
245 let mut port = None;
246 // Split once by colon
247 if let Some(new_host) = s.split_once(':') {
248 // Add the host
249 host = String::from(new_host.0);
250 // Get the port
251 if !new_host.1.is_empty() {
252 port = Some({
253 match new_host.1.parse::<u16>() {
254 Err(_) => return Err(AuthorityParseError::InvalidPort),
255 Ok(n) => n,
256 }
257 })
258 }
259 } else {
260 // The entire thing should be the host so let's do that
261 host = s.to_string();
262 }
263 if host.is_empty() {
264 return Err(AuthorityParseError::MissingHost);
265 }
266 Ok(Self { host, port })
267 }
268}
269
270/// The path part of the URL.
271///
272/// This part is optional in a URL and specifies the specific resource to access
273///
274/// This implementation is based on the API of [`std::path::Path`]
275///
276/// # Constructing this struct
277/// You can just use the `from()` implementations for this. You can also just
278/// construct it from its raw parts, although I do not recommend it.
279///
280/// ## Example:
281///```
282/// # use gmi::url::Path;
283/// # fn main() {
284/// let path = Path::from("/help/me");
285/// assert_eq!(path.to_string(), "/help/me");
286/// # }
287/// ```
288
289#[derive(Debug, PartialEq, Eq, Clone)]
290pub struct Path {
291 /// The raw path string of the URL
292 pub raw_path: String,
293}
294
295impl core::fmt::Display for Path {
296 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
297 write!(f, "{}", self.raw_path)
298 }
299}
300
301impl From<&str> for Path {
302 fn from(s: &str) -> Self {
303 Self {
304 raw_path: s.to_owned(),
305 }
306 }
307}
308
309impl Path {
310 /// Returns a path with the parent part
311 ///
312 /// # Example
313 /// ```
314 /// # fn main() {
315 /// use gmi::url::Path;
316 /// assert_eq!(Path::from("/hi/hello/").parent(), Some(Path::from("/hi/")));
317 /// # }
318 /// ```
319 pub fn parent(&self) -> Option<Self> {
320 // If the path is the root, there is no parent
321 if self.raw_path == "/" {
322 return None;
323 }
324 // If the path is empty, there is no parent
325 if self.raw_path == "" {
326 return None;
327 }
328 let raw_path = {
329 // Check if the path ends in a slash
330 if self.raw_path.ends_with('/') {
331 self.raw_path[..self.raw_path.len() - 1].to_owned()
332 } else {
333 self.raw_path.clone()
334 }
335 };
336 // Split on the final / and give that back
337 match raw_path.rsplit_once('/') {
338 None => None,
339 Some((parent,_)) => Some({
340 let mut raw_path = parent.to_owned();
341 raw_path.push('/');
342 Self { raw_path }
343 }),
344 }
345 }
346
347 /// Returns true if the path is an absolute path
348 ///
349 /// # Example
350 /// ```
351 /// # use gmi::url::Path;
352 /// # fn main() {
353 /// let path = Path::from("/a/good/path");
354 /// assert!(path.is_absolute());
355 /// # }
356 /// ```
357 pub fn is_absolute(&self) -> bool {
358 // This is actually pretty simple. A path is absolute if it begins with a slash
359 self.raw_path.starts_with('/')
360 }
361
362 /// Returns true if the path is a relative path
363 ///
364 /// # Example
365 /// ```
366 /// # use gmi::url::Path;
367 /// # fn main() {
368 /// let path = Path::from("a/relative/path");
369 /// assert!(path.is_relative());
370 /// # }
371 /// ```
372 pub fn is_relative(&self) -> bool {
373 !self.is_absolute()
374 }
375
376 /// Returns the file name with no associated hierarchy
377 ///
378 /// # Example
379 /// ```
380 /// # fn main() {
381 /// use gmi::url::Path;
382 /// assert_eq!(Path::from("/help/me.hi").file_name(), Some("me.hi"));
383 /// # }
384 /// ```
385 pub fn file_name(&self) -> Option<&str> {
386 // Is the path empty?
387 if self.raw_path.trim().is_empty() {
388 return None;
389 }
390 // Does the path end in a slash?
391 if self.raw_path.trim().ends_with('/') {
392 return None;
393 }
394
395 return Some(self.raw_path.trim().rsplit_once('/').unwrap().1);
396 }
397
398 /// Creates a new [`Path`] with a different [`Path`] adjoined to `self`.
399 ///
400 /// # Example
401 /// ```
402 /// # fn main() {
403 /// use gmi::url::Path;
404 /// assert_eq!(Path::from("/help/").merge_path(&Path::from("me")), Path::from("/help/me"));
405 /// # }
406 /// ```
407 pub fn merge_path(&self, other_path: &Self) -> Self {
408 // If the other path is empty, we don't need to do anything
409 if other_path.raw_path.trim().is_empty() {
410 return self.clone();
411 }
412 // If the other path is an absolute path, there is no merging and the
413 // other path completely takes over
414 if other_path.is_absolute() {
415 Self {
416 raw_path: other_path.to_string(),
417 }
418 } else {
419 // The other path is relative, so we'll just append the other path to this path.
420 // If this path is empty, we can just set the path to the other path, but absolute.
421 if self.raw_path.trim().is_empty() {
422 let mut new_path = String::from("/");
423 new_path.push_str(&other_path.raw_path);
424 return Self { raw_path: new_path };
425 }
426
427 // If the path ends in a slash, it's already a directory and we can just append our new
428 // path to this path
429 if self.raw_path.ends_with('/') {
430 let mut new_path = self.clone();
431 new_path.raw_path.push_str(&other_path.raw_path);
432 return new_path;
433 }
434
435 // The path ends in some file name, so we'll take that off the path, and then put this
436 // new path on top of it
437 let path = self.raw_path.trim().rsplit_once('/').unwrap().0;
438 let mut new_raw_path = String::from(path);
439 new_raw_path.push('/');
440 new_raw_path.push_str(&other_path.raw_path);
441 Self {
442 raw_path: new_raw_path,
443 }
444 }
445 }
446
447 /// Removes any relative dot pathing from the path.
448 ///
449 /// # Example
450 /// ```
451 /// # use gmi::url::Path;
452 /// # fn main() {
453 /// let mut path = Path::from("/a/dotted/../path/./with/stuff");
454 /// path.dedotify();
455 /// assert_eq!(path.to_string(), "/a/path/with/stuff");
456 /// # }
457 pub fn dedotify(&mut self) {
458 // Input buffer
459 let mut input = self.raw_path.clone();
460 let input_ends_with_slash = input.ends_with('/');
461 // Output buffer
462 let mut output = String::new();
463 while !input.is_empty() && input != "/" {
464 // A. If input buffer starts with "../" or "./" remove them
465 if input.starts_with("../") || input.starts_with("./") {
466 input = input
467 .trim_start_matches("../")
468 .trim_start_matches("./")
469 .to_string();
470 }
471 // B. If input starts with "/./", "/." replace with /
472 if input.starts_with("/./") {
473 input = input.replacen("/./", "/", 1);
474 }
475 if input.starts_with("/.") && !input.starts_with("/..") {
476 input = input.replacen("/.", "/", 1);
477 }
478 // C. If input starts with "/../" or "/.." then replace with "/" and remove
479 // previous path segment from output buffer
480 if input.starts_with("/../") {
481 input = input.replacen("/../", "/", 1);
482 let output_split = output.rsplit_once('/').unwrap_or(("", ""));
483 let output_split = if output_split.1 == "" {
484 output_split.0.rsplit_once('/').unwrap_or(("", ""))
485 } else {
486 output_split
487 };
488 output = String::from(output_split.0);
489 }
490 if input.starts_with("/..") {
491 input = input.replacen("/..", "/", 1);
492 let output_split = output.rsplit_once('/').unwrap_or(("", ""));
493 let output_split = if output_split.1 == "" {
494 output_split.0.rsplit_once('/').unwrap_or(("", ""))
495 } else {
496 output_split
497 };
498 output = String::from(output_split.0);
499 }
500
501 // D. If the input buffer only consists of "." or ".." then remove it
502 if input == "." || input == ".." {
503 input = String::new();
504 }
505
506 // E. Move the first path segment of the input buffer to the end of the
507 // output buffer and remove it from the input buffer
508 if input.starts_with('/') {
509 input = (&input[1..]).to_string();
510 output.push('/');
511 }
512 let input_clone = input.clone();
513 let (input_left, input_right) = input.split_once('/').unwrap_or((&input_clone, ""));
514 output.push_str(input_left);
515 let mut new_input = String::from('/');
516 new_input.push_str(input_right);
517 input = new_input;
518 println!("out: {}, in: {}", output, input);
519 }
520 if input_ends_with_slash && !output.ends_with('/') {
521 output.push('/');
522 }
523 self.raw_path = output;
524 }
525}
526
527/// The query part of the URL.
528///
529/// This part is optional in a URL and consists of "fragments"
530/// The first query part is the first fragment, and each fragment following is separated by the
531/// character '#'
532#[derive(Debug, Eq, PartialEq, Clone)]
533pub struct Query {
534 /// The various fragments of the query portion of the URL
535 pub fragments: Vec<String>,
536}
537
538impl core::fmt::Display for Query {
539 fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result {
540 write!(f, "?{}", self.fragments[0])?;
541 for p in self.fragments[1..].iter() {
542 write!(f, "#{}", p)?;
543 }
544 Ok(())
545 }
546}
547
548impl From<&str> for Query {
549 /// # NOTE
550 ///
551 /// See [`parse_str`](Query::parse_str) for various
552 /// parsing infos
553 fn from(s: &str) -> Self {
554 Self::parse_str(s)
555 }
556}
557
558impl Query {
559 /// Parses a query part into separate parts. You can also use [`Query::from()`]
560 ///
561 /// This requires the query part to be already separated from the URL. The str will not start
562 /// with a '?', and if it does, the first fragment will contain it
563 ///
564 /// # Examples:
565 /// ```
566 /// # use gmi::url::Query;
567 /// # fn main() {
568 /// let query = Query::parse_str("test#query");
569 /// assert_eq!(query.fragments[0], "test");
570 /// assert_eq!(query.fragments[1], "query");
571 /// # }
572 pub fn parse_str(raw_query: &str) -> Self {
573 Self {
574 fragments: raw_query.split('#').map(|s| String::from(s)).collect(),
575 }
576 }
577}
578
579/// Returns if a specific character is part of the reserved
580/// characters list of the URL
581///
582/// See [section 2.2 of RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986#section-2.2) for
583/// more details
584///
585/// # Example
586/// ```
587/// # use gmi::url;
588/// # fn main() {
589/// assert!(url::is_reserved_char('!'));
590/// assert!(!url::is_reserved_char('c'));
591/// # }
592/// ```
593pub fn is_reserved_char(c: char) -> bool {
594 // All alphanumeric characters are unreserved
595 if c.is_alphanumeric() {
596 return false;
597 }
598 match c {
599 '-' | '.' | '_' | '~' => false,
600 _ => true,
601 }
602}
603
604/// Percent encodes any reserved characters in a string
605///
606/// See [section 2.1 of RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986#section-2.1) for
607/// more details
608///
609/// # Example
610/// ```
611/// # use gmi::url;
612/// # fn main() {
613/// let percent_encoding = url::percent_encode_reserved_characters("this is a test");
614/// assert_eq!(percent_encoding, "this%20is%20a%20test");
615/// # }
616/// ```
617pub fn percent_encode_reserved_characters(data: &str) -> String {
618 let mut ret = String::new();
619 for c in data.chars() {
620 if is_reserved_char(c) {
621 ret.push_str(&percent_encode(c));
622 } else {
623 ret.push(c);
624 }
625 }
626 ret
627}
628
629/// Percent encodes a singular character
630/// See [`percent_encode_reserved_characters`] for more info on this
631///
632/// # Example:
633/// ```
634/// # use gmi::url;
635/// # fn main() {
636/// assert_eq!(url::percent_encode('!'), "%21");
637/// # }
638pub fn percent_encode(c: char) -> String {
639 let mut ret = String::new();
640 let mut buf = [0; 4];
641 c.encode_utf8(&mut buf);
642 for i in 0..c.len_utf8() {
643 ret.push_str(&format!("%{:02x}", buf[i]));
644 }
645 ret
646}
647
648#[cfg(test)]
649mod test {
650 // use crate::url::*;
651 mod bare_fns {
652 use crate::url::*;
653 #[test]
654 fn test_percent_encode() {
655 assert_eq!(percent_encode(' '), "%20");
656 assert_eq!(percent_encode('か'), "%e3%81%8b");
657 }
658 #[test]
659 fn test_percent_encode_reserved_characters() {
660 assert_eq!(
661 percent_encode_reserved_characters("this is a test"),
662 "this%20is%20a%20test"
663 );
664 }
665 }
666 mod authority {
667 use std::convert::TryFrom;
668 use crate::url::*;
669 #[test]
670 fn authority_parse_str_simple() {
671 assert_eq!(
672 Authority::try_from("example.com"),
673 Ok(Authority {
674 host: "example.com".to_string(),
675 port: None,
676 })
677 );
678 }
679 #[test]
680 fn authority_parse_str_with_port() {
681 assert_eq!(
682 Authority::try_from("example.com:1234"),
683 Ok(Authority {
684 host: "example.com".to_string(),
685 port: Some(1234),
686 })
687 );
688 }
689 #[test]
690 fn authority_parse_str_invalid_port() {
691 assert_eq!(
692 Authority::try_from("example.com:fjdklg"),
693 Err(AuthorityParseError::InvalidPort)
694 );
695 }
696 #[test]
697 fn authority_parse_str_missing_host() {
698 assert_eq!(
699 Authority::try_from(""),
700 Err(AuthorityParseError::MissingHost)
701 );
702 assert_eq!(
703 Authority::try_from(":1323"),
704 Err(AuthorityParseError::MissingHost)
705 );
706 }
707 }
708 mod query {
709 use crate::url::*;
710 #[test]
711 fn query_test() {
712 let q = Query::from("this=test#is_this");
713 assert_eq!(q.fragments, vec!["this=test", "is_this"]);
714 }
715 }
716
717 mod path {
718 use crate::url::*;
719 #[test]
720 fn path_parent() {
721 let path = Path::from("/just/a/test/path.txt");
722 let parent = path.parent().unwrap();
723 assert_eq!(parent.raw_path, "/just/a/test/");
724 }
725 #[test]
726 fn path_ancestors() {
727 /*
728 let path = Path::from("/just/a/test/path.txt");
729 let mut ancestors = path.ancestors();
730 assert_eq!(ancestors.next(), Some(Path::from("/just/a/test/")));
731 assert_eq!(ancestors.next(), Some(Path::from("/just/a/")));
732 assert_eq!(ancestors.next(), Some(Path::from("/just/")));
733 assert_eq!(ancestors.next(), Some(Path::from("/")));
734 assert_eq!(ancestors.next(), None);
735 */
736 }
737 #[test]
738 fn path_merge() {
739 let path_ending_file = Path::from("/a/test/path");
740 let path_ending_dir = Path::from("/a/test/path/");
741 let empty_path = Path::from("");
742 let root_path = Path::from("/");
743 let new_relative_path = Path::from("with/the/new/part");
744 let new_absolute_path = Path::from("/this/is/the/new/part/");
745 assert_eq!(
746 path_ending_file.merge_path(&new_relative_path).raw_path,
747 "/a/test/with/the/new/part"
748 );
749 assert_eq!(
750 path_ending_dir.merge_path(&new_relative_path).raw_path,
751 "/a/test/path/with/the/new/part"
752 );
753
754 assert_eq!(
755 path_ending_file.merge_path(&new_absolute_path).raw_path,
756 "/this/is/the/new/part/"
757 );
758 assert_eq!(
759 path_ending_dir.merge_path(&new_absolute_path).raw_path,
760 "/this/is/the/new/part/"
761 );
762
763 assert_eq!(
764 path_ending_file.merge_path(&empty_path).raw_path,
765 "/a/test/path"
766 );
767 assert_eq!(
768 path_ending_dir.merge_path(&empty_path).raw_path,
769 "/a/test/path/"
770 );
771
772 assert_eq!(path_ending_file.merge_path(&root_path).raw_path, "/");
773 assert_eq!(path_ending_dir.merge_path(&root_path).raw_path, "/");
774
775 assert_eq!(
776 empty_path.merge_path(&new_relative_path).raw_path,
777 "/with/the/new/part"
778 );
779 assert_eq!(
780 empty_path.merge_path(&new_absolute_path).raw_path,
781 "/this/is/the/new/part/"
782 );
783
784 assert_eq!(root_path.merge_path(&empty_path).raw_path, "/");
785 assert_eq!(
786 root_path.merge_path(&new_relative_path).raw_path,
787 "/with/the/new/part"
788 );
789 assert_eq!(
790 root_path.merge_path(&new_absolute_path).raw_path,
791 "/this/is/the/new/part/"
792 );
793 }
794
795 #[test]
796 fn dedotify() {
797 std::thread::sleep(std::time::Duration::from_secs(1));
798 let mut p = Path::from("help/");
799 p.dedotify();
800 assert_eq!(p.raw_path, "help/");
801 }
802 }
803}