diskr 0.1.50

Lightweight terminal file explorer and disk/storage manager for macOS
use anyhow::{bail, Context, Result};
use std::path::{Path, PathBuf};

use crate::bulkstat::SizeInfo;

pub(crate) const SIZE_CACHE_MAX_ENTRIES: usize = 50_000;

const SIZE_CACHE_VERSION: u64 = 1;

#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct CachedSize {
    pub path: PathBuf,
    pub size: SizeInfo,
    pub inaccessible: u32,
    pub scanned_at: u64,
}

pub(crate) fn state_dir() -> PathBuf {
    let base = std::env::var_os("HOME")
        .map(PathBuf::from)
        .unwrap_or_else(std::env::temp_dir);
    base.join("Library/Application Support/diskr")
}

fn size_cache_file() -> PathBuf {
    state_dir().join("size-cache.json")
}

pub(crate) fn load_size_cache() -> Result<Vec<CachedSize>> {
    load_size_cache_from_path(&size_cache_file())
}

pub(crate) fn store_size_cache(entries: &[CachedSize]) -> Result<()> {
    let path = size_cache_file();
    if let Some(dir) = path.parent() {
        std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
    }
    store_size_cache_to_path(&path, entries)
}

fn load_size_cache_from_path(path: &Path) -> Result<Vec<CachedSize>> {
    let text = match std::fs::read_to_string(path) {
        Ok(text) => text,
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
        Err(err) => return Err(err).with_context(|| format!("read {}", path.display())),
    };
    let value: serde_json::Value = serde_json::from_str(&text)
        .with_context(|| format!("parse {} (delete it to reset cache)", path.display()))?;
    let version = value.get("version").and_then(|v| v.as_u64()).unwrap_or(0);
    if version != SIZE_CACHE_VERSION {
        bail!(
            "unexpected size-cache version in {} (delete it to reset cache)",
            path.display()
        );
    }
    let entries = value
        .get("entries")
        .and_then(|v| v.as_array())
        .ok_or_else(|| anyhow::anyhow!("missing entries in {}", path.display()))?;
    Ok(entries.iter().filter_map(cached_size_from_json).collect())
}

fn store_size_cache_to_path(path: &Path, entries: &[CachedSize]) -> Result<()> {
    let entries: Vec<serde_json::Value> = entries
        .iter()
        .map(|entry| {
            serde_json::json!({
                "path": entry.path.to_string_lossy(),
                "logical": entry.size.logical,
                "allocated": entry.size.allocated,
                "inaccessible": entry.inaccessible,
                "scanned_at": entry.scanned_at,
            })
        })
        .collect();
    let value = serde_json::json!({
        "version": SIZE_CACHE_VERSION,
        "entries": entries,
    });
    let text = serde_json::to_string_pretty(&value)?;
    std::fs::write(path, text).with_context(|| format!("write {}", path.display()))?;
    Ok(())
}

fn cached_size_from_json(value: &serde_json::Value) -> Option<CachedSize> {
    let path = value.get("path")?.as_str()?;
    if path.is_empty() {
        return None;
    }
    let logical = value.get("logical")?.as_u64()?;
    let allocated = value.get("allocated")?.as_u64()?;
    let scanned_at = value.get("scanned_at")?.as_u64()?;
    let inaccessible = value
        .get("inaccessible")
        .and_then(|v| v.as_u64())
        .and_then(|n| u32::try_from(n).ok())
        .unwrap_or(0);

    Some(CachedSize {
        path: PathBuf::from(path),
        size: SizeInfo::new(logical, allocated),
        inaccessible,
        scanned_at,
    })
}

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

    #[test]
    fn size_cache_round_trips_schema_v1() {
        let path = temp_file("round_trip");
        let entries = vec![CachedSize {
            path: PathBuf::from("/tmp/example"),
            size: SizeInfo::new(123, 456),
            inaccessible: 2,
            scanned_at: 42,
        }];

        store_size_cache_to_path(&path, &entries).unwrap();
        let loaded = load_size_cache_from_path(&path).unwrap();

        assert_eq!(loaded, entries);
        let _ = std::fs::remove_file(path);
    }

    #[test]
    fn size_cache_skips_malformed_entries() {
        let path = temp_file("malformed");
        std::fs::write(
            &path,
            r#"{"version":1,"entries":[{"path":"/tmp/a","logical":1,"allocated":2,"scanned_at":3},{"path":""}]}"#,
        )
        .unwrap();

        let loaded = load_size_cache_from_path(&path).unwrap();

        assert_eq!(loaded.len(), 1);
        assert_eq!(loaded[0].path, PathBuf::from("/tmp/a"));
        let _ = std::fs::remove_file(path);
    }

    fn temp_file(name: &str) -> PathBuf {
        let nanos = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        std::env::temp_dir().join(format!(
            "diskr_state_{name}_{}_{}.json",
            std::process::id(),
            nanos
        ))
    }
}