use std::collections::HashSet;
use std::path::{Path, PathBuf};
pub struct Mark {
pub path: PathBuf,
pub size: u64,
pub is_dir: bool,
}
#[derive(Default)]
pub struct Marks {
order: Vec<Mark>,
index: HashSet<PathBuf>,
bytes: u64,
}
impl Marks {
pub fn new() -> Self {
Self::default()
}
pub fn toggle(&mut self, path: &Path, size: u64, is_dir: bool) -> bool {
if self.remove(path) {
false
} else {
self.insert(path, size, is_dir);
true
}
}
pub fn insert(&mut self, path: &Path, size: u64, is_dir: bool) -> bool {
if !self.index.insert(path.to_path_buf()) {
return false;
}
self.order.push(Mark {
path: path.to_path_buf(),
size,
is_dir,
});
self.bytes = self.bytes.saturating_add(size);
true
}
pub fn remove(&mut self, path: &Path) -> bool {
if !self.index.remove(path) {
return false;
}
if let Some(i) = self.order.iter().position(|m| m.path == path) {
let gone = self.order.remove(i);
self.bytes = self.bytes.saturating_sub(gone.size);
}
true
}
pub fn contains(&self, path: &Path) -> bool {
self.index.contains(path)
}
pub fn clear(&mut self) {
self.order.clear();
self.index.clear();
self.bytes = 0;
}
pub fn is_empty(&self) -> bool {
self.order.is_empty()
}
pub fn len(&self) -> usize {
self.order.len()
}
pub fn bytes(&self) -> u64 {
self.bytes
}
pub fn marks(&self) -> &[Mark] {
&self.order
}
pub fn mark_all<'a>(&mut self, visible: impl Iterator<Item = (&'a Path, u64, bool)>) {
for (path, size, is_dir) in visible {
self.insert(path, size, is_dir);
}
}
pub fn invert<'a>(&mut self, visible: impl Iterator<Item = (&'a Path, u64, bool)>) {
for (path, size, is_dir) in visible {
self.toggle(path, size, is_dir);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn file(m: &mut Marks, path: &str, size: u64) -> bool {
m.insert(Path::new(path), size, false)
}
fn paths(m: &Marks) -> Vec<&str> {
m.marks().iter().filter_map(|k| k.path.to_str()).collect()
}
fn listing<'a>(
rows: &'a [(&'a str, u64, bool)],
) -> impl Iterator<Item = (&'a Path, u64, bool)> {
rows.iter().map(|(p, s, d)| (Path::new(*p), *s, *d))
}
#[test]
fn empty_set_reports_empty() {
let m = Marks::new();
assert!(m.is_empty());
assert_eq!(m.len(), 0);
assert_eq!(m.bytes(), 0);
assert!(m.marks().is_empty());
assert!(!m.contains(Path::new("/a/b.txt")));
}
#[test]
fn toggle_round_trips_and_reports_state_after() {
let mut m = Marks::new();
assert!(m.toggle(Path::new("/a/b.txt"), 10, false));
assert!(m.contains(Path::new("/a/b.txt")));
assert_eq!(m.len(), 1);
assert!(!m.toggle(Path::new("/a/b.txt"), 10, false));
assert!(!m.contains(Path::new("/a/b.txt")));
assert_eq!(m.len(), 0);
assert_eq!(m.bytes(), 0);
}
#[test]
fn reinserting_is_idempotent_in_count_and_bytes() {
let mut m = Marks::new();
assert!(file(&mut m, "/a/b.txt", 100));
assert!(!file(&mut m, "/a/b.txt", 100));
assert!(!file(&mut m, "/a/b.txt", 999));
assert_eq!(m.len(), 1);
assert_eq!(m.bytes(), 100);
assert_eq!(paths(&m), vec!["/a/b.txt"]);
}
#[test]
fn mark_order_is_stable_and_survives_unrelated_removal() {
let mut m = Marks::new();
file(&mut m, "/z/first", 1);
file(&mut m, "/a/second", 2);
file(&mut m, "/m/third", 3);
assert_eq!(paths(&m), vec!["/z/first", "/a/second", "/m/third"]);
assert!(m.remove(Path::new("/a/second")));
assert_eq!(paths(&m), vec!["/z/first", "/m/third"]);
file(&mut m, "/a/second", 2);
assert_eq!(paths(&m), vec!["/z/first", "/m/third", "/a/second"]);
}
#[test]
fn remove_reports_whether_it_had_been_marked() {
let mut m = Marks::new();
file(&mut m, "/a/b.txt", 7);
assert!(m.remove(Path::new("/a/b.txt")));
assert!(!m.remove(Path::new("/a/b.txt")));
assert!(!m.remove(Path::new("/never/marked")));
}
#[test]
fn bytes_accounting_through_every_mutator() {
let mut m = Marks::new();
file(&mut m, "/a/one", 100);
file(&mut m, "/a/two", 250);
assert_eq!(m.bytes(), 350);
assert!(!m.toggle(Path::new("/a/one"), 100, false));
assert_eq!(m.bytes(), 250);
assert!(m.toggle(Path::new("/a/one"), 100, false));
assert_eq!(m.bytes(), 350);
assert!(m.remove(Path::new("/a/two")));
assert_eq!(m.bytes(), 100);
m.clear();
assert_eq!(m.bytes(), 0);
assert!(m.is_empty());
assert_eq!(m.len(), 0);
}
#[test]
fn directories_contribute_no_bytes() {
let mut m = Marks::new();
assert!(m.insert(Path::new("/a/dir"), 0, true));
assert!(m.insert(Path::new("/a/file"), 42, false));
assert_eq!(m.len(), 2);
assert_eq!(m.bytes(), 42);
assert!(m.marks()[0].is_dir);
assert!(!m.marks()[1].is_dir);
}
#[test]
fn mark_all_skips_marked_and_appends_the_rest_in_listing_order() {
let mut m = Marks::new();
file(&mut m, "/d/b", 2);
let rows = [("/d/a", 1, false), ("/d/b", 2, false), ("/d/c", 0, true)];
m.mark_all(listing(&rows));
assert_eq!(paths(&m), vec!["/d/b", "/d/a", "/d/c"]);
assert_eq!(m.len(), 3);
assert_eq!(m.bytes(), 3);
}
#[test]
fn mark_all_is_idempotent() {
let mut m = Marks::new();
let rows = [("/d/a", 1, false), ("/d/b", 2, false)];
m.mark_all(listing(&rows));
m.mark_all(listing(&rows));
assert_eq!(m.len(), 2);
assert_eq!(m.bytes(), 3);
}
#[test]
fn invert_unmarks_the_marked_and_marks_the_rest() {
let mut m = Marks::new();
file(&mut m, "/d/b", 2);
let rows = [("/d/a", 1, false), ("/d/b", 2, false), ("/d/c", 4, false)];
m.invert(listing(&rows));
assert_eq!(paths(&m), vec!["/d/a", "/d/c"]);
assert_eq!(m.bytes(), 5);
m.invert(listing(&rows));
assert_eq!(paths(&m), vec!["/d/b"]);
assert_eq!(m.bytes(), 2);
}
#[test]
fn invert_leaves_marks_outside_the_listing_alone() {
let mut m = Marks::new();
file(&mut m, "/elsewhere/keep", 9);
let rows = [("/d/a", 1, false)];
m.invert(listing(&rows));
assert_eq!(paths(&m), vec!["/elsewhere/keep", "/d/a"]);
assert_eq!(m.bytes(), 10);
}
#[test]
fn contains_is_an_exact_path_match_not_a_prefix() {
let mut m = Marks::new();
file(&mut m, "/a/bc.txt", 1);
assert!(m.contains(Path::new("/a/bc.txt")));
assert!(!m.contains(Path::new("/a")));
assert!(!m.contains(Path::new("/a/b")));
assert!(!m.contains(Path::new("/a/bc.txt.bak")));
assert!(!m.contains(Path::new("/a/bc.txt/inner")));
}
#[test]
fn default_matches_new() {
let m = Marks::default();
assert!(m.is_empty());
assert_eq!(m.bytes(), 0);
}
}