projclean 0.8.0

Project dependencies & build artifacts cleanup tool
use anyhow::Result;
use crossbeam_utils::sync::WaitGroup;
use jwalk::WalkDirGeneric;
use remove_dir_all::remove_dir_all;
use std::cmp::Ordering;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::atomic::{self, AtomicBool};
use std::sync::mpsc::{Receiver, Sender};
use std::sync::Arc;
use std::time::{Duration, SystemTime};
use threadpool::ThreadPool;

use crate::{Config, Message, PathItem};

pub fn search(
    entry: PathBuf,
    config: Config,
    tx: Sender<Message>,
    running: Arc<AtomicBool>,
) -> Result<()> {
    let config_clone = config.clone();
    let walk_dir = WalkDirGeneric::<((), Option<(String, Vec<String>)>)>::new(entry.clone())
        .skip_hidden(false)
        .process_read_dir(move |_depth, _path, _state, children| {
            let mut checker = Checker::new(&config_clone);
            for dir_entry in children.iter().flatten() {
                if let Some(name) = dir_entry.file_name.to_str() {
                    if config_clone.exclude.contains(&name.to_string()) {
                        continue;
                    }
                    checker.check(name);
                }
            }
            let matches = checker.to_matches();
            children.iter_mut().for_each(|dir_entry_result| {
                if let Ok(dir_entry) = dir_entry_result {
                    if let Some(name) = dir_entry.file_name.to_str() {
                        if config_clone.exclude.contains(&name.to_string()) {
                            dir_entry.read_children_path = None;
                        } else if let Some((rule_id, purges)) = matches.get(name) {
                            dir_entry.read_children_path = None;
                            dir_entry.client_state = Some((rule_id.to_string(), purges.to_vec()));
                        }
                    }
                }
            });
        });

    for dir_entry_result in walk_dir {
        if !running.load(atomic::Ordering::SeqCst) {
            let _ = tx.send(Message::DoneSearch);
            return Ok(());
        }
        if let Ok(dir_entry) = &dir_entry_result {
            if let Some((rule_id, purges)) = dir_entry.client_state.as_ref() {
                let entry_path = dir_entry.path();
                for purge in purges {
                    let mut path = entry_path.clone();
                    for part in purge.split('/').skip(1) {
                        path.push(part)
                    }
                    if !path.exists() {
                        continue;
                    }
                    let time = last_modified(&path).ok();
                    if let (Some((expect, order)), Some(time)) = (config.time, time) {
                        if !compare(order, expect, (time.as_secs_f64() / 86400.0).ceil() as _) {
                            continue;
                        }
                    }

                    let size = du(&path).ok();
                    if let (Some((expect, order)), Some(size)) = (config.size, size) {
                        if !compare(order, expect, size) {
                            continue;
                        }
                    }
                    let relative_path = path.strip_prefix(&entry)?.to_path_buf();
                    let path_item = PathItem::new(path, relative_path, rule_id, time, size);
                    let _ = tx.send(Message::AddPath(path_item));
                }
            }
        }
    }

    let _ = tx.send(Message::DoneSearch);

    Ok(())
}

pub fn ls(rx: Receiver<Message>) -> Result<()> {
    for message in rx {
        match message {
            Message::AddPath(path) => {
                println!("{}", path.path.display());
            }
            Message::DoneSearch => break,
            _ => {}
        }
    }
    Ok(())
}

pub fn delete_all(rx: Receiver<Message>) -> Result<()> {
    let wg = WaitGroup::new();
    let pool = ThreadPool::default();
    for message in rx {
        match message {
            Message::AddPath(path) => {
                spawn_delete_path(pool.clone(), path.path.clone(), wg.clone());
            }
            Message::DoneSearch => break,
            _ => {}
        }
    }
    wg.wait();
    Ok(())
}

