mdbook_core/utils/
fs.rs

1//! Filesystem utilities and helpers.
2
3use anyhow::{Context, Result};
4use std::fs;
5use std::path::{Component, Path, PathBuf};
6use tracing::debug;
7
8/// Reads a file into a string.
9///
10/// Equivalent to [`std::fs::read_to_string`] with better error messages.
11pub fn read_to_string<P: AsRef<Path>>(path: P) -> Result<String> {
12    let path = path.as_ref();
13    fs::read_to_string(path).with_context(|| format!("failed to read `{}`", path.display()))
14}
15
16/// Writes a file to disk.
17///
18/// Equivalent to [`std::fs::write`] with better error messages. This will
19/// also create the parent directory if it doesn't exist.
20pub fn write<P: AsRef<Path>, C: AsRef<[u8]>>(path: P, contents: C) -> Result<()> {
21    let path = path.as_ref();
22    debug!("Writing `{}`", path.display());
23    if let Some(parent) = path.parent() {
24        create_dir_all(parent)?;
25    }
26    fs::write(path, contents.as_ref())
27        .with_context(|| format!("failed to write `{}`", path.display()))
28}
29
30/// Equivalent to [`std::fs::create_dir_all`] with better error messages.
31pub fn create_dir_all(p: impl AsRef<Path>) -> Result<()> {
32    let p = p.as_ref();
33    fs::create_dir_all(p)
34        .with_context(|| format!("failed to create directory `{}`", p.display()))?;
35    Ok(())
36}
37
38/// Takes a path and returns a path containing just enough `../` to point to
39/// the root of the given path.
40///
41/// This is mostly interesting for a relative path to point back to the
42/// directory from where the path starts.
43///
44/// ```rust
45/// # use std::path::Path;
46/// # use mdbook_core::utils::fs::path_to_root;
47/// let path = Path::new("some/relative/path");
48/// assert_eq!(path_to_root(path), "../../");
49/// ```
50///
51/// **note:** it's not very fool-proof, if you find a situation where
52/// it doesn't return the correct path.
53/// Consider [submitting a new issue](https://github.com/rust-lang/mdBook/issues)
54/// or a [pull-request](https://github.com/rust-lang/mdBook/pulls) to improve it.
55pub fn path_to_root<P: Into<PathBuf>>(path: P) -> String {
56    // Remove filename and add "../" for every directory
57
58    path.into()
59        .parent()
60        .expect("")
61        .components()
62        .fold(String::new(), |mut s, c| {
63            match c {
64                Component::Normal(_) => s.push_str("../"),
65                _ => {
66                    debug!("Other path component... {:?}", c);
67                }
68            }
69            s
70        })
71}
72
73/// Removes all the content of a directory but not the directory itself.
74pub fn remove_dir_content(dir: &Path) -> Result<()> {
75    for item in fs::read_dir(dir)
76        .with_context(|| format!("failed to read directory `{}`", dir.display()))?
77        .flatten()
78    {
79        let item = item.path();
80        if item.is_dir() {
81            fs::remove_dir_all(&item)
82                .with_context(|| format!("failed to remove `{}`", item.display()))?;
83        } else {
84            fs::remove_file(&item)
85                .with_context(|| format!("failed to remove `{}`", item.display()))?;
86        }
87    }
88    Ok(())
89}
90
91/// Copies all files of a directory to another one except the files
92/// with the extensions given in the `ext_blacklist` array
93pub fn copy_files_except_ext(
94    from: &Path,
95    to: &Path,
96    recursive: bool,
97    avoid_dir: Option<&PathBuf>,
98    ext_blacklist: &[&str],
99) -> Result<()> {
100    debug!(
101        "Copying all files from {} to {} (blacklist: {:?}), avoiding {:?}",
102        from.display(),
103        to.display(),
104        ext_blacklist,
105        avoid_dir
106    );
107
108    // Check that from and to are different
109    if from == to {
110        return Ok(());
111    }
112
113    for entry in fs::read_dir(from)? {
114        let entry = entry?.path();
115        let metadata = entry
116            .metadata()
117            .with_context(|| format!("Failed to read {entry:?}"))?;
118
119        let entry_file_name = entry.file_name().unwrap();
120        let target_file_path = to.join(entry_file_name);
121
122        // If the entry is a dir and the recursive option is enabled, call itself
123        if metadata.is_dir() && recursive {
124            if entry == to.as_os_str() {
125                continue;
126            }
127
128            if let Some(avoid) = avoid_dir {
129                if entry == *avoid {
130                    continue;
131                }
132            }
133
134            // check if output dir already exists
135            if !target_file_path.exists() {
136                fs::create_dir(&target_file_path)?;
137            }
138
139            copy_files_except_ext(&entry, &target_file_path, true, avoid_dir, ext_blacklist)?;
140        } else if metadata.is_file() {
141            // Check if it is in the blacklist
142            if let Some(ext) = entry.extension() {
143                if ext_blacklist.contains(&ext.to_str().unwrap()) {
144                    continue;
145                }
146            }
147            debug!("Copying {entry:?} to {target_file_path:?}");
148            copy(&entry, &target_file_path)?;
149        }
150    }
151    Ok(())
152}
153
154/// Copies a file.
155fn copy<P: AsRef<Path>, Q: AsRef<Path>>(from: P, to: Q) -> Result<()> {
156    let from = from.as_ref();
157    let to = to.as_ref();
158    return copy_inner(from, to)
159        .with_context(|| format!("failed to copy `{}` to `{}`", from.display(), to.display()));
160
161    // This is a workaround for an issue with the macOS file watcher.
162    // Rust's `std::fs::copy` function uses `fclonefileat`, which creates
163    // clones on APFS. Unfortunately fs events seem to trigger on both
164    // sides of the clone, and there doesn't seem to be a way to differentiate
165    // which side it is.
166    // https://github.com/notify-rs/notify/issues/465#issuecomment-1657261035
167    // contains more information.
168    //
169    // This is essentially a copy of the simple copy code path in Rust's
170    // standard library.
171    #[cfg(target_os = "macos")]
172    fn copy_inner(from: &Path, to: &Path) -> Result<()> {
173        use std::fs::OpenOptions;
174        use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
175
176        let mut reader = std::fs::File::open(from)?;
177        let metadata = reader.metadata()?;
178        if !metadata.is_file() {
179            anyhow::bail!(
180                "expected a file, `{}` appears to be {:?}",
181                from.display(),
182                metadata.file_type()
183            );
184        }
185        let perm = metadata.permissions();
186        let mut writer = OpenOptions::new()
187            .mode(perm.mode())
188            .write(true)
189            .create(true)
190            .truncate(true)
191            .open(to)?;
192        let writer_metadata = writer.metadata()?;
193        if writer_metadata.is_file() {
194            // Set the correct file permissions, in case the file already existed.
195            // Don't set the permissions on already existing non-files like
196            // pipes/FIFOs or device nodes.
197            writer.set_permissions(perm)?;
198        }
199        std::io::copy(&mut reader, &mut writer)?;
200        Ok(())
201    }
202
203    #[cfg(not(target_os = "macos"))]
204    fn copy_inner(from: &Path, to: &Path) -> Result<()> {
205        fs::copy(from, to)?;
206        Ok(())
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use std::io::Result;
214    use std::path::Path;
215
216    #[cfg(target_os = "windows")]
217    fn symlink<P: AsRef<Path>, Q: AsRef<Path>>(src: P, dst: Q) -> Result<()> {
218        std::os::windows::fs::symlink_file(src, dst)
219    }
220
221    #[cfg(not(target_os = "windows"))]
222    fn symlink<P: AsRef<Path>, Q: AsRef<Path>>(src: P, dst: Q) -> Result<()> {
223        std::os::unix::fs::symlink(src, dst)
224    }
225
226    #[test]
227    fn copy_files_except_ext_test() {
228        let tmp = match tempfile::TempDir::new() {
229            Ok(t) => t,
230            Err(e) => panic!("Could not create a temp dir: {e}"),
231        };
232
233        // Create a couple of files
234        write(tmp.path().join("file.txt"), "").unwrap();
235        write(tmp.path().join("file.md"), "").unwrap();
236        write(tmp.path().join("file.png"), "").unwrap();
237        write(tmp.path().join("sub_dir/file.png"), "").unwrap();
238        write(tmp.path().join("sub_dir_exists/file.txt"), "").unwrap();
239        if let Err(err) = symlink(tmp.path().join("file.png"), tmp.path().join("symlink.png")) {
240            panic!("Could not symlink file.png: {err}");
241        }
242
243        // Create output dir
244        create_dir_all(tmp.path().join("output")).unwrap();
245        create_dir_all(tmp.path().join("output/sub_dir_exists")).unwrap();
246
247        if let Err(e) =
248            copy_files_except_ext(tmp.path(), &tmp.path().join("output"), true, None, &["md"])
249        {
250            panic!("Error while executing the function:\n{e:?}");
251        }
252
253        // Check if the correct files where created
254        if !tmp.path().join("output/file.txt").exists() {
255            panic!("output/file.txt should exist")
256        }
257        if tmp.path().join("output/file.md").exists() {
258            panic!("output/file.md should not exist")
259        }
260        if !tmp.path().join("output/file.png").exists() {
261            panic!("output/file.png should exist")
262        }
263        if !tmp.path().join("output/sub_dir/file.png").exists() {
264            panic!("output/sub_dir/file.png should exist")
265        }
266        if !tmp.path().join("output/sub_dir_exists/file.txt").exists() {
267            panic!("output/sub_dir/file.png should exist")
268        }
269        if !tmp.path().join("output/symlink.png").exists() {
270            panic!("output/symlink.png should exist")
271        }
272    }
273}