fdu-core 0.3.0

The fdu engine: incremental hierarchical tallies over large directory trees
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
//! Completeness and provenance for one coherent report snapshot.

use std::path::PathBuf;
use std::time::{Duration, SystemTime};

use crate::{Coverage, CoverageReason, Freshness, Index, Issue, Source};

use super::{IgnoredEntries, ReportSource, Request};

/// Completeness of the facts used to answer one report.
#[derive(Clone, Debug)]
pub struct TreeStatus {
    /// Whether every requested structural and content fact is represented.
    pub complete: bool,
    /// Coverage of the least complete requested tier.
    pub coverage: Coverage,
    /// Bounded, path-ordered operational failure details.
    pub errors: Vec<Issue>,
    /// Further failure details omitted by the shared retention bound.
    pub errors_omitted: u64,
}

impl TreeStatus {
    /// Derive status from the same immutable index snapshot used to build report rows.
    pub fn of(index: &Index, request: &Request) -> Self {
        let state = index.state();
        let mut details: Vec<(PathBuf, Issue)> = Vec::with_capacity(crate::MAX_RETAINED_ISSUES);
        let mut detail_count = 0_u64;
        for issue in index.issues() {
            detail_count = detail_count.saturating_add(1);
            retain_first_detail(
                &mut details,
                (issue.path.clone().unwrap_or_default(), issue.clone()),
            );
        }
        let unknown_population = request.query.selection.ignored != IgnoredEntries::Include
            && index.control_table().refused_len() > 0;
        if unknown_population {
            for refusal in index.control_table().refusals() {
                detail_count = detail_count.saturating_add(1);
                let issue = Issue::provider_failure(
                    Some(&refusal.path),
                    format!(
                        "ignored population is unknown below this refused control ({})",
                        refusal.reason.label()
                    ),
                );
                retain_first_detail(&mut details, (refusal.path, issue));
            }
        }
        let wanted = index.content_identity(request.basis.content);
        let admitted = index.content().and_then(|content| content.admit(&wanted));
        let mut content_failures = 0_u64;
        if request.basis.content.is_enabled() {
            if let Some(content) = admitted {
                for (path, analysis) in content.records() {
                    let Some(reason) = analysis.operational_failure() else {
                        continue;
                    };
                    content_failures = content_failures.saturating_add(1);
                    detail_count = detail_count.saturating_add(1);
                    let detail = analysis.error.clone().unwrap_or_else(|| match reason {
                        crate::content::CoverageReason::IoError => {
                            "content analysis could not read the file".to_string()
                        }
                        crate::content::CoverageReason::ChangedDuringRead => {
                            "file changed during content analysis".to_string()
                        }
                        _ => unreachable!("operational_failure returns only operational reasons"),
                    });
                    retain_first_detail(
                        &mut details,
                        (path.to_path_buf(), Issue::provider_failure(Some(path), detail)),
                    );
                }
            }
        }
        let content_tier_partial = request.basis.content.is_enabled()
            && admitted
                .and_then(crate::stored_state::ContentProjection::state)
                .is_some_and(|tier| tier.freshness == Freshness::Partial);
        let content_pending = index.content_has_pending(request.basis.content);
        if content_tier_partial && content_failures == 0 {
            detail_count = detail_count.saturating_add(1);
            retain_first_detail(
                &mut details,
                (
                    PathBuf::new(),
                    Issue::provider_failure(
                        None,
                        "content analysis results became stale before they could be retained"
                            .to_string(),
                    ),
                ),
            );
        }
        let retained = u64::try_from(details.len()).unwrap_or(u64::MAX);
        let errors = details.into_iter().map(|(_, issue)| issue).collect();
        let complete = state.coverage == Coverage::Complete
            && content_failures == 0
            && !content_tier_partial
            && !content_pending
            && !unknown_population;
        Self {
            complete,
            coverage: if content_failures == 0
                && !content_tier_partial
                && !content_pending
                && !unknown_population
            {
                state.coverage
            } else {
                Coverage::Partial(CoverageReason::Failed)
            },
            errors,
            errors_omitted: state
                .issues
                .omitted
                .saturating_add(detail_count.saturating_sub(retained)),
        }
    }

