sett 0.3.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 anyhow::{bail, ensure, Context, Result};
use tracing::warn;

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> {
    // TODO: use try_reduce once it's stable
    // https://doc.rust-lang.org/std/iter/trait.Iterator.html#method.try_reduce
    let mut total = 0;
    for f in files {
        total += fs::metadata(f)?.len();
    }
    Ok(total)
}

/// 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> {
    let path = Path::new(path.as_ref());
    ensure!(
        path.is_absolute(),
        "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))
            })
            .context("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<()> {
    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) {
                bail!(msg);
            }
            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<()> {
    let Ok(available_space) = get_available_space(&dest) else {
        warn!(
            "Unable to check available space on the device for path {}.",
            dest.as_ref().display()
        );
        return Ok(());
    };
    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) {
            bail!(msg);
        }
        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> {
    let mut abs_paths = paths
        .iter()
        .map(|p| {
            let p = p.as_ref();
            p.is_absolute()
                .then_some(p)
                .with_context(|| format!("Path {p:?} is not absolute"))
        })
        .collect::<Result<Vec<_>, _>>()?;
    abs_paths.sort();

    let first = abs_paths.first().context("No files found")?.components();
    let last = abs_paths.last().context("No files found")?.components();

    Ok(first
        .zip(last)
        .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() -> Result<()> {
        let empty: [&Path; 0] = [];
        assert!(get_common_path(&empty).is_err());
        assert!(get_common_path(&["foo/bar"]).is_err());

        #[cfg(windows)]
        macro_rules! os_prefix {
            ($path:expr) => {{
                ["C:", $path].join("")
            }};
        }
        #[cfg(not(windows))]
        macro_rules! os_prefix {
            ($path:expr) => {{
                $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")
            ])?,
            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"),
            ])?,
            PathBuf::from(os_prefix!("/home/chuck/data/log"))
        );
        Ok(())
    }

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

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

    #[test]
    #[cfg(windows)]
    fn test_to_posix_path_windows() -> Result<()> {
        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]))
            );
        }
        Ok(())
    }
}