fdu-core 0.3.0

The fdu engine: incremental hierarchical tallies over large directory trees
Documentation
//! Public report rows retain scoped completeness after a partial cold walk.
#![cfg(unix)]

use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::time::SystemTime;

use fdu_core::content::AnalysisSet;
use fdu_core::query::{
    Basis, Delivery, ModifiedWindow, Query, Request, Scope, Section, Selection, ViewSpec,
};
use fdu_core::{CachePolicy, EntryKind};

#[test]
fn partial_cold_list_keeps_healthy_age_and_refuses_unknown_modification_times() {
    let root = tempfile::tempdir().expect("root");
    let blocked = root.path().join("ancestor/blocked");
    fs::create_dir_all(&blocked).expect("blocked directory");
    fs::write(blocked.join("unknown.txt"), b"unread").expect("unknown descendant");
    fs::create_dir(root.path().join("healthy")).expect("healthy directory");
    fs::write(root.path().join("healthy/known.txt"), b"known").expect("known descendant");
    fs::set_permissions(&blocked, fs::Permissions::from_mode(0o000)).expect("deny listing");
    let permission_probe = fs::read_dir(&blocked);
    if permission_probe.is_ok() {
        fs::set_permissions(&blocked, fs::Permissions::from_mode(0o700))
            .expect("restore permissions");
        if std::env::var_os("FDU_TEST_ALLOW_NO_PERMISSION_BITS").as_deref()
            == Some(std::ffi::OsStr::new("1"))
        {
            eprintln!(
                "skipped by FDU_TEST_ALLOW_NO_PERMISSION_BITS=1: host permits denied listing"
            );
            return;
        }
        panic!("permission fixture precondition failed: host permits mode-000 directory listing");
    }
    assert_eq!(
        permission_probe.expect_err("denied listing").kind(),
        std::io::ErrorKind::PermissionDenied
    );
    let basis =
        Basis { root: root.path().into(), scope: Scope::default(), content: AnalysisSet::NONE };
    let opened = fdu_core::open(&basis, &Delivery::new(CachePolicy::Off, None));
    fs::set_permissions(&blocked, fs::Permissions::from_mode(0o700)).expect("restore permissions");
    let (index, open_report) = opened.expect("partial answer");
    assert!(!open_report.is_complete());
    let now = SystemTime::now();
    let mut request = Request::new(
        basis,
        Query {
            views: vec![ViewSpec::List],
            format: fdu_core::report_format::Format::Json,
            selection: Selection { kinds: vec![EntryKind::Dir], ..Selection::default() },
            ..Query::default()
        },
        now,
    );
    let answer = fdu_core::query::report(&index, &request, now).expect("list report");
    assert!(!answer.status.complete, "root remains partial");
    let Section::Files { rows, .. } = &answer.sections[0] else {
        panic!("flat list section");
    };
    assert_eq!(rows.len(), 3);
    for path in ["ancestor", "ancestor/blocked"] {
        let row = rows.iter().find(|row| row.path == Path::new(path)).expect("incomplete row");
        assert_eq!(row.complete, Some(false), "{path}");
        assert_eq!(row.age_ns, None, "{path}");
    }
    let healthy = rows.iter().find(|row| row.path == Path::new("healthy")).expect("healthy row");
    assert_eq!(healthy.complete, Some(true));
    assert!(healthy.age_ns.is_some());
    assert_eq!(healthy.files, Some(1));
    for modified in [
        ModifiedWindow { since: Some(0), before: None },
        ModifiedWindow { since: None, before: Some(i64::MAX) },
    ] {
        request.query.selection.modified = modified;
        let filtered = fdu_core::query::report(&index, &request, now).expect("time-bounded list");
        let Section::Files { rows, .. } = &filtered.sections[0] else {
            panic!("flat list section");
        };
        assert_eq!(rows.len(), 1, "unknown times cannot satisfy either bound");
        assert_eq!(rows[0].path, Path::new("healthy"));
    }
}

