1mod gitignore;
35
36use std::collections::{BTreeMap, BTreeSet, HashMap};
37use std::path::{Component, Path, PathBuf};
38use std::sync::Arc;
39
40use gitignore::Gitignore;
41
42pub const CONTROL_FILE_NAME: &str = ".gitignore";
44
45pub const DEFAULT_CONTROL_BUDGET: usize = 4 * 1024 * 1024;
53
54pub const DEFAULT_CONTROL_LINE_LIMIT: usize = 16 * 1024;
61
62#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
73pub struct ControlLimits {
74 pub budget: Option<usize>,
81 pub line_limit: Option<usize>,
84}
85
86impl Default for ControlLimits {
87 fn default() -> Self {
89 Self { budget: Some(DEFAULT_CONTROL_BUDGET), line_limit: Some(DEFAULT_CONTROL_LINE_LIMIT) }
90 }
91}
92
93impl ControlLimits {
94 pub const fn limit_for(self, reason: ControlRefusalReason) -> Option<usize> {
96 match reason {
97 ControlRefusalReason::Budget => self.budget,
98 ControlRefusalReason::LineLimit => self.line_limit,
99 }
100 }
101}
102
103impl std::fmt::Display for ControlLimits {
106 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
107 write!(
108 formatter,
109 "budget {}, line limit {}",
110 limit_display(self.budget),
111 limit_display(self.line_limit)
112 )
113 }
114}
115
116pub(crate) fn limit_display(limit: Option<usize>) -> String {
118 limit.map_or_else(
119 || "all".to_string(),
120 |bytes| crate::report_format::human_bytes(u64::try_from(bytes).unwrap_or(u64::MAX)),
121 )
122}
123
124pub(crate) const CONTROL_SOURCE_OVERHEAD: usize = 64;
126
127#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
129pub struct ControlIdentity {
130 pub bytes: u64,
132 pub fingerprint: u64,
134}
135
136#[derive(Debug)]
138struct SharedContent {
139 bytes: Vec<u8>,
140 identity: ControlIdentity,
141 matcher: Gitignore,
142 content_cost: usize,
144}
145
146#[derive(Clone, Debug)]
151struct Holding {
152 content: Arc<SharedContent>,
153 holders: usize,
154}
155
156#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
158pub enum ControlRefusalReason {
159 Budget,
161 LineLimit,
163}
164
165impl ControlRefusalReason {
166 pub const fn label(self) -> &'static str {
169 match self {
170 Self::Budget => "budget",
171 Self::LineLimit => "line_limit",
172 }
173 }
174}
175
176#[derive(Clone, Copy, PartialEq, Eq, Debug)]
178pub enum ControlAdmission {
179 Retained {
182 changed: bool,
184 },
185 Refused(ControlRefusalReason),
188}
189
190#[derive(Clone, Copy, PartialEq, Eq, Debug)]
192enum Verdict {
193 Unchanged,
195 Admit { retained_cost: usize },
197 Refuse(ControlRefusalReason),
199}
200
201#[derive(Clone, PartialEq, Eq, Debug, Hash)]
203pub struct RefusedControl {
204 pub path: PathBuf,
206 pub reason: ControlRefusalReason,
208}
209
210#[derive(Clone, PartialEq, Eq, Debug)]
216pub enum ControlCoverage {
217 NotObserved,
219 Observed(ControlObservation),
221}
222
223#[derive(Clone, PartialEq, Eq, Debug)]
225pub struct ControlObservation {
226 pub limits: ControlLimits,
228 pub applied: u64,
230 pub rules: u64,
232 pub refused: u64,
234 pub refusals: Vec<RefusedControl>,
237}
238
239impl ControlObservation {
240 pub const fn is_complete(&self) -> bool {
242 self.refused == 0
243 }
244
245 pub fn lists_every_refusal(&self) -> bool {
247 u64::try_from(self.refusals.len()).is_ok_and(|listed| listed == self.refused)
248 }
249}
250
251#[derive(Clone, Debug)]
264pub struct ControlTable {
265 by_directory: BTreeMap<PathBuf, Arc<SharedContent>>,
266 shared: HashMap<ControlIdentity, Vec<Holding>>,
269 refused: BTreeMap<PathBuf, ControlRefusalReason>,
272 limits: ControlLimits,
273 source_bytes: usize,
274 retained_cost: usize,
275}
276
277impl Default for ControlTable {
278 fn default() -> Self {
279 Self::with_limits(ControlLimits::default())
280 }
281}
282
283impl ControlTable {
284 pub(crate) fn with_limits(limits: ControlLimits) -> Self {
286 Self {
287 by_directory: BTreeMap::new(),
288 shared: HashMap::new(),
289 refused: BTreeMap::new(),
290 limits,
291 source_bytes: 0,
292 retained_cost: 0,
293 }
294 }
295
296 pub fn upsert(&mut self, path: &Path, source: Vec<u8>) -> crate::Result<ControlAdmission> {
309 let identity = identity(&source);
310 self.upsert_identified(path, source, identity)
311 }
312
313 fn upsert_identified(
314 &mut self,
315 path: &Path,
316 source: Vec<u8>,
317 identity: ControlIdentity,
318 ) -> crate::Result<ControlAdmission> {
319 let directory = control_directory(path)?;
320 match self.verdict(directory, &source, identity) {
321 Verdict::Unchanged => Ok(ControlAdmission::Retained { changed: false }),
322 Verdict::Refuse(reason) => {
323 crate::counters::bump(|counts| {
324 counts.control_refused = counts.control_refused.saturating_add(1);
325 });
326 Ok(self.refuse(directory, reason))
327 }
328 Verdict::Admit { retained_cost } => {
329 self.detach(directory);
330 self.attach(directory, source, identity);
331 self.refused.remove(directory);
332 debug_assert_eq!(self.retained_cost, retained_cost);
333 Ok(ControlAdmission::Retained { changed: true })
334 }
335 }
336 }
337
338 fn verdict(&self, directory: &Path, source: &[u8], identity: ControlIdentity) -> Verdict {
343 if self.by_directory.get(directory).is_some_and(|current| current.bytes == source) {
344 return Verdict::Unchanged;
345 }
346 if self.limits.line_limit.is_some_and(|line_limit| {
347 source.split(|byte| *byte == b'\n').any(|line| line.len() > line_limit)
348 }) {
349 return Verdict::Refuse(ControlRefusalReason::LineLimit);
350 }
351 let content_charge =
352 if self.holding(identity, source).is_some() { 0 } else { content_cost(source) };
353 let retained_cost = self
356 .retained_cost
357 .saturating_sub(self.release_charge(directory))
358 .saturating_add(directory_cost(directory))
359 .saturating_add(content_charge);
360 if self.limits.budget.is_some_and(|budget| retained_cost > budget) {
361 return Verdict::Refuse(ControlRefusalReason::Budget);
362 }
363 Verdict::Admit { retained_cost }
364 }
365
366 pub(crate) fn upsert_is_inert(&self, path: &Path, source: &[u8]) -> bool {
374 let Ok(directory) = control_directory(path) else {
375 return false;
376 };
377 match self.verdict(directory, source, identity(source)) {
378 Verdict::Unchanged => true,
379 Verdict::Refuse(reason) => self.refused.get(directory) == Some(&reason),
380 Verdict::Admit { .. } => false,
381 }
382 }
383
384 pub(crate) fn remove_is_inert(&self, path: &Path) -> bool {
386 control_directory(path).is_ok_and(|directory| {
387 !self.by_directory.contains_key(directory) && !self.refused.contains_key(directory)
388 })
389 }
390
391 pub(crate) fn has_record_at_or_below(&self, subtree: &Path) -> bool {
396 has_key_at_or_below(&self.by_directory, subtree)
397 || has_key_at_or_below(&self.refused, subtree)
398 }
399
400 pub(crate) fn record_refusal(
402 &mut self,
403 path: &Path,
404 reason: ControlRefusalReason,
405 ) -> crate::Result<()> {
406 let directory = control_directory(path)?;
407 self.refuse(directory, reason);
408 Ok(())
409 }
410
411 fn refuse(&mut self, directory: &Path, reason: ControlRefusalReason) -> ControlAdmission {
412 self.detach(directory);
413 self.refused.insert(directory.to_path_buf(), reason);
414 ControlAdmission::Refused(reason)
415 }
416
417 pub fn remove(&mut self, path: &Path) -> crate::Result<bool> {
423 let directory = control_directory(path)?;
424 let retained = self.detach(directory);
425 let refused = self.refused.remove(directory).is_some();
426 Ok(retained || refused)
427 }
428
429 pub(crate) fn remove_subtree(&mut self, subtree: &Path) {
431 let directories: Vec<PathBuf> = self
432 .by_directory
433 .keys()
434 .filter(|directory| directory.starts_with(subtree))
435 .cloned()
436 .collect();
437 for directory in directories {
438 self.detach(&directory);
439 }
440 self.refused.retain(|directory, _| !directory.starts_with(subtree));
441 }
442
443 fn holding(&self, identity: ControlIdentity, source: &[u8]) -> Option<&Holding> {
445 self.shared
446 .get(&identity)?
447 .iter()
448 .find(|holding| holding.content.bytes.as_slice() == source)
449 }
450
451 fn release_charge(&self, directory: &Path) -> usize {
453 let Some(content) = self.by_directory.get(directory) else {
454 return 0;
455 };
456 let last_holder = self.shared.get(&content.identity).is_some_and(|holdings| {
457 holdings
458 .iter()
459 .any(|holding| Arc::ptr_eq(&holding.content, content) && holding.holders == 1)
460 });
461 directory_cost(directory).saturating_add(if last_holder { content.content_cost } else { 0 })
462 }
463
464 fn detach(&mut self, directory: &Path) -> bool {
466 let Some(content) = self.by_directory.remove(directory) else {
467 return false;
468 };
469 let holdings = self.shared.get_mut(&content.identity).expect("a retained content is held");
470 let position = holdings
471 .iter()
472 .position(|holding| Arc::ptr_eq(&holding.content, &content))
473 .expect("a retained content is listed under its own identity");
474 holdings[position].holders -= 1;
475 if holdings[position].holders == 0 {
476 holdings.swap_remove(position);
477 self.retained_cost -= content.content_cost;
478 if holdings.is_empty() {
479 self.shared.remove(&content.identity);
480 }
481 }
482 self.retained_cost -= directory_cost(directory);
483 self.source_bytes -= content.bytes.len();
484 true
485 }
486
487 fn attach(&mut self, directory: &Path, source: Vec<u8>, identity: ControlIdentity) {
489 let holdings = self.shared.entry(identity).or_default();
490 let content = if let Some(holding) =
491 holdings.iter_mut().find(|holding| holding.content.bytes == source)
492 {
493 holding.holders += 1;
494 crate::counters::bump(|counts| {
495 counts.control_sources_shared = counts.control_sources_shared.saturating_add(1);
496 });
497 Arc::clone(&holding.content)
498 } else {
499 let content_cost = content_cost(&source);
500 let matcher = Gitignore::parse(&source);
501 let content =
502 Arc::new(SharedContent { bytes: source, identity, matcher, content_cost });
503 holdings.push(Holding { content: Arc::clone(&content), holders: 1 });
504 self.retained_cost += content_cost;
505 content
506 };
507 self.retained_cost += directory_cost(directory);
508 self.source_bytes += content.bytes.len();
509 self.by_directory.insert(directory.to_path_buf(), content);
510 }
511
512 pub fn matcher_for<'a>(&'a self, path: &'a Path) -> ControlMatcher<'a> {
514 ControlMatcher { table: self, path }
515 }
516
517 pub(crate) fn chain_for(&self, directory: &Path) -> ControlChain {
528 debug_assert!(
529 directory.components().all(|component| matches!(component, Component::Normal(_))),
530 "control chains are resolved for normalized relative directories: {}",
531 directory.display()
532 );
533 let mut governing = Vec::new();
534 if !self.by_directory.is_empty() {
535 let depth = gitignore::with_components(directory, None, |components| components.len());
536 for (up, ancestor) in directory.ancestors().enumerate() {
537 if let Some(source) = self.by_directory.get(ancestor) {
538 governing.push((depth.saturating_sub(up), Arc::clone(source)));
539 }
540 }
541 }
542 ControlChain { governing }
543 }
544
545 pub fn is_ignored(&self, path: &Path, is_dir: bool) -> bool {
551 let components: Vec<_> = path.components().collect();
552 let mut current = PathBuf::new();
553 let mut parent_ignored = false;
554 for (position, component) in components.iter().enumerate() {
555 current.push(component.as_os_str());
556 if parent_ignored {
557 return true;
558 }
559 let current_is_dir = position + 1 < components.len() || is_dir;
560 parent_ignored = self.matcher_for(¤t).is_ignored(current_is_dir);
561 }
562 parent_ignored
563 }
564
565 pub fn affected_subtree(path: &Path) -> crate::Result<PathBuf> {
567 Ok(control_directory(path)?.to_path_buf())
568 }
569
570 pub(crate) fn changes_from(
572 &self,
573 previous: &Self,
574 ) -> Vec<(PathBuf, Option<ControlIdentity>, Option<ControlIdentity>)> {
575 let directories: BTreeSet<&Path> = previous
576 .by_directory
577 .keys()
578 .chain(self.by_directory.keys())
579 .map(PathBuf::as_path)
580 .collect();
581 directories
582 .into_iter()
583 .filter_map(|directory| {
584 let before = previous.by_directory.get(directory);
585 let after = self.by_directory.get(directory);
586 let changed = match (before, after) {
587 (Some(before), Some(after)) => {
588 !Arc::ptr_eq(before, after) && before.bytes != after.bytes
589 }
590 (None, None) => false,
591 (Some(_), None) | (None, Some(_)) => true,
592 };
593 changed.then(|| {
594 (
595 control_path(directory),
596 before.map(|source| source.identity),
597 after.map(|source| source.identity),
598 )
599 })
600 })
601 .collect()
602 }
603
604 pub(crate) fn refusal_changes_from(
606 &self,
607 previous: &Self,
608 ) -> Vec<(PathBuf, Option<ControlRefusalReason>, Option<ControlRefusalReason>)> {
609 let directories: BTreeSet<&Path> =
610 previous.refused.keys().chain(self.refused.keys()).map(PathBuf::as_path).collect();
611 directories
612 .into_iter()
613 .filter_map(|directory| {
614 let before = previous.refused.get(directory).copied();
615 let after = self.refused.get(directory).copied();
616 (before != after).then(|| (control_path(directory), before, after))
617 })
618 .collect()
619 }
620
621 pub(crate) fn sources(&self) -> impl ExactSizeIterator<Item = (PathBuf, &[u8])> {
623 self.by_directory
624 .iter()
625 .map(|(directory, source)| (control_path(directory), source.bytes.as_slice()))
626 }
627
628 pub fn refusals(&self) -> impl ExactSizeIterator<Item = RefusedControl> + '_ {
630 self.refused.iter().map(|(directory, reason)| RefusedControl {
631 path: control_path(directory),
632 reason: *reason,
633 })
634 }
635
636 pub(crate) fn classification_known(&self, path: &Path) -> bool {
641 !path
642 .parent()
643 .into_iter()
644 .flat_map(Path::ancestors)
645 .any(|directory| self.refused.contains_key(directory))
646 }
647
648 pub fn refused_len(&self) -> usize {
650 self.refused.len()
651 }
652
653 pub const fn limits(&self) -> ControlLimits {
655 self.limits
656 }
657
658 pub fn observation(&self) -> ControlObservation {
660 ControlObservation {
661 limits: self.limits,
662 applied: u64::try_from(self.len()).unwrap_or(u64::MAX),
663 rules: self
664 .by_directory
665 .values()
666 .fold(0_u64, |total, content| total.saturating_add(content.matcher.rule_count())),
667 refused: u64::try_from(self.refused_len()).unwrap_or(u64::MAX),
668 refusals: self.refusals().take(crate::MAX_RETAINED_ISSUES).collect(),
669 }
670 }
671
672 pub const fn source_bytes(&self) -> usize {
674 self.source_bytes
675 }
676
677 pub const fn retained_cost(&self) -> usize {
679 self.retained_cost
680 }
681
682 pub fn source_is(&self, path: &Path, source: &[u8]) -> bool {
684 control_directory(path)
685 .ok()
686 .and_then(|directory| self.by_directory.get(directory))
687 .is_some_and(|current| current.bytes == source)
688 }
689
690 pub(crate) fn contains(&self, path: &Path) -> bool {
695 control_directory(path).ok().is_some_and(|directory| {
696 self.by_directory.contains_key(directory) || self.refused.contains_key(directory)
697 })
698 }
699
700 pub fn len(&self) -> usize {
702 self.by_directory.len()
703 }
704
705 pub fn is_empty(&self) -> bool {
707 self.by_directory.is_empty()
708 }
709
710 pub(crate) fn is_vacant(&self) -> bool {
712 self.by_directory.is_empty() && self.refused.is_empty()
713 }
714}
715
716pub struct ControlMatcher<'a> {
718 table: &'a ControlTable,
719 path: &'a Path,
720}
721
722impl ControlMatcher<'_> {
723 pub fn is_ignored(&self, is_dir: bool) -> bool {
729 if self.table.by_directory.is_empty() {
734 return false;
735 }
736 for directory in self.path.parent().into_iter().flat_map(Path::ancestors) {
740 let Some(source) = self.table.by_directory.get(directory) else {
741 continue;
742 };
743 let relative = self.path.strip_prefix(directory).unwrap_or(self.path);
744 if let Some(ignored) = source.matcher.matches(relative, is_dir) {
745 return ignored;
746 }
747 }
748 false
749 }
750}
751
752#[derive(Clone, Debug, Default)]
755pub(crate) struct ControlChain {
756 governing: Vec<(usize, Arc<SharedContent>)>,
757}
758
759impl ControlChain {
760 pub(crate) fn is_ignored(&self, directory: &Path, name: &[u8], is_dir: bool) -> bool {
766 if self.governing.is_empty() {
767 return false;
768 }
769 gitignore::with_components(directory, Some(name), |components| {
770 self.governing
771 .iter()
772 .find_map(|(leading, source)| {
773 let relative = components.get(*leading..).unwrap_or_default();
774 source.matcher.matches_components(relative, is_dir)
775 })
776 .unwrap_or(false)
777 })
778 }
779}
780
781fn has_key_at_or_below<V>(directories: &BTreeMap<PathBuf, V>, subtree: &Path) -> bool {
787 directories
788 .range::<Path, _>((std::ops::Bound::Included(subtree), std::ops::Bound::Unbounded))
789 .next()
790 .is_some_and(|(directory, _)| directory.starts_with(subtree))
791}
792
793pub fn is_control_file(path: &Path) -> bool {
800 path.file_name().is_some_and(|name| name == CONTROL_FILE_NAME)
801}
802
803#[derive(Clone, Copy, PartialEq, Eq, Debug)]
805pub(crate) enum ControlSpelling {
806 Exact,
809 Variant,
813}
814
815pub(crate) fn control_spelling(name: &std::ffi::OsStr) -> Option<ControlSpelling> {
820 let bytes = name.as_encoded_bytes();
821 let exact = CONTROL_FILE_NAME.as_bytes();
822 if bytes.len() != exact.len() {
823 None
824 } else if bytes == exact {
825 Some(ControlSpelling::Exact)
826 } else if bytes.eq_ignore_ascii_case(exact) {
827 Some(ControlSpelling::Variant)
828 } else {
829 None
830 }
831}
832
833pub(crate) fn path_control_spelling(path: &Path) -> Option<ControlSpelling> {
835 path.file_name().and_then(control_spelling)
836}
837
838pub(crate) fn sibling_control_path(path: &Path) -> PathBuf {
840 control_path(path.parent().unwrap_or_else(|| Path::new("")))
841}
842
843pub(crate) fn unreadable_control(root: &Path, error: &crate::Error) -> Option<PathBuf> {
852 let crate::Error::Io { .. } = error else {
853 return None;
854 };
855 crate::Issue::from_error_under(root, error).path.and_then(|path| governing_control(&path))
856}
857
858pub(crate) fn governing_control(path: &Path) -> Option<PathBuf> {
861 path_control_spelling(path).map(|_| sibling_control_path(path))
862}
863
864fn control_directory(path: &Path) -> crate::Result<&Path> {
865 if !is_control_file(path) {
866 return Err(crate::Error::InvalidControlPath(path.to_path_buf()));
867 }
868 Ok(path.parent().unwrap_or_else(|| Path::new("")))
869}
870
871fn control_path(directory: &Path) -> PathBuf {
872 directory.join(CONTROL_FILE_NAME)
873}
874
875fn identity(bytes: &[u8]) -> ControlIdentity {
876 const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
877 const FNV_PRIME: u64 = 0x100_0000_01b3;
878
879 let mut fingerprint = FNV_OFFSET_BASIS;
880 for byte in bytes {
881 fingerprint ^= u64::from(*byte);
882 fingerprint = fingerprint.wrapping_mul(FNV_PRIME);
883 }
884 ControlIdentity { bytes: u64::try_from(bytes.len()).unwrap_or(u64::MAX), fingerprint }
885}
886
887fn directory_cost(directory: &Path) -> usize {
889 CONTROL_SOURCE_OVERHEAD.saturating_add(directory.as_os_str().as_encoded_bytes().len())
890}
891
892fn content_cost(source: &[u8]) -> usize {
895 let (newlines, segment_shells) = source.iter().fold((0usize, 0usize), |counts, byte| {
896 (counts.0 + usize::from(*byte == b'\n'), counts.1 + usize::from(*byte == b'/'))
897 });
898 let pattern_shells = newlines.saturating_add(1);
899 source
900 .len()
901 .saturating_mul(2)
902 .saturating_add(pattern_shells.saturating_mul(64))
903 .saturating_add(segment_shells.saturating_mul(24))
904}
905
906#[cfg(test)]
908fn retained_source_cost(directory: &Path, source: &[u8]) -> usize {
909 directory_cost(directory).saturating_add(content_cost(source))
910}
911
912#[cfg(test)]
913pub(crate) fn source_at_test_limit() -> Vec<u8> {
914 let mut source = Vec::new();
915 loop {
916 let previous_len = source.len();
917 source.extend(std::iter::repeat_n(b'a', DEFAULT_CONTROL_LINE_LIMIT));
918 source.push(b'\n');
919 if retained_source_cost(Path::new(""), &source) > DEFAULT_CONTROL_BUDGET {
920 source.truncate(previous_len);
921 break;
922 }
923 }
924 let remaining = DEFAULT_CONTROL_BUDGET - retained_source_cost(Path::new(""), &source);
925 source.extend(std::iter::repeat_n(b'a', (remaining / 2).min(DEFAULT_CONTROL_LINE_LIMIT)));
926 assert_eq!(retained_source_cost(Path::new(""), &source), DEFAULT_CONTROL_BUDGET);
927 source
928}
929
930#[cfg(test)]
931impl ControlTable {
932 fn assert_consistent(&self) {
934 let mut distinct: Vec<&Arc<SharedContent>> = Vec::new();
935 let mut directory_charges = 0;
936 let mut source_bytes = 0;
937 for (directory, content) in &self.by_directory {
938 directory_charges += directory_cost(directory);
939 source_bytes += content.bytes.len();
940 if !distinct.iter().any(|seen| Arc::ptr_eq(seen, content)) {
941 distinct.push(content);
942 }
943 }
944 let content_charges: usize = distinct.iter().map(|content| content.content_cost).sum();
945 assert_eq!(self.retained_cost, directory_charges + content_charges, "retained cost");
946 assert_eq!(self.source_bytes, source_bytes, "source bytes");
947 let holdings: usize = self.shared.values().map(Vec::len).sum();
948 assert_eq!(holdings, distinct.len(), "one holding per distinct content");
949 for (identity, holdings) in &self.shared {
950 assert!(!holdings.is_empty(), "no empty identity list survives");
951 for holding in holdings {
952 assert_eq!(holding.content.identity, *identity);
953 let holders = self
954 .by_directory
955 .values()
956 .filter(|content| Arc::ptr_eq(content, &holding.content))
957 .count();
958 assert_eq!(holding.holders, holders, "holder count");
959 }
960 for (index, left) in holdings.iter().enumerate() {
961 for right in &holdings[index + 1..] {
962 assert_ne!(left.content.bytes, right.content.bytes, "equal bytes are shared");
963 }
964 }
965 }
966 assert!(
967 self.refused.keys().all(|directory| !self.by_directory.contains_key(directory)),
968 "a refused directory retains no source"
969 );
970 assert!(
971 self.limits.budget.is_none_or(|budget| self.retained_cost <= budget),
972 "within budget"
973 );
974 assert!(
975 self.limits.line_limit.is_none_or(|line_limit| {
976 distinct.iter().all(|content| {
977 content.bytes.split(|byte| *byte == b'\n').all(|line| line.len() <= line_limit)
978 })
979 }),
980 "within the line limit"
981 );
982 }
983}
984
985#[cfg(test)]
986mod tests {
987 use super::*;
988
989 struct SplitMix(u64);
991
992 impl SplitMix {
993 fn next(&mut self) -> u64 {
994 self.0 = self.0.wrapping_add(0x9e37_79b9_7f4a_7c15);
995 let mut value = self.0;
996 value = (value ^ (value >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
997 value = (value ^ (value >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
998 value ^ (value >> 31)
999 }
1000
1001 fn below(&mut self, bound: usize) -> usize {
1002 usize::try_from(self.next() % u64::try_from(bound).expect("small bound")).expect("fits")
1003 }
1004 }
1005
1006 #[test]
1011 fn rule_totals_count_accepted_patterns_per_governing_location() {
1012 let mut table = ControlTable::default();
1013 let source = b"# comment\n\n*.log\n!important.log\n*.log\n[bad\n";
1014 table.upsert(Path::new(".gitignore"), source.to_vec()).expect("root control");
1015 table.upsert(Path::new("nested/.gitignore"), source.to_vec()).expect("nested control");
1016 assert_eq!(table.observation().applied, 2);
1017 assert_eq!(table.observation().rules, 6);
1018 table
1019 .upsert(Path::new("nested/.gitignore"), b"# empty\n".to_vec())
1020 .expect("replacement control");
1021 assert_eq!(table.observation().applied, 2);
1022 assert_eq!(table.observation().rules, 3);
1023 }
1024
1025 #[test]
1026 fn charges_and_refusals_stay_exact_through_random_upserts_and_removals() {
1027 const DIRECTORIES: [&str; 6] = ["", "a", "a/b", "b", "b/c/d", "c"];
1028 let long_line = [vec![b'x'; DEFAULT_CONTROL_LINE_LIMIT + 1], b"\n".to_vec()].concat();
1029 let large = b"pattern/\n".repeat(40);
1030 let contents: [&[u8]; 6] =
1031 [b"*.log\n", b"target/\n", b"!keep\n*.tmp\n", b"", &large, &long_line];
1032 let budget = 2 * retained_source_cost(Path::new("b/c/d"), &large);
1034 for seed in 0..64 {
1035 let mut random = SplitMix(seed);
1036 let limits = ControlLimits {
1037 budget: (seed % 2 == 0).then_some(budget),
1038 line_limit: (seed % 4 < 2).then_some(DEFAULT_CONTROL_LINE_LIMIT),
1039 };
1040 let mut table = ControlTable::with_limits(limits);
1041 let mut holds_record: BTreeMap<&Path, bool> = BTreeMap::new();
1042 for step in 0..300 {
1043 let directory = Path::new(DIRECTORIES[random.below(DIRECTORIES.len())]);
1044 let path = directory.join(CONTROL_FILE_NAME);
1045 match random.below(8) {
1046 0 => {
1047 table.remove_subtree(directory);
1048 for (held, record) in &mut holds_record {
1049 if held.starts_with(directory) {
1050 *record = false;
1051 }
1052 }
1053 }
1054 1 | 2 => {
1055 table.remove(&path).expect("control path");
1056 holds_record.insert(directory, false);
1057 }
1058 _ => {
1059 let content = contents[random.below(contents.len())].to_vec();
1060 let admission = table.upsert(&path, content.clone()).expect("control path");
1061 if content == long_line && limits.line_limit.is_some() {
1062 assert_eq!(
1063 admission,
1064 ControlAdmission::Refused(ControlRefusalReason::LineLimit)
1065 );
1066 }
1067 if limits.budget.is_none() && limits.line_limit.is_none() {
1068 assert!(
1069 matches!(admission, ControlAdmission::Retained { .. }),
1070 "an unbounded table refuses nothing"
1071 );
1072 }
1073 holds_record.insert(directory, true);
1074 }
1075 }
1076 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1077 table.assert_consistent();
1078 for (directory, record) in &holds_record {
1079 let path = directory.join(CONTROL_FILE_NAME);
1080 assert_eq!(table.contains(&path), *record, "{}", path.display());
1081 }
1082 let records = holds_record.values().filter(|record| **record).count();
1083 assert_eq!(table.len() + table.refused_len(), records);
1084 }))
1085 .unwrap_or_else(|_| panic!("seed {seed}, step {step}: inconsistent table"));
1086 }
1087 for directory in DIRECTORIES {
1088 table.remove(&Path::new(directory).join(CONTROL_FILE_NAME)).expect("control path");
1089 }
1090 assert_eq!(table.retained_cost(), 0, "seed {seed}");
1091 assert_eq!(table.source_bytes(), 0, "seed {seed}");
1092 assert!(table.shared.is_empty(), "seed {seed}");
1093 assert!(table.is_vacant(), "seed {seed}");
1094 }
1095 }
1096
1097 #[test]
1098 fn a_resolved_chain_answers_as_the_per_entry_matcher_does() {
1099 let mut table = ControlTable::default();
1100 for (path, source) in [
1101 (".gitignore", &b"*.log\n/build/\n!keep.log\nsub/*.tmp\n"[..]),
1102 ("a/.gitignore", b"!*.log\n*.o\n/deep/**\n"),
1103 ("a/b/.gitignore", b"*.log\n!x.o\n"),
1104 ("c/.gitignore", b"# comment only\n"),
1105 ] {
1106 table.upsert(Path::new(path), source.to_vec()).expect("fixture control");
1107 }
1108 let directories =
1109 ["", "a", "a/b", "a/b/c", "a/deep", "a/deep/er", "build", "c", "c/sub", "sub", "x/y"];
1110 let names = ["x.log", "keep.log", "x.o", "y.o", "build", "deep", "t.tmp", "plain"];
1111 for directory in directories {
1112 let chain = table.chain_for(Path::new(directory));
1113 for name in names {
1114 for is_dir in [false, true] {
1115 let path = Path::new(directory).join(name);
1116 assert_eq!(
1117 chain.is_ignored(Path::new(directory), name.as_bytes(), is_dir),
1118 table.matcher_for(&path).is_ignored(is_dir),
1119 "{} (dir {is_dir})",
1120 path.display()
1121 );
1122 }
1123 }
1124 }
1125 assert!(!ControlTable::default().chain_for(Path::new("a")).is_ignored(
1126 Path::new("a"),
1127 b"x.log",
1128 false
1129 ));
1130 }
1131
1132 #[test]
1133 fn a_chain_agrees_past_the_inline_buffers_and_beside_unrelated_controls() {
1134 let mut table = ControlTable::default();
1135 table.upsert(Path::new("z/.gitignore"), b"*.log\n".to_vec()).expect("unrelated");
1136 let unrelated = table.chain_for(Path::new("a/b"));
1137 assert!(!unrelated.is_ignored(Path::new("a/b"), b"x.log", false));
1138 assert!(!table.matcher_for(Path::new("a/b/x.log")).is_ignored(false));
1139
1140 let deep: PathBuf = (0..33).map(|at| format!("d{at}")).collect();
1143 table.upsert(Path::new(".gitignore"), b"**/x.log\n/d0/**/y.log\n".to_vec()).expect("root");
1144 table.upsert(&deep.join(".gitignore"), b"!x.log\n*.tmp\n".to_vec()).expect("deep");
1145 for depth in [31usize, 32, 33, 34] {
1146 let directory: PathBuf = (0..depth).map(|at| format!("d{at}")).collect();
1147 let chain = table.chain_for(&directory);
1148 for name in ["x.log", "y.log", "z.tmp", "plain"] {
1149 let path = directory.join(name);
1150 assert_eq!(
1151 chain.is_ignored(&directory, name.as_bytes(), false),
1152 table.matcher_for(&path).is_ignored(false),
1153 "{name} at depth {depth}"
1154 );
1155 }
1156 }
1157 }
1158
1159 #[test]
1160 fn a_fingerprint_collision_never_shares_a_matcher() {
1161 let collision = ControlIdentity { bytes: 6, fingerprint: 7 };
1162 let mut table = ControlTable::default();
1163 table
1164 .upsert_identified(Path::new("a/.gitignore"), b"*.log\n".to_vec(), collision)
1165 .expect("first");
1166 table
1167 .upsert_identified(Path::new("b/.gitignore"), b"*.tmp\n".to_vec(), collision)
1168 .expect("second");
1169 table.assert_consistent();
1170
1171 assert_eq!(table.shared[&collision].len(), 2);
1172 assert!(table.is_ignored(Path::new("a/x.log"), false));
1173 assert!(!table.is_ignored(Path::new("a/x.tmp"), false));
1174 assert!(table.is_ignored(Path::new("b/x.tmp"), false));
1175 assert!(!table.is_ignored(Path::new("b/x.log"), false));
1176 assert_eq!(
1177 table.retained_cost(),
1178 retained_source_cost(Path::new("a"), b"*.log\n")
1179 + retained_source_cost(Path::new("b"), b"*.tmp\n")
1180 );
1181
1182 table.remove(Path::new("a/.gitignore")).expect("remove");
1183 table.assert_consistent();
1184 assert!(table.is_ignored(Path::new("b/x.tmp"), false));
1185 }
1186
1187 const CHANGED: ControlAdmission = ControlAdmission::Retained { changed: true };
1188 const UNCHANGED: ControlAdmission = ControlAdmission::Retained { changed: false };
1189 const OVER_BUDGET: ControlAdmission = ControlAdmission::Refused(ControlRefusalReason::Budget);
1190 const OVER_LINE_LIMIT: ControlAdmission =
1191 ControlAdmission::Refused(ControlRefusalReason::LineLimit);
1192
1193 fn budgeted(budget: Option<usize>) -> ControlTable {
1195 ControlTable::with_limits(ControlLimits { budget, ..ControlLimits::default() })
1196 }
1197
1198 #[test]
1199 fn replacing_the_last_holder_releases_its_content_for_the_bound() {
1200 let mut table = ControlTable::default();
1201 let first = source_at_test_limit();
1202 assert_eq!(
1203 table.upsert(Path::new(".gitignore"), first.clone()).expect("control path"),
1204 CHANGED
1205 );
1206 assert_eq!(table.upsert(Path::new("copy/.gitignore"), first).expect("path"), OVER_BUDGET);
1208 assert_eq!(
1209 table.upsert(Path::new(".gitignore"), b"small\n".to_vec()).expect("control path"),
1210 CHANGED
1211 );
1212 table.assert_consistent();
1213 assert_eq!(table.retained_cost(), retained_source_cost(Path::new(""), b"small\n"));
1214 }
1215
1216 #[test]
1217 fn creation_edit_and_last_removal_are_exact() {
1218 let mut table = ControlTable::default();
1219 assert_eq!(
1220 table.upsert(Path::new(".gitignore"), b"*.log\n".to_vec()).expect("control path"),
1221 CHANGED
1222 );
1223 let original = table.clone();
1224 assert_eq!(
1225 table.upsert(Path::new(".gitignore"), b"*.log\n".to_vec()).expect("control path"),
1226 UNCHANGED
1227 );
1228 assert_eq!(
1229 table.upsert(Path::new(".gitignore"), b"*.tmp\n".to_vec()).expect("control path"),
1230 CHANGED
1231 );
1232 assert_eq!(table.changes_from(&original).len(), 1);
1233 assert!(table.remove(Path::new(".gitignore")).expect("remove"));
1234 assert!(table.is_empty());
1235 assert_eq!(table.source_bytes(), 0);
1236 assert!(!table.remove(Path::new(".gitignore")).expect("missing is a no-op"));
1237 }
1238
1239 #[test]
1241 fn the_budget_admits_its_own_size_and_refuses_one_byte_over_it() {
1242 let source = b"*.log\n".to_vec();
1243 let exact = retained_source_cost(Path::new("a"), &source);
1244 let mut at_budget = budgeted(Some(exact));
1245 assert_eq!(
1246 at_budget.upsert(Path::new("a/.gitignore"), source.clone()).expect("control path"),
1247 CHANGED
1248 );
1249 assert_eq!(at_budget.retained_cost(), exact);
1250
1251 let mut under_budget = budgeted(Some(exact - 1));
1252 assert_eq!(
1253 under_budget.upsert(Path::new("a/.gitignore"), source).expect("control path"),
1254 OVER_BUDGET
1255 );
1256 assert_eq!(under_budget.retained_cost(), 0);
1257 assert!(under_budget.contains(Path::new("a/.gitignore")));
1258 assert_eq!(
1259 under_budget.observation(),
1260 ControlObservation {
1261 limits: ControlLimits {
1262 budget: Some(exact - 1),
1263 line_limit: Some(DEFAULT_CONTROL_LINE_LIMIT),
1264 },
1265 applied: 0,
1266 rules: 0,
1267 refused: 1,
1268 refusals: vec![RefusedControl {
1269 path: PathBuf::from("a/.gitignore"),
1270 reason: ControlRefusalReason::Budget,
1271 }],
1272 }
1273 );
1274 }
1275
1276 #[test]
1277 fn a_refused_source_drops_the_rules_it_replaces_and_freed_budget_admits_it_later() {
1278 let mut table = ControlTable::default();
1279 let first = source_at_test_limit();
1280 assert_eq!(
1281 table.upsert(Path::new(".gitignore"), first.clone()).expect("control path"),
1282 CHANGED
1283 );
1284 assert_eq!(
1285 table
1286 .upsert(Path::new("nested/.gitignore"), b"*.log\n".to_vec())
1287 .expect("control path"),
1288 OVER_BUDGET
1289 );
1290 let before = table.clone();
1291
1292 let mut grown = first;
1295 grown.push(b'\n');
1296 assert_eq!(
1297 table.upsert(Path::new(".gitignore"), grown).expect("control path"),
1298 OVER_BUDGET
1299 );
1300 assert!(table.is_empty());
1301 assert_eq!(table.changes_from(&before).len(), 1);
1302 assert_eq!(
1303 table.refusal_changes_from(&before),
1304 vec![(PathBuf::from(".gitignore"), None, Some(ControlRefusalReason::Budget))]
1305 );
1306
1307 assert_eq!(
1309 table
1310 .upsert(Path::new("nested/.gitignore"), b"*.log\n".to_vec())
1311 .expect("control path"),
1312 CHANGED
1313 );
1314 assert_eq!(table.refused_len(), 1);
1315 assert!(table.remove(Path::new(".gitignore")).expect("remove refused"));
1317 assert_eq!(table.refused_len(), 0);
1318 table.assert_consistent();
1319 }
1320
1321 #[test]
1322 fn identical_sources_share_one_content_charge() {
1323 let source = b"target/\n*.log\nnode_modules/\n".to_vec();
1324 let mut table = ControlTable::default();
1325 table.upsert(Path::new("a/.gitignore"), source.clone()).expect("first holder");
1326 let one = table.retained_cost();
1327 table.upsert(Path::new("bb/.gitignore"), source.clone()).expect("second holder");
1328
1329 assert_eq!(table.retained_cost() - one, CONTROL_SOURCE_OVERHEAD + "bb".len());
1331 assert_eq!(table.source_bytes(), 2 * source.len());
1332 assert!(table.is_ignored(Path::new("a/debug.log"), false));
1333 assert!(table.is_ignored(Path::new("bb/debug.log"), false));
1334 }
1335
1336 #[test]
1339 fn the_line_limit_admits_its_own_length_and_refuses_one_byte_over_it() {
1340 let mut table = ControlTable::default();
1341 let at_limit = vec![b'a'; DEFAULT_CONTROL_LINE_LIMIT];
1342 assert_eq!(
1343 table.upsert(Path::new("a/.gitignore"), at_limit).expect("control path"),
1344 CHANGED
1345 );
1346 let over = [b"*.log\n".as_slice(), &vec![b'a'; DEFAULT_CONTROL_LINE_LIMIT + 1]].concat();
1347 assert_eq!(
1348 table.upsert(Path::new("b/.gitignore"), over).expect("control path"),
1349 OVER_LINE_LIMIT
1350 );
1351 assert!(!table.is_ignored(Path::new("b/debug.log"), false), "no rule of it applies");
1352 assert_eq!(
1353 table.refusals().collect::<Vec<_>>(),
1354 vec![RefusedControl {
1355 path: PathBuf::from("b/.gitignore"),
1356 reason: ControlRefusalReason::LineLimit,
1357 }]
1358 );
1359 }
1360
1361 #[test]
1364 fn the_budget_and_the_line_limit_lift_independently() {
1365 let long = [b"*.log\n".as_slice(), &vec![b'a'; DEFAULT_CONTROL_LINE_LIMIT + 1]].concat();
1366 let large = source_at_test_limit();
1367
1368 let mut no_budget = budgeted(None);
1369 assert_eq!(
1370 no_budget.upsert(Path::new("big/.gitignore"), large.clone()).expect("path"),
1371 CHANGED
1372 );
1373 assert_eq!(
1374 no_budget.upsert(Path::new("c/.gitignore"), b"*.tmp\n".to_vec()).expect("path"),
1375 CHANGED
1376 );
1377 assert!(no_budget.retained_cost() > DEFAULT_CONTROL_BUDGET);
1378 assert_eq!(
1379 no_budget.upsert(Path::new(".gitignore"), long.clone()).expect("path"),
1380 OVER_LINE_LIMIT
1381 );
1382
1383 let mut raised_budget = budgeted(Some(16 * DEFAULT_CONTROL_BUDGET));
1384 assert_eq!(
1385 raised_budget.upsert(Path::new(".gitignore"), long.clone()).expect("path"),
1386 OVER_LINE_LIMIT
1387 );
1388
1389 let mut no_line_limit = ControlTable::with_limits(ControlLimits {
1390 line_limit: None,
1391 ..ControlLimits::default()
1392 });
1393 assert_eq!(no_line_limit.upsert(Path::new(".gitignore"), long).expect("path"), CHANGED);
1394 assert!(no_line_limit.is_ignored(Path::new("debug.log"), false));
1395 assert_eq!(
1396 no_line_limit.upsert(Path::new("big/.gitignore"), large).expect("path"),
1397 OVER_BUDGET
1398 );
1399 no_line_limit.assert_consistent();
1400 }
1401
1402 #[test]
1403 fn a_listing_of_refusals_is_bounded_and_says_when_it_is_truncated() {
1404 let mut table = budgeted(Some(0));
1405 let refused = crate::MAX_RETAINED_ISSUES + 1;
1406 for directory in 0..refused {
1407 let path = PathBuf::from(format!("d{directory:03}/.gitignore"));
1408 assert_eq!(table.upsert(&path, b"*\n".to_vec()).expect("control path"), OVER_BUDGET);
1409 }
1410 let observation = table.observation();
1411 assert_eq!(observation.refused, u64::try_from(refused).expect("small"));
1412 assert_eq!(observation.refusals.len(), crate::MAX_RETAINED_ISSUES);
1413 assert_eq!(observation.refusals[0].path, Path::new("d000/.gitignore"));
1414 assert!(!observation.lists_every_refusal());
1415 assert!(!observation.is_complete());
1416 }
1417
1418 #[test]
1419 fn control_identity_uses_standard_fnv1a_vectors() {
1420 assert_eq!(identity(b"").fingerprint, 0xcbf2_9ce4_8422_2325);
1421 assert_eq!(identity(b"a").fingerprint, 0xaf63_dc4c_8601_ec8c);
1422 assert_eq!(identity(b"foobar").fingerprint, 0x8594_4171_f739_67e8);
1423 }
1424
1425 #[test]
1426 fn nested_negation_and_control_removal_change_the_governed_subtree() {
1427 let mut table = ControlTable::default();
1428 table.upsert(Path::new(".gitignore"), b"*.log\n".to_vec()).expect("root");
1429 table.upsert(Path::new("docs/.gitignore"), b"!keep.log\n".to_vec()).expect("nested");
1430
1431 assert!(table.is_ignored(Path::new("debug.log"), false));
1432 assert!(table.is_ignored(Path::new("docs/other.log"), false));
1433 assert!(!table.is_ignored(Path::new("docs/keep.log"), false));
1434 assert_eq!(
1435 ControlTable::affected_subtree(Path::new("docs/.gitignore")).expect("scope"),
1436 Path::new("docs")
1437 );
1438
1439 table.remove(Path::new("docs/.gitignore")).expect("remove nested");
1440 assert!(table.is_ignored(Path::new("docs/keep.log"), false));
1441 }
1442
1443 #[test]
1444 fn ignored_parent_cannot_be_reincluded_from_inside_it() {
1445 let mut table = ControlTable::default();
1446 table.upsert(Path::new(".gitignore"), b"vendor/\n".to_vec()).expect("root");
1447 table
1448 .upsert(Path::new("vendor/.gitignore"), b"!keep.txt\n".to_vec())
1449 .expect("retained but inactive nested control");
1450
1451 assert!(table.is_ignored(Path::new("vendor"), true));
1452 assert!(table.is_ignored(Path::new("vendor/keep.txt"), false));
1453 }
1454
1455 #[test]
1459 fn a_control_name_is_spelled_in_any_ascii_case_and_recorded_by_one_path() {
1460 use std::ffi::OsStr;
1461
1462 assert_eq!(control_spelling(OsStr::new(".gitignore")), Some(ControlSpelling::Exact));
1463 for variant in [".GITIGNORE", ".GitIgnore", ".gitIGNORE", ".gitignorE"] {
1464 assert_eq!(control_spelling(OsStr::new(variant)), Some(ControlSpelling::Variant));
1465 }
1466 for other in [".gıtıgnore", ".gitignor", ".gitignore~", "gitignore.", ".gitignor3", ""] {
1469 assert_eq!(control_spelling(OsStr::new(other)), None, "{other:?}");
1470 }
1471 #[cfg(unix)]
1472 {
1473 use std::os::unix::ffi::OsStrExt as _;
1474 assert_eq!(control_spelling(OsStr::from_bytes(b".gitignor\xff")), None);
1475 }
1476
1477 assert_eq!(
1478 governing_control(Path::new("a/.GITIGNORE")),
1479 Some(PathBuf::from("a/.gitignore"))
1480 );
1481 assert_eq!(governing_control(Path::new(".gitignore")), Some(PathBuf::from(".gitignore")));
1482 assert_eq!(governing_control(Path::new("a/README")), None);
1483 assert!(is_control_file(Path::new("a/.gitignore")));
1484 assert!(!is_control_file(Path::new("a/.GITIGNORE")), "operations name one path");
1485 let mut table = ControlTable::default();
1486 assert!(table.upsert(Path::new("a/.GITIGNORE"), b"*.log\n".to_vec()).is_err());
1487 }
1488}