diskr-cli 1.0.0

Save your disk space, without fear of deleting the wrong thing.
Documentation
use crate::scanner::FsNode;
use once_cell::sync::Lazy;
use regex::Regex;
use serde::Serialize;
use std::collections::HashMap;
use std::path::PathBuf;
use std::process::Command;

#[derive(Debug, Clone, Serialize)]
pub struct DockerFinding {
    pub path: PathBuf,
    pub size: u64,
    pub label: String,
}

pub fn find_docker_bloat(root: &FsNode) -> Vec<DockerFinding> {
    let mut out = Vec::new();
    walk_docker(root, &mut out);
    out
}

fn walk_docker(node: &FsNode, out: &mut Vec<DockerFinding>) {
    if !node.is_dir { return; }
    let path_str = node.path.to_string_lossy();
    if path_str.contains("/docker/overlay2") || path_str.contains("/docker/volumes") {
        out.push(DockerFinding { path: node.path.clone(), size: node.size, label: "docker storage".into() });
        return;
    }
    for c in &node.children {
        walk_docker(c, out);
    }
}

pub fn docker_prune_suggested() -> bool {
    Command::new("docker").arg("info").output().map(|o| o.status.success()).unwrap_or(false)
}

pub fn run_docker_prune(volumes: bool) -> Result<String, std::io::Error> {
    let mut cmd = Command::new("docker");
    cmd.arg("system").arg("prune").arg("-f");
    if volumes {
        cmd.arg("--volumes");
    }
    let out = cmd.output()?;
    Ok(String::from_utf8_lossy(&out.stdout).to_string())
}

static SENSITIVE_NAME: Lazy<Regex> = Lazy::new(|| {
    Regex::new(r"(?i)^(id_rsa|id_ed25519|id_ecdsa|credentials\.json|\.env|.*\.pem|.*\.key|.*\.p12|.*\.pfx)$").unwrap()
});

static UNSAFE_DIRS: Lazy<Vec<&'static str>> = Lazy::new(|| vec!["Downloads", "Desktop", "Public", "tmp", "Temp"]);

const VENDORED_DIR_FRAGMENTS: &[&str] = &[
    "/site-packages/",
    "/venv/",
    "/.venv/",
    "/node_modules/",
    "/vendor/",
    "/.cargo/registry/",
    "/target/",
    "/.git/",
    "/dist/",
    "/build/",
    "/.local/share/flatpak/",
    "/.var/app/",
    "/.cache/",
    "/.local/share/Steam/",
    "/steamapps/",
    "/steamrt",
];

fn is_in_vendored_dir(path: &std::path::Path) -> bool {
    let s = path.to_string_lossy();
    VENDORED_DIR_FRAGMENTS.iter().any(|f| s.contains(f))
}

fn pem_is_private_key(path: &std::path::Path) -> bool {
    match std::fs::read_to_string(path) {
        Err(_) => false,
        Ok(text) => text.contains("PRIVATE KEY"),
    }
}

#[derive(Debug, Clone, Serialize)]
pub struct SensitiveFinding {
    pub path: PathBuf,
    pub reason: String,
}

pub fn scan_sensitive_files(root: &FsNode) -> Vec<SensitiveFinding> {
    let mut files = Vec::new();
    root.flatten_files(&mut files);
    files
        .into_iter()
        .filter(|f| !is_in_vendored_dir(&f.path))
        .filter_map(|f| {
            let name = f.path.file_name()?.to_str()?;
            if !SENSITIVE_NAME.is_match(name) {
                return None;
            }
            if name.to_ascii_lowercase().ends_with(".pem") && !pem_is_private_key(&f.path) {
                return None;
            }
            let in_unsafe_dir = f.path.components().any(|c| {
                UNSAFE_DIRS.iter().any(|u| c.as_os_str().to_string_lossy().eq_ignore_ascii_case(u))
            });
            let reason = if in_unsafe_dir {
                format!("credential-like file '{name}' in an exposed directory")
            } else {
                format!("credential-like file '{name}'")
            };
            Some(SensitiveFinding { path: f.path.clone(), reason })
        })
        .collect()
}

