1use 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#[derive(Clone, PartialEq, Eq, Debug, Default)]
18pub struct AnalyzerTally<T> {
19 pub analyzed_files: u64,
21 pub metrics: T,
23 pub coverage: BTreeMap<CoverageReason, u64>,
25}
26
27#[derive(Clone, PartialEq, Eq, Debug, Default)]
29pub struct MetricTally {
30 pub files: u64,
32 pub bytes: u64,
34 pub lines: AnalyzerTally<BasicMetrics>,
36 pub code: AnalyzerTally<CodeMetrics>,
38 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#[derive(Clone, PartialEq, Eq, Debug, Default)]
197pub struct ContentRollUp {
198 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#[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
248fn 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
272impl 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#[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#[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 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 pub fn len(&self) -> usize {
317 self.files.len()
318 }
319
320 pub fn is_empty(&self) -> bool {
322 self.files.is_empty()
323 }
324
325 pub fn identity(&self) -> Option<&ContentTierIdentity> {
328 self.identity.as_ref()
329 }
330
331 pub fn profile(&self) -> Option<AnalysisSet> {
333 self.identity.as_ref().map(|identity| identity.analysis)
334 }
335
336 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 pub fn file(&self, path: &Path) -> Option<&FileAnalysis> {
351 self.files.get(path_bytes(&normalized(path)))
352 }
353
354 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 #[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 #[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 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 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 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 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 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 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 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 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}