1use std::path::PathBuf;
4use std::time::{Duration, SystemTime};
5
6use crate::{Coverage, CoverageReason, Freshness, Index, Issue, Source};
7
8use super::{IgnoredEntries, ReportSource, Request};
9
10#[derive(Clone, Debug)]
12pub struct TreeStatus {
13 pub complete: bool,
15 pub coverage: Coverage,
17 pub errors: Vec<Issue>,
19 pub errors_omitted: u64,
21}
22
23impl TreeStatus {
24 pub fn of(index: &Index, request: &Request) -> Self {
26 let state = index.state();
27 let mut details: Vec<(PathBuf, Issue)> = Vec::with_capacity(crate::MAX_RETAINED_ISSUES);
28 let mut detail_count = 0_u64;
29 for issue in index.issues() {
30 detail_count = detail_count.saturating_add(1);
31 retain_first_detail(
32 &mut details,
33 (issue.path.clone().unwrap_or_default(), issue.clone()),
34 );
35 }
36 let unknown_population = request.query.selection.ignored != IgnoredEntries::Include
37 && index.control_table().refused_len() > 0;
38 if unknown_population {
39 for refusal in index.control_table().refusals() {
40 detail_count = detail_count.saturating_add(1);
41 let issue = Issue::provider_failure(
42 Some(&refusal.path),
43 format!(
44 "ignored population is unknown below this refused control ({})",
45 refusal.reason.label()
46 ),
47 );
48 retain_first_detail(&mut details, (refusal.path, issue));
49 }
50 }
51 let wanted = index.content_identity(request.basis.content);
52 let admitted = index.content().and_then(|content| content.admit(&wanted));
53 let mut content_failures = 0_u64;
54 if request.basis.content.is_enabled() {
55 if let Some(content) = admitted {
56 for (path, analysis) in content.records() {
57 let Some(reason) = analysis.operational_failure() else {
58 continue;
59 };
60 content_failures = content_failures.saturating_add(1);
61 detail_count = detail_count.saturating_add(1);
62 let detail = analysis.error.clone().unwrap_or_else(|| match reason {
63 crate::content::CoverageReason::IoError => {
64 "content analysis could not read the file".to_string()
65 }
66 crate::content::CoverageReason::ChangedDuringRead => {
67 "file changed during content analysis".to_string()
68 }
69 _ => unreachable!("operational_failure returns only operational reasons"),
70 });
71 retain_first_detail(
72 &mut details,
73 (path.to_path_buf(), Issue::provider_failure(Some(path), detail)),
74 );
75 }
76 }
77 }
78 let content_tier_partial = request.basis.content.is_enabled()
79 && admitted
80 .and_then(crate::stored_state::ContentProjection::state)
81 .is_some_and(|tier| tier.freshness == Freshness::Partial);
82 let content_pending = index.content_has_pending(request.basis.content);
83 if content_tier_partial && content_failures == 0 {
84 detail_count = detail_count.saturating_add(1);
85 retain_first_detail(
86 &mut details,
87 (
88 PathBuf::new(),
89 Issue::provider_failure(
90 None,
91 "content analysis results became stale before they could be retained"
92 .to_string(),
93 ),
94 ),
95 );
96 }
97 let retained = u64::try_from(details.len()).unwrap_or(u64::MAX);
98 let errors = details.into_iter().map(|(_, issue)| issue).collect();
99 let complete = state.coverage == Coverage::Complete
100 && content_failures == 0
101 && !content_tier_partial
102 && !content_pending
103 && !unknown_population;
104 Self {
105 complete,
106 coverage: if content_failures == 0
107 && !content_tier_partial
108 && !content_pending
109 && !unknown_population
110 {
111 state.coverage
112 } else {
113 Coverage::Partial(CoverageReason::Failed)
114 },
115 errors,
116 errors_omitted: state
117 .issues
118 .omitted
119 .saturating_add(detail_count.saturating_sub(retained)),
120 }
121 }
122
123 pub(crate) fn of_walk(root: &std::path::Path, scan: &mut crate::ScanReport) -> Self {
128 crate::scan::normalize_walk_errors(root, &mut scan.errors);
129 let complete = scan.is_complete();
130 let mut details = Vec::with_capacity(crate::MAX_RETAINED_ISSUES);
131 let mut count = 0_u64;
132 for error in &scan.errors {
133 count = count.saturating_add(1);
134 let issue = crate::Issue::from_error_under(root, error);
135 retain_first_detail(&mut details, (issue.path.clone().unwrap_or_default(), issue));
136 }
137 let retained = u64::try_from(details.len()).unwrap_or(u64::MAX);
138 let errors = details.into_iter().map(|(_, issue)| issue).collect();
139 Self {
140 complete,
141 coverage: if complete {
142 Coverage::Complete
143 } else {
144 Coverage::Partial(CoverageReason::Inaccessible)
145 },
146 errors,
147 errors_omitted: count.saturating_sub(retained),
148 }
149 }
150}
151
152fn retain_first_detail(details: &mut Vec<(PathBuf, Issue)>, detail: (PathBuf, Issue)) {
153 let position = details
154 .binary_search_by(|current| {
155 current.0.cmp(&detail.0).then_with(|| current.1.message.cmp(&detail.1.message))
156 })
157 .unwrap_or_else(|position| position);
158 if position >= crate::MAX_RETAINED_ISSUES {
159 return;
160 }
161 details.insert(position, detail);
162 if details.len() > crate::MAX_RETAINED_ISSUES {
163 details.pop();
164 }
165}
166
167#[derive(Clone, Copy, PartialEq, Eq, Debug)]
169pub struct TierState {
170 pub source: Source,
172 pub freshness: Freshness,
174 pub observed_at_ns: Option<i64>,
176}
177
178#[derive(Clone, Copy, PartialEq, Eq, Debug)]
180pub struct TierProvenance {
181 pub entries: TierState,
183 pub content: Option<TierState>,
185}
186
187#[derive(Clone, Debug)]
189pub struct ReportProvenance {
190 pub source: ReportSource,
192 pub freshness: Freshness,
194 pub scan_started_at: Option<SystemTime>,
196 pub generated_at: SystemTime,
198 pub tiers: TierProvenance,
200}
201
202impl ReportProvenance {
203 pub fn of(
205 index: &Index,
206 content_requested: crate::content::AnalysisSet,
207 generated_at: SystemTime,
208 ) -> Self {
209 let state = index.state();
210 let entries = TierState {
211 source: state.source,
212 freshness: state.freshness,
213 observed_at_ns: Some(index.writing_pass_started_at_ns()),
214 };
215 let scan_started_at = u64::try_from(index.writing_pass_started_at_ns())
216 .ok()
217 .map(|nanos| SystemTime::UNIX_EPOCH + Duration::from_nanos(nanos));
218 let content_pending = index.content_has_pending(content_requested);
219 let wanted = index.content_identity(content_requested);
220 let content = if content_requested.is_enabled() {
221 index.content().and_then(|content| content.admit(&wanted)).and_then(|content| {
222 content.state().map(|state| TierState {
223 source: state.source,
224 freshness: if content_pending { Freshness::Partial } else { state.freshness },
225 observed_at_ns: state.observed_at_ns,
226 })
227 })
228 } else {
229 None
230 };
231 let source = content.map_or(entries.source, |state| entries.source.max(state.source));
232 let freshness = content
233 .map_or(entries.freshness, |state| least_fresh(entries.freshness, state.freshness));
234 Self {
235 source: report_source(source),
236 freshness,
237 scan_started_at,
238 generated_at,
239 tiers: TierProvenance { entries, content },
240 }
241 }
242
243 pub(crate) fn of_walk(started: SystemTime, generated_at: SystemTime, complete: bool) -> Self {
244 let observed_at_ns = crate::query::system_time_to_nanos(started);
245 let freshness = if complete { Freshness::Fresh } else { Freshness::Partial };
246 let entries = TierState { source: Source::Scanned, freshness, observed_at_ns };
247 Self {
248 source: ReportSource::ColdScan,
249 freshness,
250 scan_started_at: Some(started),
251 generated_at,
252 tiers: TierProvenance { entries, content: None },
253 }
254 }
255}
256
257fn least_fresh(left: Freshness, right: Freshness) -> Freshness {
258 let rank = |freshness| match freshness {
259 Freshness::Fresh => 0,
260 Freshness::Reconciling => 1,
261 Freshness::Stale => 2,
262 Freshness::Partial => 3,
263 };
264 if rank(left) >= rank(right) { left } else { right }
265}
266
267fn report_source(source: Source) -> ReportSource {
268 match source {
269 Source::Scanned => ReportSource::ColdScan,
270 Source::Revalidated | Source::JournalScoped => ReportSource::WarmRevalidate,
271 Source::Cached => ReportSource::CacheOnly,
272 }
273}
274
275#[cfg(test)]
276mod tests {
277 use std::fs;
278 use std::time::UNIX_EPOCH;
279
280 use super::{ReportProvenance, TreeStatus};
281 use crate::content::{AnalysisRequest, AnalysisSet, analyze_index};
282 use crate::query::{Basis, Query, Request};
283 use crate::scan::ScanConfig;
284 use crate::{Freshness, Index, Source};
285
286 #[test]
287 fn partial_content_tier_keeps_status_incomplete_without_a_failure_record() {
288 let mut index = Index::new("/unused");
289 let analysis = AnalysisSet::NONE.with_lines();
290 index.prepare_content_analysis(AnalysisRequest {
291 profile: analysis,
292 ..AnalysisRequest::default()
293 });
294 index.set_content_tier_state(Source::Scanned, Freshness::Partial, Some(1));
295 let mut basis = Basis::held_by(&index);
296 basis.content = analysis;
297 let request = Request::new(basis, Query::default(), UNIX_EPOCH);
298
299 let status = TreeStatus::of(&index, &request);
300
301 assert!(!status.complete);
302 assert_eq!(status.coverage, crate::Coverage::Partial(crate::CoverageReason::Failed));
303 assert_eq!(
304 status.errors[0].message,
305 "content analysis results became stale before they could be retained"
306 );
307 }
308
309 #[test]
310 fn metadata_only_provenance_excludes_an_unrequested_partial_content_tier() {
311 let mut index = Index::new("/unused");
312 index.prepare_content_analysis(AnalysisRequest {
313 profile: AnalysisSet::NONE.with_lines(),
314 ..AnalysisRequest::default()
315 });
316 index.set_content_tier_state(Source::Cached, Freshness::Partial, None);
317
318 let provenance = ReportProvenance::of(&index, AnalysisSet::NONE, UNIX_EPOCH);
319
320 assert_eq!(provenance.tiers.content, None);
321 assert_eq!(provenance.freshness, Freshness::Fresh);
322 assert_eq!(provenance.source, crate::query::ReportSource::ColdScan);
323 }
324
325 #[test]
326 fn repeated_content_read_failure_stays_incomplete_until_a_verified_recovery() {
327 let root = tempfile::tempdir().expect("root");
328 let path = root.path().join("failed.txt");
329 fs::write(&path, b"hello").expect("fixture");
330 let scan = ScanConfig::default();
331 let (mut index, report) = crate::scan::scan_into_index(root.path(), &scan).expect("scan");
332 assert!(report.is_complete());
333 fs::remove_file(&path).expect("make the retained candidate unreadable");
334 let analysis = AnalysisRequest { profile: AnalysisSet::NONE.with_lines(), workers: 1 };
335 let first = analyze_index(&mut index, analysis);
336 assert_eq!(first.lines.io_errors, 1);
337 let request = Request::new(Basis::held_by(&index), Query::default(), UNIX_EPOCH);
338
339 let first_status = TreeStatus::of(&index, &request);
340 assert!(!first_status.complete);
341 assert_eq!(first_status.errors.len(), 1);
342 assert_eq!(
343 first_status.errors[0].path.as_deref(),
344 Some(std::path::Path::new("failed.txt"))
345 );
346
347 let repeated = analyze_index(&mut index, analysis);
348 assert_eq!(repeated.lines.io_errors, 1, "a failed record must be retried");
349 assert!(!TreeStatus::of(&index, &request).complete);
350
351 fs::write(&path, b"recovered").expect("recover file");
352 crate::scan::reconcile(&mut index, &scan, &mut |_| {}).expect("reconcile recovery");
353 let recovered = analyze_index(&mut index, analysis);
354 assert_eq!(recovered.lines.analyzed, 1);
355 let status = TreeStatus::of(&index, &request);
356 assert!(status.complete, "a successful reread clears the operational failure");
357 assert!(status.errors.is_empty());
358 }
359
360 #[test]
364 fn walk_status_counts_each_unreadable_path_once() {
365 let root = std::path::Path::new("/root");
366 let denied = |number: usize| crate::Error::Io {
367 path: root.join(format!("denied-{number:02}")),
368 source: std::io::Error::from(std::io::ErrorKind::PermissionDenied),
369 };
370 let mut scan = crate::ScanReport::default();
371 for number in (0..40).rev() {
372 scan.errors.push(denied(number));
373 scan.errors.push(denied(number));
374 }
375
376 let status = TreeStatus::of_walk(root, &mut scan);
377
378 assert!(!status.complete);
379 assert_eq!(status.errors.len(), 40);
380 assert_eq!(status.errors_omitted, 0);
381 assert_eq!(status.errors[0].path.as_deref(), Some(std::path::Path::new("denied-00")));
382 }
383
384 #[test]
385 fn content_failures_retain_the_first_paths_and_count_every_omission() {
386 let root = tempfile::tempdir().expect("root");
387 for number in (0..66).rev() {
388 fs::write(root.path().join(format!("file-{number:02}.txt")), b"x").expect("fixture");
389 }
390 let (mut index, report) =
391 crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
392 assert!(report.is_complete());
393 for number in 0..66 {
394 fs::remove_file(root.path().join(format!("file-{number:02}.txt")))
395 .expect("make candidate unreadable");
396 }
397 analyze_index(
398 &mut index,
399 AnalysisRequest { profile: AnalysisSet::NONE.with_lines(), workers: 2 },
400 );
401 let request = Request::new(Basis::held_by(&index), Query::default(), UNIX_EPOCH);
402
403 let status = TreeStatus::of(&index, &request);
404
405 assert!(!status.complete);
406 assert_eq!(status.errors.len(), crate::MAX_RETAINED_ISSUES);
407 assert_eq!(status.errors_omitted, 2);
408 let retained_paths: Vec<_> =
409 status.errors.iter().map(|issue| issue.path.as_deref().expect("path")).collect();
410 assert_eq!(retained_paths[0], std::path::Path::new("file-00.txt"));
411 assert_eq!(retained_paths[63], std::path::Path::new("file-63.txt"));
412 assert!(retained_paths.windows(2).all(|pair| pair[0] < pair[1]));
413 assert!(!retained_paths.contains(&std::path::Path::new("file-64.txt")));
414 assert!(!retained_paths.contains(&std::path::Path::new("file-65.txt")));
415 assert_eq!(
416 index
417 .content()
418 .expect("content")
419 .records()
420 .filter(|(_, record)| { record.operational_failure().is_some() })
421 .count(),
422 66,
423 "the diagnostic bound must not discard retained failure state"
424 );
425 }
426}