    /// Derive status from a one-shot walk, collapsing repeated causes first.
    ///
    /// Workers can meet one unreadable path more than once; the index retains each cause
    /// once, so this path must too or the two routes disagree about `errors_omitted`.
    pub(crate) fn of_walk(root: &std::path::Path, scan: &mut crate::ScanReport) -> Self {
        crate::scan::normalize_walk_errors(root, &mut scan.errors);
        let complete = scan.is_complete();
        let mut details = Vec::with_capacity(crate::MAX_RETAINED_ISSUES);
        let mut count = 0_u64;
        for error in &scan.errors {
            count = count.saturating_add(1);
            let issue = crate::Issue::from_error_under(root, error);
            retain_first_detail(&mut details, (issue.path.clone().unwrap_or_default(), issue));
        }
        let retained = u64::try_from(details.len()).unwrap_or(u64::MAX);
        let errors = details.into_iter().map(|(_, issue)| issue).collect();
        Self {
            complete,
            coverage: if complete {
                Coverage::Complete
            } else {
                Coverage::Partial(CoverageReason::Inaccessible)
            },
            errors,
            errors_omitted: count.saturating_sub(retained),
        }
    }
}

fn retain_first_detail(details: &mut Vec<(PathBuf, Issue)>, detail: (PathBuf, Issue)) {
    let position = details
        .binary_search_by(|current| {
            current.0.cmp(&detail.0).then_with(|| current.1.message.cmp(&detail.1.message))
        })
        .unwrap_or_else(|position| position);
    if position >= crate::MAX_RETAINED_ISSUES {
        return;
    }
    details.insert(position, detail);
    if details.len() > crate::MAX_RETAINED_ISSUES {
        details.pop();
    }
}

/// Source and currency of one retained tier.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct TierState {
    /// Weakest source represented by this tier.
    pub source: Source,
    /// Current trust state of this tier.
    pub freshness: Freshness,
    /// When this tier's facts were observed, or `None` when the stored format cannot say.
    pub observed_at_ns: Option<i64>,
}

/// Provenance of each tier contributing to a report.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct TierProvenance {
    /// Retained filesystem-entry tier.
    pub entries: TierState,
    /// Sparse content-analysis tier when requested and present.
    pub content: Option<TierState>,
}

/// How and when the coherent answer was produced.
#[derive(Clone, Debug)]
pub struct ReportProvenance {
    /// Weakest source among tiers contributing to the report.
    pub source: ReportSource,
    /// Least fresh tier contributing to the report.
    pub freshness: Freshness,
    /// Conservative start watermark of the entry verification pass.
    pub scan_started_at: Option<SystemTime>,
    /// Caller-supplied instant at which this answer was generated.
    pub generated_at: SystemTime,
    /// Per-tier source, currency, and observation time.
    pub tiers: TierProvenance,
}

impl ReportProvenance {
    /// Derive report provenance for the requested tiers from one immutable index snapshot.
    pub fn of(
        index: &Index,
        content_requested: crate::content::AnalysisSet,
        generated_at: SystemTime,
    ) -> Self {
        let state = index.state();
        let entries = TierState {
            source: state.source,
            freshness: state.freshness,
            observed_at_ns: Some(index.writing_pass_started_at_ns()),
        };
        let scan_started_at = u64::try_from(index.writing_pass_started_at_ns())
            .ok()
            .map(|nanos| SystemTime::UNIX_EPOCH + Duration::from_nanos(nanos));
        let content_pending = index.content_has_pending(content_requested);
        let wanted = index.content_identity(content_requested);
        let content = if content_requested.is_enabled() {
            index.content().and_then(|content| content.admit(&wanted)).and_then(|content| {
                content.state().map(|state| TierState {
                    source: state.source,
                    freshness: if content_pending { Freshness::Partial } else { state.freshness },
                    observed_at_ns: state.observed_at_ns,
                })
            })
        } else {
            None
        };
        let source = content.map_or(entries.source, |state| entries.source.max(state.source));
        let freshness = content
            .map_or(entries.freshness, |state| least_fresh(entries.freshness, state.freshness));
        Self {
            source: report_source(source),
            freshness,
            scan_started_at,
            generated_at,
            tiers: TierProvenance { entries, content },
        }
    }

