sett 0.4.0

Rust port of sett (data compression, encryption and transfer tool).
Documentation
//! Filesystem utilities

use std::borrow::Cow;
use std::fs;
use std::path::{Path, PathBuf};
use tempfile::NamedTempFile;

use crate::{task::Mode, utils::to_human_readable_size};

/// Calculates the combined size of the provided files.
pub(super) fn get_combined_file_size<P: AsRef<Path>, I: Iterator<Item = P>>(
    files: I,
) -> Result<u64, std::io::Error> {
    files.map(|f| Ok(fs::metadata(f)?.len())).sum()
}

/// Returns available disk space in bytes.
///
/// The provided absolute path is used to find the corresponding device.
fn get_available_space<P: AsRef<Path>>(path: P) -> Result<u64, std::io::Error> {
    let path = Path::new(path.as_ref());
    if !path.is_absolute() {
        return Err(std::io::Error::other(format!(
            "Available space can only be checked for absolute paths (provided path {path:?})"
        )));
    }
    #[cfg(not(windows))]
    {
        let stat = rustix::fs::statvfs(path)?;
        Ok(stat.f_frsize * stat.f_bavail)
    }
    #[cfg(windows)]
    {
        use sysinfo::Disks;
        let disks = Disks::new_with_refreshed_list();
        Ok(disks
            .iter()
            .find(|disk| {
                disk.mount_point()
                    .canonicalize()
                    .map_or(false, |mount_point| path.starts_with(mount_point))
            })
            .ok_or_else(|| std::io::Error::other("Unable to find device for the given path"))?
            .available_space())
    }
}

/// Checks whether given path is writeable.
///
/// The provided absolute path is used to find the corresponding device.
/// A warning is logged if `force` is `true` instead of returning an error.
pub(super) fn check_writeable(path: impl AsRef<Path>, mode: Mode) -> Result<(), std::io::Error> {
    match NamedTempFile::new_in(path.as_ref()) {
        Ok(marker_file) => {
            marker_file.close()?;
        }
        Err(_) => {
            let msg = format!(
                "Destination directory '{}' is not writeable.",
                path.as_ref().to_string_lossy()
            );
            if !matches!(mode, Mode::Force) {
                return Err(std::io::Error::new(
                    std::io::ErrorKind::ReadOnlyFilesystem,
                    msg,
                ));
            }
            tracing::warn!("{} Operation will most likely fail.", msg);
        }
    }
    Ok(())
}

/// Checks if there is sufficient available space on the given device.
///
/// The provided absolute path is used to find the corresponding device.
/// A warning is logged if `force` is `true` instead of returning an error.
pub(super) fn check_space(
    required_space: u64,
    dest: impl AsRef<Path>,
    mode: Mode,
) -> Result<(), std::io::Error> {
    let Ok(available_space) = get_available_space(&dest) else {
        tracing::warn!(
            "Unable to check available space on the device for path {}.",
            dest.as_ref().display()
        );
        return Ok(());
    };
    tracing::trace!(
        available = available_space,
        required = required_space,
        "available storage check"
    );
    if available_space < required_space {
        let msg = format!(
            "No space left on device. Required: {}, available: {}.",
            to_human_readable_size(required_space),
            to_human_readable_size(available_space),
        );
        if !matches!(mode, Mode::Force) {
            return Err(std::io::Error::new(std::io::ErrorKind::StorageFull, msg));
        }
        tracing::warn!("{} Operation will most likely fail.", msg);
    }
    Ok(())
}

/// Return the longest common sub-path.
///
/// Returns an error if any path is relative.
pub(super) fn get_common_path<P: AsRef<Path>>(paths: &[P]) -> Result<PathBuf, std::io::Error> {
    let mut abs_paths = paths
        .iter()
        .map(|p| {
            let p = p.as_ref();
            p.is_absolute()
                .then_some(p)
                .ok_or_else(|| std::io::Error::other(format!("Path {p:?} is not absolute")))
        })
        .collect::<Result<Vec<_>, _>>()?;
    abs_paths.sort();

    let (Some(first), Some(last)) = (abs_paths.first(), abs_paths.last()) else {
        return Err(std::io::Error::new(
            std::io::ErrorKind::NotFound,
            "No files found",
        ));
    };

    Ok(first
        .components()
        .zip(last.components())
        .take_while(|(x, y)| x == y)
        .map(|(x, _)| x.as_os_str().to_string_lossy().into_owned())
        .collect())
}

