elio 1.7.0

Snappy, batteries-included terminal file manager with rich previews, inline images, bulk actions, and trash support.
Documentation
use std::{fs, io, path::Path};

#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub(crate) struct DirectoryStats {
    pub item_count: usize,
    pub folder_count: usize,
    pub file_count: usize,
    pub total_size_bytes: u64,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum DirectoryStatsScanResult {
    Complete(DirectoryStats),
    Incomplete {
        partial: DirectoryStats,
        error: String,
    },
    Canceled,
}

pub(crate) fn scan_directory_stats(
    root: &Path,
    canceled: &dyn Fn() -> bool,
) -> DirectoryStatsScanResult {
    let mut stats = DirectoryStats::default();
    let mut first_error = None::<io::Error>;
    let mut pending = vec![root.to_path_buf()];

    while let Some(dir) = pending.pop() {
        if canceled() {
            return DirectoryStatsScanResult::Canceled;
        }

        let entries = match fs::read_dir(&dir) {
            Ok(entries) => entries,
            Err(error) => {
                first_error.get_or_insert(error);
                continue;
            }
        };

        for entry in entries {
            if canceled() {
                return DirectoryStatsScanResult::Canceled;
            }

            let entry = match entry {
                Ok(entry) => entry,
                Err(error) => {
                    first_error.get_or_insert(error);
                    continue;
                }
            };

            let path = entry.path();
            let metadata = match fs::symlink_metadata(&path) {
                Ok(metadata) => metadata,
                Err(error) => {
                    first_error.get_or_insert(error);
                    continue;
                }
            };

            stats.item_count = stats.item_count.saturating_add(1);
            if metadata.file_type().is_dir() {
                stats.folder_count = stats.folder_count.saturating_add(1);
                pending.push(path);
            } else {
                stats.file_count = stats.file_count.saturating_add(1);
                stats.total_size_bytes = stats.total_size_bytes.saturating_add(metadata.len());
            }
        }
    }

    match first_error {
        Some(error) => DirectoryStatsScanResult::Incomplete {
            partial: stats,
            error: directory_stats_error_message(&error),
        },
        None => DirectoryStatsScanResult::Complete(stats),
    }
}

fn directory_stats_error_message(error: &io::Error) -> String {
    match error.kind() {
        io::ErrorKind::PermissionDenied => "Some entries unreadable".to_string(),
        io::ErrorKind::NotFound => "Folder changed while scanning".to_string(),
        _ => "Folder totals incomplete".to_string(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::{
        fs,
        path::PathBuf,
        time::{SystemTime, UNIX_EPOCH},
    };

    #[cfg(unix)]
    use std::os::unix::fs::symlink;

    fn temp_path(label: &str) -> PathBuf {
        let unique = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .expect("system time should be after unix epoch")
            .as_nanos();
        std::env::temp_dir().join(format!("elio-directory-stats-{label}-{unique}"))
    }

    #[test]
    fn recursive_directory_stats_include_nested_entries_and_sizes() {
        let root = temp_path("recursive");
        let nested = root.join("nested");
        fs::create_dir_all(&nested).expect("failed to create nested dir");
        fs::write(root.join("a.txt"), vec![b'a'; 500]).expect("failed to write file");
        fs::write(nested.join("b.txt"), vec![b'b'; 700]).expect("failed to write nested file");

        let result = scan_directory_stats(&root, &|| false);

        assert_eq!(
            result,
            DirectoryStatsScanResult::Complete(DirectoryStats {
                item_count: 3,
                folder_count: 1,
                file_count: 2,
                total_size_bytes: 1_200,
            })
        );

        fs::remove_dir_all(root).expect("failed to remove temp root");
    }

    #[cfg(unix)]
    #[test]
    fn recursive_directory_stats_do_not_follow_symlinked_directories() {
        let root = temp_path("symlink-dir");
        let nested = root.join("nested");
        let linked = root.join("linked");
        fs::create_dir_all(&nested).expect("failed to create nested dir");
        fs::write(nested.join("inside.txt"), vec![b'x'; 900]).expect("failed to write file");
        symlink(&nested, &linked).expect("failed to create symlink");

        let result = scan_directory_stats(&root, &|| false);
        let symlink_size = fs::symlink_metadata(&linked)
            .expect("failed to stat symlink")
            .len();

        assert_eq!(
            result,
            DirectoryStatsScanResult::Complete(DirectoryStats {
                item_count: 3,
                folder_count: 1,
                file_count: 2,
                total_size_bytes: 900 + symlink_size,
            })
        );

        fs::remove_dir_all(root).expect("failed to remove temp root");
    }
}