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 by_directory_bytes: HashMap<std::ffi::OsString, Arc<SharedContent>>,
272 shared: HashMap<ControlIdentity, Vec<Holding>>,
275 refused: BTreeMap<PathBuf, ControlRefusalReason>,
278 limits: ControlLimits,
279 source_bytes: usize,
280 retained_cost: usize,
281}
282
283impl Default for ControlTable {
284 fn default() -> Self {
285 Self::with_limits(ControlLimits::default())
286 }
287}
288
289impl ControlTable {
290 pub(crate) fn with_limits(limits: ControlLimits) -> Self {
292 Self {
293 by_directory: BTreeMap::new(),
294 by_directory_bytes: HashMap::new(),
295 shared: HashMap::new(),
296 refused: BTreeMap::new(),
297 limits,
298 source_bytes: 0,
299 retained_cost: 0,
300 }
301 }
302
303 pub fn upsert(&mut self, path: &Path, source: Vec<u8>) -> crate::Result<ControlAdmission> {
316 let identity = identity(&source);
317 self.upsert_identified(path, source, identity)
318 }
319
320 fn upsert_identified(
321 &mut self,
322 path: &Path,
323 source: Vec<u8>,
324 identity: ControlIdentity,
325 ) -> crate::Result<ControlAdmission> {
326 let directory = control_directory(path)?;
327 match self.verdict(directory, &source, identity) {
328 Verdict::Unchanged => Ok(ControlAdmission::Retained { changed: false }),
329 Verdict::Refuse(reason) => {
330 crate::counters::bump(|counts| {
331 counts.control_refused = counts.control_refused.saturating_add(1);
332 });
333 Ok(self.refuse(directory, reason))
334 }
335 Verdict::Admit { retained_cost } => {
336 self.detach(directory);
337 self.attach(directory, source, identity);
338 self.refused.remove(directory);
339 debug_assert_eq!(self.retained_cost, retained_cost);
340 Ok(ControlAdmission::Retained { changed: true })
341 }
342 }
343 }
344
345 fn verdict(&self, directory: &Path, source: &[u8], identity: ControlIdentity) -> Verdict {
350 if self.by_directory.get(directory).is_some_and(|current| current.bytes == source) {
351 return Verdict::Unchanged;
352 }
353 if self.limits.line_limit.is_some_and(|line_limit| {
354 source.split(|byte| *byte == b'\n').any(|line| line.len() > line_limit)
355 }) {
356 return Verdict::Refuse(ControlRefusalReason::LineLimit);
357 }
358 let content_charge =
359 if self.holding(identity, source).is_some() { 0 } else { content_cost(source) };
360 let retained_cost = self
363 .retained_cost
364 .saturating_sub(self.release_charge(directory))
365 .saturating_add(directory_cost(directory))
366 .saturating_add(content_charge);
367 if self.limits.budget.is_some_and(|budget| retained_cost > budget) {
368 return Verdict::Refuse(ControlRefusalReason::Budget);
369 }
370 Verdict::Admit { retained_cost }
371 }
372
373 pub(crate) fn upsert_is_inert(&self, path: &Path, source: &[u8]) -> bool {
381 let Ok(directory) = control_directory(path) else {
382 return false;
383 };
384 match self.verdict(directory, source, identity(source)) {
385 Verdict::Unchanged => true,
386 Verdict::Refuse(reason) => self.refused.get(directory) == Some(&reason),
387 Verdict::Admit { .. } => false,
388 }
389 }
390
391 pub(crate) fn remove_is_inert(&self, path: &Path) -> bool {
393 control_directory(path).is_ok_and(|directory| {
394 !self.by_directory.contains_key(directory) && !self.refused.contains_key(directory)
395 })
396 }
397
398 pub(crate) fn has_record_at_or_below(&self, subtree: &Path) -> bool {
403 has_key_at_or_below(&self.by_directory, subtree)
404 || has_key_at_or_below(&self.refused, subtree)
405 }
406
407 pub(crate) fn record_refusal(
409 &mut self,
410 path: &Path,
411 reason: ControlRefusalReason,
412 ) -> crate::Result<()> {
413 let directory = control_directory(path)?;
414 self.refuse(directory, reason);
415 Ok(())
416 }
417
418 fn refuse(&mut self, directory: &Path, reason: ControlRefusalReason) -> ControlAdmission {
419 self.detach(directory);
420 self.refused.insert(directory.to_path_buf(), reason);
421 ControlAdmission::Refused(reason)
422 }
423
424 pub fn remove(&mut self, path: &Path) -> crate::Result<bool> {
430 let directory = control_directory(path)?;
431 let retained = self.detach(directory);
432 let refused = self.refused.remove(directory).is_some();
433 Ok(retained || refused)
434 }
435
436 pub(crate) fn remove_subtree(&mut self, subtree: &Path) {
438 let directories: Vec<PathBuf> = self
439 .by_directory
440 .keys()
441 .filter(|directory| directory.starts_with(subtree))
442 .cloned()
443 .collect();
444 for directory in directories {
445 self.detach(&directory);
446 }
447 self.refused.retain(|directory, _| !directory.starts_with(subtree));
448 }
449
450 fn holding(&self, identity: ControlIdentity, source: &[u8]) -> Option<&Holding> {
452 self.shared
453 .get(&identity)?
454 .iter()
455 .find(|holding| holding.content.bytes.as_slice() == source)
456 }
457
458 fn release_charge(&self, directory: &Path) -> usize {
460 let Some(content) = self.by_directory.get(directory) else {
461 return 0;
462 };
463 let last_holder = self.shared.get(&content.identity).is_some_and(|holdings| {
464 holdings
465 .iter()
466 .any(|holding| Arc::ptr_eq(&holding.content, content) && holding.holders == 1)
467 });
468 directory_cost(directory).saturating_add(if last_holder { content.content_cost } else { 0 })
469 }
470
471 fn detach(&mut self, directory: &Path) -> bool {
473 let Some(content) = self.by_directory.remove(directory) else {
474 return false;
475 };
476 self.by_directory_bytes.remove(directory.as_os_str());
477 let holdings = self.shared.get_mut(&content.identity).expect("a retained content is held");
478 let position = holdings
479 .iter()
480 .position(|holding| Arc::ptr_eq(&holding.content, &content))
481 .expect("a retained content is listed under its own identity");
482 holdings[position].holders -= 1;
483 if holdings[position].holders == 0 {
484 holdings.swap_remove(position);
485 self.retained_cost -= content.content_cost;
486 if holdings.is_empty() {
487 self.shared.remove(&content.identity);
488 }
489 }
490 self.retained_cost -= directory_cost(directory);
491 self.source_bytes -= content.bytes.len();
492 true
493 }
494
495 fn attach(&mut self, directory: &Path, source: Vec<u8>, identity: ControlIdentity) {
497 let holdings = self.shared.entry(identity).or_default();
498 let content = if let Some(holding) =
499 holdings.iter_mut().find(|holding| holding.content.bytes == source)
500 {
501 holding.holders += 1;
502 crate::counters::bump(|counts| {
503 counts.control_sources_shared = counts.control_sources_shared.saturating_add(1);
504 });
505 Arc::clone(&holding.content)
506 } else {
507 let content_cost = content_cost(&source);
508 let matcher = Gitignore::parse(&source);
509 let content =
510 Arc::new(SharedContent { bytes: source, identity, matcher, content_cost });
511 holdings.push(Holding { content: Arc::clone(&content), holders: 1 });
512 self.retained_cost += content_cost;
513 content
514 };
515 self.retained_cost += directory_cost(directory);
516 self.source_bytes += content.bytes.len();
517 self.by_directory_bytes.insert(directory.as_os_str().to_os_string(), Arc::clone(&content));
518 self.by_directory.insert(directory.to_path_buf(), content);
519 }
520
521 pub fn matcher_for<'a>(&'a self, path: &'a Path) -> ControlMatcher<'a> {
523 ControlMatcher { table: self, path }
524 }
525
526 pub(crate) fn chain_for(&self, directory: &Path) -> ControlChain {
537 debug_assert!(
538 directory.components().all(|component| matches!(component, Component::Normal(_))),
539 "control chains are resolved for normalized relative directories: {}",
540 directory.display()
541 );
542 let mut governing = Vec::new();
543 if !self.by_directory_bytes.is_empty() {
544 let depth = gitignore::with_components(directory, None, |components| components.len());
545 for (up, ancestor) in ancestors(directory.as_os_str()).enumerate() {
546 if let Some(source) = self.by_directory_bytes.get(ancestor) {
547 governing.push((depth.saturating_sub(up), Arc::clone(source)));
548 }
549 }
550 }
551 ControlChain { governing }
552 }
553
554 pub(crate) fn chain_below(
566 &self,
567 above: &Arc<ControlChain>,
568 directory: &Path,
569 ) -> Arc<ControlChain> {
570 let Some(source) = self.by_directory_bytes.get(directory.as_os_str()) else {
571 return Arc::clone(above);
572 };
573 let depth = gitignore::with_components(directory, None, |components| components.len());
574 let mut governing = Vec::with_capacity(above.governing.len() + 1);
575 governing.push((depth, Arc::clone(source)));
576 governing.extend(above.governing.iter().cloned());
577 Arc::new(ControlChain { governing })
578 }
579
580 pub fn is_ignored(&self, path: &Path, is_dir: bool) -> bool {
586 let components: Vec<_> = path.components().collect();
587 let mut current = PathBuf::new();
588 let mut parent_ignored = false;
589 for (position, component) in components.iter().enumerate() {
590 current.push(component.as_os_str());
591 if parent_ignored {
592 return true;
593 }
594 let current_is_dir = position + 1 < components.len() || is_dir;
595 parent_ignored = self.matcher_for(¤t).is_ignored(current_is_dir);
596 }
597 parent_ignored
598 }
599
600 pub fn affected_subtree(path: &Path) -> crate::Result<PathBuf> {
602 Ok(control_directory(path)?.to_path_buf())
603 }
604
605 pub(crate) fn changes_from(
607 &self,
608 previous: &Self,
609 ) -> Vec<(PathBuf, Option<ControlIdentity>, Option<ControlIdentity>)> {
610 let directories: BTreeSet<&Path> = previous
611 .by_directory
612 .keys()
613 .chain(self.by_directory.keys())
614 .map(PathBuf::as_path)
615 .collect();
616 directories
617 .into_iter()
618 .filter_map(|directory| {
619 let before = previous.by_directory.get(directory);
620 let after = self.by_directory.get(directory);
621 let changed = match (before, after) {
622 (Some(before), Some(after)) => {
623 !Arc::ptr_eq(before, after) && before.bytes != after.bytes
624 }
625 (None, None) => false,
626 (Some(_), None) | (None, Some(_)) => true,
627 };
628 changed.then(|| {
629 (
630 control_path(directory),
631 before.map(|source| source.identity),
632 after.map(|source| source.identity),
633 )
634 })
635 })
636 .collect()
637 }
638
639 pub(crate) fn refusal_changes_from(
641 &self,
642 previous: &Self,
643 ) -> Vec<(PathBuf, Option<ControlRefusalReason>, Option<ControlRefusalReason>)> {
644 let directories: BTreeSet<&Path> =
645 previous.refused.keys().chain(self.refused.keys()).map(PathBuf::as_path).collect();
646 directories
647 .into_iter()
648 .filter_map(|directory| {
649 let before = previous.refused.get(directory).copied();
650 let after = self.refused.get(directory).copied();
651 (before != after).then(|| (control_path(directory), before, after))
652 })
653 .collect()
654 }
655
656 pub(crate) fn sources(&self) -> impl ExactSizeIterator<Item = (PathBuf, &[u8])> {
658 self.by_directory
659 .iter()
660 .map(|(directory, source)| (control_path(directory), source.bytes.as_slice()))
661 }
662
663 pub fn refusals(&self) -> impl ExactSizeIterator<Item = RefusedControl> + '_ {
665 self.refused.iter().map(|(directory, reason)| RefusedControl {
666 path: control_path(directory),
667 reason: *reason,
668 })
669 }
670
671 pub(crate) fn classification_known(&self, path: &Path) -> bool {
676 path.parent().is_none_or(|directory| self.children_classification_known(directory))
677 }
678
679 pub(crate) fn children_classification_known(&self, directory: &Path) -> bool {
681 !directory.ancestors().any(|ancestor| self.refused.contains_key(ancestor))
682 }
683
684 pub fn refused_len(&self) -> usize {
686 self.refused.len()
687 }
688
689 pub const fn limits(&self) -> ControlLimits {
691 self.limits
692 }
693
694 pub fn observation(&self) -> ControlObservation {
696 ControlObservation {
697 limits: self.limits,
698 applied: u64::try_from(self.len()).unwrap_or(u64::MAX),
699 rules: self
700 .by_directory
701 .values()
702 .fold(0_u64, |total, content| total.saturating_add(content.matcher.rule_count())),
703 refused: u64::try_from(self.refused_len()).unwrap_or(u64::MAX),
704 refusals: self.refusals().take(crate::MAX_RETAINED_ISSUES).collect(),
705 }
706 }
707
708 pub const fn source_bytes(&self) -> usize {
710 self.source_bytes
711 }
712
713 pub const fn retained_cost(&self) -> usize {
715 self.retained_cost
716 }
717
718 pub fn source_is(&self, path: &Path, source: &[u8]) -> bool {
720 control_directory(path)
721 .ok()
722 .and_then(|directory| self.by_directory.get(directory))
723 .is_some_and(|current| current.bytes == source)
724 }
725
726 pub(crate) fn contains(&self, path: &Path) -> bool {
731 control_directory(path).ok().is_some_and(|directory| {
732 self.by_directory.contains_key(directory) || self.refused.contains_key(directory)
733 })
734 }
735
736 pub fn len(&self) -> usize {
738 self.by_directory.len()
739 }
740
741 pub fn is_empty(&self) -> bool {
743 self.by_directory.is_empty()
744 }
745
746 pub(crate) fn is_vacant(&self) -> bool {
748 self.by_directory.is_empty() && self.refused.is_empty()
749 }
750}
751
752pub struct ControlMatcher<'a> {
754 table: &'a ControlTable,
755 path: &'a Path,
756}
757
758impl ControlMatcher<'_> {
759 pub fn is_ignored(&self, is_dir: bool) -> bool {
765 if self.table.by_directory.is_empty() {
770 return false;
771 }
772 for directory in self.path.parent().into_iter().flat_map(Path::ancestors) {
776 let Some(source) = self.table.by_directory.get(directory) else {
777 continue;
778 };
779 let relative = self.path.strip_prefix(directory).unwrap_or(self.path);
780 if let Some(ignored) = source.matcher.matches(relative, is_dir) {
781 return ignored;
782 }
783 }
784 false
785 }
786}
787
788#[derive(Clone, Debug, Default)]
791pub(crate) struct ControlChain {
792 governing: Vec<(usize, Arc<SharedContent>)>,
793}
794
795impl ControlChain {
796 pub(crate) fn is_empty(&self) -> bool {
798 self.governing.is_empty()
799 }
800
801 pub(crate) fn same_as(&self, other: &Self) -> bool {
804 self.governing.len() == other.governing.len()
805 && self
806 .governing
807 .iter()
808 .zip(&other.governing)
809 .all(|(left, right)| left.0 == right.0 && Arc::ptr_eq(&left.1, &right.1))
810 }
811
812 #[cfg(test)]
815 pub(crate) fn is_ignored(&self, directory: &Path, name: &[u8], is_dir: bool) -> bool {
816 with_directory_components(directory, |components| {
817 self.is_ignored_within(components, name, is_dir)
818 })
819 }
820
821 pub(crate) fn is_ignored_within(&self, directory: &[&[u8]], name: &[u8], is_dir: bool) -> bool {
830 if self.governing.is_empty() {
831 return false;
832 }
833 let name = gitignore::Name::new(name);
834 let mut tally = gitignore::Tally::default();
835 let ignored = self
836 .governing
837 .iter()
838 .find_map(|(leading, source)| {
839 let relative = directory.get(*leading..).unwrap_or_default();
840 source.matcher.decide(relative, &name, is_dir, &mut tally)
841 })
842 .unwrap_or(false);
843 tally.record();
844 ignored
845 }
846}
847
848pub(crate) fn with_directory_components<R>(
851 directory: &Path,
852 each: impl FnOnce(&[&[u8]]) -> R,
853) -> R {
854 gitignore::with_components(directory, None, each)
855}
856
857#[derive(Debug)]
863pub(crate) struct SplitDirectory {
864 bytes: Vec<u8>,
865 ends: Vec<usize>,
867}
868
869impl SplitDirectory {
870 pub(crate) fn new(directory: &Path) -> Self {
871 with_directory_components(directory, |components| {
872 let mut split = Self {
873 bytes: Vec::with_capacity(components.iter().map(|component| component.len()).sum()),
874 ends: Vec::with_capacity(components.len()),
875 };
876 for component in components {
877 split.bytes.extend_from_slice(component);
878 split.ends.push(split.bytes.len());
879 }
880 split
881 })
882 }
883
884 pub(crate) fn with_components<R>(&self, each: impl FnOnce(&[&[u8]]) -> R) -> R {
887 let components = self.ends.iter().scan(0, |start, &end| {
888 let component = &self.bytes[*start..end];
889 *start = end;
890 Some(component)
891 });
892 gitignore::with_collected(components, each)
893 }
894}
895
896pub(crate) fn split_parent(path: &std::path::Path) -> (&std::ffi::OsStr, &std::ffi::OsStr) {
900 let parsed = || {
901 (
902 path.parent().map_or(std::ffi::OsStr::new(""), std::path::Path::as_os_str),
903 path.file_name().unwrap_or(std::ffi::OsStr::new("")),
904 )
905 };
906 #[cfg(unix)]
907 let split = {
908 use std::os::unix::ffi::OsStrExt as _;
909 let bytes = path.as_os_str().as_bytes();
910 match bytes.iter().rposition(|byte| *byte == b'/') {
911 Some(at) => (
912 std::ffi::OsStr::from_bytes(&bytes[..at]),
913 std::ffi::OsStr::from_bytes(&bytes[at + 1..]),
914 ),
915 None => (std::ffi::OsStr::new(""), path.as_os_str()),
916 }
917 };
918 #[cfg(not(unix))]
919 let split = parsed();
920 debug_assert_eq!(split, parsed(), "a walked path is normalized and relative");
921 split
922}
923
924#[cfg(unix)]
927pub(crate) fn ancestors(directory: &std::ffi::OsStr) -> impl Iterator<Item = &std::ffi::OsStr> {
928 use std::os::unix::ffi::OsStrExt as _;
929 let bytes = directory.as_bytes();
930 let mut next = Some(bytes.len());
931 std::iter::from_fn(move || {
932 let end = next?;
933 next = (end > 0).then(|| bytes[..end].iter().rposition(|byte| *byte == b'/').unwrap_or(0));
934 Some(std::ffi::OsStr::from_bytes(&bytes[..end]))
935 })
936}
937
938#[cfg(not(unix))]
939pub(crate) fn ancestors(directory: &std::ffi::OsStr) -> impl Iterator<Item = &std::ffi::OsStr> {
940 std::path::Path::new(directory).ancestors().map(std::path::Path::as_os_str)
941}
942
943fn has_key_at_or_below<V>(directories: &BTreeMap<PathBuf, V>, subtree: &Path) -> bool {
949 directories
950 .range::<Path, _>((std::ops::Bound::Included(subtree), std::ops::Bound::Unbounded))
951 .next()
952 .is_some_and(|(directory, _)| directory.starts_with(subtree))
953}
954
955pub fn is_control_file(path: &Path) -> bool {
962 path.file_name().is_some_and(|name| name == CONTROL_FILE_NAME)
963}
964
965#[derive(Clone, Copy, PartialEq, Eq, Debug)]
967pub(crate) enum ControlSpelling {
968 Exact,
971 Variant,
975}
976
977pub(crate) fn control_spelling(name: &std::ffi::OsStr) -> Option<ControlSpelling> {
982 let bytes = name.as_encoded_bytes();
983 let exact = CONTROL_FILE_NAME.as_bytes();
984 if bytes.len() != exact.len() {
985 None
986 } else if bytes == exact {
987 Some(ControlSpelling::Exact)
988 } else if bytes.eq_ignore_ascii_case(exact) {
989 Some(ControlSpelling::Variant)
990 } else {
991 None
992 }
993}
994
995pub(crate) fn path_control_spelling(path: &Path) -> Option<ControlSpelling> {
997 path.file_name().and_then(control_spelling)
998}
999
1000pub(crate) fn sibling_control_path(path: &Path) -> PathBuf {
1002 control_path(path.parent().unwrap_or_else(|| Path::new("")))
1003}
1004
1005pub(crate) fn unreadable_control(root: &Path, error: &crate::Error) -> Option<PathBuf> {
1014 let crate::Error::Io { .. } = error else {
1015 return None;
1016 };
1017 crate::Issue::from_error_under(root, error).path.and_then(|path| governing_control(&path))
1018}
1019
1020pub(crate) fn governing_control(path: &Path) -> Option<PathBuf> {
1023 path_control_spelling(path).map(|_| sibling_control_path(path))
1024}
1025
1026fn control_directory(path: &Path) -> crate::Result<&Path> {
1027 if !is_control_file(path) {
1028 return Err(crate::Error::InvalidControlPath(path.to_path_buf()));
1029 }
1030 Ok(path.parent().unwrap_or_else(|| Path::new("")))
1031}
1032
1033fn control_path(directory: &Path) -> PathBuf {
1034 directory.join(CONTROL_FILE_NAME)
1035}
1036
1037fn identity(bytes: &[u8]) -> ControlIdentity {
1038 const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
1039 const FNV_PRIME: u64 = 0x100_0000_01b3;
1040
1041 let mut fingerprint = FNV_OFFSET_BASIS;
1042 for byte in bytes {
1043 fingerprint ^= u64::from(*byte);
1044 fingerprint = fingerprint.wrapping_mul(FNV_PRIME);
1045 }
1046 ControlIdentity { bytes: u64::try_from(bytes.len()).unwrap_or(u64::MAX), fingerprint }
1047}
1048
1049fn directory_cost(directory: &Path) -> usize {
1051 CONTROL_SOURCE_OVERHEAD.saturating_add(directory.as_os_str().as_encoded_bytes().len())
1052}
1053
1054fn content_cost(source: &[u8]) -> usize {
1057 let (newlines, segment_shells) = source.iter().fold((0usize, 0usize), |counts, byte| {
1058 (counts.0 + usize::from(*byte == b'\n'), counts.1 + usize::from(*byte == b'/'))
1059 });
1060 let pattern_shells = newlines.saturating_add(1);
1061 source
1062 .len()
1063 .saturating_mul(2)
1064 .saturating_add(pattern_shells.saturating_mul(64))
1065 .saturating_add(segment_shells.saturating_mul(24))
1066}
1067
1068#[cfg(test)]
1070fn retained_source_cost(directory: &Path, source: &[u8]) -> usize {
1071 directory_cost(directory).saturating_add(content_cost(source))
1072}
1073
1074#[cfg(test)]
1075pub(crate) fn source_at_test_limit() -> Vec<u8> {
1076 let mut source = Vec::new();
1077 loop {
1078 let previous_len = source.len();
1079 source.extend(std::iter::repeat_n(b'a', DEFAULT_CONTROL_LINE_LIMIT));
1080 source.push(b'\n');
1081 if retained_source_cost(Path::new(""), &source) > DEFAULT_CONTROL_BUDGET {
1082 source.truncate(previous_len);
1083 break;
1084 }
1085 }
1086 let remaining = DEFAULT_CONTROL_BUDGET - retained_source_cost(Path::new(""), &source);
1087 source.extend(std::iter::repeat_n(b'a', (remaining / 2).min(DEFAULT_CONTROL_LINE_LIMIT)));
1088 assert_eq!(retained_source_cost(Path::new(""), &source), DEFAULT_CONTROL_BUDGET);
1089 source
1090}
1091
1092#[cfg(test)]
1093impl ControlTable {
1094 fn assert_consistent(&self) {
1096 let mut distinct: Vec<&Arc<SharedContent>> = Vec::new();
1097 let mut directory_charges = 0;
1098 let mut source_bytes = 0;
1099 for (directory, content) in &self.by_directory {
1100 directory_charges += directory_cost(directory);
1101 source_bytes += content.bytes.len();
1102 if !distinct.iter().any(|seen| Arc::ptr_eq(seen, content)) {
1103 distinct.push(content);
1104 }
1105 }
1106 let content_charges: usize = distinct.iter().map(|content| content.content_cost).sum();
1107 assert_eq!(self.retained_cost, directory_charges + content_charges, "retained cost");
1108 assert_eq!(self.source_bytes, source_bytes, "source bytes");
1109 let holdings: usize = self.shared.values().map(Vec::len).sum();
1110 assert_eq!(holdings, distinct.len(), "one holding per distinct content");
1111 for (identity, holdings) in &self.shared {
1112 assert!(!holdings.is_empty(), "no empty identity list survives");
1113 for holding in holdings {
1114 assert_eq!(holding.content.identity, *identity);
1115 let holders = self
1116 .by_directory
1117 .values()
1118 .filter(|content| Arc::ptr_eq(content, &holding.content))
1119 .count();
1120 assert_eq!(holding.holders, holders, "holder count");
1121 }
1122 for (index, left) in holdings.iter().enumerate() {
1123 for right in &holdings[index + 1..] {
1124 assert_ne!(left.content.bytes, right.content.bytes, "equal bytes are shared");
1125 }
1126 }
1127 }
1128 assert!(
1129 self.refused.keys().all(|directory| !self.by_directory.contains_key(directory)),
1130 "a refused directory retains no source"
1131 );
1132 assert_eq!(
1135 self.by_directory_bytes.len(),
1136 self.by_directory.len(),
1137 "one byte key per directory"
1138 );
1139 for (directory, content) in &self.by_directory {
1140 let by_bytes = self
1141 .by_directory_bytes
1142 .get(directory.as_os_str())
1143 .expect("every directory is keyed by its bytes");
1144 assert!(
1145 Arc::ptr_eq(by_bytes, content),
1146 "{}: the byte-keyed index shares the directory's content",
1147 directory.display()
1148 );
1149 }
1150 assert!(
1151 self.limits.budget.is_none_or(|budget| self.retained_cost <= budget),
1152 "within budget"
1153 );
1154 assert!(
1155 self.limits.line_limit.is_none_or(|line_limit| {
1156 distinct.iter().all(|content| {
1157 content.bytes.split(|byte| *byte == b'\n').all(|line| line.len() <= line_limit)
1158 })
1159 }),
1160 "within the line limit"
1161 );
1162 }
1163}
1164
1165#[cfg(test)]
1166mod tests {
1167 use super::*;
1168
1169 struct SplitMix(u64);
1171
1172 impl SplitMix {
1173 fn next(&mut self) -> u64 {
1174 self.0 = self.0.wrapping_add(0x9e37_79b9_7f4a_7c15);
1175 let mut value = self.0;
1176 value = (value ^ (value >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
1177 value = (value ^ (value >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
1178 value ^ (value >> 31)
1179 }
1180
1181 fn below(&mut self, bound: usize) -> usize {
1182 usize::try_from(self.next() % u64::try_from(bound).expect("small bound")).expect("fits")
1183 }
1184 }
1185
1186 #[test]
1191 fn rule_totals_count_accepted_patterns_per_governing_location() {
1192 let mut table = ControlTable::default();
1193 let source = b"# comment\n\n*.log\n!important.log\n*.log\n[bad\n";
1194 table.upsert(Path::new(".gitignore"), source.to_vec()).expect("root control");
1195 table.upsert(Path::new("nested/.gitignore"), source.to_vec()).expect("nested control");
1196 assert_eq!(table.observation().applied, 2);
1197 assert_eq!(table.observation().rules, 6);
1198 table
1199 .upsert(Path::new("nested/.gitignore"), b"# empty\n".to_vec())
1200 .expect("replacement control");
1201 assert_eq!(table.observation().applied, 2);
1202 assert_eq!(table.observation().rules, 3);
1203 }
1204
1205 #[test]
1206 fn charges_and_refusals_stay_exact_through_random_upserts_and_removals() {
1207 const DIRECTORIES: [&str; 6] = ["", "a", "a/b", "b", "b/c/d", "c"];
1208 let long_line = [vec![b'x'; DEFAULT_CONTROL_LINE_LIMIT + 1], b"\n".to_vec()].concat();
1209 let large = b"pattern/\n".repeat(40);
1210 let contents: [&[u8]; 6] =
1211 [b"*.log\n", b"target/\n", b"!keep\n*.tmp\n", b"", &large, &long_line];
1212 let budget = 2 * retained_source_cost(Path::new("b/c/d"), &large);
1214 for seed in 0..64 {
1215 let mut random = SplitMix(seed);
1216 let limits = ControlLimits {
1217 budget: (seed % 2 == 0).then_some(budget),
1218 line_limit: (seed % 4 < 2).then_some(DEFAULT_CONTROL_LINE_LIMIT),
1219 };
1220 let mut table = ControlTable::with_limits(limits);
1221 let mut holds_record: BTreeMap<&Path, bool> = BTreeMap::new();
1222 for step in 0..300 {
1223 let directory = Path::new(DIRECTORIES[random.below(DIRECTORIES.len())]);
1224 let path = directory.join(CONTROL_FILE_NAME);
1225 match random.below(8) {
1226 0 => {
1227 table.remove_subtree(directory);
1228 for (held, record) in &mut holds_record {
1229 if held.starts_with(directory) {
1230 *record = false;
1231 }
1232 }
1233 }
1234 1 | 2 => {
1235 table.remove(&path).expect("control path");
1236 holds_record.insert(directory, false);
1237 }
1238 _ => {
1239 let content = contents[random.below(contents.len())].to_vec();
1240 let admission = table.upsert(&path, content.clone()).expect("control path");
1241 if content == long_line && limits.line_limit.is_some() {
1242 assert_eq!(
1243 admission,
1244 ControlAdmission::Refused(ControlRefusalReason::LineLimit)
1245 );
1246 }
1247 if limits.budget.is_none() && limits.line_limit.is_none() {
1248 assert!(
1249 matches!(admission, ControlAdmission::Retained { .. }),
1250 "an unbounded table refuses nothing"
1251 );
1252 }
1253 holds_record.insert(directory, true);
1254 }
1255 }
1256 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1257 table.assert_consistent();
1258 for (directory, record) in &holds_record {
1259 let path = directory.join(CONTROL_FILE_NAME);
1260 assert_eq!(table.contains(&path), *record, "{}", path.display());
1261 }
1262 let records = holds_record.values().filter(|record| **record).count();
1263 assert_eq!(table.len() + table.refused_len(), records);
1264 }))
1265 .unwrap_or_else(|_| panic!("seed {seed}, step {step}: inconsistent table"));
1266 }
1267 for directory in DIRECTORIES {
1268 table.remove(&Path::new(directory).join(CONTROL_FILE_NAME)).expect("control path");
1269 }
1270 assert_eq!(table.retained_cost(), 0, "seed {seed}");
1271 assert_eq!(table.source_bytes(), 0, "seed {seed}");
1272 assert!(table.shared.is_empty(), "seed {seed}");
1273 assert!(table.is_vacant(), "seed {seed}");
1274 }
1275 }
1276
1277 #[test]
1278 fn a_resolved_chain_answers_as_the_per_entry_matcher_does() {
1279 let mut table = ControlTable::default();
1280 for (path, source) in [
1281 (".gitignore", &b"*.log\n/build/\n!keep.log\nsub/*.tmp\n"[..]),
1282 ("a/.gitignore", b"!*.log\n*.o\n/deep/**\n"),
1283 ("a/b/.gitignore", b"*.log\n!x.o\n"),
1284 ("c/.gitignore", b"# comment only\n"),
1285 ] {
1286 table.upsert(Path::new(path), source.to_vec()).expect("fixture control");
1287 }
1288 let directories =
1289 ["", "a", "a/b", "a/b/c", "a/deep", "a/deep/er", "build", "c", "c/sub", "sub", "x/y"];
1290 let names = ["x.log", "keep.log", "x.o", "y.o", "build", "deep", "t.tmp", "plain"];
1291 for directory in directories {
1292 let chain = table.chain_for(Path::new(directory));
1293 for name in names {
1294 for is_dir in [false, true] {
1295 let path = Path::new(directory).join(name);
1296 assert_eq!(
1297 chain.is_ignored(Path::new(directory), name.as_bytes(), is_dir),
1298 table.matcher_for(&path).is_ignored(is_dir),
1299 "{} (dir {is_dir})",
1300 path.display()
1301 );
1302 }
1303 }
1304 }
1305 assert!(!ControlTable::default().chain_for(Path::new("a")).is_ignored(
1306 Path::new("a"),
1307 b"x.log",
1308 false
1309 ));
1310 }
1311
1312 #[test]
1317 fn chains_derived_parent_first_are_the_ones_the_table_resolves() {
1318 let long_line = [vec![b'x'; DEFAULT_CONTROL_LINE_LIMIT + 1], b"\n".to_vec()].concat();
1319 let large = b"pattern/\n".repeat(40);
1320 let contents: [&[u8]; 6] =
1321 [b"*.log\n", b"!keep\n*.tmp\n", b"", b"/a/x.log\n!*.log\n", &large, &long_line];
1322 let budget = 3 * retained_source_cost(Path::new("a/b/c"), &large);
1323 for seed in 0..200 {
1324 let mut random = SplitMix(seed);
1325 let mut table = ControlTable::with_limits(ControlLimits {
1326 budget: Some(budget),
1327 ..ControlLimits::default()
1328 });
1329 let mut listings = std::collections::VecDeque::from([(
1330 PathBuf::new(),
1331 Arc::<ControlChain>::default(),
1332 )]);
1333 while let Some((directory, above)) = listings.pop_front() {
1334 let control = directory.join(CONTROL_FILE_NAME);
1335 let listed = match random.below(4) {
1336 0 | 1 => {
1337 let content = contents[random.below(contents.len())].to_vec();
1338 table.upsert(&control, content).expect("control path");
1339 true
1340 }
1341 2 => {
1342 table.remove(&control).expect("control path");
1343 true
1344 }
1345 _ => false,
1346 };
1347 let chain = if listed { table.chain_below(&above, &directory) } else { above };
1348 let resolved = table.chain_for(&directory);
1349 assert!(chain.same_as(&resolved), "seed {seed}: {}", directory.display());
1350 for name in ["x.log", "keep", "x.tmp", "pattern", "a"] {
1351 for is_dir in [false, true] {
1352 assert_eq!(
1353 chain.is_ignored(&directory, name.as_bytes(), is_dir),
1354 resolved.is_ignored(&directory, name.as_bytes(), is_dir),
1355 "seed {seed}: {}/{name}",
1356 directory.display()
1357 );
1358 }
1359 }
1360 if directory.components().count() < 4 {
1361 for name in ["a", "b", "c"] {
1362 if random.below(3) > 0 {
1363 listings.push_back((directory.join(name), Arc::clone(&chain)));
1364 }
1365 }
1366 }
1367 }
1368 table.assert_consistent();
1369 }
1370 }
1371
1372 #[test]
1373 fn a_chain_agrees_past_the_inline_buffers_and_beside_unrelated_controls() {
1374 let mut table = ControlTable::default();
1375 table.upsert(Path::new("z/.gitignore"), b"*.log\n".to_vec()).expect("unrelated");
1376 let unrelated = table.chain_for(Path::new("a/b"));
1377 assert!(!unrelated.is_ignored(Path::new("a/b"), b"x.log", false));
1378 assert!(!table.matcher_for(Path::new("a/b/x.log")).is_ignored(false));
1379
1380 let deep: PathBuf = (0..33).map(|at| format!("d{at}")).collect();
1383 table.upsert(Path::new(".gitignore"), b"**/x.log\n/d0/**/y.log\n".to_vec()).expect("root");
1384 table.upsert(&deep.join(".gitignore"), b"!x.log\n*.tmp\n".to_vec()).expect("deep");
1385 for depth in [31usize, 32, 33, 34] {
1386 let directory: PathBuf = (0..depth).map(|at| format!("d{at}")).collect();
1387 let chain = table.chain_for(&directory);
1388 for name in ["x.log", "y.log", "z.tmp", "plain"] {
1389 let path = directory.join(name);
1390 assert_eq!(
1391 chain.is_ignored(&directory, name.as_bytes(), false),
1392 table.matcher_for(&path).is_ignored(false),
1393 "{name} at depth {depth}"
1394 );
1395 }
1396 }
1397 }
1398
1399 #[test]
1402 fn matching_counts_lookups_hits_and_rules_tested() {
1403 let _serial = crate::counters::test_serial();
1404 crate::counters::enable(true);
1405 crate::counters::test_thread_reset();
1406 let mut table = ControlTable::default();
1407 table
1408 .upsert(Path::new(".gitignore"), b"*.o\nMakefile\n/build\n*.c.[01]*\n".to_vec())
1409 .expect("root control");
1410 let chain = table.chain_for(Path::new(""));
1411 assert!(chain.is_ignored_within(&[], b"main.o", false));
1414 assert!(chain.is_ignored_within(&[], b"a.c.0x", false));
1416 let counts = crate::counters::test_thread_snapshot();
1417 crate::counters::enable(false);
1418 assert_eq!(
1419 (counts.ignore_bucket_probes, counts.ignore_bucket_hits, counts.ignore_patterns_tested),
1420 (4, 1, 1)
1421 );
1422 }
1423
1424 #[test]
1425 fn a_fingerprint_collision_never_shares_a_matcher() {
1426 let collision = ControlIdentity { bytes: 6, fingerprint: 7 };
1427 let mut table = ControlTable::default();
1428 table
1429 .upsert_identified(Path::new("a/.gitignore"), b"*.log\n".to_vec(), collision)
1430 .expect("first");
1431 table
1432 .upsert_identified(Path::new("b/.gitignore"), b"*.tmp\n".to_vec(), collision)
1433 .expect("second");
1434 table.assert_consistent();
1435
1436 assert_eq!(table.shared[&collision].len(), 2);
1437 assert!(table.is_ignored(Path::new("a/x.log"), false));
1438 assert!(!table.is_ignored(Path::new("a/x.tmp"), false));
1439 assert!(table.is_ignored(Path::new("b/x.tmp"), false));
1440 assert!(!table.is_ignored(Path::new("b/x.log"), false));
1441 assert_eq!(
1442 table.retained_cost(),
1443 retained_source_cost(Path::new("a"), b"*.log\n")
1444 + retained_source_cost(Path::new("b"), b"*.tmp\n")
1445 );
1446
1447 table.remove(Path::new("a/.gitignore")).expect("remove");
1448 table.assert_consistent();
1449 assert!(table.is_ignored(Path::new("b/x.tmp"), false));
1450 }
1451
1452 const CHANGED: ControlAdmission = ControlAdmission::Retained { changed: true };
1453 const UNCHANGED: ControlAdmission = ControlAdmission::Retained { changed: false };
1454 const OVER_BUDGET: ControlAdmission = ControlAdmission::Refused(ControlRefusalReason::Budget);
1455 const OVER_LINE_LIMIT: ControlAdmission =
1456 ControlAdmission::Refused(ControlRefusalReason::LineLimit);
1457
1458 fn budgeted(budget: Option<usize>) -> ControlTable {
1460 ControlTable::with_limits(ControlLimits { budget, ..ControlLimits::default() })
1461 }
1462
1463 #[test]
1464 fn replacing_the_last_holder_releases_its_content_for_the_bound() {
1465 let mut table = ControlTable::default();
1466 let first = source_at_test_limit();
1467 assert_eq!(
1468 table.upsert(Path::new(".gitignore"), first.clone()).expect("control path"),
1469 CHANGED
1470 );
1471 assert_eq!(table.upsert(Path::new("copy/.gitignore"), first).expect("path"), OVER_BUDGET);
1473 assert_eq!(
1474 table.upsert(Path::new(".gitignore"), b"small\n".to_vec()).expect("control path"),
1475 CHANGED
1476 );
1477 table.assert_consistent();
1478 assert_eq!(table.retained_cost(), retained_source_cost(Path::new(""), b"small\n"));
1479 }
1480
1481 #[test]
1482 fn creation_edit_and_last_removal_are_exact() {
1483 let mut table = ControlTable::default();
1484 assert_eq!(
1485 table.upsert(Path::new(".gitignore"), b"*.log\n".to_vec()).expect("control path"),
1486 CHANGED
1487 );
1488 let original = table.clone();
1489 assert_eq!(
1490 table.upsert(Path::new(".gitignore"), b"*.log\n".to_vec()).expect("control path"),
1491 UNCHANGED
1492 );
1493 assert_eq!(
1494 table.upsert(Path::new(".gitignore"), b"*.tmp\n".to_vec()).expect("control path"),
1495 CHANGED
1496 );
1497 assert_eq!(table.changes_from(&original).len(), 1);
1498 assert!(table.remove(Path::new(".gitignore")).expect("remove"));
1499 assert!(table.is_empty());
1500 assert_eq!(table.source_bytes(), 0);
1501 assert!(!table.remove(Path::new(".gitignore")).expect("missing is a no-op"));
1502 }
1503
1504 #[test]
1506 fn the_budget_admits_its_own_size_and_refuses_one_byte_over_it() {
1507 let source = b"*.log\n".to_vec();
1508 let exact = retained_source_cost(Path::new("a"), &source);
1509 let mut at_budget = budgeted(Some(exact));
1510 assert_eq!(
1511 at_budget.upsert(Path::new("a/.gitignore"), source.clone()).expect("control path"),
1512 CHANGED
1513 );
1514 assert_eq!(at_budget.retained_cost(), exact);
1515
1516 let mut under_budget = budgeted(Some(exact - 1));
1517 assert_eq!(
1518 under_budget.upsert(Path::new("a/.gitignore"), source).expect("control path"),
1519 OVER_BUDGET
1520 );
1521 assert_eq!(under_budget.retained_cost(), 0);
1522 assert!(under_budget.contains(Path::new("a/.gitignore")));
1523 assert_eq!(
1524 under_budget.observation(),
1525 ControlObservation {
1526 limits: ControlLimits {
1527 budget: Some(exact - 1),
1528 line_limit: Some(DEFAULT_CONTROL_LINE_LIMIT),
1529 },
1530 applied: 0,
1531 rules: 0,
1532 refused: 1,
1533 refusals: vec![RefusedControl {
1534 path: PathBuf::from("a/.gitignore"),
1535 reason: ControlRefusalReason::Budget,
1536 }],
1537 }
1538 );
1539 }
1540
1541 #[test]
1542 fn a_refused_source_drops_the_rules_it_replaces_and_freed_budget_admits_it_later() {
1543 let mut table = ControlTable::default();
1544 let first = source_at_test_limit();
1545 assert_eq!(
1546 table.upsert(Path::new(".gitignore"), first.clone()).expect("control path"),
1547 CHANGED
1548 );
1549 assert_eq!(
1550 table
1551 .upsert(Path::new("nested/.gitignore"), b"*.log\n".to_vec())
1552 .expect("control path"),
1553 OVER_BUDGET
1554 );
1555 let before = table.clone();
1556
1557 let mut grown = first;
1560 grown.push(b'\n');
1561 assert_eq!(
1562 table.upsert(Path::new(".gitignore"), grown).expect("control path"),
1563 OVER_BUDGET
1564 );
1565 assert!(table.is_empty());
1566 assert_eq!(table.changes_from(&before).len(), 1);
1567 assert_eq!(
1568 table.refusal_changes_from(&before),
1569 vec![(PathBuf::from(".gitignore"), None, Some(ControlRefusalReason::Budget))]
1570 );
1571
1572 assert_eq!(
1574 table
1575 .upsert(Path::new("nested/.gitignore"), b"*.log\n".to_vec())
1576 .expect("control path"),
1577 CHANGED
1578 );
1579 assert_eq!(table.refused_len(), 1);
1580 assert!(table.remove(Path::new(".gitignore")).expect("remove refused"));
1582 assert_eq!(table.refused_len(), 0);
1583 table.assert_consistent();
1584 }
1585
1586 #[test]
1587 fn identical_sources_share_one_content_charge() {
1588 let source = b"target/\n*.log\nnode_modules/\n".to_vec();
1589 let mut table = ControlTable::default();
1590 table.upsert(Path::new("a/.gitignore"), source.clone()).expect("first holder");
1591 let one = table.retained_cost();
1592 table.upsert(Path::new("bb/.gitignore"), source.clone()).expect("second holder");
1593
1594 assert_eq!(table.retained_cost() - one, CONTROL_SOURCE_OVERHEAD + "bb".len());
1596 assert_eq!(table.source_bytes(), 2 * source.len());
1597 assert!(table.is_ignored(Path::new("a/debug.log"), false));
1598 assert!(table.is_ignored(Path::new("bb/debug.log"), false));
1599 }
1600
1601 #[test]
1604 fn the_line_limit_admits_its_own_length_and_refuses_one_byte_over_it() {
1605 let mut table = ControlTable::default();
1606 let at_limit = vec![b'a'; DEFAULT_CONTROL_LINE_LIMIT];
1607 assert_eq!(
1608 table.upsert(Path::new("a/.gitignore"), at_limit).expect("control path"),
1609 CHANGED
1610 );
1611 let over = [b"*.log\n".as_slice(), &vec![b'a'; DEFAULT_CONTROL_LINE_LIMIT + 1]].concat();
1612 assert_eq!(
1613 table.upsert(Path::new("b/.gitignore"), over).expect("control path"),
1614 OVER_LINE_LIMIT
1615 );
1616 assert!(!table.is_ignored(Path::new("b/debug.log"), false), "no rule of it applies");
1617 assert_eq!(
1618 table.refusals().collect::<Vec<_>>(),
1619 vec![RefusedControl {
1620 path: PathBuf::from("b/.gitignore"),
1621 reason: ControlRefusalReason::LineLimit,
1622 }]
1623 );
1624 }
1625
1626 #[test]
1629 fn the_budget_and_the_line_limit_lift_independently() {
1630 let long = [b"*.log\n".as_slice(), &vec![b'a'; DEFAULT_CONTROL_LINE_LIMIT + 1]].concat();
1631 let large = source_at_test_limit();
1632
1633 let mut no_budget = budgeted(None);
1634 assert_eq!(
1635 no_budget.upsert(Path::new("big/.gitignore"), large.clone()).expect("path"),
1636 CHANGED
1637 );
1638 assert_eq!(
1639 no_budget.upsert(Path::new("c/.gitignore"), b"*.tmp\n".to_vec()).expect("path"),
1640 CHANGED
1641 );
1642 assert!(no_budget.retained_cost() > DEFAULT_CONTROL_BUDGET);
1643 assert_eq!(
1644 no_budget.upsert(Path::new(".gitignore"), long.clone()).expect("path"),
1645 OVER_LINE_LIMIT
1646 );
1647
1648 let mut raised_budget = budgeted(Some(16 * DEFAULT_CONTROL_BUDGET));
1649 assert_eq!(
1650 raised_budget.upsert(Path::new(".gitignore"), long.clone()).expect("path"),
1651 OVER_LINE_LIMIT
1652 );
1653
1654 let mut no_line_limit = ControlTable::with_limits(ControlLimits {
1655 line_limit: None,
1656 ..ControlLimits::default()
1657 });
1658 assert_eq!(no_line_limit.upsert(Path::new(".gitignore"), long).expect("path"), CHANGED);
1659 assert!(no_line_limit.is_ignored(Path::new("debug.log"), false));
1660 assert_eq!(
1661 no_line_limit.upsert(Path::new("big/.gitignore"), large).expect("path"),
1662 OVER_BUDGET
1663 );
1664 no_line_limit.assert_consistent();
1665 }
1666
1667 #[test]
1668 fn a_listing_of_refusals_is_bounded_and_says_when_it_is_truncated() {
1669 let mut table = budgeted(Some(0));
1670 let refused = crate::MAX_RETAINED_ISSUES + 1;
1671 for directory in 0..refused {
1672 let path = PathBuf::from(format!("d{directory:03}/.gitignore"));
1673 assert_eq!(table.upsert(&path, b"*\n".to_vec()).expect("control path"), OVER_BUDGET);
1674 }
1675 let observation = table.observation();
1676 assert_eq!(observation.refused, u64::try_from(refused).expect("small"));
1677 assert_eq!(observation.refusals.len(), crate::MAX_RETAINED_ISSUES);
1678 assert_eq!(observation.refusals[0].path, Path::new("d000/.gitignore"));
1679 assert!(!observation.lists_every_refusal());
1680 assert!(!observation.is_complete());
1681 }
1682
1683 #[test]
1684 fn control_identity_uses_standard_fnv1a_vectors() {
1685 assert_eq!(identity(b"").fingerprint, 0xcbf2_9ce4_8422_2325);
1686 assert_eq!(identity(b"a").fingerprint, 0xaf63_dc4c_8601_ec8c);
1687 assert_eq!(identity(b"foobar").fingerprint, 0x8594_4171_f739_67e8);
1688 }
1689
1690 #[test]
1691 fn nested_negation_and_control_removal_change_the_governed_subtree() {
1692 let mut table = ControlTable::default();
1693 table.upsert(Path::new(".gitignore"), b"*.log\n".to_vec()).expect("root");
1694 table.upsert(Path::new("docs/.gitignore"), b"!keep.log\n".to_vec()).expect("nested");
1695
1696 assert!(table.is_ignored(Path::new("debug.log"), false));
1697 assert!(table.is_ignored(Path::new("docs/other.log"), false));
1698 assert!(!table.is_ignored(Path::new("docs/keep.log"), false));
1699 assert_eq!(
1700 ControlTable::affected_subtree(Path::new("docs/.gitignore")).expect("scope"),
1701 Path::new("docs")
1702 );
1703
1704 table.remove(Path::new("docs/.gitignore")).expect("remove nested");
1705 assert!(table.is_ignored(Path::new("docs/keep.log"), false));
1706 }
1707
1708 #[test]
1709 fn ignored_parent_cannot_be_reincluded_from_inside_it() {
1710 let mut table = ControlTable::default();
1711 table.upsert(Path::new(".gitignore"), b"vendor/\n".to_vec()).expect("root");
1712 table
1713 .upsert(Path::new("vendor/.gitignore"), b"!keep.txt\n".to_vec())
1714 .expect("retained but inactive nested control");
1715
1716 assert!(table.is_ignored(Path::new("vendor"), true));
1717 assert!(table.is_ignored(Path::new("vendor/keep.txt"), false));
1718 }
1719
1720 #[test]
1724 fn a_control_name_is_spelled_in_any_ascii_case_and_recorded_by_one_path() {
1725 use std::ffi::OsStr;
1726
1727 assert_eq!(control_spelling(OsStr::new(".gitignore")), Some(ControlSpelling::Exact));
1728 for variant in [".GITIGNORE", ".GitIgnore", ".gitIGNORE", ".gitignorE"] {
1729 assert_eq!(control_spelling(OsStr::new(variant)), Some(ControlSpelling::Variant));
1730 }
1731 for other in [".gıtıgnore", ".gitignor", ".gitignore~", "gitignore.", ".gitignor3", ""] {
1734 assert_eq!(control_spelling(OsStr::new(other)), None, "{other:?}");
1735 }
1736 #[cfg(unix)]
1737 {
1738 use std::os::unix::ffi::OsStrExt as _;
1739 assert_eq!(control_spelling(OsStr::from_bytes(b".gitignor\xff")), None);
1740 }
1741
1742 assert_eq!(
1743 governing_control(Path::new("a/.GITIGNORE")),
1744 Some(PathBuf::from("a/.gitignore"))
1745 );
1746 assert_eq!(governing_control(Path::new(".gitignore")), Some(PathBuf::from(".gitignore")));
1747 assert_eq!(governing_control(Path::new("a/README")), None);
1748 assert!(is_control_file(Path::new("a/.gitignore")));
1749 assert!(!is_control_file(Path::new("a/.GITIGNORE")), "operations name one path");
1750 let mut table = ControlTable::default();
1751 assert!(table.upsert(Path::new("a/.GITIGNORE"), b"*.log\n".to_vec()).is_err());
1752 }
1753}