/// Return a POSIX path representation.
pub(super) fn to_posix_path(p: &Path) -> Option<Cow<'_, str>> {
    #[cfg(not(windows))]
    {
        p.to_str().map(Cow::Borrowed)
    }
    #[cfg(windows)]
    {
        use std::path::Component;
        let components = p
            .components()
            .map(|c| match c {
                Component::RootDir => Some(""),
                Component::CurDir => Some("."),
                Component::ParentDir => Some(".."),
                Component::Normal(s) => s.to_str(),
                Component::Prefix(prefix) => prefix.as_os_str().to_str(),
            })
            // TODO: use `intersperse` once it's stable
            // https://doc.rust-lang.org/std/iter/trait.Iterator.html#method.intersperse
            .collect::<Option<Vec<_>>>()?;
        Some(Cow::Owned(
            if components.len() == 1 && components[0].is_empty() {
                String::from("/")
            } else {
                components.join("/")
            },
        ))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_get_common_path() {
        let empty: [&Path; 0] = [];
        assert!(get_common_path(&empty).is_err());
        assert!(get_common_path(&["foo/bar"]).is_err());

        #[cfg(windows)]
        fn os_prefix(path: &str) -> String {
            ["C:", path].join("")
        }
        #[cfg(not(windows))]
        fn os_prefix(path: &str) -> &str {
            path
        }

        assert_eq!(
            get_common_path(&[
                os_prefix("/home/chuck/foo/data.json"),
                os_prefix("/home/chuck/bar/secret.csv"),
                os_prefix("/tmp/test.txt")
            ])
            .unwrap(),
            PathBuf::from(os_prefix("/"))
        );
        assert_eq!(
            get_common_path(&[
                os_prefix("/home/chuck/data/log/foo/2022-02-24T09-09-24.txt"),
                os_prefix("/home/chuck/data/log/foo/log.1"),
                os_prefix("/home/chuck/data/log/foo/log.7"),
                os_prefix("/home/chuck/data/log/foo/baz/2022-02-24T09-05-43.txt"),
                os_prefix("/home/chuck/data/log/foo/log.3"),
                os_prefix("/home/chuck/data/log/foo/log.6"),
                os_prefix("/home/chuck/data/log/foo/2022-02-24T09-07-37.txt"),
                os_prefix("/home/chuck/data/log/bar/baz/log.1"),
                os_prefix("/home/chuck/data/log/foo/2022-02-24T09-07-00.txt"),
                os_prefix("/home/chuck/data/log/bar/baz/log.2"),
            ])
            .unwrap(),
            PathBuf::from(os_prefix("/home/chuck/data/log"))
        );
    }

    #[test]
    fn test_get_available_space() {
        let available_space =
            get_available_space(std::env::temp_dir().canonicalize().unwrap()).unwrap();
        assert!(available_space > 0);
    }

    #[test]
    fn test_to_posix_path() {
        for p in [
            "foo/bar/baz",
            "/",
            "/foo/bar",
            "./foo",
            "../foo",
            "./foo/../bar",
        ] {
            assert_eq!(to_posix_path(Path::new(p)), Some(Cow::Borrowed(p)));
        }
    }

    #[test]
    #[cfg(windows)]
    fn test_to_posix_path_windows() {
        for row in [
            [r"foo\bar\baz", "foo/bar/baz"],
            [r"\", "/"],
            [r"\foo\bar", "/foo/bar"],
            [r".\foo", "./foo"],
            [r"..\foo", "../foo"],
            [r".\foo\..\bar", "./foo/../bar"],
        ] {
            assert_eq!(
                to_posix_path(Path::new(row[0])),
                Some(Cow::Borrowed(row[1]))
            );
        }
    }
}