#[derive(Debug, Clone, Serialize)]
pub struct HardlinkGroup {
    pub inode: u64,
    pub paths: Vec<PathBuf>,
    pub size: u64,
}

pub fn find_hardlinks(root: &FsNode) -> Vec<HardlinkGroup> {
    let mut files = Vec::new();
    root.flatten_files(&mut files);
    let mut by_inode: HashMap<u64, Vec<&FsNode>> = HashMap::new();
    for f in files.iter().filter(|f| f.nlink > 1) {
        by_inode.entry(f.inode).or_default().push(f);
    }
    by_inode.into_iter()
        .filter(|(_, v)| v.len() > 1)
        .map(|(inode, v)| HardlinkGroup {
            inode,
            size: v[0].size,
            paths: v.into_iter().map(|f| f.path.clone()).collect(),
        })
        .collect()
}

#[derive(Debug, Clone, Serialize)]
pub struct AppUsage {
    pub name: String,
    pub last_used_days: Option<i64>,
    pub package_manager: String,
}

fn manually_installed_apt_packages() -> Option<HashMap<String, ()>> {
    let out = Command::new("apt-mark").arg("showmanual").output().ok()?;
    if !out.status.success() {
        return None;
    }
    let text = String::from_utf8_lossy(&out.stdout);
    let set: HashMap<String, ()> = text.lines().map(|l| (l.trim().to_string(), ())).collect();
    if set.is_empty() { None } else { Some(set) }
}

fn packages_with_desktop_entries() -> Option<HashMap<String, ()>> {
    let dirs = ["/usr/share/applications", "/var/lib/snapd/desktop/applications"];
    let mut desktop_files: Vec<PathBuf> = Vec::new();
    for dir in dirs {
        if let Ok(entries) = std::fs::read_dir(dir) {
            for entry in entries.flatten() {
                let path = entry.path();
                if path.extension().and_then(|e| e.to_str()) == Some("desktop") {
                    desktop_files.push(path);
                }
            }
        }
    }
    if desktop_files.is_empty() {
        return None;
    }
    let out = Command::new("dpkg").arg("-S").args(&desktop_files).output().ok()?;
    let text = String::from_utf8_lossy(&out.stdout);
    let mut set: HashMap<String, ()> = HashMap::new();
    for line in text.lines() {
        let Some((pkgs, _path)) = line.split_once(':') else { continue };
        for pkg in pkgs.split(',') {
            let pkg = pkg.trim();
            if !pkg.is_empty() {
                set.insert(pkg.to_string(), ());
            }
        }
    }
    if set.is_empty() { None } else { Some(set) }
}

static NON_APP_PACKAGE: Lazy<Regex> = Lazy::new(|| {
    Regex::new(concat!(
        r"(?i)^(",
        r"lib.*|.*-dev|.*-dbg|.*-doc|.*-common|.*-data|.*-dbgsym|",
        r"linux-.*|.*-firmware|locales.*|.*-l10n|.*-locale-.*|language-pack-.*|",
        r"fonts-.*|hunspell-.*|mythes-.*|gir1\.2-.*|python3.*|",
        r"xserver-.*|systemd.*|nvidia-.*|system76-.*|",
        r"dpkg|apt|apt-.*|base-files|base-passwd|coreutils|util-linux|",
        r"bash|grep|sed|tar|gzip|login|mount|udev|sudo|grub-.*|initramfs-tools.*",
        r")$"
    ))
    .unwrap()
});

static SNAP_RUNTIME: Lazy<Regex> = Lazy::new(|| {
    Regex::new(r"^(core[0-9]*|bare|snapd|gtk-common-themes|gnome-[0-9]+-[0-9]+|gnome-[0-9x]+-sdk|kde-frameworks-[0-9.]+-.*)$").unwrap()
});

