1mod gitignore;
22
23use std::collections::{BTreeMap, BTreeSet, HashMap};
24use std::path::{Path, PathBuf};
25use std::sync::Arc;
26
27use gitignore::Gitignore;
28
29pub const CONTROL_FILE_NAME: &str = ".gitignore";
31
32pub const DEFAULT_CONTROL_BUDGET: usize = 4 * 1024 * 1024;
40
41pub const DEFAULT_CONTROL_LINE_LIMIT: usize = 16 * 1024;
48
49#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
60pub struct ControlLimits {
61 pub budget: Option<usize>,
68 pub line_limit: Option<usize>,
71}
72
73impl Default for ControlLimits {
74 fn default() -> Self {
76 Self { budget: Some(DEFAULT_CONTROL_BUDGET), line_limit: Some(DEFAULT_CONTROL_LINE_LIMIT) }
77 }
78}
79
80impl ControlLimits {
81 pub const fn limit_for(self, reason: ControlRefusalReason) -> Option<usize> {
83 match reason {
84 ControlRefusalReason::Budget => self.budget,
85 ControlRefusalReason::LineLimit => self.line_limit,
86 }
87 }
88}
89
90impl std::fmt::Display for ControlLimits {
93 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94 write!(
95 formatter,
96 "budget {}, line limit {}",
97 limit_display(self.budget),
98 limit_display(self.line_limit)
99 )
100 }
101}
102
103pub(crate) fn limit_display(limit: Option<usize>) -> String {
105 limit.map_or_else(
106 || "all".to_string(),
107 |bytes| crate::report_format::human_bytes(u64::try_from(bytes).unwrap_or(u64::MAX)),
108 )
109}
110
111pub(crate) const CONTROL_SOURCE_OVERHEAD: usize = 64;
113
114#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
116pub struct ControlIdentity {
117 pub bytes: u64,
119 pub fingerprint: u64,
121}
122
123#[derive(Debug)]
125struct SharedContent {
126 bytes: Vec<u8>,
127 identity: ControlIdentity,
128 matcher: Gitignore,
129 content_cost: usize,
131}
132
133#[derive(Clone, Debug)]
138struct Holding {
139 content: Arc<SharedContent>,
140 holders: usize,
141}
142
143#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
145pub enum ControlRefusalReason {
146 Budget,
148 LineLimit,
150}
151
152impl ControlRefusalReason {
153 pub const fn label(self) -> &'static str {
156 match self {
157 Self::Budget => "budget",
158 Self::LineLimit => "line_limit",
159 }
160 }
161}
162
163#[derive(Clone, Copy, PartialEq, Eq, Debug)]
165pub enum ControlAdmission {
166 Retained {
169 changed: bool,
171 },
172 Refused(ControlRefusalReason),
175}
176
177#[derive(Clone, Copy, PartialEq, Eq, Debug)]
179enum Verdict {
180 Unchanged,
182 Admit { retained_cost: usize },
184 Refuse(ControlRefusalReason),
186}
187
188#[derive(Clone, PartialEq, Eq, Debug, Hash)]
190pub struct RefusedControl {
191 pub path: PathBuf,
193 pub reason: ControlRefusalReason,
195}
196
197#[derive(Clone, PartialEq, Eq, Debug)]
203pub enum ControlCoverage {
204 NotObserved,
206 Observed(ControlObservation),
208}
209
210#[derive(Clone, PartialEq, Eq, Debug)]
212pub struct ControlObservation {
213 pub limits: ControlLimits,
215 pub applied: u64,
217 pub rules: u64,
219 pub refused: u64,
221 pub refusals: Vec<RefusedControl>,
224}
225
226impl ControlObservation {
227 pub const fn is_complete(&self) -> bool {
229 self.refused == 0
230 }
231
232 pub fn lists_every_refusal(&self) -> bool {
234 u64::try_from(self.refusals.len()).is_ok_and(|listed| listed == self.refused)
235 }
236}
237
238#[derive(Clone, Debug)]
251pub struct ControlTable {
252 by_directory: BTreeMap<PathBuf, Arc<SharedContent>>,
253 shared: HashMap<ControlIdentity, Vec<Holding>>,
256 refused: BTreeMap<PathBuf, ControlRefusalReason>,
259 limits: ControlLimits,
260 source_bytes: usize,
261 retained_cost: usize,
262}
263
264impl Default for ControlTable {
265 fn default() -> Self {
266 Self::with_limits(ControlLimits::default())
267 }
268}
269
270impl ControlTable {
271 pub(crate) fn with_limits(limits: ControlLimits) -> Self {
273 Self {
274 by_directory: BTreeMap::new(),
275 shared: HashMap::new(),
276 refused: BTreeMap::new(),
277 limits,
278 source_bytes: 0,
279 retained_cost: 0,
280 }
281 }
282
283 pub fn upsert(&mut self, path: &Path, source: Vec<u8>) -> crate::Result<ControlAdmission> {
296 let identity = identity(&source);
297 self.upsert_identified(path, source, identity)
298 }
299
300 fn upsert_identified(
301 &mut self,
302 path: &Path,
303 source: Vec<u8>,
304 identity: ControlIdentity,
305 ) -> crate::Result<ControlAdmission> {
306 let directory = control_directory(path)?;
307 match self.verdict(directory, &source, identity) {
308 Verdict::Unchanged => Ok(ControlAdmission::Retained { changed: false }),
309 Verdict::Refuse(reason) => {
310 crate::counters::bump(|counts| {
311 counts.control_refused = counts.control_refused.saturating_add(1);
312 });
313 Ok(self.refuse(directory, reason))
314 }
315 Verdict::Admit { retained_cost } => {
316 self.detach(directory);
317 self.attach(directory, source, identity);
318 self.refused.remove(directory);
319 debug_assert_eq!(self.retained_cost, retained_cost);
320 Ok(ControlAdmission::Retained { changed: true })
321 }
322 }
323 }
324
325 fn verdict(&self, directory: &Path, source: &[u8], identity: ControlIdentity) -> Verdict {
330 if self.by_directory.get(directory).is_some_and(|current| current.bytes == source) {
331 return Verdict::Unchanged;
332 }
333 if self.limits.line_limit.is_some_and(|line_limit| {
334 source.split(|byte| *byte == b'\n').any(|line| line.len() > line_limit)
335 }) {
336 return Verdict::Refuse(ControlRefusalReason::LineLimit);
337 }
338 let content_charge =
339 if self.holding(identity, source).is_some() { 0 } else { content_cost(source) };
340 let retained_cost = self
343 .retained_cost
344 .saturating_sub(self.release_charge(directory))
345 .saturating_add(directory_cost(directory))
346 .saturating_add(content_charge);
347 if self.limits.budget.is_some_and(|budget| retained_cost > budget) {
348 return Verdict::Refuse(ControlRefusalReason::Budget);
349 }
350 Verdict::Admit { retained_cost }
351 }
352
353 pub(crate) fn upsert_is_inert(&self, path: &Path, source: &[u8]) -> bool {
361 let Ok(directory) = control_directory(path) else {
362 return false;
363 };
364 match self.verdict(directory, source, identity(source)) {
365 Verdict::Unchanged => true,
366 Verdict::Refuse(reason) => self.refused.get(directory) == Some(&reason),
367 Verdict::Admit { .. } => false,
368 }
369 }
370
371 pub(crate) fn remove_is_inert(&self, path: &Path) -> bool {
373 control_directory(path).is_ok_and(|directory| {
374 !self.by_directory.contains_key(directory) && !self.refused.contains_key(directory)
375 })
376 }
377
378 pub(crate) fn has_record_at_or_below(&self, subtree: &Path) -> bool {
383 has_key_at_or_below(&self.by_directory, subtree)
384 || has_key_at_or_below(&self.refused, subtree)
385 }
386
387 pub(crate) fn record_refusal(
389 &mut self,
390 path: &Path,
391 reason: ControlRefusalReason,
392 ) -> crate::Result<()> {
393 let directory = control_directory(path)?;
394 self.refuse(directory, reason);
395 Ok(())
396 }
397
398 fn refuse(&mut self, directory: &Path, reason: ControlRefusalReason) -> ControlAdmission {
399 self.detach(directory);
400 self.refused.insert(directory.to_path_buf(), reason);
401 ControlAdmission::Refused(reason)
402 }
403
404 pub fn remove(&mut self, path: &Path) -> crate::Result<bool> {
410 let directory = control_directory(path)?;
411 let retained = self.detach(directory);
412 let refused = self.refused.remove(directory).is_some();
413 Ok(retained || refused)
414 }
415
416 pub(crate) fn remove_subtree(&mut self, subtree: &Path) {
418 let directories: Vec<PathBuf> = self
419 .by_directory
420 .keys()
421 .filter(|directory| directory.starts_with(subtree))
422 .cloned()
423 .collect();
424 for directory in directories {
425 self.detach(&directory);
426 }
427 self.refused.retain(|directory, _| !directory.starts_with(subtree));
428 }
429
430 fn holding(&self, identity: ControlIdentity, source: &[u8]) -> Option<&Holding> {
432 self.shared
433 .get(&identity)?
434 .iter()
435 .find(|holding| holding.content.bytes.as_slice() == source)
436 }
437
438 fn release_charge(&self, directory: &Path) -> usize {
440 let Some(content) = self.by_directory.get(directory) else {
441 return 0;
442 };
443 let last_holder = self.shared.get(&content.identity).is_some_and(|holdings| {
444 holdings
445 .iter()
446 .any(|holding| Arc::ptr_eq(&holding.content, content) && holding.holders == 1)
447 });
448 directory_cost(directory).saturating_add(if last_holder { content.content_cost } else { 0 })
449 }
450
451 fn detach(&mut self, directory: &Path) -> bool {
453 let Some(content) = self.by_directory.remove(directory) else {
454 return false;
455 };
456 let holdings = self.shared.get_mut(&content.identity).expect("a retained content is held");
457 let position = holdings
458 .iter()
459 .position(|holding| Arc::ptr_eq(&holding.content, &content))
460 .expect("a retained content is listed under its own identity");
461 holdings[position].holders -= 1;
462 if holdings[position].holders == 0 {
463 holdings.swap_remove(position);
464 self.retained_cost -= content.content_cost;
465 if holdings.is_empty() {
466 self.shared.remove(&content.identity);
467 }
468 }
469 self.retained_cost -= directory_cost(directory);
470 self.source_bytes -= content.bytes.len();
471 true
472 }
473
474 fn attach(&mut self, directory: &Path, source: Vec<u8>, identity: ControlIdentity) {
476 let holdings = self.shared.entry(identity).or_default();
477 let content = if let Some(holding) =
478 holdings.iter_mut().find(|holding| holding.content.bytes == source)
479 {
480 holding.holders += 1;
481 crate::counters::bump(|counts| {
482 counts.control_sources_shared = counts.control_sources_shared.saturating_add(1);
483 });
484 Arc::clone(&holding.content)
485 } else {
486 let content_cost = content_cost(&source);
487 let matcher = Gitignore::parse(&source);
488 let content =
489 Arc::new(SharedContent { bytes: source, identity, matcher, content_cost });
490 holdings.push(Holding { content: Arc::clone(&content), holders: 1 });
491 self.retained_cost += content_cost;
492 content
493 };
494 self.retained_cost += directory_cost(directory);
495 self.source_bytes += content.bytes.len();
496 self.by_directory.insert(directory.to_path_buf(), content);
497 }
498
499 pub fn matcher_for<'a>(&'a self, path: &'a Path) -> ControlMatcher<'a> {
501 ControlMatcher { table: self, path }
502 }
503
504 pub fn is_ignored(&self, path: &Path, is_dir: bool) -> bool {
510 let components: Vec<_> = path.components().collect();
511 let mut current = PathBuf::new();
512 let mut parent_ignored = false;
513 for (position, component) in components.iter().enumerate() {
514 current.push(component.as_os_str());
515 if parent_ignored {
516 return true;
517 }
518 let current_is_dir = position + 1 < components.len() || is_dir;
519 parent_ignored = self.matcher_for(¤t).is_ignored(current_is_dir);
520 }
521 parent_ignored
522 }
523
524 pub fn affected_subtree(path: &Path) -> crate::Result<PathBuf> {
526 Ok(control_directory(path)?.to_path_buf())
527 }
528
529 pub(crate) fn changes_from(
531 &self,
532 previous: &Self,
533 ) -> Vec<(PathBuf, Option<ControlIdentity>, Option<ControlIdentity>)> {
534 let directories: BTreeSet<&Path> = previous
535 .by_directory
536 .keys()
537 .chain(self.by_directory.keys())
538 .map(PathBuf::as_path)
539 .collect();
540 directories
541 .into_iter()
542 .filter_map(|directory| {
543 let before = previous.by_directory.get(directory);
544 let after = self.by_directory.get(directory);
545 let changed = match (before, after) {
546 (Some(before), Some(after)) => {
547 !Arc::ptr_eq(before, after) && before.bytes != after.bytes
548 }
549 (None, None) => false,
550 (Some(_), None) | (None, Some(_)) => true,
551 };
552 changed.then(|| {
553 (
554 control_path(directory),
555 before.map(|source| source.identity),
556 after.map(|source| source.identity),
557 )
558 })
559 })
560 .collect()
561 }
562
563 pub(crate) fn refusal_changes_from(
565 &self,
566 previous: &Self,
567 ) -> Vec<(PathBuf, Option<ControlRefusalReason>, Option<ControlRefusalReason>)> {
568 let directories: BTreeSet<&Path> =
569 previous.refused.keys().chain(self.refused.keys()).map(PathBuf::as_path).collect();
570 directories
571 .into_iter()
572 .filter_map(|directory| {
573 let before = previous.refused.get(directory).copied();
574 let after = self.refused.get(directory).copied();
575 (before != after).then(|| (control_path(directory), before, after))
576 })
577 .collect()
578 }
579
580 pub(crate) fn sources(&self) -> impl ExactSizeIterator<Item = (PathBuf, &[u8])> {
582 self.by_directory
583 .iter()
584 .map(|(directory, source)| (control_path(directory), source.bytes.as_slice()))
585 }
586
587 pub fn refusals(&self) -> impl ExactSizeIterator<Item = RefusedControl> + '_ {
589 self.refused.iter().map(|(directory, reason)| RefusedControl {
590 path: control_path(directory),
591 reason: *reason,
592 })
593 }
594
595 pub(crate) fn classification_known(&self, path: &Path) -> bool {
600 !path
601 .parent()
602 .into_iter()
603 .flat_map(Path::ancestors)
604 .any(|directory| self.refused.contains_key(directory))
605 }
606
607 pub fn refused_len(&self) -> usize {
609 self.refused.len()
610 }
611
612 pub const fn limits(&self) -> ControlLimits {
614 self.limits
615 }
616
617 pub fn observation(&self) -> ControlObservation {
619 ControlObservation {
620 limits: self.limits,
621 applied: u64::try_from(self.len()).unwrap_or(u64::MAX),
622 rules: self
623 .by_directory
624 .values()
625 .fold(0_u64, |total, content| total.saturating_add(content.matcher.rule_count())),
626 refused: u64::try_from(self.refused_len()).unwrap_or(u64::MAX),
627 refusals: self.refusals().take(crate::MAX_RETAINED_ISSUES).collect(),
628 }
629 }
630
631 pub const fn source_bytes(&self) -> usize {
633 self.source_bytes
634 }
635
636 pub const fn retained_cost(&self) -> usize {
638 self.retained_cost
639 }
640
641 pub fn source_is(&self, path: &Path, source: &[u8]) -> bool {
643 control_directory(path)
644 .ok()
645 .and_then(|directory| self.by_directory.get(directory))
646 .is_some_and(|current| current.bytes == source)
647 }
648
649 pub(crate) fn contains(&self, path: &Path) -> bool {
654 control_directory(path).ok().is_some_and(|directory| {
655 self.by_directory.contains_key(directory) || self.refused.contains_key(directory)
656 })
657 }
658
659 pub fn len(&self) -> usize {
661 self.by_directory.len()
662 }
663
664 pub fn is_empty(&self) -> bool {
666 self.by_directory.is_empty()
667 }
668
669 pub(crate) fn is_vacant(&self) -> bool {
671 self.by_directory.is_empty() && self.refused.is_empty()
672 }
673}
674
675pub struct ControlMatcher<'a> {
677 table: &'a ControlTable,
678 path: &'a Path,
679}
680
681impl ControlMatcher<'_> {
682 pub fn is_ignored(&self, is_dir: bool) -> bool {
688 if self.table.by_directory.is_empty() {
693 return false;
694 }
695 for directory in self.path.parent().into_iter().flat_map(Path::ancestors) {
699 let Some(source) = self.table.by_directory.get(directory) else {
700 continue;
701 };
702 let relative = self.path.strip_prefix(directory).unwrap_or(self.path);
703 if let Some(ignored) = source.matcher.matches(relative, is_dir) {
704 return ignored;
705 }
706 }
707 false
708 }
709}
710
711fn has_key_at_or_below<V>(directories: &BTreeMap<PathBuf, V>, subtree: &Path) -> bool {
717 directories
718 .range::<Path, _>((std::ops::Bound::Included(subtree), std::ops::Bound::Unbounded))
719 .next()
720 .is_some_and(|(directory, _)| directory.starts_with(subtree))
721}
722
723pub fn is_control_file(path: &Path) -> bool {
725 path.file_name().is_some_and(|name| name == CONTROL_FILE_NAME)
726}
727
728fn control_directory(path: &Path) -> crate::Result<&Path> {
729 if !is_control_file(path) {
730 return Err(crate::Error::InvalidControlPath(path.to_path_buf()));
731 }
732 Ok(path.parent().unwrap_or_else(|| Path::new("")))
733}
734
735fn control_path(directory: &Path) -> PathBuf {
736 directory.join(CONTROL_FILE_NAME)
737}
738
739fn identity(bytes: &[u8]) -> ControlIdentity {
740 const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
741 const FNV_PRIME: u64 = 0x100_0000_01b3;
742
743 let mut fingerprint = FNV_OFFSET_BASIS;
744 for byte in bytes {
745 fingerprint ^= u64::from(*byte);
746 fingerprint = fingerprint.wrapping_mul(FNV_PRIME);
747 }
748 ControlIdentity { bytes: u64::try_from(bytes.len()).unwrap_or(u64::MAX), fingerprint }
749}
750
751fn directory_cost(directory: &Path) -> usize {
753 CONTROL_SOURCE_OVERHEAD.saturating_add(directory.as_os_str().as_encoded_bytes().len())
754}
755
756fn content_cost(source: &[u8]) -> usize {
759 let (newlines, segment_shells) = source.iter().fold((0usize, 0usize), |counts, byte| {
760 (counts.0 + usize::from(*byte == b'\n'), counts.1 + usize::from(*byte == b'/'))
761 });
762 let pattern_shells = newlines.saturating_add(1);
763 source
764 .len()
765 .saturating_mul(2)
766 .saturating_add(pattern_shells.saturating_mul(64))
767 .saturating_add(segment_shells.saturating_mul(24))
768}
769
770#[cfg(test)]
772fn retained_source_cost(directory: &Path, source: &[u8]) -> usize {
773 directory_cost(directory).saturating_add(content_cost(source))
774}
775
776#[cfg(test)]
777pub(crate) fn source_at_test_limit() -> Vec<u8> {
778 let mut source = Vec::new();
779 loop {
780 let previous_len = source.len();
781 source.extend(std::iter::repeat_n(b'a', DEFAULT_CONTROL_LINE_LIMIT));
782 source.push(b'\n');
783 if retained_source_cost(Path::new(""), &source) > DEFAULT_CONTROL_BUDGET {
784 source.truncate(previous_len);
785 break;
786 }
787 }
788 let remaining = DEFAULT_CONTROL_BUDGET - retained_source_cost(Path::new(""), &source);
789 source.extend(std::iter::repeat_n(b'a', (remaining / 2).min(DEFAULT_CONTROL_LINE_LIMIT)));
790 assert_eq!(retained_source_cost(Path::new(""), &source), DEFAULT_CONTROL_BUDGET);
791 source
792}
793
794#[cfg(test)]
795impl ControlTable {
796 fn assert_consistent(&self) {
798 let mut distinct: Vec<&Arc<SharedContent>> = Vec::new();
799 let mut directory_charges = 0;
800 let mut source_bytes = 0;
801 for (directory, content) in &self.by_directory {
802 directory_charges += directory_cost(directory);
803 source_bytes += content.bytes.len();
804 if !distinct.iter().any(|seen| Arc::ptr_eq(seen, content)) {
805 distinct.push(content);
806 }
807 }
808 let content_charges: usize = distinct.iter().map(|content| content.content_cost).sum();
809 assert_eq!(self.retained_cost, directory_charges + content_charges, "retained cost");
810 assert_eq!(self.source_bytes, source_bytes, "source bytes");
811 let holdings: usize = self.shared.values().map(Vec::len).sum();
812 assert_eq!(holdings, distinct.len(), "one holding per distinct content");
813 for (identity, holdings) in &self.shared {
814 assert!(!holdings.is_empty(), "no empty identity list survives");
815 for holding in holdings {
816 assert_eq!(holding.content.identity, *identity);
817 let holders = self
818 .by_directory
819 .values()
820 .filter(|content| Arc::ptr_eq(content, &holding.content))
821 .count();
822 assert_eq!(holding.holders, holders, "holder count");
823 }
824 for (index, left) in holdings.iter().enumerate() {
825 for right in &holdings[index + 1..] {
826 assert_ne!(left.content.bytes, right.content.bytes, "equal bytes are shared");
827 }
828 }
829 }
830 assert!(
831 self.refused.keys().all(|directory| !self.by_directory.contains_key(directory)),
832 "a refused directory retains no source"
833 );
834 assert!(
835 self.limits.budget.is_none_or(|budget| self.retained_cost <= budget),
836 "within budget"
837 );
838 assert!(
839 self.limits.line_limit.is_none_or(|line_limit| {
840 distinct.iter().all(|content| {
841 content.bytes.split(|byte| *byte == b'\n').all(|line| line.len() <= line_limit)
842 })
843 }),
844 "within the line limit"
845 );
846 }
847}
848
849#[cfg(test)]
850mod tests {
851 use super::*;
852
853 struct SplitMix(u64);
855
856 impl SplitMix {
857 fn next(&mut self) -> u64 {
858 self.0 = self.0.wrapping_add(0x9e37_79b9_7f4a_7c15);
859 let mut value = self.0;
860 value = (value ^ (value >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
861 value = (value ^ (value >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
862 value ^ (value >> 31)
863 }
864
865 fn below(&mut self, bound: usize) -> usize {
866 usize::try_from(self.next() % u64::try_from(bound).expect("small bound")).expect("fits")
867 }
868 }
869
870 #[test]
875 fn rule_totals_count_accepted_patterns_per_governing_location() {
876 let mut table = ControlTable::default();
877 let source = b"# comment\n\n*.log\n!important.log\n*.log\n[bad\n";
878 table.upsert(Path::new(".gitignore"), source.to_vec()).expect("root control");
879 table.upsert(Path::new("nested/.gitignore"), source.to_vec()).expect("nested control");
880 assert_eq!(table.observation().applied, 2);
881 assert_eq!(table.observation().rules, 6);
882 table
883 .upsert(Path::new("nested/.gitignore"), b"# empty\n".to_vec())
884 .expect("replacement control");
885 assert_eq!(table.observation().applied, 2);
886 assert_eq!(table.observation().rules, 3);
887 }
888
889 #[test]
890 fn charges_and_refusals_stay_exact_through_random_upserts_and_removals() {
891 const DIRECTORIES: [&str; 6] = ["", "a", "a/b", "b", "b/c/d", "c"];
892 let long_line = [vec![b'x'; DEFAULT_CONTROL_LINE_LIMIT + 1], b"\n".to_vec()].concat();
893 let large = b"pattern/\n".repeat(40);
894 let contents: [&[u8]; 6] =
895 [b"*.log\n", b"target/\n", b"!keep\n*.tmp\n", b"", &large, &long_line];
896 let budget = 2 * retained_source_cost(Path::new("b/c/d"), &large);
898 for seed in 0..64 {
899 let mut random = SplitMix(seed);
900 let limits = ControlLimits {
901 budget: (seed % 2 == 0).then_some(budget),
902 line_limit: (seed % 4 < 2).then_some(DEFAULT_CONTROL_LINE_LIMIT),
903 };
904 let mut table = ControlTable::with_limits(limits);
905 let mut holds_record: BTreeMap<&Path, bool> = BTreeMap::new();
906 for step in 0..300 {
907 let directory = Path::new(DIRECTORIES[random.below(DIRECTORIES.len())]);
908 let path = directory.join(CONTROL_FILE_NAME);
909 match random.below(8) {
910 0 => {
911 table.remove_subtree(directory);
912 for (held, record) in &mut holds_record {
913 if held.starts_with(directory) {
914 *record = false;
915 }
916 }
917 }
918 1 | 2 => {
919 table.remove(&path).expect("control path");
920 holds_record.insert(directory, false);
921 }
922 _ => {
923 let content = contents[random.below(contents.len())].to_vec();
924 let admission = table.upsert(&path, content.clone()).expect("control path");
925 if content == long_line && limits.line_limit.is_some() {
926 assert_eq!(
927 admission,
928 ControlAdmission::Refused(ControlRefusalReason::LineLimit)
929 );
930 }
931 if limits.budget.is_none() && limits.line_limit.is_none() {
932 assert!(
933 matches!(admission, ControlAdmission::Retained { .. }),
934 "an unbounded table refuses nothing"
935 );
936 }
937 holds_record.insert(directory, true);
938 }
939 }
940 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
941 table.assert_consistent();
942 for (directory, record) in &holds_record {
943 let path = directory.join(CONTROL_FILE_NAME);
944 assert_eq!(table.contains(&path), *record, "{}", path.display());
945 }
946 let records = holds_record.values().filter(|record| **record).count();
947 assert_eq!(table.len() + table.refused_len(), records);
948 }))
949 .unwrap_or_else(|_| panic!("seed {seed}, step {step}: inconsistent table"));
950 }
951 for directory in DIRECTORIES {
952 table.remove(&Path::new(directory).join(CONTROL_FILE_NAME)).expect("control path");
953 }
954 assert_eq!(table.retained_cost(), 0, "seed {seed}");
955 assert_eq!(table.source_bytes(), 0, "seed {seed}");
956 assert!(table.shared.is_empty(), "seed {seed}");
957 assert!(table.is_vacant(), "seed {seed}");
958 }
959 }
960
961 #[test]
962 fn a_fingerprint_collision_never_shares_a_matcher() {
963 let collision = ControlIdentity { bytes: 6, fingerprint: 7 };
964 let mut table = ControlTable::default();
965 table
966 .upsert_identified(Path::new("a/.gitignore"), b"*.log\n".to_vec(), collision)
967 .expect("first");
968 table
969 .upsert_identified(Path::new("b/.gitignore"), b"*.tmp\n".to_vec(), collision)
970 .expect("second");
971 table.assert_consistent();
972
973 assert_eq!(table.shared[&collision].len(), 2);
974 assert!(table.is_ignored(Path::new("a/x.log"), false));
975 assert!(!table.is_ignored(Path::new("a/x.tmp"), false));
976 assert!(table.is_ignored(Path::new("b/x.tmp"), false));
977 assert!(!table.is_ignored(Path::new("b/x.log"), false));
978 assert_eq!(
979 table.retained_cost(),
980 retained_source_cost(Path::new("a"), b"*.log\n")
981 + retained_source_cost(Path::new("b"), b"*.tmp\n")
982 );
983
984 table.remove(Path::new("a/.gitignore")).expect("remove");
985 table.assert_consistent();
986 assert!(table.is_ignored(Path::new("b/x.tmp"), false));
987 }
988
989 const CHANGED: ControlAdmission = ControlAdmission::Retained { changed: true };
990 const UNCHANGED: ControlAdmission = ControlAdmission::Retained { changed: false };
991 const OVER_BUDGET: ControlAdmission = ControlAdmission::Refused(ControlRefusalReason::Budget);
992 const OVER_LINE_LIMIT: ControlAdmission =
993 ControlAdmission::Refused(ControlRefusalReason::LineLimit);
994
995 fn budgeted(budget: Option<usize>) -> ControlTable {
997 ControlTable::with_limits(ControlLimits { budget, ..ControlLimits::default() })
998 }
999
1000 #[test]
1001 fn replacing_the_last_holder_releases_its_content_for_the_bound() {
1002 let mut table = ControlTable::default();
1003 let first = source_at_test_limit();
1004 assert_eq!(
1005 table.upsert(Path::new(".gitignore"), first.clone()).expect("control path"),
1006 CHANGED
1007 );
1008 assert_eq!(table.upsert(Path::new("copy/.gitignore"), first).expect("path"), OVER_BUDGET);
1010 assert_eq!(
1011 table.upsert(Path::new(".gitignore"), b"small\n".to_vec()).expect("control path"),
1012 CHANGED
1013 );
1014 table.assert_consistent();
1015 assert_eq!(table.retained_cost(), retained_source_cost(Path::new(""), b"small\n"));
1016 }
1017
1018 #[test]
1019 fn creation_edit_and_last_removal_are_exact() {
1020 let mut table = ControlTable::default();
1021 assert_eq!(
1022 table.upsert(Path::new(".gitignore"), b"*.log\n".to_vec()).expect("control path"),
1023 CHANGED
1024 );
1025 let original = table.clone();
1026 assert_eq!(
1027 table.upsert(Path::new(".gitignore"), b"*.log\n".to_vec()).expect("control path"),
1028 UNCHANGED
1029 );
1030 assert_eq!(
1031 table.upsert(Path::new(".gitignore"), b"*.tmp\n".to_vec()).expect("control path"),
1032 CHANGED
1033 );
1034 assert_eq!(table.changes_from(&original).len(), 1);
1035 assert!(table.remove(Path::new(".gitignore")).expect("remove"));
1036 assert!(table.is_empty());
1037 assert_eq!(table.source_bytes(), 0);
1038 assert!(!table.remove(Path::new(".gitignore")).expect("missing is a no-op"));
1039 }
1040
1041 #[test]
1043 fn the_budget_admits_its_own_size_and_refuses_one_byte_over_it() {
1044 let source = b"*.log\n".to_vec();
1045 let exact = retained_source_cost(Path::new("a"), &source);
1046 let mut at_budget = budgeted(Some(exact));
1047 assert_eq!(
1048 at_budget.upsert(Path::new("a/.gitignore"), source.clone()).expect("control path"),
1049 CHANGED
1050 );
1051 assert_eq!(at_budget.retained_cost(), exact);
1052
1053 let mut under_budget = budgeted(Some(exact - 1));
1054 assert_eq!(
1055 under_budget.upsert(Path::new("a/.gitignore"), source).expect("control path"),
1056 OVER_BUDGET
1057 );
1058 assert_eq!(under_budget.retained_cost(), 0);
1059 assert!(under_budget.contains(Path::new("a/.gitignore")));
1060 assert_eq!(
1061 under_budget.observation(),
1062 ControlObservation {
1063 limits: ControlLimits {
1064 budget: Some(exact - 1),
1065 line_limit: Some(DEFAULT_CONTROL_LINE_LIMIT),
1066 },
1067 applied: 0,
1068 rules: 0,
1069 refused: 1,
1070 refusals: vec![RefusedControl {
1071 path: PathBuf::from("a/.gitignore"),
1072 reason: ControlRefusalReason::Budget,
1073 }],
1074 }
1075 );
1076 }
1077
1078 #[test]
1079 fn a_refused_source_drops_the_rules_it_replaces_and_freed_budget_admits_it_later() {
1080 let mut table = ControlTable::default();
1081 let first = source_at_test_limit();
1082 assert_eq!(
1083 table.upsert(Path::new(".gitignore"), first.clone()).expect("control path"),
1084 CHANGED
1085 );
1086 assert_eq!(
1087 table
1088 .upsert(Path::new("nested/.gitignore"), b"*.log\n".to_vec())
1089 .expect("control path"),
1090 OVER_BUDGET
1091 );
1092 let before = table.clone();
1093
1094 let mut grown = first;
1097 grown.push(b'\n');
1098 assert_eq!(
1099 table.upsert(Path::new(".gitignore"), grown).expect("control path"),
1100 OVER_BUDGET
1101 );
1102 assert!(table.is_empty());
1103 assert_eq!(table.changes_from(&before).len(), 1);
1104 assert_eq!(
1105 table.refusal_changes_from(&before),
1106 vec![(PathBuf::from(".gitignore"), None, Some(ControlRefusalReason::Budget))]
1107 );
1108
1109 assert_eq!(
1111 table
1112 .upsert(Path::new("nested/.gitignore"), b"*.log\n".to_vec())
1113 .expect("control path"),
1114 CHANGED
1115 );
1116 assert_eq!(table.refused_len(), 1);
1117 assert!(table.remove(Path::new(".gitignore")).expect("remove refused"));
1119 assert_eq!(table.refused_len(), 0);
1120 table.assert_consistent();
1121 }
1122
1123 #[test]
1124 fn identical_sources_share_one_content_charge() {
1125 let source = b"target/\n*.log\nnode_modules/\n".to_vec();
1126 let mut table = ControlTable::default();
1127 table.upsert(Path::new("a/.gitignore"), source.clone()).expect("first holder");
1128 let one = table.retained_cost();
1129 table.upsert(Path::new("bb/.gitignore"), source.clone()).expect("second holder");
1130
1131 assert_eq!(table.retained_cost() - one, CONTROL_SOURCE_OVERHEAD + "bb".len());
1133 assert_eq!(table.source_bytes(), 2 * source.len());
1134 assert!(table.is_ignored(Path::new("a/debug.log"), false));
1135 assert!(table.is_ignored(Path::new("bb/debug.log"), false));
1136 }
1137
1138 #[test]
1141 fn the_line_limit_admits_its_own_length_and_refuses_one_byte_over_it() {
1142 let mut table = ControlTable::default();
1143 let at_limit = vec![b'a'; DEFAULT_CONTROL_LINE_LIMIT];
1144 assert_eq!(
1145 table.upsert(Path::new("a/.gitignore"), at_limit).expect("control path"),
1146 CHANGED
1147 );
1148 let over = [b"*.log\n".as_slice(), &vec![b'a'; DEFAULT_CONTROL_LINE_LIMIT + 1]].concat();
1149 assert_eq!(
1150 table.upsert(Path::new("b/.gitignore"), over).expect("control path"),
1151 OVER_LINE_LIMIT
1152 );
1153 assert!(!table.is_ignored(Path::new("b/debug.log"), false), "no rule of it applies");
1154 assert_eq!(
1155 table.refusals().collect::<Vec<_>>(),
1156 vec![RefusedControl {
1157 path: PathBuf::from("b/.gitignore"),
1158 reason: ControlRefusalReason::LineLimit,
1159 }]
1160 );
1161 }
1162
1163 #[test]
1166 fn the_budget_and_the_line_limit_lift_independently() {
1167 let long = [b"*.log\n".as_slice(), &vec![b'a'; DEFAULT_CONTROL_LINE_LIMIT + 1]].concat();
1168 let large = source_at_test_limit();
1169
1170 let mut no_budget = budgeted(None);
1171 assert_eq!(
1172 no_budget.upsert(Path::new("big/.gitignore"), large.clone()).expect("path"),
1173 CHANGED
1174 );
1175 assert_eq!(
1176 no_budget.upsert(Path::new("c/.gitignore"), b"*.tmp\n".to_vec()).expect("path"),
1177 CHANGED
1178 );
1179 assert!(no_budget.retained_cost() > DEFAULT_CONTROL_BUDGET);
1180 assert_eq!(
1181 no_budget.upsert(Path::new(".gitignore"), long.clone()).expect("path"),
1182 OVER_LINE_LIMIT
1183 );
1184
1185 let mut raised_budget = budgeted(Some(16 * DEFAULT_CONTROL_BUDGET));
1186 assert_eq!(
1187 raised_budget.upsert(Path::new(".gitignore"), long.clone()).expect("path"),
1188 OVER_LINE_LIMIT
1189 );
1190
1191 let mut no_line_limit = ControlTable::with_limits(ControlLimits {
1192 line_limit: None,
1193 ..ControlLimits::default()
1194 });
1195 assert_eq!(no_line_limit.upsert(Path::new(".gitignore"), long).expect("path"), CHANGED);
1196 assert!(no_line_limit.is_ignored(Path::new("debug.log"), false));
1197 assert_eq!(
1198 no_line_limit.upsert(Path::new("big/.gitignore"), large).expect("path"),
1199 OVER_BUDGET
1200 );
1201 no_line_limit.assert_consistent();
1202 }
1203
1204 #[test]
1205 fn a_listing_of_refusals_is_bounded_and_says_when_it_is_truncated() {
1206 let mut table = budgeted(Some(0));
1207 let refused = crate::MAX_RETAINED_ISSUES + 1;
1208 for directory in 0..refused {
1209 let path = PathBuf::from(format!("d{directory:03}/.gitignore"));
1210 assert_eq!(table.upsert(&path, b"*\n".to_vec()).expect("control path"), OVER_BUDGET);
1211 }
1212 let observation = table.observation();
1213 assert_eq!(observation.refused, u64::try_from(refused).expect("small"));
1214 assert_eq!(observation.refusals.len(), crate::MAX_RETAINED_ISSUES);
1215 assert_eq!(observation.refusals[0].path, Path::new("d000/.gitignore"));
1216 assert!(!observation.lists_every_refusal());
1217 assert!(!observation.is_complete());
1218 }
1219
1220 #[test]
1221 fn control_identity_uses_standard_fnv1a_vectors() {
1222 assert_eq!(identity(b"").fingerprint, 0xcbf2_9ce4_8422_2325);
1223 assert_eq!(identity(b"a").fingerprint, 0xaf63_dc4c_8601_ec8c);
1224 assert_eq!(identity(b"foobar").fingerprint, 0x8594_4171_f739_67e8);
1225 }
1226
1227 #[test]
1228 fn nested_negation_and_control_removal_change_the_governed_subtree() {
1229 let mut table = ControlTable::default();
1230 table.upsert(Path::new(".gitignore"), b"*.log\n".to_vec()).expect("root");
1231 table.upsert(Path::new("docs/.gitignore"), b"!keep.log\n".to_vec()).expect("nested");
1232
1233 assert!(table.is_ignored(Path::new("debug.log"), false));
1234 assert!(table.is_ignored(Path::new("docs/other.log"), false));
1235 assert!(!table.is_ignored(Path::new("docs/keep.log"), false));
1236 assert_eq!(
1237 ControlTable::affected_subtree(Path::new("docs/.gitignore")).expect("scope"),
1238 Path::new("docs")
1239 );
1240
1241 table.remove(Path::new("docs/.gitignore")).expect("remove nested");
1242 assert!(table.is_ignored(Path::new("docs/keep.log"), false));
1243 }
1244
1245 #[test]
1246 fn ignored_parent_cannot_be_reincluded_from_inside_it() {
1247 let mut table = ControlTable::default();
1248 table.upsert(Path::new(".gitignore"), b"vendor/\n".to_vec()).expect("root");
1249 table
1250 .upsert(Path::new("vendor/.gitignore"), b"!keep.txt\n".to_vec())
1251 .expect("retained but inactive nested control");
1252
1253 assert!(table.is_ignored(Path::new("vendor"), true));
1254 assert!(table.is_ignored(Path::new("vendor/keep.txt"), false));
1255 }
1256}