fdu 0.2.0

Fastest native du replacement and detailed file analytics for Python and Rust
Documentation
//! A watch session must leave a usable cache behind even when it is killed outright.
//!
//! Watch sessions end by signal far more often than they end politely -- Ctrl-C, a
//! closed terminal, a supervisor restart -- so persisting only at exit would persist
//! approximately never, and every session would hand the next run a cold start it had
//! already paid for. This drives the real binary and kills it without warning, because
//! the property is specifically about the ending no handler gets to observe.
#![cfg(all(feature = "watch", unix))]

use std::fs;
use std::io::{BufRead, BufReader};
use std::path::Path;
use std::process::{Child, Command, Stdio};
use std::sync::mpsc;
use std::thread::sleep;
use std::time::{Duration, Instant};

/// Generous: a cold scan, an event round trip, and a save all have to fit.
const DEADLINE: Duration = Duration::from_secs(30);

/// Reap a watcher even when an assertion fails before the test's explicit kill.
struct WatchChild(Child);

impl Drop for WatchChild {
    fn drop(&mut self) {
        let _ = self.0.kill();
        let _ = self.0.wait();
    }
}

/// Identify the snapshot on disk by size and modification time.
///
/// Distinguishes one *write* from another, which is the whole difficulty here: the
/// initial `open()` leaves a snapshot before the watch loop starts, so presence alone
/// says nothing about whether the loop ever saved.
fn snapshot_fingerprint(cache_dir: &Path) -> Option<(u64, std::time::SystemTime)> {
    let entries = fs::read_dir(cache_dir.join("fdu")).ok()?;
    entries
        .flatten()
        // An atomic save writes a staging sibling before renaming it over the
        // conventional metadata file. Only the final 16-hex-key name proves a
        // replacement committed.
        .filter(|entry| {
            entry.file_name().to_str().is_some_and(|name| {
                name.strip_suffix(".metadata.bin").is_some_and(|key| {
                    key.len() == 16 && key.bytes().all(|byte| byte.is_ascii_hexdigit())
                })
            })
        })
        .filter_map(|entry| entry.metadata().ok())
        .filter(|meta| meta.len() > 0)
        .find_map(|meta| Some((meta.len(), meta.modified().ok()?)))
}

/// Wait until the snapshot differs from `before`, or give up.
fn wait_for_rewrite(cache_dir: &Path, before: Option<(u64, std::time::SystemTime)>) -> bool {
    let started = Instant::now();
    while started.elapsed() < DEADLINE {
        let now = snapshot_fingerprint(cache_dir);
        if now.is_some() && now != before {
            return true;
        }
        sleep(Duration::from_millis(100));
    }
    false
}

/// Wait until the spawned watcher has provably registered, then return its baseline.
///
/// A watch process is not watching the moment it starts: registration is requested first
/// and takes effect a little later, and anything written before then produces no event at
/// all. The engine reports that honestly as a setup race meaning "relist the root", so a
/// test whose subject write lands in that window is asserting against a reply it never
/// asked for. Both tests here used a fixed sleep, which is a guess about how long
/// registration takes, and the guess is wrong exactly when the machine is busy.
///
/// A warm-up write proves it instead. When the snapshot changes because of that write,
/// the watcher is demonstrably live and persisting, so the subject write that follows
/// cannot fall into the setup window. The value returned is the fingerprint *after* the
/// warm-up, which is the baseline a later rewrite must differ from.
fn establish_watch(cache_dir: &Path, tree: &Path) -> Option<(u64, std::time::SystemTime)> {
    let before = snapshot_fingerprint(cache_dir);
    fs::write(tree.join("warmup.txt"), b"warmup").expect("write warm-up file");
    assert!(
        wait_for_rewrite(cache_dir, before),
        "the watcher never persisted a warm-up change within {DEADLINE:?}, so it was never \
         observed to be watching and nothing after this point would be evidence about fdu",
    );
    snapshot_fingerprint(cache_dir)
}

/// Run `fdu` to completion and return stdout.
fn report(tree: &Path, cache: &Path, args: &[&str]) -> String {
    let output = Command::new(env!("CARGO_BIN_EXE_fdu"))
        .args(args)
        .arg(tree)
        .env("XDG_CACHE_HOME", cache)
        // FDU_CACHE_DIR outranks XDG_CACHE_HOME; an exported one would reach the real cache.
        .env_remove("FDU_CACHE_DIR")
        .output()
        .expect("run fdu");
    assert!(
        output.status.success(),
        "fdu {args:?} failed: {}",
        String::from_utf8_lossy(&output.stderr),
    );
    String::from_utf8_lossy(&output.stdout).into_owned()
}