fn spawn_delete_path(pool: ThreadPool, path: PathBuf, wg: WaitGroup) {
    pool.execute(move || {
        match remove_dir_all(&path) {
            Ok(_) => println!("Delete {}", path.display()),
            Err(err) => eprintln!("Failed to delete {}, {}", path.display(), err),
        }
        drop(wg);
    });
}

fn compare<T: PartialOrd>(order: Ordering, expect: T, target: T) -> bool {
    match order {
        Ordering::Less => target < expect,
        Ordering::Equal => target == expect,
        Ordering::Greater => target > expect,
    }
}

#[derive(Debug)]
struct Checker<'a, 'b> {
    matches: HashMap<&'a str, CheckMatches<'a, 'b>>,
    config: &'a Config,
}

#[derive(Debug, Default)]
struct CheckMatches<'a, 'b> {
    purge: HashMap<&'b str, &'a Vec<String>>,
    check: HashSet<&'b str>,
}

impl<'a, 'b> Checker<'a, 'b> {
    fn new(config: &'a Config) -> Self {
        Self {
            config,
            matches: Default::default(),
        }
    }

    fn check(&mut self, name: &'b str) {
        for rule in &self.config.rules {
            let matches = self.matches.entry(rule.get_id()).or_default();
            if let Some(purges) = rule.check_target(name) {
                matches.purge.insert(name, purges.as_ref());
            }
            if rule.check_project(name) {
                matches.check.insert(name);
            }
        }
    }

    fn to_matches(&self) -> HashMap<String, (&'a str, &'a Vec<String>)> {
        let mut output = HashMap::new();
        for (rule_id, matches) in &self.matches {
            if !matches.purge.is_empty()
                && (!matches.check.is_empty() || self.config.is_rule_no_detect(rule_id))
            {
                for (name, purges) in &matches.purge {
                    if !output.contains_key(*name) {
                        output.insert(name.to_string(), (*rule_id, *purges));
                    }
                }
            }
        }
        output
    }
}

fn du(path: &Path) -> Result<u64> {
    let mut total: u64 = 0;

    for dir_entry_result in WalkDirGeneric::<((), Option<u64>)>::new(path)
        .skip_hidden(false)
        .process_read_dir(|_, _, _, dir_entry_results| {
            dir_entry_results.iter_mut().for_each(|dir_entry_result| {
                if let Ok(dir_entry) = dir_entry_result {
                    if !dir_entry.file_type.is_dir() {
                        dir_entry.client_state =
                            Some(dir_entry.metadata().map(|m| m.len()).unwrap_or_default());
                    }
                }
            })
        })
    {
        let dir_entry = dir_entry_result?;
        if let Some(len) = &dir_entry.client_state {
            total += len;
        }
    }
    Ok(total)
}

fn last_modified(path: &Path) -> Result<Duration> {
    let metdata = std::fs::metadata(path)?;
    let modified = metdata.modified()?;
    let now = SystemTime::now();
    let output = now.duration_since(modified)?;
    Ok(output)
}

#[cfg(test)]
mod tests {
    use super::*;
    macro_rules! assert_match_paths {
        ($id:literal, $names:expr) => {
            let none: &[&str] = &[];
            assert_match_paths!($id, $names, none);
        };
        ($id:literal, $names:expr, $matched:expr) => {
            let mut config = Config::default();
            let ret = config.add_rule($id);
            assert!(ret.is_ok());
            let mut checker = Checker::new(&config);
            for name in $names {
                checker.check(name);
            }
            let matches = checker.to_matches();
            let matched_names: Vec<&str> = matches.keys().map(|v| v.as_str()).collect();
            assert_eq!(matched_names, $matched);
        };
    }

    #[test]
    fn test_match_paths() {
        assert_match_paths!("target@Cargo.toml", &["target", "Cargo.toml"], &["target"]);
        assert_match_paths!("target@Cargo.toml", &["target.rs", "Cargo.toml"]);
        assert_match_paths!("Debug,Release@*.sln", &["Debug", "Demo.sln"], &["Debug"]);
    }
}