#[test]
fn cached_partial_directory_rows_match_cold_and_pruned_subtrees_remain_complete() {
    let root = tempfile::tempdir().expect("root");
    let cache = tempfile::tempdir().expect("cache");
    let blocked = root.path().join("ancestor/blocked");
    fs::create_dir_all(&blocked).expect("blocked directory");
    fs::write(blocked.join("unknown.txt"), b"unread\n").expect("unknown descendant");
    fs::create_dir(root.path().join("healthy")).expect("healthy directory");
    fs::write(root.path().join("healthy/known.txt"), b"known\n").expect("known descendant");
    let basis = Basis {
        root: root.path().into(),
        scope: Scope::default(),
        content: AnalysisSet::LINES_ONLY,
    };
    let cache_path = cache.path().join("state.fdu");
    let (_, baseline) =
        fdu_core::open(&basis, &Delivery::new(CachePolicy::Auto, Some(cache_path.clone())))
            .expect("baseline cache");
    assert!(baseline.is_complete());
    fs::set_permissions(&blocked, fs::Permissions::from_mode(0o000)).expect("deny listing");
    let probe = fs::read_dir(&blocked);
    if probe.is_ok() {
        fs::set_permissions(&blocked, fs::Permissions::from_mode(0o700)).expect("restore");
        if std::env::var_os("FDU_TEST_ALLOW_NO_PERMISSION_BITS").as_deref()
            == Some(std::ffi::OsStr::new("1"))
        {
            eprintln!(
                "skipped by FDU_TEST_ALLOW_NO_PERMISSION_BITS=1: host permits denied listing"
            );
            return;
        }
        panic!("permission fixture precondition failed: host permits mode-000 directory listing");
    }
    assert_eq!(probe.expect_err("denied listing").kind(), std::io::ErrorKind::PermissionDenied);
    let results: Vec<_> = [CachePolicy::Off, CachePolicy::Auto, CachePolicy::On]
        .into_iter()
        .map(|policy| fdu_core::open(&basis, &Delivery::new(policy, Some(cache_path.clone()))))
        .collect();
    fs::set_permissions(&blocked, fs::Permissions::from_mode(0o700)).expect("restore");
    let now = SystemTime::now();
    let mut request = Request::new(
        basis,
        Query {
            views: vec![ViewSpec::List],
            format: fdu_core::report_format::Format::Json,
            selection: Selection { kinds: vec![EntryKind::Dir], ..Selection::default() },
            ..Query::default()
        },
        now,
    );
    for (position, result) in results.into_iter().enumerate() {
        let (index, report) = result.expect("partial answer");
        assert_eq!(
            report.path_taken,
            if position == 0 {
                fdu_core::OpenPath::ColdScan
            } else {
                fdu_core::OpenPath::WarmRevalidate
            }
        );
        assert!(!report.is_complete());
        request.query.selection.exclude.clear();
        let answer = fdu_core::query::report(&index, &request, now).expect("rows");
        let Section::Files { rows, .. } = &answer.sections[0] else {
            panic!("list");
        };
        assert_eq!(rows.len(), 3);
        for row in rows {
            let complete = row.path == Path::new("healthy");
            assert_eq!(row.complete, Some(complete), "{}", row.path.display());
            assert_eq!(row.age_ns.is_some(), complete, "{}", row.path.display());
        }
        request
            .query
            .selection
            .exclude
            .push(fdu_core::query::Pattern::parse("blocked").expect("pattern"));
        let pruned = fdu_core::query::report(&index, &request, now).expect("pruned rows");
        assert!(!pruned.status.complete, "query pruning does not change root coverage");
        let Section::Files { rows, .. } = &pruned.sections[0] else {
            panic!("list");
        };
        assert_eq!(rows.len(), 2);
        for row in rows {
            assert_eq!(row.complete, Some(true), "{}", row.path.display());
            assert!(row.age_ns.is_some(), "{}", row.path.display());
        }
    }
}