refine 3.1.0

Refine your file collections using Rust!
use super::Refine;
use crate::entries::Entry;
use crate::fetcher::{DirPolicy, Fetcher, InputInfo, Recursion};
use crate::info;
use crate::utils::{display_abort, natural_cmp};
use anyhow::Result;
use clap::{Args, ValueEnum};
use human_repr::HumanCount;
use std::cmp::Ordering;
use std::sync::OnceLock;
use yansi::{Color, Paint};

#[derive(Debug, Args)]
pub struct List {
    /// Sort by.
    #[arg(short = 'b', long, default_value_t = By::Size, value_name = "STR", value_enum)]
    by: By,
    /// Reverse the default order (size/count:desc, name/path:asc).
    #[arg(short = 'r', long)]
    rev: bool,
    /// Show file paths.
    #[arg(short = 'p', long)]
    paths: bool,
    /// Do not calculate directory sizes.
    #[arg(short = 'c', long)]
    no_calc_dirs: bool,
}

#[derive(Debug, Copy, Clone, PartialEq, ValueEnum)]
pub enum By {
    #[value(alias = "s")]
    Size,
    #[value(alias = "c")]
    Count,
    #[value(alias = "n")]
    Name,
    #[value(alias = "p")]
    Path,
}

#[derive(Debug)]
pub struct Media {
    entry: Entry,
    size_count: Option<(u64, u32)>,
}

const ORDERING: &[(By, bool)] = &[
    (By::Size, true),
    (By::Count, true),
    (By::Name, false),
    (By::Path, false),
];

static CALC_DIR_SIZES: OnceLock<bool> = OnceLock::new();

impl Refine for List {
    type Media = Media;
    const OPENING_LINE: &'static str = "List files";
    const DIR_POLICY: DirPolicy = DirPolicy::AtLimit;

    fn tweak(&mut self, info: &InputInfo) {
        self.rev ^= ORDERING.iter().find(|(b, _)| *b == self.by).unwrap().1;
        if self.by == By::Path && !self.paths {
            self.paths = true;
            info!("Enabling file paths due to sorting by paths.\n");
        }
        if info.num_valid > 1 && !self.paths {
            self.paths = true;
            info!("Enabling file paths due to multiple input paths.\n");
        }
        CALC_DIR_SIZES.set(!self.no_calc_dirs).unwrap();
    }

    fn peek(&mut self, medias: &[Self::Media]) {
        // detect if there are entries within subdirectories, and if so, enable paths to disambiguate them.
        if !self.paths {
            let first = medias.first().and_then(|m| m.entry.parent());
            if medias.iter().any(|m| m.entry.parent() != first) {
                self.paths = true;
                info!("Enabling file paths due to entries across multiple directories.\n");
            }
        }
    }

    fn refine(&self, mut medias: Vec<Self::Media>) -> Result<()> {
        // step: sort the files by size, count, name, or path.
        let compare: fn(&Media, &Media) -> _ = match self.by {
            By::Size => |m, n| {
                m.size_count
                    .map(|(s, _)| s)
                    .cmp(&n.size_count.map(|(s, _)| s))
            },
            By::Count => |m, n| {
                m.size_count
                    .map(|(_, c)| c)
                    .cmp(&n.size_count.map(|(_, c)| c))
            },
            By::Name => |m, n| natural_cmp(m.entry.file_name(), n.entry.file_name()),
            By::Path => |_, _| Ordering::Equal, // bypass to the secondary sort.
        };
        let compare: &dyn Fn(&_, &_) -> _ = match self.rev {
            false => &compare,
            true => &|m, n| compare(m, n).reverse(),
        };
        medias.sort_unstable_by(|m, n| {
            compare(m, n).then_with(|| natural_cmp(m.entry.to_str(), n.entry.to_str())) // primary + secondary sort.
        });

        // step: display the results.
        medias.iter().for_each(|m| {
            let (size, count) = match m.size_count {
                Some((s, c)) => (&*format!("{}", s.human_count_bytes()), Some(c)),
                None => ("?", None),
            };
            match self.paths {
                true => print!("{size:>8} {}", m.entry.display_inverted_path()),
                false => print!("{size:>8} {}", m.entry.display_filename()),
            };
            if m.entry.is_dir()
                && let Some(count) = count
            {
                print!(" {}", "".paint(Color::Blue).linger());
                match count {
                    0 => print!("empty"),
                    1 => print!("1 file"),
                    _ => print!("{count} files"),
                }
            }
            println!("{}", "".resetting());
        });

        // step: display a summary receipt.
        if !medias.is_empty() {
            println!();
        }
        let (mut size, mut count) = (0, 0);
        medias
            .iter()
            .filter_map(|m| m.size_count)
            .for_each(|(s, c)| {
                size += s;
                count += c;
            });
        println!("listed entries: {}{}", medias.len(), display_abort(true),);
        println!("  total: {} in {count} files", size.human_count("B"),);

        Ok(())
    }
}

impl TryFrom<Entry> for Media {
    type Error = (Entry, anyhow::Error);

    fn try_from(entry: Entry) -> Result<Self, Self::Error> {
        let size_count = match (entry.is_dir(), CALC_DIR_SIZES.get().unwrap()) {
            (true, false) => None,
            (true, true) => {
                let fetcher = Fetcher::single(&entry, Recursion::Full);
                let mut count = 0;
                let sum = fetcher
                    .fetch(DirPolicy::Never)
                    .map(|e| {
                        count += 1;
                        e.metadata().map_or(0, |md| md.len())
                    })
                    .sum::<u64>();
                Some((sum, count))
            }
            (false, _) => {
                let size = entry.metadata().map_or(0, |md| md.len());
                Some((size, 1))
            }
        };
        Ok(Self { entry, size_count })
    }
}