use std::borrow::{Borrow, Cow};
use std::cmp::Ordering;
use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use crate::stored_state::ContentTierIdentity;
use crate::{Freshness, Source};
use super::content_model::{
AnalysisSet, AnalyzerOutcome, BasicMetrics, CodeMetrics, ContentProvenance, CoverageReason,
FileAnalysis, WordMetrics,
};
#[derive(Clone, PartialEq, Eq, Debug, Default)]
pub struct AnalyzerTally<T> {
pub analyzed_files: u64,
pub metrics: T,
pub coverage: BTreeMap<CoverageReason, u64>,
}
#[derive(Clone, PartialEq, Eq, Debug, Default)]
pub struct MetricTally {
pub files: u64,
pub bytes: u64,
pub lines: AnalyzerTally<BasicMetrics>,
pub code: AnalyzerTally<CodeMetrics>,
pub words: AnalyzerTally<WordMetrics>,
}
impl MetricTally {
fn add(&mut self, analysis: &FileAnalysis) {
self.files = self.files.saturating_add(1);
self.bytes = self.bytes.saturating_add(analysis.bytes);
add_basic(&mut self.lines, analysis.lines);
if let Some(outcome) = analysis.code {
add_code(&mut self.code, outcome);
}
if let Some(outcome) = analysis.words {
add_words(&mut self.words, outcome);
}
}
fn subtract(&mut self, analysis: &FileAnalysis) {
self.files = self.files.saturating_sub(1);
self.bytes = self.bytes.saturating_sub(analysis.bytes);
sub_basic(&mut self.lines, analysis.lines);
if let Some(outcome) = analysis.code {
sub_code(&mut self.code, outcome);
}
if let Some(outcome) = analysis.words {
sub_words(&mut self.words, outcome);
}
}
fn merge(&mut self, other: &Self) {
self.files = self.files.saturating_add(other.files);
self.bytes = self.bytes.saturating_add(other.bytes);
merge_basic(&mut self.lines, &other.lines);
merge_code(&mut self.code, &other.code);
merge_words(&mut self.words, &other.words);
}
}
fn add_coverage<T>(tally: &mut AnalyzerTally<T>, outcome: &AnalyzerOutcome<T>) {
*tally.coverage.entry(outcome.coverage()).or_default() += 1;
if outcome.coverage() == CoverageReason::Analyzed {
tally.analyzed_files = tally.analyzed_files.saturating_add(1);
}
}
fn sub_coverage<T>(tally: &mut AnalyzerTally<T>, outcome: &AnalyzerOutcome<T>) {
if let Some(count) = tally.coverage.get_mut(&outcome.coverage()) {
*count = count.saturating_sub(1);
if *count == 0 {
tally.coverage.remove(&outcome.coverage());
}
}
if outcome.coverage() == CoverageReason::Analyzed {
tally.analyzed_files = tally.analyzed_files.saturating_sub(1);
}
}
fn merge_coverage<T>(tally: &mut AnalyzerTally<T>, other: &AnalyzerTally<T>) {
tally.analyzed_files = tally.analyzed_files.saturating_add(other.analyzed_files);
for (reason, count) in &other.coverage {
let slot = tally.coverage.entry(*reason).or_default();
*slot = slot.saturating_add(*count);
}
}
fn add_basic(tally: &mut AnalyzerTally<BasicMetrics>, outcome: AnalyzerOutcome<BasicMetrics>) {
add_coverage(tally, &outcome);
let Some(value) = outcome.value() else { return };
tally.metrics.physical_lines =
tally.metrics.physical_lines.saturating_add(value.physical_lines);
tally.metrics.blank_lines = tally.metrics.blank_lines.saturating_add(value.blank_lines);
tally.metrics.nonblank_lines =
tally.metrics.nonblank_lines.saturating_add(value.nonblank_lines);
tally.metrics.raw_words = tally.metrics.raw_words.saturating_add(value.raw_words);
}
fn sub_basic(tally: &mut AnalyzerTally<BasicMetrics>, outcome: AnalyzerOutcome<BasicMetrics>) {
sub_coverage(tally, &outcome);
let Some(value) = outcome.value() else { return };
tally.metrics.physical_lines =
tally.metrics.physical_lines.saturating_sub(value.physical_lines);
tally.metrics.blank_lines = tally.metrics.blank_lines.saturating_sub(value.blank_lines);
tally.metrics.nonblank_lines =
tally.metrics.nonblank_lines.saturating_sub(value.nonblank_lines);
tally.metrics.raw_words = tally.metrics.raw_words.saturating_sub(value.raw_words);
}
fn merge_basic(tally: &mut AnalyzerTally<BasicMetrics>, other: &AnalyzerTally<BasicMetrics>) {
merge_coverage(tally, other);
tally.metrics.physical_lines =
tally.metrics.physical_lines.saturating_add(other.metrics.physical_lines);
tally.metrics.blank_lines = tally.metrics.blank_lines.saturating_add(other.metrics.blank_lines);
tally.metrics.nonblank_lines =
tally.metrics.nonblank_lines.saturating_add(other.metrics.nonblank_lines);
tally.metrics.raw_words = tally.metrics.raw_words.saturating_add(other.metrics.raw_words);
}
fn add_code(tally: &mut AnalyzerTally<CodeMetrics>, outcome: AnalyzerOutcome<CodeMetrics>) {
add_coverage(tally, &outcome);
let Some(value) = outcome.value() else { return };
tally.metrics.code_lines = tally.metrics.code_lines.saturating_add(value.code_lines);
tally.metrics.comment_lines = tally.metrics.comment_lines.saturating_add(value.comment_lines);
tally.metrics.code_blank_lines =
tally.metrics.code_blank_lines.saturating_add(value.code_blank_lines);
}
fn sub_code(tally: &mut AnalyzerTally<CodeMetrics>, outcome: AnalyzerOutcome<CodeMetrics>) {
sub_coverage(tally, &outcome);
let Some(value) = outcome.value() else { return };
tally.metrics.code_lines = tally.metrics.code_lines.saturating_sub(value.code_lines);
tally.metrics.comment_lines = tally.metrics.comment_lines.saturating_sub(value.comment_lines);
tally.metrics.code_blank_lines =
tally.metrics.code_blank_lines.saturating_sub(value.code_blank_lines);
}
fn merge_code(tally: &mut AnalyzerTally<CodeMetrics>, other: &AnalyzerTally<CodeMetrics>) {
merge_coverage(tally, other);
tally.metrics.code_lines = tally.metrics.code_lines.saturating_add(other.metrics.code_lines);
tally.metrics.comment_lines =
tally.metrics.comment_lines.saturating_add(other.metrics.comment_lines);
tally.metrics.code_blank_lines =
tally.metrics.code_blank_lines.saturating_add(other.metrics.code_blank_lines);
}
fn add_words(tally: &mut AnalyzerTally<WordMetrics>, outcome: AnalyzerOutcome<WordMetrics>) {
add_coverage(tally, &outcome);
let Some(value) = outcome.value() else { return };
tally.metrics.paragraphs = tally.metrics.paragraphs.saturating_add(value.paragraphs);
tally.metrics.visible_words = tally.metrics.visible_words.saturating_add(value.visible_words);
tally.metrics.logical_word_stats.add_assign(value.logical_word_stats);
tally.metrics.visible_logical_word_stats.add_assign(value.visible_logical_word_stats);
}
fn sub_words(tally: &mut AnalyzerTally<WordMetrics>, outcome: AnalyzerOutcome<WordMetrics>) {
sub_coverage(tally, &outcome);
let Some(value) = outcome.value() else { return };
tally.metrics.paragraphs = tally.metrics.paragraphs.saturating_sub(value.paragraphs);
tally.metrics.visible_words = tally.metrics.visible_words.saturating_sub(value.visible_words);
sub_word_stats(&mut tally.metrics.logical_word_stats, value.logical_word_stats);
sub_word_stats(&mut tally.metrics.visible_logical_word_stats, value.visible_logical_word_stats);
}
fn merge_words(tally: &mut AnalyzerTally<WordMetrics>, other: &AnalyzerTally<WordMetrics>) {
merge_coverage(tally, other);
tally.metrics.paragraphs = tally.metrics.paragraphs.saturating_add(other.metrics.paragraphs);
tally.metrics.visible_words =
tally.metrics.visible_words.saturating_add(other.metrics.visible_words);
tally.metrics.logical_word_stats.add_assign(other.metrics.logical_word_stats);
tally.metrics.visible_logical_word_stats.add_assign(other.metrics.visible_logical_word_stats);
}
fn sub_word_stats(tally: &mut super::LogicalWordStats, value: super::LogicalWordStats) {
tally.wide_chars = tally.wide_chars.saturating_sub(value.wide_chars);
tally.nonwide_tokens = tally.nonwide_tokens.saturating_sub(value.nonwide_tokens);
tally.nonwide_chars = tally.nonwide_chars.saturating_sub(value.nonwide_chars);
}
#[derive(Clone, PartialEq, Eq, Debug, Default)]
pub struct ContentRollUp {
pub total: MetricTally,
}
impl ContentRollUp {
fn add(&mut self, analysis: &FileAnalysis) {
self.total.add(analysis);
}
fn subtract(&mut self, analysis: &FileAnalysis) {
self.total.subtract(analysis);
}
fn merge(&mut self, other: &Self) {
self.total.merge(&other.total);
}
}
#[derive(Clone, PartialEq, Eq, Debug)]
struct PathKey(PathBuf);
impl PathKey {
fn new(path: PathBuf) -> Self {
match normalized(&path) {
Cow::Borrowed(_) => Self(path),
Cow::Owned(rebuilt) => Self(rebuilt),
}
}
fn bytes(&self) -> &[u8] {
self.0.as_os_str().as_encoded_bytes()
}
}
fn normalized(path: &Path) -> Cow<'_, Path> {
if std::path::MAIN_SEPARATOR != '/'
&& std::path::is_separator('/')
&& path.as_os_str().as_encoded_bytes().contains(&b'/')
{
Cow::Owned(path.components().collect())
} else {
Cow::Borrowed(path)
}
}
impl Ord for PathKey {
fn cmp(&self, other: &Self) -> Ordering {
self.bytes().cmp(other.bytes())
}
}
impl PartialOrd for PathKey {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Borrow<[u8]> for PathKey {
fn borrow(&self) -> &[u8] {
self.bytes()
}
}
fn path_bytes(path: &Path) -> &[u8] {
path.as_os_str().as_encoded_bytes()
}
#[derive(Clone, PartialEq, Eq, Debug, Default)]
pub struct ContentIndex {
identity: Option<ContentTierIdentity>,
state: Option<ContentTierState>,
files: BTreeMap<PathKey, FileAnalysis>,
rollups: HashMap<PathBuf, ContentRollUp>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) struct ContentTierState {
pub source: Source,
pub freshness: Freshness,
pub observed_at_ns: Option<i64>,
}
impl ContentIndex {
pub(crate) fn admit(
&self,
wanted: &ContentTierIdentity,
) -> Option<crate::stored_state::ContentProjection<'_>> {
wanted.admit(self.identity()?)?.project(self)
}
pub fn len(&self) -> usize {
self.files.len()
}
pub fn is_empty(&self) -> bool {
self.files.is_empty()
}
pub fn identity(&self) -> Option<&ContentTierIdentity> {
self.identity.as_ref()
}
pub fn profile(&self) -> Option<AnalysisSet> {
self.identity.as_ref().map(|identity| identity.analysis)
}
pub fn provenance(&self) -> Option<ContentProvenance> {
self.identity.as_ref().map(ContentTierIdentity::record_provenance)
}
pub(crate) const fn state(&self) -> Option<ContentTierState> {
self.state
}
pub(crate) fn set_state(&mut self, state: ContentTierState) {
self.state = Some(state);
}
pub fn file(&self, path: &Path) -> Option<&FileAnalysis> {
self.files.get(path_bytes(&normalized(path)))
}
pub fn rollup(&self, path: &Path) -> Option<&ContentRollUp> {
self.rollups.get(path)
}
pub(crate) fn records(&self) -> impl Iterator<Item = (&Path, &FileAnalysis)> {
self.files.iter().map(|(key, analysis)| (key.0.as_path(), analysis))
}
#[must_use = "a refused record was not committed"]
pub(crate) fn commit(
&mut self,
path: PathBuf,
profile: AnalysisSet,
provenance: &ContentProvenance,
analysis: FileAnalysis,
) -> bool {
let Some(analysis) = self
.identity
.as_ref()
.and_then(|identity| identity.admit_record(profile, provenance))
.and_then(|admission| admission.record(analysis))
.map(crate::stored_state::AdmittedRecord::into_record)
else {
return false;
};
self.insert_record(path, analysis, true);
true
}
#[must_use = "a refused record was not committed"]
pub(crate) fn commit_without_rollup(
&mut self,
path: PathBuf,
analysis: crate::stored_state::AdmittedRecord<'_>,
) -> bool {
let Some(identity) = self.identity.as_ref() else {
return false;
};
let Some(analysis) = analysis.for_tier(identity) else {
return false;
};
self.insert_record(path, analysis, false);
true
}
fn insert_record(&mut self, path: PathBuf, analysis: FileAnalysis, update_rollups: bool) {
let key = PathKey::new(path);
if let Some(previous) = self.files.remove(key.bytes()) {
if update_rollups {
self.merge_ancestors(&key.0, &previous, false);
}
}
if update_rollups {
self.merge_ancestors(&key.0, &analysis, true);
}
self.files.insert(key, analysis);
}
pub(crate) fn invalidate(&mut self, path: &Path) {
let path = normalized(path);
let mut removed: Vec<(PathBuf, FileAnalysis)> = Vec::new();
if let Some((key, analysis)) = self.files.get_key_value(path_bytes(&path)) {
removed.push((key.0.clone(), analysis.clone()));
}
let prefix: Vec<u8> = if path.as_os_str().is_empty() {
Vec::new()
} else {
let mut prefix = path_bytes(&path).to_vec();
prefix.push(std::path::MAIN_SEPARATOR as u8);
prefix
};
removed.extend(
self.files
.range::<[u8], _>((
std::ops::Bound::Included(prefix.as_slice()),
std::ops::Bound::Unbounded,
))
.take_while(|(key, _)| key.bytes().starts_with(&prefix))
.filter(|(key, _)| key.0 != *path)
.map(|(key, analysis)| (key.0.clone(), analysis.clone())),
);
for (candidate, analysis) in removed {
self.files.remove(path_bytes(&candidate));
self.merge_ancestors(&candidate, &analysis, false);
}
}
pub(crate) fn prepare(&mut self, identity: ContentTierIdentity) {
if self.admit(&identity).is_some() {
return;
}
self.files.clear();
self.rollups.clear();
self.state = None;
self.identity = Some(identity);
}
fn merge_ancestors(&mut self, file: &Path, analysis: &FileAnalysis, add: bool) {
let mut directory = file.parent();
while let Some(path) = directory {
if add {
if let Some(rollup) = self.rollups.get_mut(path) {
rollup.add(analysis);
} else {
self.rollups.entry(path.to_path_buf()).or_default().add(analysis);
}
} else if let Some(rollup) = self.rollups.get_mut(path) {
rollup.subtract(analysis);
if rollup.total.files == 0 {
self.rollups.remove(path);
}
}
directory = path.parent();
}
}
pub(crate) fn rebuild_rollups(&mut self) {
self.rollups.clear();
for (key, analysis) in &self.files {
let Some(parent) = key.0.parent() else {
continue;
};
if let Some(rollup) = self.rollups.get_mut(parent) {
rollup.add(analysis);
} else {
self.rollups.entry(parent.to_path_buf()).or_default().add(analysis);
}
}
let parents: Vec<PathBuf> = self.rollups.keys().cloned().collect();
for dir in &parents {
let mut ancestor = dir.parent();
while let Some(path) = ancestor {
if !self.rollups.contains_key(path) {
self.rollups.insert(path.to_path_buf(), ContentRollUp::default());
}
ancestor = path.parent();
}
}
let mut dirs: Vec<PathBuf> = self.rollups.keys().cloned().collect();
dirs.sort_unstable_by(|left, right| {
right
.components()
.count()
.cmp(&left.components().count())
.then_with(|| left.as_os_str().cmp(right.as_os_str()))
});
for dir in dirs {
let Some(parent) = dir.parent() else {
continue;
};
let Some(child) = self.rollups.remove(&dir) else {
continue;
};
if let Some(parent_rollup) = self.rollups.get_mut(parent) {
parent_rollup.merge(&child);
} else {
self.rollups.entry(parent.to_path_buf()).or_default().merge(&child);
}
self.rollups.insert(dir, child);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::classify::classify_path;
use crate::content::{AnalysisSet, AnalyzerOutcome, BasicMetrics, FileAnalysis};
use crate::{AnalyzerProvenance, EntryTierIdentity, Fingerprint, ScanConfig};
fn lines() -> AnalysisSet {
AnalysisSet::NONE.with_lines()
}
fn identity_for(analysis: AnalysisSet) -> ContentTierIdentity {
ContentTierIdentity::for_request(
ScanConfig::default().snapshot_identity().entries,
analysis,
)
}
fn prepared() -> ContentIndex {
let mut index = ContentIndex::default();
index.prepare(identity_for(lines()));
index
}
fn analysis(path: &str, lines: u64) -> FileAnalysis {
FileAnalysis {
fingerprint: Fingerprint::default(),
bytes: 10,
detection: classify_path(Path::new(path)).into(),
lines: AnalyzerOutcome::analyzed(BasicMetrics {
physical_lines: lines,
nonblank_lines: lines,
..BasicMetrics::default()
}),
code: None,
words: None,
error: None,
}
}
fn try_commit(index: &mut ContentIndex, path: PathBuf, record: FileAnalysis) -> bool {
let identity = identity_for(lines());
index.commit(path, identity.analysis, &identity.record_provenance(), record)
}
fn restore(index: &mut ContentIndex, path: PathBuf, record: FileAnalysis) -> bool {
let identity = identity_for(lines());
let admitted = identity
.admit(&identity)
.expect("same identity")
.record(record)
.expect("matching record");
index.commit_without_rollup(path, admitted)
}
fn commit(index: &mut ContentIndex, path: &str, record: FileAnalysis) {
assert!(try_commit(index, PathBuf::from(path), record), "{path} must commit");
}
#[test]
fn prepare_clears_on_any_identity_change() {
let base = identity_for(lines());
let mut other_version = base.clone();
other_version.provenance.analyzers[0].1 = crate::content::AnalyzerVersion(2);
let changes = [
("a wider analyzer set", identity_for(AnalysisSet::ALL)),
("another analyzer set", identity_for(AnalysisSet::NONE.with_code())),
(
"another entry tier",
ContentTierIdentity {
entries: EntryTierIdentity { engine: base.entries.engine ^ 1, ..base.entries },
..base.clone()
},
),
(
"other type rules",
ContentTierIdentity {
entries: EntryTierIdentity {
type_rules_fingerprint: base.entries.type_rules_fingerprint ^ 1,
..base.entries
},
..base.clone()
},
),
(
"other options",
ContentTierIdentity {
provenance: AnalyzerProvenance {
options_fingerprint: crate::content::OptionsFingerprint(
base.provenance.options_fingerprint.0 ^ 1,
),
..base.provenance.clone()
},
..base.clone()
},
),
("another analyzer version", other_version),
];
for (name, identity) in changes {
let mut index = prepared();
commit(&mut index, "src/lib.rs", analysis("src/lib.rs", 2));
index.prepare(base.clone());
assert_eq!(index.len(), 1, "the same identity keeps its records");
index.prepare(identity.clone());
assert!(index.is_empty(), "{name} clears the records");
assert!(index.rollup(Path::new("")).is_none(), "{name} clears the roll-ups");
assert_eq!(index.identity(), Some(&identity), "{name} is the tier's identity now");
}
}
#[test]
fn commit_refuses_a_record_when_the_tier_is_unprepared() {
let mut unprepared = ContentIndex::default();
assert!(
!try_commit(&mut unprepared, PathBuf::from("a.rs"), analysis("a.rs", 1)),
"a tier prepared for nothing holds no record"
);
assert_eq!(unprepared, ContentIndex::default());
}
#[test]
fn commit_refuses_records_whose_unit_slots_do_not_match_the_prepared_profile() {
let mut index = ContentIndex::default();
index.prepare(identity_for(AnalysisSet::ALL));
let record = analysis("a.rs", 1);
assert!(
!index.commit(
PathBuf::from("a.rs"),
AnalysisSet::ALL,
&identity_for(AnalysisSet::ALL).record_provenance(),
record
),
"an all-unit tier must not admit a lines-only record"
);
assert!(index.is_empty());
let mut index = prepared();
let mut record = analysis("a.rs", 1);
record.code = Some(AnalyzerOutcome::unavailable(CoverageReason::Unsupported));
assert!(
!try_commit(&mut index, PathBuf::from("a.rs"), record),
"a lines-only tier must not admit an unrequested code slot"
);
assert!(index.is_empty());
}
#[test]
fn admitted_records_cannot_cross_tiers_and_projection_refuses_other_provenance() {
let wanted = identity_for(lines());
let record = || analysis("a.rs", 1);
let proof = wanted.admit(&wanted).expect("exact identity");
let mut other = ContentIndex::default();
let other_identity = identity_for(AnalysisSet::ALL);
other.prepare(other_identity);
assert!(!other.commit_without_rollup(
PathBuf::from("a.rs"),
proof.record(record()).expect("matching record")
));
assert!(other.is_empty());
let mut exact = prepared();
assert!(exact.commit_without_rollup(
PathBuf::from("a.rs"),
proof.record(record()).expect("matching record")
));
let projected = exact.admit(&wanted).expect("same request projects");
assert_eq!(projected.file(Path::new("a.rs")), Some(&record()));
let mut foreign = wanted.clone();
foreign.provenance.options_fingerprint.0 ^= 1;
assert!(exact.admit(&foreign).is_none());
assert!(!exact.commit(
PathBuf::from("b.rs"),
foreign.analysis,
&foreign.record_provenance(),
record()
));
assert_eq!(exact.len(), 1, "foreign provenance changes nothing");
}
#[test]
fn replacement_and_subtree_invalidation_update_every_rollup() {
let mut index = prepared();
commit(&mut index, "src/lib.rs", analysis("src/lib.rs", 2));
commit(&mut index, "src/main.rs", analysis("src/main.rs", 3));
assert_eq!(
index.rollup(Path::new("")).expect("root").total.lines.metrics.physical_lines,
5
);
assert_eq!(index.rollup(Path::new("src")).expect("src").total.files, 2);
commit(&mut index, "src/lib.rs", analysis("src/lib.rs", 7));
assert_eq!(
index.rollup(Path::new("")).expect("root").total.lines.metrics.physical_lines,
10
);
index.invalidate(Path::new("src"));
assert!(index.is_empty());
assert!(index.rollup(Path::new("")).is_none());
}
#[test]
fn invalidation_by_byte_prefix_stops_at_the_separator() {
let mut index = prepared();
for path in ["src/a.rs", "src/deep/b.rs", "src-extra/a.rs", "src2/b.rs", "srcfile"] {
commit(&mut index, path, analysis(path, 1));
}
assert_eq!(index.len(), 5);
let other = PathBuf::from("src\\c.rs");
assert!(try_commit(&mut index, other.clone(), analysis("src/c.rs", 1)));
let beneath = other.starts_with("src");
assert_eq!(index.file(Path::new("src/c.rs")).is_some(), beneath);
assert!(index.file(&other).is_some());
index.invalidate(Path::new("src"));
assert_eq!(index.len(), if beneath { 3 } else { 4 });
assert_eq!(index.file(&other).is_none(), beneath);
assert!(index.file(Path::new("src/a.rs")).is_none());
assert!(index.file(Path::new("src/deep/b.rs")).is_none());
assert!(index.file(Path::new("src-extra/a.rs")).is_some());
assert!(index.file(Path::new("src2/b.rs")).is_some());
assert!(index.file(Path::new("srcfile")).is_some());
assert_eq!(
index.rollup(Path::new("")).expect("root").total.files,
if beneath { 3 } else { 4 }
);
assert!(index.rollup(Path::new("src")).is_none());
index.invalidate(Path::new("srcfile"));
assert_eq!(index.len(), if beneath { 2 } else { 3 });
assert!(index.file(Path::new("srcfile")).is_none());
index.invalidate(Path::new(""));
assert!(index.is_empty());
assert!(index.rollup(Path::new("")).is_none());
}
#[test]
fn records_are_ordered_deterministically_by_bytes() {
let mut index = prepared();
for path in ["b/x.rs", "a/z.rs", "a/y.rs", "a-b/q.rs"] {
commit(&mut index, path, analysis(path, 1));
}
let order: Vec<&Path> = index.records().map(|(path, _)| path).collect();
assert_eq!(
order,
vec![
Path::new("a-b/q.rs"),
Path::new("a/y.rs"),
Path::new("a/z.rs"),
Path::new("b/x.rs")
]
);
}
#[test]
fn bottom_up_rebuild_matches_incremental_nested_rollups() {
let paths = ["README.md", "a/keep.rs", "a/b/nested.rs", "a/b/c/deep.rs", "a/b/c/other.py"];
let mut binary = analysis("a/b/c/image.png", 0);
binary.lines = AnalyzerOutcome::unavailable(CoverageReason::Binary);
let mut incremental = prepared();
for path in paths {
commit(&mut incremental, path, analysis(path, 3));
}
commit(&mut incremental, "a/b/c/image.png", binary.clone());
let mut rebuilt = prepared();
for path in paths {
assert!(
restore(&mut rebuilt, PathBuf::from(path), analysis(path, 3)),
"{path} must commit"
);
}
assert!(
restore(&mut rebuilt, PathBuf::from("a/b/c/image.png"), binary),
"binary coverage must commit"
);
assert!(rebuilt.rollup(Path::new("")).is_none(), "deferred inserts leave roll-ups empty");
rebuilt.rebuild_rollups();
for dir in ["", "a", "a/b", "a/b/c"] {
assert_eq!(
rebuilt.rollup(Path::new(dir)),
incremental.rollup(Path::new(dir)),
"{dir:?} roll-up"
);
}
assert_eq!(rebuilt, incremental);
}
}