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 refused: u64,
219 pub refusals: Vec<RefusedControl>,
222}
223
224impl ControlObservation {
225 pub const fn is_complete(&self) -> bool {
227 self.refused == 0
228 }
229
230 pub fn lists_every_refusal(&self) -> bool {
232 u64::try_from(self.refusals.len()).is_ok_and(|listed| listed == self.refused)
233 }
234}
235
236#[derive(Clone, Debug)]
249pub struct ControlTable {
250 by_directory: BTreeMap<PathBuf, Arc<SharedContent>>,
251 shared: HashMap<ControlIdentity, Vec<Holding>>,
254 refused: BTreeMap<PathBuf, ControlRefusalReason>,
257 limits: ControlLimits,
258 source_bytes: usize,
259 retained_cost: usize,
260}
261
262impl Default for ControlTable {
263 fn default() -> Self {
264 Self::with_limits(ControlLimits::default())
265 }
266}
267
268impl ControlTable {
269 pub(crate) fn with_limits(limits: ControlLimits) -> Self {
271 Self {
272 by_directory: BTreeMap::new(),
273 shared: HashMap::new(),
274 refused: BTreeMap::new(),
275 limits,
276 source_bytes: 0,
277 retained_cost: 0,
278 }
279 }
280
281 pub fn upsert(&mut self, path: &Path, source: Vec<u8>) -> crate::Result<ControlAdmission> {
294 let identity = identity(&source);
295 self.upsert_identified(path, source, identity)
296 }
297
298 fn upsert_identified(
299 &mut self,
300 path: &Path,
301 source: Vec<u8>,
302 identity: ControlIdentity,
303 ) -> crate::Result<ControlAdmission> {
304 let directory = control_directory(path)?;
305 match self.verdict(directory, &source, identity) {
306 Verdict::Unchanged => Ok(ControlAdmission::Retained { changed: false }),
307 Verdict::Refuse(reason) => {
308 crate::counters::bump(|counts| {
309 counts.control_refused = counts.control_refused.saturating_add(1);
310 });
311 Ok(self.refuse(directory, reason))
312 }
313 Verdict::Admit { retained_cost } => {
314 self.detach(directory);
315 self.attach(directory, source, identity);
316 self.refused.remove(directory);
317 debug_assert_eq!(self.retained_cost, retained_cost);
318 Ok(ControlAdmission::Retained { changed: true })
319 }
320 }
321 }
322
323 fn verdict(&self, directory: &Path, source: &[u8], identity: ControlIdentity) -> Verdict {
328 if self.by_directory.get(directory).is_some_and(|current| current.bytes == source) {
329 return Verdict::Unchanged;
330 }
331 if self.limits.line_limit.is_some_and(|line_limit| {
332 source.split(|byte| *byte == b'\n').any(|line| line.len() > line_limit)
333 }) {
334 return Verdict::Refuse(ControlRefusalReason::LineLimit);
335 }
336 let content_charge =
337 if self.holding(identity, source).is_some() { 0 } else { content_cost(source) };
338 let retained_cost = self
341 .retained_cost
342 .saturating_sub(self.release_charge(directory))
343 .saturating_add(directory_cost(directory))
344 .saturating_add(content_charge);
345 if self.limits.budget.is_some_and(|budget| retained_cost > budget) {
346 return Verdict::Refuse(ControlRefusalReason::Budget);
347 }
348 Verdict::Admit { retained_cost }
349 }
350
351 pub(crate) fn upsert_is_inert(&self, path: &Path, source: &[u8]) -> bool {
359 let Ok(directory) = control_directory(path) else {
360 return false;
361 };
362 match self.verdict(directory, source, identity(source)) {
363 Verdict::Unchanged => true,
364 Verdict::Refuse(reason) => self.refused.get(directory) == Some(&reason),
365 Verdict::Admit { .. } => false,
366 }
367 }
368
369 pub(crate) fn remove_is_inert(&self, path: &Path) -> bool {
371 control_directory(path).is_ok_and(|directory| {
372 !self.by_directory.contains_key(directory) && !self.refused.contains_key(directory)
373 })
374 }
375
376 pub(crate) fn has_record_at_or_below(&self, subtree: &Path) -> bool {
381 has_key_at_or_below(&self.by_directory, subtree)
382 || has_key_at_or_below(&self.refused, subtree)
383 }
384
385 pub(crate) fn record_refusal(
387 &mut self,
388 path: &Path,
389 reason: ControlRefusalReason,
390 ) -> crate::Result<()> {
391 let directory = control_directory(path)?;
392 self.refuse(directory, reason);
393 Ok(())
394 }
395
396 fn refuse(&mut self, directory: &Path, reason: ControlRefusalReason) -> ControlAdmission {
397 self.detach(directory);
398 self.refused.insert(directory.to_path_buf(), reason);
399 ControlAdmission::Refused(reason)
400 }
401
402 pub fn remove(&mut self, path: &Path) -> crate::Result<bool> {
408 let directory = control_directory(path)?;
409 let retained = self.detach(directory);
410 let refused = self.refused.remove(directory).is_some();
411 Ok(retained || refused)
412 }
413
414 pub(crate) fn remove_subtree(&mut self, subtree: &Path) {
416 let directories: Vec<PathBuf> = self
417 .by_directory
418 .keys()
419 .filter(|directory| directory.starts_with(subtree))
420 .cloned()
421 .collect();
422 for directory in directories {
423 self.detach(&directory);
424 }
425 self.refused.retain(|directory, _| !directory.starts_with(subtree));
426 }
427
428 fn holding(&self, identity: ControlIdentity, source: &[u8]) -> Option<&Holding> {
430 self.shared
431 .get(&identity)?
432 .iter()
433 .find(|holding| holding.content.bytes.as_slice() == source)
434 }
435
436 fn release_charge(&self, directory: &Path) -> usize {
438 let Some(content) = self.by_directory.get(directory) else {
439 return 0;
440 };
441 let last_holder = self.shared.get(&content.identity).is_some_and(|holdings| {
442 holdings
443 .iter()
444 .any(|holding| Arc::ptr_eq(&holding.content, content) && holding.holders == 1)
445 });
446 directory_cost(directory).saturating_add(if last_holder { content.content_cost } else { 0 })
447 }
448
449 fn detach(&mut self, directory: &Path) -> bool {
451 let Some(content) = self.by_directory.remove(directory) else {
452 return false;
453 };
454 let holdings = self.shared.get_mut(&content.identity).expect("a retained content is held");
455 let position = holdings
456 .iter()
457 .position(|holding| Arc::ptr_eq(&holding.content, &content))
458 .expect("a retained content is listed under its own identity");
459 holdings[position].holders -= 1;
460 if holdings[position].holders == 0 {
461 holdings.swap_remove(position);
462 self.retained_cost -= content.content_cost;
463 if holdings.is_empty() {
464 self.shared.remove(&content.identity);
465 }
466 }
467 self.retained_cost -= directory_cost(directory);
468 self.source_bytes -= content.bytes.len();
469 true
470 }
471
472 fn attach(&mut self, directory: &Path, source: Vec<u8>, identity: ControlIdentity) {
474 let holdings = self.shared.entry(identity).or_default();
475 let content = if let Some(holding) =
476 holdings.iter_mut().find(|holding| holding.content.bytes == source)
477 {
478 holding.holders += 1;
479 crate::counters::bump(|counts| {
480 counts.control_sources_shared = counts.control_sources_shared.saturating_add(1);
481 });
482 Arc::clone(&holding.content)
483 } else {
484 let content_cost = content_cost(&source);
485 let matcher = Gitignore::parse(&source);
486 let content =
487 Arc::new(SharedContent { bytes: source, identity, matcher, content_cost });
488 holdings.push(Holding { content: Arc::clone(&content), holders: 1 });
489 self.retained_cost += content_cost;
490 content
491 };
492 self.retained_cost += directory_cost(directory);
493 self.source_bytes += content.bytes.len();
494 self.by_directory.insert(directory.to_path_buf(), content);
495 }
496
497 pub fn matcher_for<'a>(&'a self, path: &'a Path) -> ControlMatcher<'a> {
499 ControlMatcher { table: self, path }
500 }
501
502 pub fn is_ignored(&self, path: &Path, is_dir: bool) -> bool {
508 let components: Vec<_> = path.components().collect();
509 let mut current = PathBuf::new();
510 let mut parent_ignored = false;
511 for (position, component) in components.iter().enumerate() {
512 current.push(component.as_os_str());
513 if parent_ignored {
514 return true;
515 }
516 let current_is_dir = position + 1 < components.len() || is_dir;
517 parent_ignored = self.matcher_for(¤t).is_ignored(current_is_dir);
518 }
519 parent_ignored
520 }
521
522 pub fn affected_subtree(path: &Path) -> crate::Result<PathBuf> {
524 Ok(control_directory(path)?.to_path_buf())
525 }
526
527 pub(crate) fn changes_from(
529 &self,
530 previous: &Self,
531 ) -> Vec<(PathBuf, Option<ControlIdentity>, Option<ControlIdentity>)> {
532 let directories: BTreeSet<&Path> = previous
533 .by_directory
534 .keys()
535 .chain(self.by_directory.keys())
536 .map(PathBuf::as_path)
537 .collect();
538 directories
539 .into_iter()
540 .filter_map(|directory| {
541 let before = previous.by_directory.get(directory);
542 let after = self.by_directory.get(directory);
543 let changed = match (before, after) {
544 (Some(before), Some(after)) => {
545 !Arc::ptr_eq(before, after) && before.bytes != after.bytes
546 }
547 (None, None) => false,
548 (Some(_), None) | (None, Some(_)) => true,
549 };
550 changed.then(|| {
551 (
552 control_path(directory),
553 before.map(|source| source.identity),
554 after.map(|source| source.identity),
555 )
556 })
557 })
558 .collect()
559 }
560
561 pub(crate) fn refusal_changes_from(
563 &self,
564 previous: &Self,
565 ) -> Vec<(PathBuf, Option<ControlRefusalReason>, Option<ControlRefusalReason>)> {
566 let directories: BTreeSet<&Path> =
567 previous.refused.keys().chain(self.refused.keys()).map(PathBuf::as_path).collect();
568 directories
569 .into_iter()
570 .filter_map(|directory| {
571 let before = previous.refused.get(directory).copied();
572 let after = self.refused.get(directory).copied();
573 (before != after).then(|| (control_path(directory), before, after))
574 })
575 .collect()
576 }
577
578 pub(crate) fn sources(&self) -> impl ExactSizeIterator<Item = (PathBuf, &[u8])> {
580 self.by_directory
581 .iter()
582 .map(|(directory, source)| (control_path(directory), source.bytes.as_slice()))
583 }
584
585 pub fn refusals(&self) -> impl ExactSizeIterator<Item = RefusedControl> + '_ {
587 self.refused.iter().map(|(directory, reason)| RefusedControl {
588 path: control_path(directory),
589 reason: *reason,
590 })
591 }
592
593 pub fn refused_len(&self) -> usize {
595 self.refused.len()
596 }
597
598 pub const fn limits(&self) -> ControlLimits {
600 self.limits
601 }
602
603 pub fn observation(&self) -> ControlObservation {
605 ControlObservation {
606 limits: self.limits,
607 applied: u64::try_from(self.len()).unwrap_or(u64::MAX),
608 refused: u64::try_from(self.refused_len()).unwrap_or(u64::MAX),
609 refusals: self.refusals().take(crate::MAX_RETAINED_ISSUES).collect(),
610 }
611 }
612
613 pub const fn source_bytes(&self) -> usize {
615 self.source_bytes
616 }
617
618 pub const fn retained_cost(&self) -> usize {
620 self.retained_cost
621 }
622
623 pub fn source_is(&self, path: &Path, source: &[u8]) -> bool {
625 control_directory(path)
626 .ok()
627 .and_then(|directory| self.by_directory.get(directory))
628 .is_some_and(|current| current.bytes == source)
629 }
630
631 pub(crate) fn contains(&self, path: &Path) -> bool {
636 control_directory(path).ok().is_some_and(|directory| {
637 self.by_directory.contains_key(directory) || self.refused.contains_key(directory)
638 })
639 }
640
641 pub fn len(&self) -> usize {
643 self.by_directory.len()
644 }
645
646 pub fn is_empty(&self) -> bool {
648 self.by_directory.is_empty()
649 }
650
651 pub(crate) fn is_vacant(&self) -> bool {
653 self.by_directory.is_empty() && self.refused.is_empty()
654 }
655}
656
657pub struct ControlMatcher<'a> {
659 table: &'a ControlTable,
660 path: &'a Path,
661}
662
663impl ControlMatcher<'_> {
664 pub fn is_ignored(&self, is_dir: bool) -> bool {
670 if self.table.by_directory.is_empty() {
675 return false;
676 }
677 for directory in self.path.parent().into_iter().flat_map(Path::ancestors) {
681 let Some(source) = self.table.by_directory.get(directory) else {
682 continue;
683 };
684 let relative = self.path.strip_prefix(directory).unwrap_or(self.path);
685 if let Some(ignored) = source.matcher.matches(relative, is_dir) {
686 return ignored;
687 }
688 }
689 false
690 }
691}
692
693fn has_key_at_or_below<V>(directories: &BTreeMap<PathBuf, V>, subtree: &Path) -> bool {
699 directories
700 .range::<Path, _>((std::ops::Bound::Included(subtree), std::ops::Bound::Unbounded))
701 .next()
702 .is_some_and(|(directory, _)| directory.starts_with(subtree))
703}
704
705pub fn is_control_file(path: &Path) -> bool {
707 path.file_name().is_some_and(|name| name == CONTROL_FILE_NAME)
708}
709
710fn control_directory(path: &Path) -> crate::Result<&Path> {
711 if !is_control_file(path) {
712 return Err(crate::Error::InvalidControlPath(path.to_path_buf()));
713 }
714 Ok(path.parent().unwrap_or_else(|| Path::new("")))
715}
716
717fn control_path(directory: &Path) -> PathBuf {
718 directory.join(CONTROL_FILE_NAME)
719}
720
721fn identity(bytes: &[u8]) -> ControlIdentity {
722 const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
723 const FNV_PRIME: u64 = 0x100_0000_01b3;
724
725 let mut fingerprint = FNV_OFFSET_BASIS;
726 for byte in bytes {
727 fingerprint ^= u64::from(*byte);
728 fingerprint = fingerprint.wrapping_mul(FNV_PRIME);
729 }
730 ControlIdentity { bytes: u64::try_from(bytes.len()).unwrap_or(u64::MAX), fingerprint }
731}
732
733fn directory_cost(directory: &Path) -> usize {
735 CONTROL_SOURCE_OVERHEAD.saturating_add(directory.as_os_str().as_encoded_bytes().len())
736}
737
738fn content_cost(source: &[u8]) -> usize {
741 let (newlines, segment_shells) = source.iter().fold((0usize, 0usize), |counts, byte| {
742 (counts.0 + usize::from(*byte == b'\n'), counts.1 + usize::from(*byte == b'/'))
743 });
744 let pattern_shells = newlines.saturating_add(1);
745 source
746 .len()
747 .saturating_mul(2)
748 .saturating_add(pattern_shells.saturating_mul(64))
749 .saturating_add(segment_shells.saturating_mul(24))
750}
751
752#[cfg(test)]
754fn retained_source_cost(directory: &Path, source: &[u8]) -> usize {
755 directory_cost(directory).saturating_add(content_cost(source))
756}
757
758#[cfg(test)]
759pub(crate) fn source_at_test_limit() -> Vec<u8> {
760 let mut source = Vec::new();
761 loop {
762 let previous_len = source.len();
763 source.extend(std::iter::repeat_n(b'a', DEFAULT_CONTROL_LINE_LIMIT));
764 source.push(b'\n');
765 if retained_source_cost(Path::new(""), &source) > DEFAULT_CONTROL_BUDGET {
766 source.truncate(previous_len);
767 break;
768 }
769 }
770 let remaining = DEFAULT_CONTROL_BUDGET - retained_source_cost(Path::new(""), &source);
771 source.extend(std::iter::repeat_n(b'a', (remaining / 2).min(DEFAULT_CONTROL_LINE_LIMIT)));
772 assert_eq!(retained_source_cost(Path::new(""), &source), DEFAULT_CONTROL_BUDGET);
773 source
774}
775
776#[cfg(test)]
777impl ControlTable {
778 fn assert_consistent(&self) {
780 let mut distinct: Vec<&Arc<SharedContent>> = Vec::new();
781 let mut directory_charges = 0;
782 let mut source_bytes = 0;
783 for (directory, content) in &self.by_directory {
784 directory_charges += directory_cost(directory);
785 source_bytes += content.bytes.len();
786 if !distinct.iter().any(|seen| Arc::ptr_eq(seen, content)) {
787 distinct.push(content);
788 }
789 }
790 let content_charges: usize = distinct.iter().map(|content| content.content_cost).sum();
791 assert_eq!(self.retained_cost, directory_charges + content_charges, "retained cost");
792 assert_eq!(self.source_bytes, source_bytes, "source bytes");
793 let holdings: usize = self.shared.values().map(Vec::len).sum();
794 assert_eq!(holdings, distinct.len(), "one holding per distinct content");
795 for (identity, holdings) in &self.shared {
796 assert!(!holdings.is_empty(), "no empty identity list survives");
797 for holding in holdings {
798 assert_eq!(holding.content.identity, *identity);
799 let holders = self
800 .by_directory
801 .values()
802 .filter(|content| Arc::ptr_eq(content, &holding.content))
803 .count();
804 assert_eq!(holding.holders, holders, "holder count");
805 }
806 for (index, left) in holdings.iter().enumerate() {
807 for right in &holdings[index + 1..] {
808 assert_ne!(left.content.bytes, right.content.bytes, "equal bytes are shared");
809 }
810 }
811 }
812 assert!(
813 self.refused.keys().all(|directory| !self.by_directory.contains_key(directory)),
814 "a refused directory retains no source"
815 );
816 assert!(
817 self.limits.budget.is_none_or(|budget| self.retained_cost <= budget),
818 "within budget"
819 );
820 assert!(
821 self.limits.line_limit.is_none_or(|line_limit| {
822 distinct.iter().all(|content| {
823 content.bytes.split(|byte| *byte == b'\n').all(|line| line.len() <= line_limit)
824 })
825 }),
826 "within the line limit"
827 );
828 }
829}
830
831#[cfg(test)]
832mod tests {
833 use super::*;
834
835 struct SplitMix(u64);
837
838 impl SplitMix {
839 fn next(&mut self) -> u64 {
840 self.0 = self.0.wrapping_add(0x9e37_79b9_7f4a_7c15);
841 let mut value = self.0;
842 value = (value ^ (value >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
843 value = (value ^ (value >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
844 value ^ (value >> 31)
845 }
846
847 fn below(&mut self, bound: usize) -> usize {
848 usize::try_from(self.next() % u64::try_from(bound).expect("small bound")).expect("fits")
849 }
850 }
851
852 #[test]
857 fn charges_and_refusals_stay_exact_through_random_upserts_and_removals() {
858 const DIRECTORIES: [&str; 6] = ["", "a", "a/b", "b", "b/c/d", "c"];
859 let long_line = [vec![b'x'; DEFAULT_CONTROL_LINE_LIMIT + 1], b"\n".to_vec()].concat();
860 let large = b"pattern/\n".repeat(40);
861 let contents: [&[u8]; 6] =
862 [b"*.log\n", b"target/\n", b"!keep\n*.tmp\n", b"", &large, &long_line];
863 let budget = 2 * retained_source_cost(Path::new("b/c/d"), &large);
865 for seed in 0..64 {
866 let mut random = SplitMix(seed);
867 let limits = ControlLimits {
868 budget: (seed % 2 == 0).then_some(budget),
869 line_limit: (seed % 4 < 2).then_some(DEFAULT_CONTROL_LINE_LIMIT),
870 };
871 let mut table = ControlTable::with_limits(limits);
872 let mut holds_record: BTreeMap<&Path, bool> = BTreeMap::new();
873 for step in 0..300 {
874 let directory = Path::new(DIRECTORIES[random.below(DIRECTORIES.len())]);
875 let path = directory.join(CONTROL_FILE_NAME);
876 match random.below(8) {
877 0 => {
878 table.remove_subtree(directory);
879 for (held, record) in &mut holds_record {
880 if held.starts_with(directory) {
881 *record = false;
882 }
883 }
884 }
885 1 | 2 => {
886 table.remove(&path).expect("control path");
887 holds_record.insert(directory, false);
888 }
889 _ => {
890 let content = contents[random.below(contents.len())].to_vec();
891 let admission = table.upsert(&path, content.clone()).expect("control path");
892 if content == long_line && limits.line_limit.is_some() {
893 assert_eq!(
894 admission,
895 ControlAdmission::Refused(ControlRefusalReason::LineLimit)
896 );
897 }
898 if limits.budget.is_none() && limits.line_limit.is_none() {
899 assert!(
900 matches!(admission, ControlAdmission::Retained { .. }),
901 "an unbounded table refuses nothing"
902 );
903 }
904 holds_record.insert(directory, true);
905 }
906 }
907 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
908 table.assert_consistent();
909 for (directory, record) in &holds_record {
910 let path = directory.join(CONTROL_FILE_NAME);
911 assert_eq!(table.contains(&path), *record, "{}", path.display());
912 }
913 let records = holds_record.values().filter(|record| **record).count();
914 assert_eq!(table.len() + table.refused_len(), records);
915 }))
916 .unwrap_or_else(|_| panic!("seed {seed}, step {step}: inconsistent table"));
917 }
918 for directory in DIRECTORIES {
919 table.remove(&Path::new(directory).join(CONTROL_FILE_NAME)).expect("control path");
920 }
921 assert_eq!(table.retained_cost(), 0, "seed {seed}");
922 assert_eq!(table.source_bytes(), 0, "seed {seed}");
923 assert!(table.shared.is_empty(), "seed {seed}");
924 assert!(table.is_vacant(), "seed {seed}");
925 }
926 }
927
928 #[test]
929 fn a_fingerprint_collision_never_shares_a_matcher() {
930 let collision = ControlIdentity { bytes: 6, fingerprint: 7 };
931 let mut table = ControlTable::default();
932 table
933 .upsert_identified(Path::new("a/.gitignore"), b"*.log\n".to_vec(), collision)
934 .expect("first");
935 table
936 .upsert_identified(Path::new("b/.gitignore"), b"*.tmp\n".to_vec(), collision)
937 .expect("second");
938 table.assert_consistent();
939
940 assert_eq!(table.shared[&collision].len(), 2);
941 assert!(table.is_ignored(Path::new("a/x.log"), false));
942 assert!(!table.is_ignored(Path::new("a/x.tmp"), false));
943 assert!(table.is_ignored(Path::new("b/x.tmp"), false));
944 assert!(!table.is_ignored(Path::new("b/x.log"), false));
945 assert_eq!(
946 table.retained_cost(),
947 retained_source_cost(Path::new("a"), b"*.log\n")
948 + retained_source_cost(Path::new("b"), b"*.tmp\n")
949 );
950
951 table.remove(Path::new("a/.gitignore")).expect("remove");
952 table.assert_consistent();
953 assert!(table.is_ignored(Path::new("b/x.tmp"), false));
954 }
955
956 const CHANGED: ControlAdmission = ControlAdmission::Retained { changed: true };
957 const UNCHANGED: ControlAdmission = ControlAdmission::Retained { changed: false };
958 const OVER_BUDGET: ControlAdmission = ControlAdmission::Refused(ControlRefusalReason::Budget);
959 const OVER_LINE_LIMIT: ControlAdmission =
960 ControlAdmission::Refused(ControlRefusalReason::LineLimit);
961
962 fn budgeted(budget: Option<usize>) -> ControlTable {
964 ControlTable::with_limits(ControlLimits { budget, ..ControlLimits::default() })
965 }
966
967 #[test]
968 fn replacing_the_last_holder_releases_its_content_for_the_bound() {
969 let mut table = ControlTable::default();
970 let first = source_at_test_limit();
971 assert_eq!(
972 table.upsert(Path::new(".gitignore"), first.clone()).expect("control path"),
973 CHANGED
974 );
975 assert_eq!(table.upsert(Path::new("copy/.gitignore"), first).expect("path"), OVER_BUDGET);
977 assert_eq!(
978 table.upsert(Path::new(".gitignore"), b"small\n".to_vec()).expect("control path"),
979 CHANGED
980 );
981 table.assert_consistent();
982 assert_eq!(table.retained_cost(), retained_source_cost(Path::new(""), b"small\n"));
983 }
984
985 #[test]
986 fn creation_edit_and_last_removal_are_exact() {
987 let mut table = ControlTable::default();
988 assert_eq!(
989 table.upsert(Path::new(".gitignore"), b"*.log\n".to_vec()).expect("control path"),
990 CHANGED
991 );
992 let original = table.clone();
993 assert_eq!(
994 table.upsert(Path::new(".gitignore"), b"*.log\n".to_vec()).expect("control path"),
995 UNCHANGED
996 );
997 assert_eq!(
998 table.upsert(Path::new(".gitignore"), b"*.tmp\n".to_vec()).expect("control path"),
999 CHANGED
1000 );
1001 assert_eq!(table.changes_from(&original).len(), 1);
1002 assert!(table.remove(Path::new(".gitignore")).expect("remove"));
1003 assert!(table.is_empty());
1004 assert_eq!(table.source_bytes(), 0);
1005 assert!(!table.remove(Path::new(".gitignore")).expect("missing is a no-op"));
1006 }
1007
1008 #[test]
1010 fn the_budget_admits_its_own_size_and_refuses_one_byte_over_it() {
1011 let source = b"*.log\n".to_vec();
1012 let exact = retained_source_cost(Path::new("a"), &source);
1013 let mut at_budget = budgeted(Some(exact));
1014 assert_eq!(
1015 at_budget.upsert(Path::new("a/.gitignore"), source.clone()).expect("control path"),
1016 CHANGED
1017 );
1018 assert_eq!(at_budget.retained_cost(), exact);
1019
1020 let mut under_budget = budgeted(Some(exact - 1));
1021 assert_eq!(
1022 under_budget.upsert(Path::new("a/.gitignore"), source).expect("control path"),
1023 OVER_BUDGET
1024 );
1025 assert_eq!(under_budget.retained_cost(), 0);
1026 assert!(under_budget.contains(Path::new("a/.gitignore")));
1027 assert_eq!(
1028 under_budget.observation(),
1029 ControlObservation {
1030 limits: ControlLimits {
1031 budget: Some(exact - 1),
1032 line_limit: Some(DEFAULT_CONTROL_LINE_LIMIT),
1033 },
1034 applied: 0,
1035 refused: 1,
1036 refusals: vec![RefusedControl {
1037 path: PathBuf::from("a/.gitignore"),
1038 reason: ControlRefusalReason::Budget,
1039 }],
1040 }
1041 );
1042 }
1043
1044 #[test]
1045 fn a_refused_source_drops_the_rules_it_replaces_and_freed_budget_admits_it_later() {
1046 let mut table = ControlTable::default();
1047 let first = source_at_test_limit();
1048 assert_eq!(
1049 table.upsert(Path::new(".gitignore"), first.clone()).expect("control path"),
1050 CHANGED
1051 );
1052 assert_eq!(
1053 table
1054 .upsert(Path::new("nested/.gitignore"), b"*.log\n".to_vec())
1055 .expect("control path"),
1056 OVER_BUDGET
1057 );
1058 let before = table.clone();
1059
1060 let mut grown = first;
1063 grown.push(b'\n');
1064 assert_eq!(
1065 table.upsert(Path::new(".gitignore"), grown).expect("control path"),
1066 OVER_BUDGET
1067 );
1068 assert!(table.is_empty());
1069 assert_eq!(table.changes_from(&before).len(), 1);
1070 assert_eq!(
1071 table.refusal_changes_from(&before),
1072 vec![(PathBuf::from(".gitignore"), None, Some(ControlRefusalReason::Budget))]
1073 );
1074
1075 assert_eq!(
1077 table
1078 .upsert(Path::new("nested/.gitignore"), b"*.log\n".to_vec())
1079 .expect("control path"),
1080 CHANGED
1081 );
1082 assert_eq!(table.refused_len(), 1);
1083 assert!(table.remove(Path::new(".gitignore")).expect("remove refused"));
1085 assert_eq!(table.refused_len(), 0);
1086 table.assert_consistent();
1087 }
1088
1089 #[test]
1090 fn identical_sources_share_one_content_charge() {
1091 let source = b"target/\n*.log\nnode_modules/\n".to_vec();
1092 let mut table = ControlTable::default();
1093 table.upsert(Path::new("a/.gitignore"), source.clone()).expect("first holder");
1094 let one = table.retained_cost();
1095 table.upsert(Path::new("bb/.gitignore"), source.clone()).expect("second holder");
1096
1097 assert_eq!(table.retained_cost() - one, CONTROL_SOURCE_OVERHEAD + "bb".len());
1099 assert_eq!(table.source_bytes(), 2 * source.len());
1100 assert!(table.is_ignored(Path::new("a/debug.log"), false));
1101 assert!(table.is_ignored(Path::new("bb/debug.log"), false));
1102 }
1103
1104 #[test]
1107 fn the_line_limit_admits_its_own_length_and_refuses_one_byte_over_it() {
1108 let mut table = ControlTable::default();
1109 let at_limit = vec![b'a'; DEFAULT_CONTROL_LINE_LIMIT];
1110 assert_eq!(
1111 table.upsert(Path::new("a/.gitignore"), at_limit).expect("control path"),
1112 CHANGED
1113 );
1114 let over = [b"*.log\n".as_slice(), &vec![b'a'; DEFAULT_CONTROL_LINE_LIMIT + 1]].concat();
1115 assert_eq!(
1116 table.upsert(Path::new("b/.gitignore"), over).expect("control path"),
1117 OVER_LINE_LIMIT
1118 );
1119 assert!(!table.is_ignored(Path::new("b/debug.log"), false), "no rule of it applies");
1120 assert_eq!(
1121 table.refusals().collect::<Vec<_>>(),
1122 vec![RefusedControl {
1123 path: PathBuf::from("b/.gitignore"),
1124 reason: ControlRefusalReason::LineLimit,
1125 }]
1126 );
1127 }
1128
1129 #[test]
1132 fn the_budget_and_the_line_limit_lift_independently() {
1133 let long = [b"*.log\n".as_slice(), &vec![b'a'; DEFAULT_CONTROL_LINE_LIMIT + 1]].concat();
1134 let large = source_at_test_limit();
1135
1136 let mut no_budget = budgeted(None);
1137 assert_eq!(
1138 no_budget.upsert(Path::new("big/.gitignore"), large.clone()).expect("path"),
1139 CHANGED
1140 );
1141 assert_eq!(
1142 no_budget.upsert(Path::new("c/.gitignore"), b"*.tmp\n".to_vec()).expect("path"),
1143 CHANGED
1144 );
1145 assert!(no_budget.retained_cost() > DEFAULT_CONTROL_BUDGET);
1146 assert_eq!(
1147 no_budget.upsert(Path::new(".gitignore"), long.clone()).expect("path"),
1148 OVER_LINE_LIMIT
1149 );
1150
1151 let mut raised_budget = budgeted(Some(16 * DEFAULT_CONTROL_BUDGET));
1152 assert_eq!(
1153 raised_budget.upsert(Path::new(".gitignore"), long.clone()).expect("path"),
1154 OVER_LINE_LIMIT
1155 );
1156
1157 let mut no_line_limit = ControlTable::with_limits(ControlLimits {
1158 line_limit: None,
1159 ..ControlLimits::default()
1160 });
1161 assert_eq!(no_line_limit.upsert(Path::new(".gitignore"), long).expect("path"), CHANGED);
1162 assert!(no_line_limit.is_ignored(Path::new("debug.log"), false));
1163 assert_eq!(
1164 no_line_limit.upsert(Path::new("big/.gitignore"), large).expect("path"),
1165 OVER_BUDGET
1166 );
1167 no_line_limit.assert_consistent();
1168 }
1169
1170 #[test]
1171 fn a_listing_of_refusals_is_bounded_and_says_when_it_is_truncated() {
1172 let mut table = budgeted(Some(0));
1173 let refused = crate::MAX_RETAINED_ISSUES + 1;
1174 for directory in 0..refused {
1175 let path = PathBuf::from(format!("d{directory:03}/.gitignore"));
1176 assert_eq!(table.upsert(&path, b"*\n".to_vec()).expect("control path"), OVER_BUDGET);
1177 }
1178 let observation = table.observation();
1179 assert_eq!(observation.refused, u64::try_from(refused).expect("small"));
1180 assert_eq!(observation.refusals.len(), crate::MAX_RETAINED_ISSUES);
1181 assert_eq!(observation.refusals[0].path, Path::new("d000/.gitignore"));
1182 assert!(!observation.lists_every_refusal());
1183 assert!(!observation.is_complete());
1184 }
1185
1186 #[test]
1187 fn control_identity_uses_standard_fnv1a_vectors() {
1188 assert_eq!(identity(b"").fingerprint, 0xcbf2_9ce4_8422_2325);
1189 assert_eq!(identity(b"a").fingerprint, 0xaf63_dc4c_8601_ec8c);
1190 assert_eq!(identity(b"foobar").fingerprint, 0x8594_4171_f739_67e8);
1191 }
1192
1193 #[test]
1194 fn nested_negation_and_control_removal_change_the_governed_subtree() {
1195 let mut table = ControlTable::default();
1196 table.upsert(Path::new(".gitignore"), b"*.log\n".to_vec()).expect("root");
1197 table.upsert(Path::new("docs/.gitignore"), b"!keep.log\n".to_vec()).expect("nested");
1198
1199 assert!(table.is_ignored(Path::new("debug.log"), false));
1200 assert!(table.is_ignored(Path::new("docs/other.log"), false));
1201 assert!(!table.is_ignored(Path::new("docs/keep.log"), false));
1202 assert_eq!(
1203 ControlTable::affected_subtree(Path::new("docs/.gitignore")).expect("scope"),
1204 Path::new("docs")
1205 );
1206
1207 table.remove(Path::new("docs/.gitignore")).expect("remove nested");
1208 assert!(table.is_ignored(Path::new("docs/keep.log"), false));
1209 }
1210
1211 #[test]
1212 fn ignored_parent_cannot_be_reincluded_from_inside_it() {
1213 let mut table = ControlTable::default();
1214 table.upsert(Path::new(".gitignore"), b"vendor/\n".to_vec()).expect("root");
1215 table
1216 .upsert(Path::new("vendor/.gitignore"), b"!keep.txt\n".to_vec())
1217 .expect("retained but inactive nested control");
1218
1219 assert!(table.is_ignored(Path::new("vendor"), true));
1220 assert!(table.is_ignored(Path::new("vendor/keep.txt"), false));
1221 }
1222}