cross/
file.rs

1use std::borrow::Cow;
2use std::ffi::OsStr;
3use std::fs::{self, File};
4use std::io::Read;
5#[cfg(target_family = "windows")]
6use std::path::Prefix;
7use std::path::{Component, Path, PathBuf};
8
9use crate::errors::*;
10
11pub trait ToUtf8 {
12    fn to_utf8(&self) -> Result<&str>;
13}
14
15impl ToUtf8 for OsStr {
16    fn to_utf8(&self) -> Result<&str> {
17        self.to_str()
18            .ok_or_else(|| eyre::eyre!("unable to convert `{self:?}` to UTF-8 string"))
19    }
20}
21
22impl ToUtf8 for Path {
23    fn to_utf8(&self) -> Result<&str> {
24        self.as_os_str().to_utf8()
25    }
26}
27
28pub trait PathExt {
29    fn as_posix(&self) -> Result<String>;
30    #[cfg(target_family = "windows")]
31    fn as_wslpath(&self) -> Result<String>;
32}
33
34#[cfg(target_family = "windows")]
35fn format_prefix(prefix: &str) -> Result<String> {
36    match prefix {
37        "" => eyre::bail!("Error: got empty windows prefix"),
38        _ => Ok(format!("/mnt/{}", prefix.to_lowercase())),
39    }
40}
41
42#[cfg(target_family = "windows")]
43fn fmt_disk(disk: u8) -> String {
44    (disk as char).to_string()
45}
46
47#[cfg(target_family = "windows")]
48fn fmt_unc(server: &std::ffi::OsStr, volume: &std::ffi::OsStr) -> Result<String> {
49    let server = server.to_utf8()?;
50    let volume = volume.to_utf8()?;
51    let bytes = volume.as_bytes();
52    if server == "localhost"
53        && bytes.len() == 2
54        && bytes[1] == b'$'
55        && matches!(bytes[0], b'A'..=b'Z' | b'a'..=b'z')
56    {
57        Ok(fmt_disk(bytes[0]))
58    } else {
59        Ok(format!("{}/{}", server, volume))
60    }
61}
62
63impl PathExt for Path {
64    fn as_posix(&self) -> Result<String> {
65        if cfg!(target_os = "windows") {
66            let push = |p: &mut String, c: &str| {
67                if !p.is_empty() && p != "/" {
68                    p.push('/');
69                }
70                p.push_str(c);
71            };
72
73            // iterate over components to join them
74            let mut output = String::new();
75            for component in self.components() {
76                match component {
77                    Component::Prefix(prefix) => {
78                        eyre::bail!("unix paths cannot handle windows prefix {prefix:?}.")
79                    }
80                    Component::RootDir => output = "/".to_owned(),
81                    Component::CurDir => push(&mut output, "."),
82                    Component::ParentDir => push(&mut output, ".."),
83                    Component::Normal(path) => push(&mut output, path.to_utf8()?),
84                }
85            }
86            Ok(output)
87        } else {
88            self.to_utf8().map(|x| x.to_owned())
89        }
90    }
91
92    // this is similar to as_posix, but it handles drive separators
93    // and doesn't assume a relative path.
94    #[cfg(target_family = "windows")]
95    fn as_wslpath(&self) -> Result<String> {
96        let path = canonicalize(self)?;
97
98        let push = |p: &mut String, c: &str, r: bool| {
99            if !r {
100                p.push('/');
101            }
102            p.push_str(c);
103        };
104        // iterate over components to join them
105        let mut output = String::new();
106        let mut root_prefix = String::new();
107        let mut was_root = false;
108        for component in path.components() {
109            match component {
110                Component::Prefix(prefix) => {
111                    root_prefix = match prefix.kind() {
112                        Prefix::Verbatim(verbatim) => verbatim.to_utf8()?.to_owned(),
113                        Prefix::VerbatimUNC(server, volume) => fmt_unc(server, volume)?,
114                        // we should never get this, but it's effectively just
115                        // a root_prefix since we force absolute paths.
116                        Prefix::VerbatimDisk(disk) => fmt_disk(disk),
117                        Prefix::UNC(server, volume) => fmt_unc(server, volume)?,
118                        Prefix::DeviceNS(ns) => ns.to_utf8()?.to_owned(),
119                        Prefix::Disk(disk) => fmt_disk(disk),
120                    }
121                }
122                Component::RootDir => output = format!("{}/", format_prefix(&root_prefix)?),
123                Component::CurDir => push(&mut output, ".", was_root),
124                Component::ParentDir => push(&mut output, "..", was_root),
125                Component::Normal(path) => push(&mut output, path.to_utf8()?, was_root),
126            }
127            was_root = component == Component::RootDir;
128        }
129
130        // remove trailing '/'
131        if was_root {
132            output.truncate(output.len() - 1);
133        }
134
135        Ok(output)
136    }
137}
138
139pub fn read<P>(path: P) -> Result<String>
140where
141    P: AsRef<Path>,
142{
143    read_(path.as_ref())
144}
145
146fn read_(path: &Path) -> Result<String> {
147    let mut s = String::new();
148    File::open(path)
149        .wrap_err_with(|| format!("couldn't open {path:?}"))?
150        .read_to_string(&mut s)
151        .wrap_err_with(|| format!("couldn't read {path:?}"))?;
152    Ok(s)
153}
154
155pub fn canonicalize(path: impl AsRef<Path>) -> Result<PathBuf> {
156    _canonicalize(path.as_ref())
157        .wrap_err_with(|| format!("when canonicalizing path `{:?}`", path.as_ref()))
158}
159
160fn _canonicalize(path: &Path) -> Result<PathBuf> {
161    #[cfg(target_os = "windows")]
162    {
163        // Docker does not support UNC paths, this will try to not use UNC paths
164        dunce::canonicalize(&path).map_err(Into::into)
165    }
166    #[cfg(not(target_os = "windows"))]
167    {
168        Path::new(&path).canonicalize().map_err(Into::into)
169    }
170}
171
172/// Pretty format a file path. Also removes the path prefix from a command if wanted
173pub fn pretty_path(path: impl AsRef<Path>, strip: impl for<'a> Fn(&'a str) -> bool) -> String {
174    let path = path.as_ref();
175    // TODO: Use Path::file_prefix
176    let file_stem = path.file_stem();
177    let file_name = path.file_name();
178    let path = if let (Some(file_stem), Some(file_name)) = (file_stem, file_name) {
179        if let Some(file_name) = file_name.to_str() {
180            if strip(file_name) {
181                Cow::Borrowed(file_stem)
182            } else {
183                Cow::Borrowed(path.as_os_str())
184            }
185        } else {
186            Cow::Borrowed(path.as_os_str())
187        }
188    } else {
189        maybe_canonicalize(path)
190    };
191
192    if let Some(path) = path.to_str() {
193        shell_escape(path).to_string()
194    } else {
195        format!("{path:?}")
196    }
197}
198
199pub fn shell_escape(string: &str) -> Cow<'_, str> {
200    let escape: &[char] = if cfg!(target_os = "windows") {
201        &['%', '$', '`', '!', '"']
202    } else {
203        &['$', '\'', '\\', '!', '"']
204    };
205
206    if string.contains(escape) {
207        Cow::Owned(format!("{string:?}"))
208    } else if string.contains(' ') {
209        Cow::Owned(format!("\"{string}\""))
210    } else {
211        Cow::Borrowed(string)
212    }
213}
214
215#[must_use]
216pub fn maybe_canonicalize(path: &Path) -> Cow<'_, OsStr> {
217    canonicalize(path).map_or_else(
218        |_| path.as_os_str().into(),
219        |p| Cow::Owned(p.as_os_str().to_owned()),
220    )
221}
222
223pub fn write_file(path: impl AsRef<Path>, overwrite: bool) -> Result<File> {
224    let path = path.as_ref();
225    fs::create_dir_all(
226        &path
227            .parent()
228            .ok_or_else(|| eyre::eyre!("could not find parent directory for `{path:?}`"))?,
229    )
230    .wrap_err_with(|| format!("couldn't create directory `{path:?}`"))?;
231
232    let mut open = fs::OpenOptions::new();
233    open.write(true);
234
235    if overwrite {
236        open.truncate(true).create(true);
237    } else {
238        open.create_new(true);
239    }
240
241    open.open(&path)
242        .wrap_err(format!("couldn't write to file `{path:?}`"))
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248    use std::fmt::Debug;
249
250    macro_rules! p {
251        ($path:expr) => {
252            Path::new($path)
253        };
254    }
255
256    fn result_eq<T: PartialEq + Eq + Debug>(x: Result<T>, y: Result<T>) {
257        match (x, y) {
258            (Ok(x), Ok(y)) => assert_eq!(x, y),
259            (x, y) => panic!("assertion failed: `(left == right)`\nleft: {x:?}\nright: {y:?}"),
260        }
261    }
262
263    #[test]
264    fn as_posix() {
265        result_eq(p!(".").join("..").as_posix(), Ok("./..".to_owned()));
266        result_eq(p!(".").join("/").as_posix(), Ok("/".to_owned()));
267        result_eq(p!("foo").join("bar").as_posix(), Ok("foo/bar".to_owned()));
268        result_eq(p!("/foo").join("bar").as_posix(), Ok("/foo/bar".to_owned()));
269    }
270
271    #[test]
272    #[cfg(target_family = "windows")]
273    fn as_posix_prefix() {
274        assert_eq!(p!("C:").join(".."), p!("C:.."));
275        assert!(p!("C:").join("..").as_posix().is_err());
276    }
277
278    #[test]
279    #[cfg(target_family = "windows")]
280    fn as_wslpath() {
281        result_eq(p!(r"C:\").as_wslpath(), Ok("/mnt/c".to_owned()));
282        result_eq(p!(r"C:\Users").as_wslpath(), Ok("/mnt/c/Users".to_owned()));
283        result_eq(
284            p!(r"\\localhost\c$\Users").as_wslpath(),
285            Ok("/mnt/c/Users".to_owned()),
286        );
287        result_eq(p!(r"\\.\C:\").as_wslpath(), Ok("/mnt/c".to_owned()));
288        result_eq(
289            p!(r"\\.\C:\Users").as_wslpath(),
290            Ok("/mnt/c/Users".to_owned()),
291        );
292    }
293
294    #[test]
295    #[cfg(target_family = "windows")]
296    fn pretty_path_windows() {
297        assert_eq!(
298            pretty_path("C:\\path\\bin\\cargo.exe", |f| f.contains("cargo")),
299            "cargo".to_owned()
300        );
301        assert_eq!(
302            pretty_path("C:\\Program Files\\Docker\\bin\\docker.exe", |_| false),
303            "\"C:\\Program Files\\Docker\\bin\\docker.exe\"".to_owned()
304        );
305        assert_eq!(
306            pretty_path("C:\\Program Files\\single'quote\\cargo.exe", |c| c
307                .contains("cargo")),
308            "cargo".to_owned()
309        );
310        assert_eq!(
311            pretty_path("C:\\Program Files\\single'quote\\cargo.exe", |_| false),
312            "\"C:\\Program Files\\single'quote\\cargo.exe\"".to_owned()
313        );
314        assert_eq!(
315            pretty_path("C:\\Program Files\\%not_var%\\cargo.exe", |_| false),
316            "\"C:\\\\Program Files\\\\%not_var%\\\\cargo.exe\"".to_owned()
317        );
318    }
319
320    #[test]
321    #[cfg(target_family = "unix")]
322    fn pretty_path_linux() {
323        assert_eq!(
324            pretty_path("/usr/bin/cargo", |f| f.contains("cargo")),
325            "cargo".to_owned()
326        );
327        assert_eq!(
328            pretty_path("/home/user/my rust/bin/cargo", |_| false),
329            "\"/home/user/my rust/bin/cargo\"".to_owned(),
330        );
331        assert_eq!(
332            pretty_path("/home/user/single'quote/cargo", |c| c.contains("cargo")),
333            "cargo".to_owned()
334        );
335        assert_eq!(
336            pretty_path("/home/user/single'quote/cargo", |_| false),
337            "\"/home/user/single'quote/cargo\"".to_owned()
338        );
339    }
340}