    pub(crate) fn of_walk(started: SystemTime, generated_at: SystemTime, complete: bool) -> Self {
        let observed_at_ns = crate::query::system_time_to_nanos(started);
        let freshness = if complete { Freshness::Fresh } else { Freshness::Partial };
        let entries = TierState { source: Source::Scanned, freshness, observed_at_ns };
        Self {
            source: ReportSource::ColdScan,
            freshness,
            scan_started_at: Some(started),
            generated_at,
            tiers: TierProvenance { entries, content: None },
        }
    }
}

fn least_fresh(left: Freshness, right: Freshness) -> Freshness {
    let rank = |freshness| match freshness {
        Freshness::Fresh => 0,
        Freshness::Reconciling => 1,
        Freshness::Stale => 2,
        Freshness::Partial => 3,
    };
    if rank(left) >= rank(right) { left } else { right }
}

fn report_source(source: Source) -> ReportSource {
    match source {
        Source::Scanned => ReportSource::ColdScan,
        Source::Revalidated | Source::JournalScoped => ReportSource::WarmRevalidate,
        Source::Cached => ReportSource::CacheOnly,
    }
}

#[cfg(test)]
mod tests {
    use std::fs;
    use std::time::UNIX_EPOCH;

    use super::{ReportProvenance, TreeStatus};
    use crate::content::{AnalysisRequest, AnalysisSet, analyze_index};
    use crate::query::{Basis, Query, Request};
    use crate::scan::ScanConfig;
    use crate::{Freshness, Index, Source};

    #[test]
    fn partial_content_tier_keeps_status_incomplete_without_a_failure_record() {
        let mut index = Index::new("/unused");
        let analysis = AnalysisSet::NONE.with_lines();
        index.prepare_content_analysis(AnalysisRequest {
            profile: analysis,
            ..AnalysisRequest::default()
        });
        index.set_content_tier_state(Source::Scanned, Freshness::Partial, Some(1));
        let mut basis = Basis::held_by(&index);
        basis.content = analysis;
        let request = Request::new(basis, Query::default(), UNIX_EPOCH);

        let status = TreeStatus::of(&index, &request);

        assert!(!status.complete);
        assert_eq!(status.coverage, crate::Coverage::Partial(crate::CoverageReason::Failed));
        assert_eq!(
            status.errors[0].message,
            "content analysis results became stale before they could be retained"
        );
    }

    #[test]
    fn metadata_only_provenance_excludes_an_unrequested_partial_content_tier() {
        let mut index = Index::new("/unused");
        index.prepare_content_analysis(AnalysisRequest {
            profile: AnalysisSet::NONE.with_lines(),
            ..AnalysisRequest::default()
        });
        index.set_content_tier_state(Source::Cached, Freshness::Partial, None);

        let provenance = ReportProvenance::of(&index, AnalysisSet::NONE, UNIX_EPOCH);

        assert_eq!(provenance.tiers.content, None);
        assert_eq!(provenance.freshness, Freshness::Fresh);
        assert_eq!(provenance.source, crate::query::ReportSource::ColdScan);
    }

