Skip to main content

fdu_core/content/
content_index.rs

1//! Sparse per-file content records and precomputed directory/group rollups.
2
3use std::borrow::{Borrow, Cow};
4use std::cmp::Ordering;
5use std::collections::{BTreeMap, HashMap};
6use std::path::{Path, PathBuf};
7
8use crate::stored_state::ContentTierIdentity;
9use crate::{Freshness, Source};
10
11use super::content_model::{
12    AnalysisSet, AnalyzerOutcome, BasicMetrics, CodeMetrics, ContentProvenance, CoverageReason,
13    FileAnalysis, WordMetrics,
14};
15
16/// Additive metrics and coverage for one analyzer unit.
17#[derive(Clone, PartialEq, Eq, Debug, Default)]
18pub struct AnalyzerTally<T> {
19    /// Files for which this analyzer produced a value.
20    pub analyzed_files: u64,
21    /// Additive values from analyzed files.
22    pub metrics: T,
23    /// Outcomes for every file on which the analyzer was requested.
24    pub coverage: BTreeMap<CoverageReason, u64>,
25}
26
27/// Additive tally across records of one content-tier identity.
28#[derive(Clone, PartialEq, Eq, Debug, Default)]
29pub struct MetricTally {
30    /// Files represented, including unavailable outcomes.
31    pub files: u64,
32    /// Apparent bytes represented.
33    pub bytes: u64,
34    /// Shared lines-unit results.
35    pub lines: AnalyzerTally<BasicMetrics>,
36    /// Code-unit results; empty when the tier did not request code.
37    pub code: AnalyzerTally<CodeMetrics>,
38    /// Words-unit results; empty when the tier did not request words.
39    pub words: AnalyzerTally<WordMetrics>,
40}
41
42impl MetricTally {
43    fn add(&mut self, analysis: &FileAnalysis) {
44        self.files = self.files.saturating_add(1);
45        self.bytes = self.bytes.saturating_add(analysis.bytes);
46        add_basic(&mut self.lines, analysis.lines);
47        if let Some(outcome) = analysis.code {
48            add_code(&mut self.code, outcome);
49        }
50        if let Some(outcome) = analysis.words {
51            add_words(&mut self.words, outcome);
52        }
53    }
54
55    fn subtract(&mut self, analysis: &FileAnalysis) {
56        self.files = self.files.saturating_sub(1);
57        self.bytes = self.bytes.saturating_sub(analysis.bytes);
58        sub_basic(&mut self.lines, analysis.lines);
59        if let Some(outcome) = analysis.code {
60            sub_code(&mut self.code, outcome);
61        }
62        if let Some(outcome) = analysis.words {
63            sub_words(&mut self.words, outcome);
64        }
65    }
66
67    fn merge(&mut self, other: &Self) {
68        self.files = self.files.saturating_add(other.files);
69        self.bytes = self.bytes.saturating_add(other.bytes);
70        merge_basic(&mut self.lines, &other.lines);
71        merge_code(&mut self.code, &other.code);
72        merge_words(&mut self.words, &other.words);
73    }
74}
75
76fn add_coverage<T>(tally: &mut AnalyzerTally<T>, outcome: &AnalyzerOutcome<T>) {
77    *tally.coverage.entry(outcome.coverage()).or_default() += 1;
78    if outcome.coverage() == CoverageReason::Analyzed {
79        tally.analyzed_files = tally.analyzed_files.saturating_add(1);
80    }
81}
82
83fn sub_coverage<T>(tally: &mut AnalyzerTally<T>, outcome: &AnalyzerOutcome<T>) {
84    if let Some(count) = tally.coverage.get_mut(&outcome.coverage()) {
85        *count = count.saturating_sub(1);
86        if *count == 0 {
87            tally.coverage.remove(&outcome.coverage());
88        }
89    }
90    if outcome.coverage() == CoverageReason::Analyzed {
91        tally.analyzed_files = tally.analyzed_files.saturating_sub(1);
92    }
93}
94
95fn merge_coverage<T>(tally: &mut AnalyzerTally<T>, other: &AnalyzerTally<T>) {
96    tally.analyzed_files = tally.analyzed_files.saturating_add(other.analyzed_files);
97    for (reason, count) in &other.coverage {
98        let slot = tally.coverage.entry(*reason).or_default();
99        *slot = slot.saturating_add(*count);
100    }
101}
102
103fn add_basic(tally: &mut AnalyzerTally<BasicMetrics>, outcome: AnalyzerOutcome<BasicMetrics>) {
104    add_coverage(tally, &outcome);
105    let Some(value) = outcome.value() else { return };
106    tally.metrics.physical_lines =
107        tally.metrics.physical_lines.saturating_add(value.physical_lines);
108    tally.metrics.blank_lines = tally.metrics.blank_lines.saturating_add(value.blank_lines);
109    tally.metrics.nonblank_lines =
110        tally.metrics.nonblank_lines.saturating_add(value.nonblank_lines);
111    tally.metrics.raw_words = tally.metrics.raw_words.saturating_add(value.raw_words);
112}
113
114fn sub_basic(tally: &mut AnalyzerTally<BasicMetrics>, outcome: AnalyzerOutcome<BasicMetrics>) {
115    sub_coverage(tally, &outcome);
116    let Some(value) = outcome.value() else { return };
117    tally.metrics.physical_lines =
118        tally.metrics.physical_lines.saturating_sub(value.physical_lines);
119    tally.metrics.blank_lines = tally.metrics.blank_lines.saturating_sub(value.blank_lines);
120    tally.metrics.nonblank_lines =
121        tally.metrics.nonblank_lines.saturating_sub(value.nonblank_lines);
122    tally.metrics.raw_words = tally.metrics.raw_words.saturating_sub(value.raw_words);
123}
124
125fn merge_basic(tally: &mut AnalyzerTally<BasicMetrics>, other: &AnalyzerTally<BasicMetrics>) {
126    merge_coverage(tally, other);
127    tally.metrics.physical_lines =
128        tally.metrics.physical_lines.saturating_add(other.metrics.physical_lines);
129    tally.metrics.blank_lines = tally.metrics.blank_lines.saturating_add(other.metrics.blank_lines);
130    tally.metrics.nonblank_lines =
131        tally.metrics.nonblank_lines.saturating_add(other.metrics.nonblank_lines);
132    tally.metrics.raw_words = tally.metrics.raw_words.saturating_add(other.metrics.raw_words);
133}
134
135fn add_code(tally: &mut AnalyzerTally<CodeMetrics>, outcome: AnalyzerOutcome<CodeMetrics>) {
136    add_coverage(tally, &outcome);
137    let Some(value) = outcome.value() else { return };
138    tally.metrics.code_lines = tally.metrics.code_lines.saturating_add(value.code_lines);
139    tally.metrics.comment_lines = tally.metrics.comment_lines.saturating_add(value.comment_lines);
140    tally.metrics.code_blank_lines =
141        tally.metrics.code_blank_lines.saturating_add(value.code_blank_lines);
142}
143
144fn sub_code(tally: &mut AnalyzerTally<CodeMetrics>, outcome: AnalyzerOutcome<CodeMetrics>) {
145    sub_coverage(tally, &outcome);
146    let Some(value) = outcome.value() else { return };
147    tally.metrics.code_lines = tally.metrics.code_lines.saturating_sub(value.code_lines);
148    tally.metrics.comment_lines = tally.metrics.comment_lines.saturating_sub(value.comment_lines);
149    tally.metrics.code_blank_lines =
150        tally.metrics.code_blank_lines.saturating_sub(value.code_blank_lines);
151}
152
153fn merge_code(tally: &mut AnalyzerTally<CodeMetrics>, other: &AnalyzerTally<CodeMetrics>) {
154    merge_coverage(tally, other);
155    tally.metrics.code_lines = tally.metrics.code_lines.saturating_add(other.metrics.code_lines);
156    tally.metrics.comment_lines =
157        tally.metrics.comment_lines.saturating_add(other.metrics.comment_lines);
158    tally.metrics.code_blank_lines =
159        tally.metrics.code_blank_lines.saturating_add(other.metrics.code_blank_lines);
160}
161
162fn add_words(tally: &mut AnalyzerTally<WordMetrics>, outcome: AnalyzerOutcome<WordMetrics>) {
163    add_coverage(tally, &outcome);
164    let Some(value) = outcome.value() else { return };
165    tally.metrics.paragraphs = tally.metrics.paragraphs.saturating_add(value.paragraphs);
166    tally.metrics.visible_words = tally.metrics.visible_words.saturating_add(value.visible_words);
167    tally.metrics.logical_word_stats.add_assign(value.logical_word_stats);
168    tally.metrics.visible_logical_word_stats.add_assign(value.visible_logical_word_stats);
169}
170
171fn sub_words(tally: &mut AnalyzerTally<WordMetrics>, outcome: AnalyzerOutcome<WordMetrics>) {
172    sub_coverage(tally, &outcome);
173    let Some(value) = outcome.value() else { return };
174    tally.metrics.paragraphs = tally.metrics.paragraphs.saturating_sub(value.paragraphs);
175    tally.metrics.visible_words = tally.metrics.visible_words.saturating_sub(value.visible_words);
176    sub_word_stats(&mut tally.metrics.logical_word_stats, value.logical_word_stats);
177    sub_word_stats(&mut tally.metrics.visible_logical_word_stats, value.visible_logical_word_stats);
178}
179
180fn merge_words(tally: &mut AnalyzerTally<WordMetrics>, other: &AnalyzerTally<WordMetrics>) {
181    merge_coverage(tally, other);
182    tally.metrics.paragraphs = tally.metrics.paragraphs.saturating_add(other.metrics.paragraphs);
183    tally.metrics.visible_words =
184        tally.metrics.visible_words.saturating_add(other.metrics.visible_words);
185    tally.metrics.logical_word_stats.add_assign(other.metrics.logical_word_stats);
186    tally.metrics.visible_logical_word_stats.add_assign(other.metrics.visible_logical_word_stats);
187}
188
189fn sub_word_stats(tally: &mut super::LogicalWordStats, value: super::LogicalWordStats) {
190    tally.wide_chars = tally.wide_chars.saturating_sub(value.wide_chars);
191    tally.nonwide_tokens = tally.nonwide_tokens.saturating_sub(value.nonwide_tokens);
192    tally.nonwide_chars = tally.nonwide_chars.saturating_sub(value.nonwide_chars);
193}
194
195/// Content totals for one directory subtree.
196#[derive(Clone, PartialEq, Eq, Debug, Default)]
197pub struct ContentRollUp {
198    /// All sparse file records beneath this directory.
199    pub total: MetricTally,
200}
201
202impl ContentRollUp {
203    fn add(&mut self, analysis: &FileAnalysis) {
204        self.total.add(analysis);
205    }
206
207    fn subtract(&mut self, analysis: &FileAnalysis) {
208        self.total.subtract(analysis);
209    }
210
211    fn merge(&mut self, other: &Self) {
212        self.total.merge(&other.total);
213    }
214}
215
216/// A relative path ordered by its bytes rather than by its components.
217///
218/// `PathBuf`'s own ordering compares component by component, re-parsing both sides on
219/// every comparison, and a `BTreeMap` keyed by it pays that on every descent: on a warm
220/// content open over 52k files, `compare_components` and `Components::next` were a third
221/// of the profile. Byte order is one `memcmp`, it is just as deterministic, and every
222/// record beneath a directory is still contiguous -- they share the directory's bytes and
223/// a separator as a prefix -- so the prefix range that invalidation relies on survives.
224/// The sidecar is written in this order and read back by key, so the order is unobservable
225/// outside this module.
226///
227/// `Path` equality ignores which separator a component boundary uses where the platform
228/// accepts more than one; bytes do not. Keys and lookups therefore pass through
229/// [`normalized`], which rebuilds a path from its components -- and so with the platform's
230/// own separator -- only on such a platform and only when the path carries the other one.
231/// Everywhere else it borrows, and a lookup allocates nothing.
232#[derive(Clone, PartialEq, Eq, Debug)]
233struct PathKey(PathBuf);
234
235impl PathKey {
236    fn new(path: PathBuf) -> Self {
237        match normalized(&path) {
238            Cow::Borrowed(_) => Self(path),
239            Cow::Owned(rebuilt) => Self(rebuilt),
240        }
241    }
242
243    fn bytes(&self) -> &[u8] {
244        self.0.as_os_str().as_encoded_bytes()
245    }
246}
247
248/// `path` spelled the way [`PathKey`] spells it.
249fn normalized(path: &Path) -> Cow<'_, Path> {
250    if std::path::MAIN_SEPARATOR != '/'
251        && std::path::is_separator('/')
252        && path.as_os_str().as_encoded_bytes().contains(&b'/')
253    {
254        Cow::Owned(path.components().collect())
255    } else {
256        Cow::Borrowed(path)
257    }
258}
259
260impl Ord for PathKey {
261    fn cmp(&self, other: &Self) -> Ordering {
262        self.bytes().cmp(other.bytes())
263    }
264}
265
266impl PartialOrd for PathKey {
267    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
268        Some(self.cmp(other))
269    }
270}
271
272// Lookups borrow the key as bytes, so `get` and `remove` take a `&Path` without
273// allocating. The contract `Borrow` demands -- that the borrowed form orders the same
274// way as the owned one -- holds by construction: `Ord` above *is* the byte order.
275impl Borrow<[u8]> for PathKey {
276    fn borrow(&self) -> &[u8] {
277        self.bytes()
278    }
279}
280
281fn path_bytes(path: &Path) -> &[u8] {
282    path.as_os_str().as_encoded_bytes()
283}
284
285/// Optional derived-data tier owned by an index only after analysis is enabled.
286///
287/// The tier holds records of exactly one [`ContentTierIdentity`]: preparing it for another
288/// identity clears it, and a record of another identity is refused. So every record the
289/// tier holds answers the request it was prepared for, and none answers any other.
290#[derive(Clone, PartialEq, Eq, Debug, Default)]
291pub struct ContentIndex {
292    identity: Option<ContentTierIdentity>,
293    state: Option<ContentTierState>,
294    files: BTreeMap<PathKey, FileAnalysis>,
295    rollups: HashMap<PathBuf, ContentRollUp>,
296}
297
298/// Operational provenance of the content tier, separate from its semantic identity.
299#[derive(Clone, Copy, PartialEq, Eq, Debug)]
300pub(crate) struct ContentTierState {
301    pub source: Source,
302    pub freshness: Freshness,
303    pub observed_at_ns: Option<i64>,
304}
305
306impl ContentIndex {
307    /// Project this record set to the requested identity before reading its values.
308    pub(crate) fn admit(
309        &self,
310        wanted: &ContentTierIdentity,
311    ) -> Option<crate::stored_state::ContentProjection<'_>> {
312        wanted.admit(self.identity()?)?.project(self)
313    }
314
315    /// Number of sparse file records.
316    pub fn len(&self) -> usize {
317        self.files.len()
318    }
319
320    /// Whether no analysis records are present.
321    pub fn is_empty(&self) -> bool {
322        self.files.is_empty()
323    }
324
325    /// The identity every record in this tier was produced under, even when the tree is
326    /// empty.
327    pub fn identity(&self) -> Option<&ContentTierIdentity> {
328        self.identity.as_ref()
329    }
330
331    /// Analyzer set this derived tier holds records for, even when the tree is empty.
332    pub fn profile(&self) -> Option<AnalysisSet> {
333        self.identity.as_ref().map(|identity| identity.analysis)
334    }
335
336    /// Analyzer, rule, and option identity every record in this derived tier carries.
337    pub fn provenance(&self) -> Option<ContentProvenance> {
338        self.identity.as_ref().map(ContentTierIdentity::record_provenance)
339    }
340
341    pub(crate) const fn state(&self) -> Option<ContentTierState> {
342        self.state
343    }
344
345    pub(crate) fn set_state(&mut self, state: ContentTierState) {
346        self.state = Some(state);
347    }
348
349    /// Borrow one file's analysis.
350    pub fn file(&self, path: &Path) -> Option<&FileAnalysis> {
351        self.files.get(path_bytes(&normalized(path)))
352    }
353
354    /// Borrow a directory's precomputed subtree rollup.
355    pub fn rollup(&self, path: &Path) -> Option<&ContentRollUp> {
356        self.rollups.get(path)
357    }
358
359    pub(crate) fn records(&self) -> impl Iterator<Item = (&Path, &FileAnalysis)> {
360        self.files.iter().map(|(key, analysis)| (key.0.as_path(), analysis))
361    }
362
363    /// Commit one record, or refuse it when it was produced under another identity than
364    /// the one this tier was prepared for.
365    ///
366    /// A refusal changes nothing. Adopting the record's identity instead would clear
367    /// every record of the prepared one, and keeping both would leave a tier whose totals
368    /// match neither request.
369    #[must_use = "a refused record was not committed"]
370    pub(crate) fn commit(
371        &mut self,
372        path: PathBuf,
373        profile: AnalysisSet,
374        provenance: &ContentProvenance,
375        analysis: FileAnalysis,
376    ) -> bool {
377        let Some(analysis) = self
378            .identity
379            .as_ref()
380            .and_then(|identity| identity.admit_record(profile, provenance))
381            .and_then(|admission| admission.record(analysis))
382            .map(crate::stored_state::AdmittedRecord::into_record)
383        else {
384            return false;
385        };
386        self.insert_record(path, analysis, true);
387        true
388    }
389
390    /// Restore only records carrying the admission proved by the sidecar header.
391    #[must_use = "a refused record was not committed"]
392    pub(crate) fn commit_without_rollup(
393        &mut self,
394        path: PathBuf,
395        analysis: crate::stored_state::AdmittedRecord<'_>,
396    ) -> bool {
397        let Some(identity) = self.identity.as_ref() else {
398            return false;
399        };
400        let Some(analysis) = analysis.for_tier(identity) else {
401            return false;
402        };
403        self.insert_record(path, analysis, false);
404        true
405    }
406
407    fn insert_record(&mut self, path: PathBuf, analysis: FileAnalysis, update_rollups: bool) {
408        let key = PathKey::new(path);
409        if let Some(previous) = self.files.remove(key.bytes()) {
410            if update_rollups {
411                self.merge_ancestors(&key.0, &previous, false);
412            }
413        }
414        if update_rollups {
415            self.merge_ancestors(&key.0, &analysis, true);
416        }
417        self.files.insert(key, analysis);
418    }
419
420    pub(crate) fn invalidate(&mut self, path: &Path) {
421        // The record at `path` itself, if it is a file, plus everything beneath it if it
422        // is a directory: in byte order those are `path` and then the contiguous run of
423        // keys that begin with `path` and the separator keys are spelled with. The root
424        // (an empty path) has no separator form and owns every record.
425        let path = normalized(path);
426        let mut removed: Vec<(PathBuf, FileAnalysis)> = Vec::new();
427        if let Some((key, analysis)) = self.files.get_key_value(path_bytes(&path)) {
428            removed.push((key.0.clone(), analysis.clone()));
429        }
430        let prefix: Vec<u8> = if path.as_os_str().is_empty() {
431            Vec::new()
432        } else {
433            let mut prefix = path_bytes(&path).to_vec();
434            prefix.push(std::path::MAIN_SEPARATOR as u8);
435            prefix
436        };
437        removed.extend(
438            self.files
439                .range::<[u8], _>((
440                    std::ops::Bound::Included(prefix.as_slice()),
441                    std::ops::Bound::Unbounded,
442                ))
443                .take_while(|(key, _)| key.bytes().starts_with(&prefix))
444                .filter(|(key, _)| key.0 != *path)
445                .map(|(key, analysis)| (key.0.clone(), analysis.clone())),
446        );
447        for (candidate, analysis) in removed {
448            self.files.remove(path_bytes(&candidate));
449            self.merge_ancestors(&candidate, &analysis, false);
450        }
451    }
452
453    /// Hold records of `identity` from here on.
454    ///
455    /// Equality, not containment: records of any other identity answer another request,
456    /// so a tier prepared for a different one is cleared, whether the stored analyzer set
457    /// is wider, narrower, or produced under other rules, versions, options, or entries.
458    pub(crate) fn prepare(&mut self, identity: ContentTierIdentity) {
459        if self.admit(&identity).is_some() {
460            return;
461        }
462        self.files.clear();
463        self.rollups.clear();
464        self.state = None;
465        self.identity = Some(identity);
466    }
467
468    fn merge_ancestors(&mut self, file: &Path, analysis: &FileAnalysis, add: bool) {
469        let mut directory = file.parent();
470        while let Some(path) = directory {
471            if add {
472                // `get_mut` before `insert`, not `entry`: `entry` needs an owned key, so
473                // it allocates a `PathBuf` for every ancestor of every file even when
474                // the roll-up is already there, which is the overwhelmingly common case.
475                if let Some(rollup) = self.rollups.get_mut(path) {
476                    rollup.add(analysis);
477                } else {
478                    self.rollups.entry(path.to_path_buf()).or_default().add(analysis);
479                }
480            } else if let Some(rollup) = self.rollups.get_mut(path) {
481                rollup.subtract(analysis);
482                if rollup.total.files == 0 {
483                    self.rollups.remove(path);
484                }
485            }
486            directory = path.parent();
487        }
488    }
489
490    /// Rebuild every directory roll-up from the files now held.
491    ///
492    /// Each file is added only to its parent, then each directory merges into its
493    /// parent from the deepest path first. This removes repeated per-file propagation
494    /// through every ancestor; the rebuild also discovers and sorts the directory set.
495    pub(crate) fn rebuild_rollups(&mut self) {
496        self.rollups.clear();
497        for (key, analysis) in &self.files {
498            let Some(parent) = key.0.parent() else {
499                continue;
500            };
501            if let Some(rollup) = self.rollups.get_mut(parent) {
502                rollup.add(analysis);
503            } else {
504                self.rollups.entry(parent.to_path_buf()).or_default().add(analysis);
505            }
506        }
507
508        let parents: Vec<PathBuf> = self.rollups.keys().cloned().collect();
509        for dir in &parents {
510            let mut ancestor = dir.parent();
511            while let Some(path) = ancestor {
512                if !self.rollups.contains_key(path) {
513                    self.rollups.insert(path.to_path_buf(), ContentRollUp::default());
514                }
515                ancestor = path.parent();
516            }
517        }
518
519        let mut dirs: Vec<PathBuf> = self.rollups.keys().cloned().collect();
520        dirs.sort_unstable_by(|left, right| {
521            right
522                .components()
523                .count()
524                .cmp(&left.components().count())
525                .then_with(|| left.as_os_str().cmp(right.as_os_str()))
526        });
527        for dir in dirs {
528            let Some(parent) = dir.parent() else {
529                continue;
530            };
531            let Some(child) = self.rollups.remove(&dir) else {
532                continue;
533            };
534            if let Some(parent_rollup) = self.rollups.get_mut(parent) {
535                parent_rollup.merge(&child);
536            } else {
537                self.rollups.entry(parent.to_path_buf()).or_default().merge(&child);
538            }
539            self.rollups.insert(dir, child);
540        }
541    }
542}
543
544#[cfg(test)]
545mod tests {
546    use super::*;
547    use crate::classify::classify_path;
548    use crate::content::{AnalysisSet, AnalyzerOutcome, BasicMetrics, FileAnalysis};
549    use crate::{AnalyzerProvenance, EntryTierIdentity, Fingerprint, ScanConfig};
550
551    fn lines() -> AnalysisSet {
552        AnalysisSet::NONE.with_lines()
553    }
554
555    fn identity_for(analysis: AnalysisSet) -> ContentTierIdentity {
556        ContentTierIdentity::for_request(
557            ScanConfig::default().snapshot_identity().entries,
558            analysis,
559        )
560    }
561
562    /// A tier prepared for the `lines` identity every [`analysis`] record carries.
563    fn prepared() -> ContentIndex {
564        let mut index = ContentIndex::default();
565        index.prepare(identity_for(lines()));
566        index
567    }
568
569    fn analysis(path: &str, lines: u64) -> FileAnalysis {
570        FileAnalysis {
571            fingerprint: Fingerprint::default(),
572            bytes: 10,
573            detection: classify_path(Path::new(path)).into(),
574            lines: AnalyzerOutcome::analyzed(BasicMetrics {
575                physical_lines: lines,
576                nonblank_lines: lines,
577                ..BasicMetrics::default()
578            }),
579            code: None,
580            words: None,
581            error: None,
582        }
583    }
584
585    fn try_commit(index: &mut ContentIndex, path: PathBuf, record: FileAnalysis) -> bool {
586        let identity = identity_for(lines());
587        index.commit(path, identity.analysis, &identity.record_provenance(), record)
588    }
589
590    fn restore(index: &mut ContentIndex, path: PathBuf, record: FileAnalysis) -> bool {
591        let identity = identity_for(lines());
592        let admitted = identity
593            .admit(&identity)
594            .expect("same identity")
595            .record(record)
596            .expect("matching record");
597        index.commit_without_rollup(path, admitted)
598    }
599
600    /// Commit a record the test expects the tier to accept.
601    fn commit(index: &mut ContentIndex, path: &str, record: FileAnalysis) {
602        assert!(try_commit(index, PathBuf::from(path), record), "{path} must commit");
603    }
604
605    #[test]
606    fn prepare_clears_on_any_identity_change() {
607        let base = identity_for(lines());
608        let mut other_version = base.clone();
609        other_version.provenance.analyzers[0].1 = crate::content::AnalyzerVersion(2);
610        let changes = [
611            ("a wider analyzer set", identity_for(AnalysisSet::ALL)),
612            ("another analyzer set", identity_for(AnalysisSet::NONE.with_code())),
613            (
614                "another entry tier",
615                ContentTierIdentity {
616                    entries: EntryTierIdentity { engine: base.entries.engine ^ 1, ..base.entries },
617                    ..base.clone()
618                },
619            ),
620            (
621                "other type rules",
622                ContentTierIdentity {
623                    entries: EntryTierIdentity {
624                        type_rules_fingerprint: base.entries.type_rules_fingerprint ^ 1,
625                        ..base.entries
626                    },
627                    ..base.clone()
628                },
629            ),
630            (
631                "other options",
632                ContentTierIdentity {
633                    provenance: AnalyzerProvenance {
634                        options_fingerprint: crate::content::OptionsFingerprint(
635                            base.provenance.options_fingerprint.0 ^ 1,
636                        ),
637                        ..base.provenance.clone()
638                    },
639                    ..base.clone()
640                },
641            ),
642            ("another analyzer version", other_version),
643        ];
644        for (name, identity) in changes {
645            let mut index = prepared();
646            commit(&mut index, "src/lib.rs", analysis("src/lib.rs", 2));
647            index.prepare(base.clone());
648            assert_eq!(index.len(), 1, "the same identity keeps its records");
649
650            index.prepare(identity.clone());
651            assert!(index.is_empty(), "{name} clears the records");
652            assert!(index.rollup(Path::new("")).is_none(), "{name} clears the roll-ups");
653            assert_eq!(index.identity(), Some(&identity), "{name} is the tier's identity now");
654        }
655    }
656
657    #[test]
658    fn commit_refuses_a_record_when_the_tier_is_unprepared() {
659        let mut unprepared = ContentIndex::default();
660        assert!(
661            !try_commit(&mut unprepared, PathBuf::from("a.rs"), analysis("a.rs", 1)),
662            "a tier prepared for nothing holds no record"
663        );
664        assert_eq!(unprepared, ContentIndex::default());
665    }
666
667    #[test]
668    fn commit_refuses_records_whose_unit_slots_do_not_match_the_prepared_profile() {
669        let mut index = ContentIndex::default();
670        index.prepare(identity_for(AnalysisSet::ALL));
671
672        let record = analysis("a.rs", 1);
673        assert!(
674            !index.commit(
675                PathBuf::from("a.rs"),
676                AnalysisSet::ALL,
677                &identity_for(AnalysisSet::ALL).record_provenance(),
678                record
679            ),
680            "an all-unit tier must not admit a lines-only record"
681        );
682        assert!(index.is_empty());
683
684        let mut index = prepared();
685        let mut record = analysis("a.rs", 1);
686        record.code = Some(AnalyzerOutcome::unavailable(CoverageReason::Unsupported));
687        assert!(
688            !try_commit(&mut index, PathBuf::from("a.rs"), record),
689            "a lines-only tier must not admit an unrequested code slot"
690        );
691        assert!(index.is_empty());
692    }
693
694    #[test]
695    fn admitted_records_cannot_cross_tiers_and_projection_refuses_other_provenance() {
696        let wanted = identity_for(lines());
697        let record = || analysis("a.rs", 1);
698        let proof = wanted.admit(&wanted).expect("exact identity");
699        let mut other = ContentIndex::default();
700        let other_identity = identity_for(AnalysisSet::ALL);
701        other.prepare(other_identity);
702        assert!(!other.commit_without_rollup(
703            PathBuf::from("a.rs"),
704            proof.record(record()).expect("matching record")
705        ));
706        assert!(other.is_empty());
707
708        let mut exact = prepared();
709        assert!(exact.commit_without_rollup(
710            PathBuf::from("a.rs"),
711            proof.record(record()).expect("matching record")
712        ));
713        let projected = exact.admit(&wanted).expect("same request projects");
714        assert_eq!(projected.file(Path::new("a.rs")), Some(&record()));
715        let mut foreign = wanted.clone();
716        foreign.provenance.options_fingerprint.0 ^= 1;
717        assert!(exact.admit(&foreign).is_none());
718        assert!(!exact.commit(
719            PathBuf::from("b.rs"),
720            foreign.analysis,
721            &foreign.record_provenance(),
722            record()
723        ));
724        assert_eq!(exact.len(), 1, "foreign provenance changes nothing");
725    }
726
727    #[test]
728    fn replacement_and_subtree_invalidation_update_every_rollup() {
729        let mut index = prepared();
730        commit(&mut index, "src/lib.rs", analysis("src/lib.rs", 2));
731        commit(&mut index, "src/main.rs", analysis("src/main.rs", 3));
732        assert_eq!(
733            index.rollup(Path::new("")).expect("root").total.lines.metrics.physical_lines,
734            5
735        );
736        assert_eq!(index.rollup(Path::new("src")).expect("src").total.files, 2);
737
738        commit(&mut index, "src/lib.rs", analysis("src/lib.rs", 7));
739        assert_eq!(
740            index.rollup(Path::new("")).expect("root").total.lines.metrics.physical_lines,
741            10
742        );
743
744        index.invalidate(Path::new("src"));
745        assert!(index.is_empty());
746        assert!(index.rollup(Path::new("")).is_none());
747    }
748
749    #[test]
750    fn invalidation_by_byte_prefix_stops_at_the_separator() {
751        // In byte order `src-extra/a.rs` sorts before `src/a.rs` and `src2/b.rs` after
752        // it; neither is beneath `src`, and the separator in the prefix is what keeps
753        // them out. The root invalidates everything, and a file path invalidates only
754        // its own record.
755        let mut index = prepared();
756        for path in ["src/a.rs", "src/deep/b.rs", "src-extra/a.rs", "src2/b.rs", "srcfile"] {
757            commit(&mut index, path, analysis(path, 1));
758        }
759        assert_eq!(index.len(), 5);
760
761        // A path spelled with the other separator is the same path wherever `Path` says
762        // so -- Windows -- and a different file named `src\\c.rs` at the root everywhere
763        // else. Either way the map agrees with `Path::starts_with` and `Path::eq`.
764        let other = PathBuf::from("src\\c.rs");
765        assert!(try_commit(&mut index, other.clone(), analysis("src/c.rs", 1)));
766        let beneath = other.starts_with("src");
767        assert_eq!(index.file(Path::new("src/c.rs")).is_some(), beneath);
768        assert!(index.file(&other).is_some());
769
770        index.invalidate(Path::new("src"));
771        assert_eq!(index.len(), if beneath { 3 } else { 4 });
772        assert_eq!(index.file(&other).is_none(), beneath);
773        assert!(index.file(Path::new("src/a.rs")).is_none());
774        assert!(index.file(Path::new("src/deep/b.rs")).is_none());
775        assert!(index.file(Path::new("src-extra/a.rs")).is_some());
776        assert!(index.file(Path::new("src2/b.rs")).is_some());
777        assert!(index.file(Path::new("srcfile")).is_some());
778        assert_eq!(
779            index.rollup(Path::new("")).expect("root").total.files,
780            if beneath { 3 } else { 4 }
781        );
782        assert!(index.rollup(Path::new("src")).is_none());
783
784        index.invalidate(Path::new("srcfile"));
785        assert_eq!(index.len(), if beneath { 2 } else { 3 });
786        assert!(index.file(Path::new("srcfile")).is_none());
787
788        index.invalidate(Path::new(""));
789        assert!(index.is_empty());
790        assert!(index.rollup(Path::new("")).is_none());
791    }
792
793    #[test]
794    fn records_are_ordered_deterministically_by_bytes() {
795        let mut index = prepared();
796        for path in ["b/x.rs", "a/z.rs", "a/y.rs", "a-b/q.rs"] {
797            commit(&mut index, path, analysis(path, 1));
798        }
799        let order: Vec<&Path> = index.records().map(|(path, _)| path).collect();
800        assert_eq!(
801            order,
802            vec![
803                Path::new("a-b/q.rs"),
804                Path::new("a/y.rs"),
805                Path::new("a/z.rs"),
806                Path::new("b/x.rs")
807            ]
808        );
809    }
810
811    #[test]
812    fn bottom_up_rebuild_matches_incremental_nested_rollups() {
813        let paths = ["README.md", "a/keep.rs", "a/b/nested.rs", "a/b/c/deep.rs", "a/b/c/other.py"];
814        let mut binary = analysis("a/b/c/image.png", 0);
815        binary.lines = AnalyzerOutcome::unavailable(CoverageReason::Binary);
816        let mut incremental = prepared();
817        for path in paths {
818            commit(&mut incremental, path, analysis(path, 3));
819        }
820        commit(&mut incremental, "a/b/c/image.png", binary.clone());
821
822        let mut rebuilt = prepared();
823        for path in paths {
824            assert!(
825                restore(&mut rebuilt, PathBuf::from(path), analysis(path, 3)),
826                "{path} must commit"
827            );
828        }
829        assert!(
830            restore(&mut rebuilt, PathBuf::from("a/b/c/image.png"), binary),
831            "binary coverage must commit"
832        );
833        assert!(rebuilt.rollup(Path::new("")).is_none(), "deferred inserts leave roll-ups empty");
834        rebuilt.rebuild_rollups();
835
836        for dir in ["", "a", "a/b", "a/b/c"] {
837            assert_eq!(
838                rebuilt.rollup(Path::new(dir)),
839                incremental.rollup(Path::new(dir)),
840                "{dir:?} roll-up"
841            );
842        }
843        assert_eq!(rebuilt, incremental);
844    }
845}