Skip to main content

gpui_cn/path_browser/
style.rs

1//! How a source writes its paths. A picker never assumes the host's rules:
2//! a Windows client can browse a Linux server and a Linux client can browse
3//! a Windows machine, so every source names its [`PathStyle`] and the picker
4//! parses, joins, compares and sorts with that.
5
6use std::{
7    borrow::Cow,
8    cmp::Ordering,
9    fmt,
10    hash::{Hash, Hasher},
11    path::PathBuf,
12};
13
14use gpui_kit::SharedString;
15
16#[derive(Clone, Copy, Debug, PartialEq, Eq)]
17enum Kind {
18    Posix,
19    Windows,
20}
21
22/// The rules of a source's paths: which characters separate the parts, how
23/// a root looks, whether names differ by case, and which names are hidden.
24///
25/// [`posix`](Self::posix) is `/`-separated with one root, case-sensitive,
26/// with dot names hidden. [`windows`](Self::windows) has drive roots such
27/// as `C:\` and share roots such as `\\server\share\`, accepts both `/` and
28/// `\` when a path is typed or pasted, writes `\`, ignores case, and hides
29/// nothing by name. [`host`](Self::host) is the style of the machine the
30/// application runs on, the default of a source.
31///
32/// ```
33/// use gpui_cn::PathStyle;
34///
35/// let windows = PathStyle::windows();
36/// let path = windows.path("c:/Users\\me/");
37/// assert_eq!(path.as_str(), "c:\\Users\\me");
38/// assert_eq!(path, windows.path("C:\\users\\ME"));
39/// assert_eq!(windows.path("C:\\").parent(), None);
40/// ```
41#[derive(Clone, Copy, Debug, PartialEq, Eq)]
42pub struct PathStyle {
43    kind: Kind,
44    display_separator: char,
45    case_sensitive: bool,
46    computer_root: bool,
47}
48
49impl PathStyle {
50    /// `/` paths with one root, case-sensitive.
51    pub fn posix() -> Self {
52        Self {
53            kind: Kind::Posix,
54            display_separator: '/',
55            case_sensitive: true,
56            computer_root: false,
57        }
58    }
59
60    /// Drive and share roots, `\` as the written separator, with `/`
61    /// accepted too, and names that ignore case.
62    pub fn windows() -> Self {
63        Self {
64            kind: Kind::Windows,
65            display_separator: '\\',
66            case_sensitive: false,
67            computer_root: false,
68        }
69    }
70
71    /// The style of the machine the application runs on.
72    pub fn host() -> Self {
73        if cfg!(windows) {
74            Self::windows()
75        } else {
76            Self::posix()
77        }
78    }
79
80    /// Writes `/` instead of `\` in a Windows-style path. A POSIX style
81    /// always writes `/`.
82    pub fn with_display_separator(mut self, separator: char) -> Self {
83        if self.kind == Kind::Windows && (separator == '/' || separator == '\\') {
84            self.display_separator = separator;
85        }
86        self
87    }
88
89    /// Sets whether two names that differ only by case are different names.
90    pub fn with_case_sensitive(mut self, case_sensitive: bool) -> Self {
91        self.case_sensitive = case_sensitive;
92        self
93    }
94
95    /// Whether a Windows-style source lists its drives as a root above the
96    /// drive roots, as "This PC" does. With it, Up from `C:\` goes to that
97    /// listing, which the source answers for the path `\`. Without it, a
98    /// drive root is its own parent.
99    pub fn with_computer_root(mut self, computer_root: bool) -> Self {
100        self.computer_root = self.kind == Kind::Windows && computer_root;
101        self
102    }
103
104    /// Whether this is a Windows style.
105    pub fn is_windows(&self) -> bool {
106        self.kind == Kind::Windows
107    }
108
109    /// The separator the picker writes.
110    pub fn separator(&self) -> char {
111        self.display_separator
112    }
113
114    /// Whether `c` separates the parts of a path when it is typed.
115    pub fn is_separator(&self, c: char) -> bool {
116        c == '/' || (self.is_windows() && c == '\\')
117    }
118
119    /// Whether names that differ only by case are different names.
120    pub fn is_case_sensitive(&self) -> bool {
121        self.case_sensitive
122    }
123
124    /// Whether the source lists a root above the drives.
125    pub fn has_computer_root(&self) -> bool {
126        self.computer_root
127    }
128
129    /// Whether a name is hidden by the style's own rule: a leading dot on
130    /// a POSIX style, nothing on a Windows style, where hidden is an
131    /// attribute the source reads.
132    pub fn is_hidden_name(&self, name: &str) -> bool {
133        !self.is_windows() && name.starts_with('.')
134    }
135
136    /// The name as the style compares it: lower case when case does not
137    /// matter.
138    pub fn fold<'a>(&self, name: &'a str) -> Cow<'a, str> {
139        if self.case_sensitive {
140            Cow::Borrowed(name)
141        } else {
142            Cow::Owned(name.to_lowercase())
143        }
144    }
145
146    /// Orders two names the way a listing sorts them: without regard to
147    /// case, then by the name itself, whatever the style's case rule.
148    pub fn compare_names(&self, a: &str, b: &str) -> Ordering {
149        a.to_lowercase()
150            .cmp(&b.to_lowercase())
151            .then_with(|| a.cmp(b))
152    }
153
154    /// The path `text` names in this style.
155    pub fn path(&self, text: impl AsRef<str>) -> SourcePath {
156        SourcePath::new(text.as_ref(), *self)
157    }
158
159    /// The path text for a pasted `text`: a `file://` URL becomes a path,
160    /// and a Windows-style path loses its surrounding quotes and spaces,
161    /// takes the style's separator, and has a lone drive letter made a
162    /// drive root. `%NAME%` in a Windows-style path is expanded through
163    /// `env`, and stays as it is when `env` does not know the name. A
164    /// POSIX-style path is left as it is: a Windows-looking path pasted
165    /// there is not guessed at.
166    pub fn normalize_pasted(&self, text: &str, env: &dyn Fn(&str) -> Option<String>) -> String {
167        let unquoted = text.trim().trim_matches(|c| c == '"' || c == '\'').trim();
168        let text = match file_url_path(unquoted, self) {
169            Some(path) => path,
170            None if unquoted.starts_with("file://") => return text.to_string(),
171            None if self.is_windows() => text.trim().to_string(),
172            None => return text.to_string(),
173        };
174        if !self.is_windows() {
175            return text;
176        }
177        let text = text
178            .trim_matches(|c| c == '"' || c == '\'' || char::is_whitespace(c))
179            .to_string();
180        let text = expand_variables(&text, env);
181        let mut out: String = text
182            .chars()
183            .map(|c| {
184                if c == '/' || c == '\\' {
185                    self.display_separator
186                } else {
187                    c
188                }
189            })
190            .collect();
191        if is_drive_only(&out) {
192            out.push(self.display_separator);
193        }
194        out
195    }
196
197    /// A fragment pasted into the middle of other path text: a Windows
198    /// style writes its separators, and nothing else changes.
199    pub(crate) fn normalize_fragment(&self, text: &str) -> String {
200        if !self.is_windows() {
201            return text.to_string();
202        }
203        text.chars()
204            .map(|c| {
205                if self.is_separator(c) {
206                    self.display_separator
207                } else {
208                    c
209                }
210            })
211            .collect()
212    }
213
214    /// Splits path text into its root and its parts. The text may mix
215    /// separators in a Windows style. Empty parts, from a doubled or a
216    /// trailing separator, are dropped.
217    pub(crate) fn parse(&self, text: &str) -> Parsed {
218        let sep = self.display_separator;
219        if !self.is_windows() {
220            let rooted = text.starts_with('/');
221            return Parsed {
222                prefix: if rooted { "/".into() } else { String::new() },
223                parts: fold_dots(text.split('/').map(str::to_string).collect()),
224                rooted,
225                computer: false,
226            };
227        }
228        let mut chars = text.chars();
229        let first = chars.next();
230        let second = chars.next();
231        let split = |rest: &str| -> Vec<String> {
232            fold_dots(
233                rest.split(|c| self.is_separator(c))
234                    .map(str::to_string)
235                    .collect(),
236            )
237        };
238        if let (Some(a), Some(':')) = (first, second)
239            && a.is_ascii_alphabetic()
240        {
241            return Parsed {
242                prefix: format!("{a}:{sep}"),
243                parts: split(&text[2..]),
244                rooted: true,
245                computer: false,
246            };
247        }
248        if first.is_some_and(|c| self.is_separator(c))
249            && second.is_some_and(|c| self.is_separator(c))
250        {
251            let mut parts: Vec<String> = text
252                .split(|c| self.is_separator(c))
253                .filter(|part| !part.is_empty())
254                .map(str::to_string)
255                .collect();
256            if parts.len() == 1 {
257                return Parsed {
258                    prefix: format!("{sep}{sep}{}{sep}", parts[0]),
259                    parts: Vec::new(),
260                    rooted: true,
261                    computer: false,
262                };
263            }
264            if parts.is_empty() {
265                return Parsed {
266                    prefix: format!("{sep}{sep}"),
267                    parts,
268                    rooted: true,
269                    computer: false,
270                };
271            }
272            if parts.len() >= 2 {
273                let server = parts.remove(0);
274                let share = parts.remove(0);
275                return Parsed {
276                    prefix: format!("{sep}{sep}{server}{sep}{share}{sep}"),
277                    parts: fold_dots(parts),
278                    rooted: true,
279                    computer: false,
280                };
281            }
282        }
283        if first.is_some_and(|c| self.is_separator(c)) {
284            let parts = split(text);
285            if parts.is_empty() {
286                return Parsed {
287                    prefix: sep.to_string(),
288                    parts,
289                    rooted: true,
290                    computer: self.computer_root,
291                };
292            }
293            return Parsed {
294                prefix: sep.to_string(),
295                parts,
296                rooted: true,
297                computer: false,
298            };
299        }
300        Parsed {
301            prefix: String::new(),
302            parts: split(text),
303            rooted: false,
304            computer: false,
305        }
306    }
307
308    /// The text of parsed path in the style's display form, with no
309    /// trailing separator except after a root.
310    pub(crate) fn display(&self, parsed: &Parsed) -> String {
311        let sep = self.display_separator.to_string();
312        format!("{}{}", parsed.prefix, parsed.parts.join(&sep))
313    }
314
315    /// The canonical form of path text: separators unified, case folded
316    /// when case does not matter, no trailing separator. Two texts are the
317    /// same path exactly when their keys are equal.
318    pub(crate) fn key(&self, text: &str) -> String {
319        let parsed = self.parse(text);
320        let mut key = parsed.prefix.replace('\\', "/");
321        key.push_str(&parsed.parts.join("/"));
322        if self.case_sensitive {
323            key
324        } else {
325            key.to_lowercase()
326        }
327    }
328}
329
330impl Default for PathStyle {
331    fn default() -> Self {
332        Self::host()
333    }
334}
335
336/// A path split into its root and parts.
337#[derive(Clone, Debug, PartialEq, Eq)]
338pub(crate) struct Parsed {
339    /// The root in display form: `/`, `C:\`, `\\server\share\`, or empty
340    /// for a path with no root.
341    pub(crate) prefix: String,
342    pub(crate) parts: Vec<String>,
343    pub(crate) rooted: bool,
344    /// Whether this is the root above the drives.
345    pub(crate) computer: bool,
346}
347
348impl Parsed {
349    /// Whether there is nothing above this path.
350    pub(crate) fn is_root(&self) -> bool {
351        self.parts.is_empty()
352    }
353}
354
355/// The parts of a path with the empty ones dropped, `.` dropped, and `..`
356/// taking the part before it away. A `..` with nothing before it goes, so a
357/// path never climbs above its root.
358fn fold_dots(parts: Vec<String>) -> Vec<String> {
359    let mut folded: Vec<String> = Vec::new();
360    for part in parts {
361        match part.as_str() {
362            "" | "." => {}
363            ".." => {
364                folded.pop();
365            }
366            _ => folded.push(part),
367        }
368    }
369    folded
370}
371
372fn is_drive_only(text: &str) -> bool {
373    let mut chars = text.chars();
374    matches!(
375        (chars.next(), chars.next(), chars.next()),
376        (Some(a), Some(':'), None) if a.is_ascii_alphabetic()
377    )
378}
379
380/// `%NAME%` replaced with what `env` says, and left alone for a name it
381/// does not know.
382fn expand_variables(text: &str, env: &dyn Fn(&str) -> Option<String>) -> String {
383    let mut out = String::new();
384    let mut rest = text;
385    while let Some(start) = rest.find('%') {
386        out.push_str(&rest[..start]);
387        let after = &rest[start + 1..];
388        match after.find('%') {
389            Some(end) if end > 0 => match env(&after[..end]) {
390                Some(value) => {
391                    out.push_str(&value);
392                    rest = &after[end + 1..];
393                }
394                None => {
395                    out.push('%');
396                    rest = after;
397                }
398            },
399            _ => {
400                out.push('%');
401                rest = after;
402            }
403        }
404    }
405    out.push_str(rest);
406    out
407}
408
409/// The path a `file://` URL names in `style`, percent-decoded, or `None`
410/// for text that is not one.
411fn file_url_path(text: &str, style: &PathStyle) -> Option<String> {
412    let rest = text.strip_prefix("file://")?;
413    let (host, path) = match rest.find('/') {
414        Some(at) => (&rest[..at], &rest[at..]),
415        None => (rest, ""),
416    };
417    let path = percent_decode(path);
418    if path.chars().any(char::is_control) {
419        return None;
420    }
421    if style.is_windows() {
422        let bytes = path.as_bytes();
423        let drive = bytes.len() >= 3
424            && bytes[0] == b'/'
425            && bytes[1].is_ascii_alphabetic()
426            && bytes[2] == b':';
427        if drive {
428            return Some(path[1..].to_string());
429        }
430        if !host.is_empty() && host != "localhost" {
431            return Some(format!("//{host}{path}"));
432        }
433    }
434    Some(path)
435}
436
437fn percent_decode(text: &str) -> String {
438    let bytes = text.as_bytes();
439    let mut out = Vec::with_capacity(bytes.len());
440    let mut at = 0;
441    while at < bytes.len() {
442        if bytes[at] == b'%'
443            && at + 2 < bytes.len()
444            && let (Some(high), Some(low)) = (hex(bytes[at + 1]), hex(bytes[at + 2]))
445        {
446            out.push(high * 16 + low);
447            at += 3;
448        } else {
449            out.push(bytes[at]);
450            at += 1;
451        }
452    }
453    String::from_utf8_lossy(&out).into_owned()
454}
455
456fn hex(byte: u8) -> Option<u8> {
457    (byte as char).to_digit(16).map(|digit| digit as u8)
458}
459
460/// A path in a source's own style: what a picker lists, what it asks a
461/// source for, and what it reports as chosen.
462///
463/// The text is the path as the source writes it, with no trailing
464/// separator except after a root. Two paths are equal when they name the
465/// same place in their style, so `C:/Users/Me` equals `c:\users\me` in a
466/// Windows style. A path made for the local disk converts with
467/// [`to_path_buf`](Self::to_path_buf); a remote one is only text.
468#[derive(Clone, Debug)]
469pub struct SourcePath {
470    text: SharedString,
471    key: SharedString,
472    style: PathStyle,
473}
474
475impl SourcePath {
476    /// The path `text` names in `style`. Separators become the style's
477    /// own, and a trailing separator goes unless it follows a root.
478    pub fn new(text: &str, style: PathStyle) -> Self {
479        let parsed = style.parse(text);
480        Self {
481            text: style.display(&parsed).into(),
482            key: style.key(text).into(),
483            style,
484        }
485    }
486
487    /// The path as the source writes it.
488    pub fn as_str(&self) -> &str {
489        &self.text
490    }
491
492    /// The style of the path.
493    pub fn style(&self) -> PathStyle {
494        self.style
495    }
496
497    /// The last part, or `None` for a root.
498    pub fn file_name(&self) -> Option<&str> {
499        let parsed = self.style.parse(&self.text);
500        let last = parsed.parts.last()?;
501        let at = self.text.len() - last.len();
502        Some(&self.text[at..])
503    }
504
505    /// The path above this one, or `None` for a root. A Windows-style
506    /// source with a computer root has it above a drive root.
507    pub fn parent(&self) -> Option<SourcePath> {
508        let mut parsed = self.style.parse(&self.text);
509        if parsed.parts.is_empty() {
510            if parsed.computer || !self.style.has_computer_root() {
511                return None;
512            }
513            let is_drive = parsed.prefix.len() == 3 && parsed.prefix.as_bytes()[1] == b':';
514            return is_drive.then(|| self.style.path(self.style.separator().to_string()));
515        }
516        parsed.parts.pop();
517        Some(Self::new(&self.style.display(&parsed), self.style))
518    }
519
520    /// The path of `name` inside this one.
521    pub fn join(&self, name: &str) -> SourcePath {
522        let sep = self.style.separator();
523        let mut text = self.text.to_string();
524        if !text.ends_with(|c| self.style.is_separator(c)) {
525            text.push(sep);
526        }
527        text.push_str(name);
528        Self::new(&text, self.style)
529    }
530
531    /// Whether this is a root: `/`, a drive, a share, or the root above
532    /// the drives.
533    pub fn is_root(&self) -> bool {
534        self.style.parse(&self.text).is_root()
535    }
536
537    /// The path as the local disk reads it. It is meaningful for a source
538    /// that lists the local disk; for a remote source it is only the text.
539    pub fn to_path_buf(&self) -> PathBuf {
540        PathBuf::from(self.text.as_ref() as &str)
541    }
542}
543
544impl PartialEq for SourcePath {
545    fn eq(&self, other: &Self) -> bool {
546        self.key == other.key
547    }
548}
549
550impl Eq for SourcePath {}
551
552impl Hash for SourcePath {
553    fn hash<H: Hasher>(&self, state: &mut H) {
554        self.key.hash(state);
555    }
556}
557
558impl fmt::Display for SourcePath {
559    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
560        f.write_str(&self.text)
561    }
562}
563
564/// Why a source could not list a directory. The picker shows the message
565/// and, for a failure that waiting can fix, an action: Retry for a lost
566/// connection or an error, and "Sign in" for a source that needs the user
567/// to log in, which calls the application's own handler.
568///
569/// A source returns one from `list` for any failure. An `io::Error`
570/// converts into the matching variant.
571#[derive(Clone, Debug, PartialEq, Eq)]
572#[non_exhaustive]
573pub enum ListError {
574    /// The directory does not exist.
575    NotFound,
576    /// The user may not list the directory.
577    PermissionDenied,
578    /// The source needs the user to sign in. The text says why, and is
579    /// shown to the user.
580    AuthRequired(SharedString),
581    /// The connection to the source was lost. The text says what happened,
582    /// and is shown to the user.
583    Disconnected(SharedString),
584    /// Any other failure. The text is shown to the user.
585    Other(SharedString),
586}
587
588impl ListError {
589    /// The text the picker shows.
590    pub fn message(&self) -> SharedString {
591        match self {
592            Self::NotFound => "That folder does not exist".into(),
593            Self::PermissionDenied => "You cannot open that folder".into(),
594            Self::AuthRequired(message) | Self::Disconnected(message) | Self::Other(message) => {
595                message.clone()
596            }
597        }
598    }
599
600    /// Whether asking again can succeed, which gives the list a Retry
601    /// action.
602    pub fn is_retryable(&self) -> bool {
603        matches!(self, Self::Disconnected(_) | Self::Other(_))
604    }
605
606    /// Whether the user must sign in, which gives the list a "Sign in"
607    /// action.
608    pub fn needs_sign_in(&self) -> bool {
609        matches!(self, Self::AuthRequired(_))
610    }
611}
612
613impl From<std::io::Error> for ListError {
614    fn from(error: std::io::Error) -> Self {
615        use std::io::ErrorKind;
616        match error.kind() {
617            ErrorKind::NotFound => Self::NotFound,
618            ErrorKind::PermissionDenied => Self::PermissionDenied,
619            ErrorKind::ConnectionAborted
620            | ErrorKind::ConnectionReset
621            | ErrorKind::ConnectionRefused
622            | ErrorKind::NotConnected
623            | ErrorKind::BrokenPipe
624            | ErrorKind::TimedOut => Self::Disconnected(error.to_string().into()),
625            _ => Self::Other(error.to_string().into()),
626        }
627    }
628}
629
630#[cfg(test)]
631mod tests {
632    use super::*;
633
634    fn no_env(_: &str) -> Option<String> {
635        None
636    }
637
638    fn pasted(style: PathStyle, text: &str) -> String {
639        style.normalize_pasted(text, &no_env)
640    }
641
642    #[test]
643    fn a_windows_path_takes_either_separator_and_writes_its_own() {
644        let windows = PathStyle::windows();
645        for text in [
646            "C:/Users/me",
647            "C:\\Users\\me",
648            "C:/Users\\me/",
649            "C:\\Users/me\\",
650        ] {
651            assert_eq!(windows.path(text).as_str(), "C:\\Users\\me", "{text}");
652        }
653        let slashes = PathStyle::windows().with_display_separator('/');
654        assert_eq!(slashes.path("C:\\Users\\me").as_str(), "C:/Users/me");
655    }
656
657    #[test]
658    fn a_posix_path_keeps_a_backslash_as_a_name_character() {
659        let posix = PathStyle::posix();
660        assert_eq!(posix.path("/home/a\\b/").as_str(), "/home/a\\b");
661        assert_eq!(posix.parse("/home/a\\b").parts, ["home", "a\\b"]);
662        assert!(!posix.is_separator('\\'));
663    }
664
665    #[test]
666    fn paths_that_name_one_place_are_equal_in_their_style() {
667        let windows = PathStyle::windows();
668        assert_eq!(windows.path("C:/Users/Me"), windows.path("c:\\users\\me\\"));
669        assert_ne!(windows.path("C:/Users/Me"), windows.path("D:/Users/Me"));
670        let posix = PathStyle::posix();
671        assert_ne!(posix.path("/Users/Me"), posix.path("/users/me"));
672        assert_eq!(
673            PathStyle::posix().with_case_sensitive(false).path("/A"),
674            PathStyle::posix().with_case_sensitive(false).path("/a")
675        );
676    }
677
678    #[test]
679    fn dots_fold_and_never_climb_above_the_root() {
680        let posix = PathStyle::posix();
681        assert_eq!(posix.path("/home/me/..").as_str(), "/home");
682        assert_eq!(posix.path("/home/./me/../you").as_str(), "/home/you");
683        assert_eq!(posix.path("/../..").as_str(), "/");
684        assert_eq!(posix.path("/a/../../b").as_str(), "/b");
685        let windows = PathStyle::windows();
686        assert_eq!(windows.path("C:\\a\\..\\b").as_str(), "C:\\b");
687        assert_eq!(windows.path("C:/a/../../..").as_str(), "C:\\");
688        assert_eq!(
689            windows.path("\\\\srv\\share\\..\\x").as_str(),
690            "\\\\srv\\share\\x"
691        );
692        assert_eq!(posix.path("/home/me/..").join("..").as_str(), "/");
693        assert_eq!(posix.path("/a").join("..").as_str(), "/");
694    }
695
696    #[test]
697    fn a_partial_unc_path_is_a_root_not_a_rooted_name() {
698        let windows = PathStyle::windows();
699        let server = windows.path("\\\\srv\\");
700        assert_eq!(server.as_str(), "\\\\srv\\");
701        assert!(server.is_root());
702        assert_eq!(server.parent(), None);
703        assert_eq!(windows.path("\\\\").parent(), None);
704        assert_eq!(windows.path("//srv").as_str(), "\\\\srv\\");
705    }
706
707    #[test]
708    fn a_quoted_file_url_is_unquoted_first_and_a_control_character_refuses_it() {
709        let posix = PathStyle::posix();
710        let windows = PathStyle::windows();
711        assert_eq!(pasted(posix, "\"file:///home/me\""), "/home/me");
712        assert_eq!(pasted(windows, "'file:///C:/Users/me'"), "C:\\Users\\me");
713        assert_eq!(pasted(posix, "file:///home/a%00b"), "file:///home/a%00b");
714        assert_eq!(pasted(windows, "file:///C:/a%0Ab"), "file:///C:/a%0Ab");
715    }
716
717    #[test]
718    fn roots_have_no_parent_and_parents_keep_the_root() {
719        let windows = PathStyle::windows();
720        assert_eq!(
721            windows.path("C:\\Users\\me").parent().unwrap().as_str(),
722            "C:\\Users"
723        );
724        assert_eq!(windows.path("C:\\Users").parent().unwrap().as_str(), "C:\\");
725        assert_eq!(windows.path("C:\\").parent(), None);
726        assert_eq!(
727            windows
728                .path("\\\\srv\\share\\dir")
729                .parent()
730                .unwrap()
731                .as_str(),
732            "\\\\srv\\share\\"
733        );
734        assert_eq!(
735            windows.path("\\\\srv\\share").parent(),
736            None,
737            "a share root stays"
738        );
739        let posix = PathStyle::posix();
740        assert_eq!(posix.path("/a/b").parent().unwrap().as_str(), "/a");
741        assert_eq!(posix.path("/a").parent().unwrap().as_str(), "/");
742        assert_eq!(posix.path("/").parent(), None);
743    }
744
745    #[test]
746    fn a_computer_root_sits_above_the_drives_only_when_the_source_has_one() {
747        let pc = PathStyle::windows().with_computer_root(true);
748        let drives = pc.path("C:\\").parent().unwrap();
749        assert_eq!(drives.as_str(), "\\");
750        assert!(drives.is_root());
751        assert_eq!(drives.parent(), None);
752        assert_eq!(pc.path("\\\\srv\\share").parent(), None);
753        assert_eq!(PathStyle::windows().path("C:\\").parent(), None);
754    }
755
756    #[test]
757    fn join_and_file_name_follow_the_style() {
758        let windows = PathStyle::windows();
759        assert_eq!(windows.path("C:\\").join("Users").as_str(), "C:\\Users");
760        assert_eq!(windows.path("C:\\Users").join("me").file_name(), Some("me"));
761        assert_eq!(windows.path("C:\\").file_name(), None);
762        assert_eq!(
763            PathStyle::posix().path("/").join(".config").as_str(),
764            "/.config"
765        );
766    }
767
768    #[test]
769    fn a_pasted_windows_path_is_cleaned_and_written_in_the_style() {
770        let windows = PathStyle::windows();
771        assert_eq!(pasted(windows, "\"C:\\Users\\me\""), "C:\\Users\\me");
772        assert_eq!(pasted(windows, "  C:/Users/me  "), "C:\\Users\\me");
773        assert_eq!(pasted(windows, "C:/Users\\me/docs"), "C:\\Users\\me\\docs");
774        assert_eq!(pasted(windows, "C:"), "C:\\", "a lone drive is its root");
775        assert_eq!(pasted(windows, "~/code"), "~\\code");
776        assert_eq!(
777            pasted(windows, "\\\\srv\\share\\dir"),
778            "\\\\srv\\share\\dir"
779        );
780        assert_eq!(pasted(windows, "//srv/share/dir"), "\\\\srv\\share\\dir");
781        assert_eq!(
782            pasted(
783                PathStyle::windows().with_display_separator('/'),
784                "C:\\Users\\me"
785            ),
786            "C:/Users/me"
787        );
788    }
789
790    #[test]
791    fn variables_expand_only_through_the_sources_lookup() {
792        let windows = PathStyle::windows();
793        let env = |name: &str| (name == "USERPROFILE").then(|| "C:\\Users\\me".to_string());
794        assert_eq!(
795            windows.normalize_pasted("%USERPROFILE%\\code", &env),
796            "C:\\Users\\me\\code"
797        );
798        assert_eq!(
799            windows.normalize_pasted("%OTHER%\\code", &env),
800            "%OTHER%\\code"
801        );
802        assert_eq!(
803            pasted(windows, "%USERPROFILE%\\code"),
804            "%USERPROFILE%\\code"
805        );
806    }
807
808    #[test]
809    fn a_file_url_becomes_a_path_in_the_style() {
810        let windows = PathStyle::windows();
811        assert_eq!(
812            pasted(windows, "file:///C:/Users/me%20x"),
813            "C:\\Users\\me x"
814        );
815        assert_eq!(
816            pasted(windows, "file://srv/share/dir"),
817            "\\\\srv\\share\\dir"
818        );
819        let posix = PathStyle::posix();
820        assert_eq!(pasted(posix, "file:///home/me"), "/home/me");
821        assert_eq!(pasted(posix, "file:///home/me%20x/a%2Fb"), "/home/me x/a/b");
822    }
823
824    #[test]
825    fn a_pasted_path_in_a_posix_style_is_left_alone() {
826        let posix = PathStyle::posix();
827        assert_eq!(pasted(posix, "C:\\Users\\me"), "C:\\Users\\me");
828        assert_eq!(pasted(posix, "\"/home/me\""), "\"/home/me\"");
829        assert_eq!(pasted(posix, "/home/a\\b"), "/home/a\\b");
830    }
831
832    #[test]
833    fn io_errors_map_to_the_matching_failure() {
834        use std::io::{Error, ErrorKind};
835        assert_eq!(
836            ListError::from(Error::from(ErrorKind::NotFound)),
837            ListError::NotFound
838        );
839        assert_eq!(
840            ListError::from(Error::from(ErrorKind::PermissionDenied)),
841            ListError::PermissionDenied
842        );
843        assert!(matches!(
844            ListError::from(Error::from(ErrorKind::ConnectionReset)),
845            ListError::Disconnected(_)
846        ));
847        assert!(ListError::from(Error::other("x")).is_retryable());
848        assert!(!ListError::NotFound.is_retryable());
849        assert!(ListError::AuthRequired("Sign in".into()).needs_sign_in());
850    }
851
852    #[test]
853    fn names_sort_without_case_and_fold_by_the_styles_case_rule() {
854        let windows = PathStyle::windows();
855        let mut names = vec!["b", "A", "a", "B"];
856        names.sort_by(|a, b| windows.compare_names(a, b));
857        assert_eq!(names, ["A", "a", "B", "b"]);
858        names.reverse();
859        names.sort_by(|a, b| PathStyle::posix().compare_names(a, b));
860        assert_eq!(names, ["A", "a", "B", "b"]);
861        assert_eq!(windows.fold("ABC"), "abc");
862        assert_eq!(PathStyle::posix().fold("ABC"), "ABC");
863    }
864}