#[test]
fn a_watch_started_from_a_warm_cache_still_persists_what_it_sees() {
    // The cold path and the warm path reach the save through different index states, and
    // only the cold one was covered. This matters now that a loaded index carries
    // `Cached` provenance: if that were ever conflated with the `Freshness` the save gates
    // on, a warm-started watch would silently stop persisting, and every existing test
    // would still pass because they all start cold.
    let root = tempfile::tempdir().expect("tempdir");
    let cache = tempfile::tempdir().expect("cache tempdir");
    let tree = root.path().join("tree");
    fs::create_dir(&tree).expect("create tree");
    fs::write(tree.join("first.txt"), b"first").expect("write first file");

    // Prime the cache, then prove the snapshot is usable with a cache-only read.
    //
    // The priming asks for `--cache on` because under `auto` a one-shot metadata report
    // writes nothing: no later one-shot report would read it. The usability proof uses
    // `--stale-ok` rather than a second ordinary run, because a one-shot metadata report
    // deliberately does not read the snapshot — the read cannot save the walk the report
    // is already doing. The watch below opens through the library path, which does read
    // it: a session amortises the load across its whole lifetime, and that warm start is
    // what this test pins.
    report(&tree, cache.path(), &["--view", "files", "--format", "json", "--cache", "on"]);
    let usable =
        report(&tree, cache.path(), &["--view", "files", "--format", "json", "--stale-ok"]);
    assert!(
        usable.contains("cache_only"),
        "expected the priming run to leave a usable snapshot, got: {usable}",
    );

    let mut child = WatchChild(
        Command::new(env!("CARGO_BIN_EXE_fdu"))
            .args(["--watch", "--view", "files", "--interval", "1s"])
            .arg(&tree)
            .env("XDG_CACHE_HOME", cache.path())
            .env_remove("FDU_CACHE_DIR")
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .spawn()
            .expect("spawn watching fdu"),
    );

    // Baseline *after* the watcher is provably registered, so a later rewrite is
    // provably the incremental one rather than that same startup file seen again.
    let initial = establish_watch(cache.path(), &tree);

    fs::write(tree.join("second.txt"), b"second").expect("write second file");
    let rewritten = wait_for_rewrite(cache.path(), initial);

    let _ = child.0.kill();
    let _ = child.0.wait();

    assert!(rewritten, "a warm-started watch never rewrote the snapshot after a change");
    let listed =
        report(&tree, cache.path(), &["--view", "files", "--format", "jsonl", "--stale-ok"]);
    assert!(
        listed.contains("second.txt"),
        "a warm-started watch did not persist what it observed. Listing was: {listed}",
    );
}

#[test]
fn a_projected_controls_off_watch_never_replaces_the_stronger_snapshot() {
    let root = tempfile::tempdir().expect("tempdir");
    let cache = tempfile::tempdir().expect("cache tempdir");
    let tree = root.path().join("tree");
    fs::create_dir(&tree).expect("create tree");
    fs::write(tree.join(".gitignore"), b"*.log\n").expect("write controls");
    fs::write(tree.join("ignored.log"), b"ignored").expect("write ignored file");
    fs::write(tree.join("first.txt"), b"first").expect("write first file");

    report(&tree, cache.path(), &["--view", "files", "--format", "json", "--cache", "on"]);
    let stronger = snapshot_fingerprint(cache.path()).expect("controls-on snapshot");

    let mut child = WatchChild(
        Command::new(env!("CARGO_BIN_EXE_fdu"))
            .args([
                "--watch",
                "--no-gitignore",
                "--view",
                "files",
                "--format",
                "jsonl",
                "--interval",
                "1s",
            ])
            .arg(&tree)
            .env("XDG_CACHE_HOME", cache.path())
            .env_remove("FDU_CACHE_DIR")
            .stdout(Stdio::piped())
            .stderr(Stdio::null())
            .spawn()
            .expect("spawn projected watcher"),
    );
    let stdout = child.0.stdout.take().expect("watch stdout");
    let (sent, received) = mpsc::channel();
    std::thread::spawn(move || {
        for line in BufReader::new(stdout).lines() {
            if sent.send(line).is_err() {
                break;
            }
        }
    });

    let initial = received
        .recv_timeout(DEADLINE)
        .expect("watch initial report")
        .expect("read initial report");
    assert!(initial.contains("fdu.report/"), "unexpected initial report: {initial}");
    let started = Instant::now();
    loop {
        let line = received
            .recv_timeout(DEADLINE.saturating_sub(started.elapsed()))
            .expect("watch initial files section")
            .expect("read initial files section");
        if line.contains("\"view\": \"files\"") {
            break;
        }
    }

    // `Session::new` binds the observer before the initial report is rendered, and the
    // files section proves that rendering completed. Seeing this change record then
    // proves the projected CLI route reached its incremental save path.
    fs::write(tree.join("warmup.txt"), b"warmup").expect("write warm-up file");
    let started = Instant::now();
    let mut observed = false;
    while started.elapsed() < DEADLINE {
        let Ok(line) = received.recv_timeout(DEADLINE.saturating_sub(started.elapsed())) else {
            break;
        };
        let Ok(line) = line else { break };
        if line.contains("\"record\": \"change\"") && line.contains("\"path\": \"warmup.txt\"") {
            observed = true;
            break;
        }
    }

    // The dirty warm-up batch may arrive before the one-second save throttle. Let the
    // idle path attempt its deferred save, then prove the projection guard kept the
    // stronger image.
    sleep(Duration::from_millis(1_500));
    let after = snapshot_fingerprint(cache.path()).expect("snapshot remains");

    let _ = child.0.kill();
    let _ = child.0.wait();

    assert!(
        observed,
        "the projected watcher did not report the change used to test its save guard"
    );
    assert_eq!(after, stronger, "the projected watch replaced the controls-on snapshot");
}