pub fn find_unused_apps(min_idle_days: i64) -> Vec<AppUsage> {
    let mut apps = Vec::new();
    let manual = manually_installed_apt_packages();
    let gui = packages_with_desktop_entries();
    if let Ok(out) = Command::new("apt").arg("list").arg("--installed").output() {
        let text = String::from_utf8_lossy(&out.stdout);
        for line in text.lines().skip(1) {
            if let Some(name) = line.split('/').next() {
                if name.is_empty() || NON_APP_PACKAGE.is_match(name) {
                    continue;
                }
                if let Some(gui) = &gui {
                    if !gui.contains_key(name) {
                        continue;
                    }
                }
                if let Some(manual) = &manual {
                    if !manual.contains_key(name) {
                        continue;
                    }
                }
                apps.push(AppUsage { name: name.to_string(), last_used_days: None, package_manager: "apt".into() });
            }
        }
    }
    if let Ok(out) = Command::new("snap").arg("list").output() {
        let text = String::from_utf8_lossy(&out.stdout);
        for line in text.lines().skip(1) {
            if let Some(name) = line.split_whitespace().next() {
                if SNAP_RUNTIME.is_match(name) {
                    continue;
                }
                apps.push(AppUsage { name: name.to_string(), last_used_days: None, package_manager: "snap".into() });
            }
        }
    }
    let xbel_seen = parse_recently_used_xbel();
    for app in apps.iter_mut() {
        let signals = [
            journalctl_last_seen(&app.name),
            xbel_seen.get(&app.name).copied(),
            config_dir_last_seen(&app.name),
        ];
        app.last_used_days = signals.into_iter().flatten().min();
    }

    apps.into_iter()
        .filter(|a| match a.last_used_days {
            Some(d) => d >= min_idle_days,
            None => true,
        })
        .collect()
}

fn parse_recently_used_xbel() -> HashMap<String, i64> {
    let mut out: HashMap<String, i64> = HashMap::new();
    let Some(home) = dirs::home_dir() else { return out };
    let Ok(text) = std::fs::read_to_string(home.join(".local/share/recently-used.xbel")) else { return out };

    static BOOKMARK: Lazy<Regex> = Lazy::new(|| Regex::new(r#"(?s)<bookmark\b[^>]*\bvisited="([^"]+)"[^>]*>(.*?)</bookmark>"#).unwrap());
    static APP_NAME: Lazy<Regex> = Lazy::new(|| Regex::new(r#"<bookmark:application\b[^>]*\bname="([^"]+)""#).unwrap());

    for cap in BOOKMARK.captures_iter(&text) {
        let Ok(visited) = chrono::DateTime::parse_from_rfc3339(&cap[1]) else { continue };
        let days = (chrono::Utc::now() - visited.with_timezone(&chrono::Utc)).num_days();
        for app_cap in APP_NAME.captures_iter(&cap[2]) {
            let name = app_cap[1].to_lowercase();
            out.entry(name).and_modify(|d| { if days < *d { *d = days; } }).or_insert(days);
        }
    }
    out
}

fn config_dir_last_seen(app_name: &str) -> Option<i64> {
    let home = dirs::home_dir()?;
    let candidates = [home.join(".config").join(app_name), home.join(".config").join(app_name.to_lowercase())];
    candidates.iter().find_map(|p| std::fs::metadata(p).ok()).and_then(|m| m.modified().ok()).map(crate::scanner::walker::age_days)
}

fn journalctl_last_seen(unit: &str) -> Option<i64> {
    let out = Command::new("journalctl")
        .arg("-u").arg(unit)
        .arg("-n").arg("1")
        .arg("--output=short-iso")
        .output().ok()?;
    let text = String::from_utf8_lossy(&out.stdout);
    let line = text.lines().next()?;
    let ts_str = line.split_whitespace().next()?;
    let ts = chrono::DateTime::parse_from_rfc3339(ts_str).ok()?;
    let days = (chrono::Utc::now() - ts.with_timezone(&chrono::Utc)).num_days();
    Some(days)
}

pub fn generate_cleanup_script(paths: &[PathBuf], use_trash: bool) -> String {
    let mut script = String::new();
    script.push_str("#!/usr/bin/env bash\n");
    script.push_str("set -euo pipefail\n\n");
    script.push_str("# Generated by diskr. Review each line before running.\n");
    script.push_str(&format!("# Items: {}\n\n", paths.len()));
    for p in paths {
        let display = p.display();
        if use_trash {
            script.push_str(&format!("trash-put \"{display}\"\n"));
        } else {
            script.push_str(&format!("rm -rf -- \"{display}\"\n"));
        }
    }
    script
}