    #[test]
    fn repeated_content_read_failure_stays_incomplete_until_a_verified_recovery() {
        let root = tempfile::tempdir().expect("root");
        let path = root.path().join("failed.txt");
        fs::write(&path, b"hello").expect("fixture");
        let scan = ScanConfig::default();
        let (mut index, report) = crate::scan::scan_into_index(root.path(), &scan).expect("scan");
        assert!(report.is_complete());
        fs::remove_file(&path).expect("make the retained candidate unreadable");
        let analysis = AnalysisRequest { profile: AnalysisSet::NONE.with_lines(), workers: 1 };
        let first = analyze_index(&mut index, analysis);
        assert_eq!(first.lines.io_errors, 1);
        let request = Request::new(Basis::held_by(&index), Query::default(), UNIX_EPOCH);

        let first_status = TreeStatus::of(&index, &request);
        assert!(!first_status.complete);
        assert_eq!(first_status.errors.len(), 1);
        assert_eq!(
            first_status.errors[0].path.as_deref(),
            Some(std::path::Path::new("failed.txt"))
        );

        let repeated = analyze_index(&mut index, analysis);
        assert_eq!(repeated.lines.io_errors, 1, "a failed record must be retried");
        assert!(!TreeStatus::of(&index, &request).complete);

        fs::write(&path, b"recovered").expect("recover file");
        crate::scan::reconcile(&mut index, &scan, &mut |_| {}).expect("reconcile recovery");
        let recovered = analyze_index(&mut index, analysis);
        assert_eq!(recovered.lines.analyzed, 1);
        let status = TreeStatus::of(&index, &request);
        assert!(status.complete, "a successful reread clears the operational failure");
        assert!(status.errors.is_empty());
    }

    /// A walk can meet one unreadable directory from several workers. The summary fast
    /// path must collapse those repeats as the index does, or `--view summary` and
    /// `--view tree` disagree about how many errors were omitted (R113-4).
    #[test]
    fn walk_status_counts_each_unreadable_path_once() {
        let root = std::path::Path::new("/root");
        let denied = |number: usize| crate::Error::Io {
            path: root.join(format!("denied-{number:02}")),
            source: std::io::Error::from(std::io::ErrorKind::PermissionDenied),
        };
        let mut scan = crate::ScanReport::default();
        for number in (0..40).rev() {
            scan.errors.push(denied(number));
            scan.errors.push(denied(number));
        }

        let status = TreeStatus::of_walk(root, &mut scan);

        assert!(!status.complete);
        assert_eq!(status.errors.len(), 40);
        assert_eq!(status.errors_omitted, 0);
        assert_eq!(status.errors[0].path.as_deref(), Some(std::path::Path::new("denied-00")));
    }

    #[test]
    fn content_failures_retain_the_first_paths_and_count_every_omission() {
        let root = tempfile::tempdir().expect("root");
        for number in (0..66).rev() {
            fs::write(root.path().join(format!("file-{number:02}.txt")), b"x").expect("fixture");
        }
        let (mut index, report) =
            crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
        assert!(report.is_complete());
        for number in 0..66 {
            fs::remove_file(root.path().join(format!("file-{number:02}.txt")))
                .expect("make candidate unreadable");
        }
        analyze_index(
            &mut index,
            AnalysisRequest { profile: AnalysisSet::NONE.with_lines(), workers: 2 },
        );
        let request = Request::new(Basis::held_by(&index), Query::default(), UNIX_EPOCH);

        let status = TreeStatus::of(&index, &request);

        assert!(!status.complete);
        assert_eq!(status.errors.len(), crate::MAX_RETAINED_ISSUES);
        assert_eq!(status.errors_omitted, 2);
        let retained_paths: Vec<_> =
            status.errors.iter().map(|issue| issue.path.as_deref().expect("path")).collect();
        assert_eq!(retained_paths[0], std::path::Path::new("file-00.txt"));
        assert_eq!(retained_paths[63], std::path::Path::new("file-63.txt"));
        assert!(retained_paths.windows(2).all(|pair| pair[0] < pair[1]));
        assert!(!retained_paths.contains(&std::path::Path::new("file-64.txt")));
        assert!(!retained_paths.contains(&std::path::Path::new("file-65.txt")));
        assert_eq!(
            index
                .content()
                .expect("content")
                .records()
                .filter(|(_, record)| { record.operational_failure().is_some() })
                .count(),
            66,
            "the diagnostic bound must not discard retained failure state"
        );
    }
}