#[test]
fn a_killed_watch_still_leaves_a_warm_cache() {
    let root = tempfile::tempdir().expect("tempdir");
    let cache = tempfile::tempdir().expect("cache tempdir");
    let tree = root.path().join("tree");
    fs::create_dir(&tree).expect("create tree");
    fs::write(tree.join("first.txt"), b"first").expect("write first file");

    let mut child = WatchChild(
        Command::new(env!("CARGO_BIN_EXE_fdu"))
            .args(["--watch", "--view", "files", "--interval", "1s"])
            .arg(&tree)
            .env("XDG_CACHE_HOME", cache.path())
            .env_remove("FDU_CACHE_DIR")
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .spawn()
            .expect("spawn watching fdu"),
    );

    // Let the initial open's snapshot land and record it, so a later write is provably a
    // second one rather than that same file seen again.
    let initial = establish_watch(cache.path(), &tree);

    fs::write(tree.join("second.txt"), b"second").expect("write second file");
    let rewritten = wait_for_rewrite(cache.path(), initial);

    // SIGKILL: the exit no signal handler can intercept. Whatever is on disk now is
    // exactly what a real interrupted session would have left.
    let _ = child.0.kill();
    let _ = child.0.wait();

    assert!(
        rewritten,
        "the watch loop never rewrote the snapshot after a change, so everything observed \
         while watching would be lost",
    );

    // The saved snapshot has to be usable, not merely present: a later run must accept
    // it as warm rather than rescanning.
    let output = Command::new(env!("CARGO_BIN_EXE_fdu"))
        .args(["--view", "summary", "--format", "json", "--stale-ok"])
        .arg(&tree)
        .env("XDG_CACHE_HOME", cache.path())
        .env_remove("FDU_CACHE_DIR")
        .output()
        .expect("run fdu against the saved cache");

    assert!(
        output.status.success(),
        "cache-only read of the watch session's snapshot failed: {}",
        String::from_utf8_lossy(&output.stderr),
    );
    let report = String::from_utf8_lossy(&output.stdout);
    assert!(
        report.contains("\"source\": \"cache_only\"")
            || report.contains("\"source\":\"cache_only\""),
        "expected a cache-only read, got: {report}",
    );

    // The load-bearing assertion. `open()` writes a snapshot before the watch loop even
    // starts, so "a snapshot exists" proves nothing about incremental saving -- this test
    // passed without the feature it exists to pin until this check was added. Only a file
    // created *after* the watch began can distinguish the two.
    let listing = Command::new(env!("CARGO_BIN_EXE_fdu"))
        .args(["--view", "files", "--format", "jsonl", "--stale-ok"])
        .arg(&tree)
        .env("XDG_CACHE_HOME", cache.path())
        .env_remove("FDU_CACHE_DIR")
        .output()
        .expect("list the saved cache");
    let listed = String::from_utf8_lossy(&listing.stdout);
    assert!(
        listed.contains("second.txt"),
        "the snapshot predates the watch session: it holds only the initial open's index, \
         so incremental saves never ran. Listing was: {listed}",
    );
}