1use std::path::Path;
10
11use crate::engine_contract::{EntryKind, Error, Result};
12use crate::query::query_glob::Pattern;
13
14#[derive(Clone, Copy, PartialEq, Eq, Debug)]
16pub enum SizeMetric {
17 Apparent,
19 Allocated,
21}
22
23impl SizeMetric {
24 pub const fn label(self) -> &'static str {
30 match self {
31 Self::Apparent => "apparent",
32 Self::Allocated => "allocated",
33 }
34 }
35
36 pub(crate) const fn of(self, attrs: &crate::Attrs) -> u64 {
38 match self {
39 Self::Apparent => attrs.size,
40 Self::Allocated => attrs.allocated,
41 }
42 }
43}
44
45impl Default for SizeMetric {
51 fn default() -> Self {
52 crate::query::Request::DEFAULTS.size
53 }
54}
55
56#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
58pub enum SortKey {
59 #[default]
61 Size,
62 Count,
64 Mtime,
66 Name,
68 Metric(&'static str),
70}
71
72#[derive(Clone, Copy, Debug, Default)]
79pub struct ModifiedWindow {
80 pub since: Option<i64>,
82 pub before: Option<i64>,
84}
85
86impl ModifiedWindow {
87 pub fn contains(&self, mtime_ns: i64) -> bool {
89 self.since.is_none_or(|since| mtime_ns >= since)
90 && self.before.is_none_or(|before| mtime_ns < before)
91 }
92
93 pub fn is_unbounded(&self) -> bool {
95 self.since.is_none() && self.before.is_none()
96 }
97}
98
99#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, Hash)]
106pub enum IgnoredEntries {
107 #[default]
109 Include,
110 Exclude,
112 Only,
114}
115
116impl IgnoredEntries {
117 pub const fn admits(self, ignored: bool) -> bool {
119 match self {
120 Self::Include => true,
121 Self::Exclude => !ignored,
122 Self::Only => ignored,
123 }
124 }
125
126 pub fn parse(value: &str) -> std::result::Result<Self, String> {
131 match value.trim().to_ascii_lowercase().as_str() {
132 "include" => Ok(Self::Include),
133 "exclude" => Ok(Self::Exclude),
134 "only" => Ok(Self::Only),
135 _ => Err("expected one of include, exclude, only".to_string()),
136 }
137 }
138
139 pub const fn label(self) -> &'static str {
141 match self {
142 Self::Include => "include",
143 Self::Exclude => "exclude",
144 Self::Only => "only",
145 }
146 }
147}
148
149#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
154pub enum Bound {
155 #[default]
157 All,
158 Limit(usize),
160}
161
162impl Bound {
163 pub fn admits(self, index: usize) -> bool {
165 match self {
166 Self::All => true,
167 Self::Limit(limit) => index < limit,
168 }
169 }
170
171 pub fn limit(self) -> Option<usize> {
173 match self {
174 Self::All => None,
175 Self::Limit(limit) => Some(limit),
176 }
177 }
178}
179
180#[derive(Clone, Debug, PartialEq, Eq)]
185pub struct ShareThreshold {
186 whole: u8,
187 fractional: Vec<u8>,
188}
189
190impl ShareThreshold {
191 pub fn one_percent() -> Self {
193 Self { whole: 1, fractional: Vec::new() }
194 }
195 pub fn parse(value: &str) -> Option<Self> {
197 let digits = value.trim().strip_suffix('%')?;
198 let (whole, fraction) = digits.split_once('.').unwrap_or((digits, ""));
199 if whole.is_empty()
200 || !whole.bytes().all(|byte| byte.is_ascii_digit())
201 || !fraction.bytes().all(|byte| byte.is_ascii_digit())
202 || (digits.contains('.') && fraction.is_empty())
203 {
204 return None;
205 }
206 let whole: u8 = whole.parse().ok()?;
207 if whole > 100 || (whole == 100 && fraction.bytes().any(|digit| digit != b'0')) {
208 return None;
209 }
210 Some(Self { whole, fractional: fraction.bytes().map(|digit| digit - b'0').collect() })
211 }
212
213 pub fn admits(&self, part: u64, whole: u64) -> bool {
215 if self.whole == 0 && self.fractional.iter().all(|digit| *digit == 0) {
216 return true;
217 }
218 if whole == 0 {
219 return false;
220 }
221 let numerator = u128::from(part) * 100;
222 let denominator = u128::from(whole);
223 let integral = numerator / denominator;
224 if integral != u128::from(self.whole) {
225 return integral > u128::from(self.whole);
226 }
227 let mut remainder = numerator % denominator;
228 for digit in &self.fractional {
229 remainder *= 10;
230 let actual = remainder / denominator;
231 if actual != u128::from(*digit) {
232 return actual > u128::from(*digit);
233 }
234 remainder %= denominator;
235 }
236 true
237 }
238
239 pub(crate) fn admitted_parts_bound(&self) -> Option<u64> {
254 const PLACES: usize = 18;
255 let places = self.fractional.len().min(PLACES);
256 let scale = 10_u128.pow(u32::try_from(places).expect("at most 18 places"));
257 let spelled = self.fractional[..places]
258 .iter()
259 .fold(u128::from(self.whole), |value, digit| value * 10 + u128::from(*digit));
260 if spelled == 0 {
261 return None;
262 }
263 u64::try_from((100 * scale).div_ceil(spelled)).ok()
265 }
266
267 pub fn label(&self) -> String {
269 let mut out = self.whole.to_string();
270 if !self.fractional.is_empty() {
271 out.push('.');
272 for digit in &self.fractional {
273 out.push(char::from(b'0' + *digit));
274 }
275 }
276 out.push('%');
277 out
278 }
279}
280
281#[derive(Clone, Debug, Default)]
283pub struct Selection {
284 pub include: Vec<Pattern>,
286 pub exclude: Vec<Pattern>,
288 pub min_size: Option<u64>,
290 pub kinds: Vec<EntryKind>,
292 pub modified: ModifiedWindow,
294 pub ignored: IgnoredEntries,
299 pub depth: Option<Bound>,
307 pub min_share: Option<ShareThreshold>,
309 pub breadth: Option<Bound>,
311 pub limit: Option<Bound>,
318 pub sort: Option<SortKey>,
324 pub reverse: bool,
326 pub size: SizeMetric,
328}
329
330#[derive(Clone, Copy, Debug)]
341pub struct Candidate<'a> {
342 pub relative: &'a Path,
344 pub name: &'a str,
346 pub kind: EntryKind,
348 pub bytes: u64,
350 pub allocated: u64,
352 pub mtime_ns: i64,
354 pub ignored: bool,
359}
360
361#[derive(Clone, Debug, Default)]
384pub struct EntrySelection {
385 pub query: Selection,
387 pub max_size: Option<u64>,
391 pub exclude_ignored: bool,
393 pub logical_extensions: Vec<String>,
397 pub exact_names: Vec<String>,
402 pub terminal_extensions: Vec<String>,
408 pub ancestor_names: Vec<String>,
413}
414
415#[derive(Clone, Copy, PartialEq, Eq, Debug)]
417pub(crate) enum NameIdentity {
418 Native,
420 Portable,
422}
423
424impl From<Selection> for EntrySelection {
425 fn from(query: Selection) -> Self {
426 Self { query, ..Self::default() }
427 }
428}
429
430impl Selection {
431 pub(crate) fn retained_heap_bytes(&self) -> usize {
433 let pattern_bytes =
434 self.include.iter().chain(&self.exclude).fold(0_usize, |total, pattern| {
435 total.saturating_add(pattern.retained_heap_bytes())
436 });
437 self.include
438 .capacity()
439 .saturating_add(self.exclude.capacity())
440 .saturating_mul(std::mem::size_of::<Pattern>())
441 .saturating_add(pattern_bytes)
442 .saturating_add(self.kinds.capacity().saturating_mul(std::mem::size_of::<EntryKind>()))
443 }
444
445 pub fn is_unfiltered(&self) -> bool {
451 self.include.is_empty()
452 && self.exclude.is_empty()
453 && self.min_size.is_none()
454 && self.kinds.is_empty()
455 && self.modified.is_unbounded()
456 && self.ignored == IgnoredEntries::Include
457 }
458
459 pub fn admits(&self, candidate: &Candidate<'_>) -> bool {
461 if !self.kinds.is_empty() && !self.kinds.contains(&candidate.kind) {
462 return false;
463 }
464 if !self.ignored.admits(candidate.ignored) {
465 return false;
466 }
467 if let Some(min_size) = self.min_size {
468 if self.size_of(candidate) < min_size {
469 return false;
470 }
471 }
472 if !self.modified.contains(candidate.mtime_ns) {
473 return false;
474 }
475 if self.exclude.iter().any(|p| p.matches(candidate.relative, candidate.name)) {
478 return false;
479 }
480 if self.include.is_empty() {
481 return true;
482 }
483 self.include.iter().any(|p| p.matches(candidate.relative, candidate.name))
484 }
485
486 pub fn size_of(&self, candidate: &Candidate<'_>) -> u64 {
488 match self.size {
489 SizeMetric::Apparent => candidate.bytes,
490 SizeMetric::Allocated => candidate.allocated,
491 }
492 }
493}
494
495impl EntrySelection {
496 pub fn admit_terminal_extension(&mut self, value: impl Into<String>) -> Result<()> {
503 let value = value.into();
504 if self.terminal_extensions.contains(&value) {
505 return Err(refusal(TERMINAL_KIND, &value, TERMINAL_UNIQUE));
506 }
507 check_value(TERMINAL_KIND, &value, TERMINAL_RULES)?;
508 self.terminal_extensions.push(value);
509 Ok(())
510 }
511
512 pub fn admit_ancestor_name(&mut self, value: impl Into<String>) -> Result<()> {
519 let value = value.into();
520 if self.ancestor_names.contains(&value) {
521 return Err(refusal(ANCESTOR_KIND, &value, ANCESTOR_UNIQUE));
522 }
523 check_value(ANCESTOR_KIND, &value, ANCESTOR_RULES)?;
524 self.ancestor_names.push(value);
525 Ok(())
526 }
527
528 pub fn validate(&self) -> Result<()> {
540 check_list(TERMINAL_KIND, &self.terminal_extensions, TERMINAL_UNIQUE, TERMINAL_RULES)?;
541 check_list(ANCESTOR_KIND, &self.ancestor_names, ANCESTOR_UNIQUE, ANCESTOR_RULES)
542 }
543
544 pub(crate) fn retained_heap_bytes(&self) -> usize {
546 self.query
547 .retained_heap_bytes()
548 .saturating_add(retained_strings(
549 &self.logical_extensions,
550 self.logical_extensions.capacity(),
551 ))
552 .saturating_add(retained_strings(&self.exact_names, self.exact_names.capacity()))
553 .saturating_add(retained_strings(
554 &self.terminal_extensions,
555 self.terminal_extensions.capacity(),
556 ))
557 .saturating_add(retained_strings(&self.ancestor_names, self.ancestor_names.capacity()))
558 }
559
560 pub fn is_unfiltered(&self) -> bool {
562 self.query.is_unfiltered()
563 && self.max_size.is_none()
564 && !self.exclude_ignored
565 && self.logical_extensions.is_empty()
566 && self.exact_names.is_empty()
567 && self.terminal_extensions.is_empty()
568 && self.ancestor_names.is_empty()
569 }
570
571 pub fn admits(&self, candidate: &Candidate<'_>) -> bool {
573 if !self.query.admits(candidate) {
574 return false;
575 }
576 if let Some(max_size) = self.max_size {
577 if self.query.size_of(candidate) > max_size {
578 return false;
579 }
580 }
581 if self.exclude_ignored && candidate.ignored {
582 return false;
583 }
584 if !self.logical_extensions.is_empty() || !self.exact_names.is_empty() {
585 if candidate.kind != EntryKind::File {
586 return false;
587 }
588 let extension_matches = crate::classify::logical_ext(candidate.name.as_ref())
589 .is_some_and(|extension| {
590 self.logical_extensions
591 .iter()
592 .any(|expected| extension.eq_ignore_ascii_case(expected))
593 });
594 let name_matches = self
595 .exact_names
596 .iter()
597 .any(|expected| candidate.name.eq_ignore_ascii_case(expected));
598 if !extension_matches && !name_matches {
599 return false;
600 }
601 }
602 if !self.terminal_extensions.is_empty() {
603 if candidate.kind != EntryKind::File {
604 return false;
605 }
606 let Some(suffix) = terminal_suffix(candidate.name) else {
607 return false;
608 };
609 if !self
610 .terminal_extensions
611 .iter()
612 .any(|expected| suffix.eq_ignore_ascii_case(expected))
613 {
614 return false;
615 }
616 }
617 if !self.ancestor_names.is_empty()
618 && !candidate.relative.parent().is_some_and(|parent| {
619 parent.components().any(|component| {
620 let std::path::Component::Normal(name) = component else {
621 return false;
622 };
623 self.ancestor_names.iter().any(|expected| name == expected.as_str())
624 })
625 })
626 {
627 return false;
628 }
629 true
630 }
631}
632
633const TERMINAL_KIND: &str = "terminal extension";
634const ANCESTOR_KIND: &str = "ancestor name";
635const TERMINAL_UNIQUE: &str = "terminal_extensions entries must be unique";
636const ANCESTOR_UNIQUE: &str = "ancestor_names entries must be unique";
637
638type Rule = (fn(&str) -> bool, &'static str);
640
641const TERMINAL_RULES: &[Rule] = &[
646 (undotted, "terminal_extensions entries must start with a dot"),
647 (not_lowercase, "terminal_extensions entries must be lowercase"),
648 (not_terminal_suffix, "terminal_extensions entries must be canonical terminal suffixes"),
649];
650
651const ANCESTOR_RULES: &[Rule] =
653 &[(not_path_component, "ancestor_names entries must be exact path-component names")];
654
655fn undotted(value: &str) -> bool {
656 !value.starts_with('.')
657}
658
659fn not_lowercase(value: &str) -> bool {
662 value.to_lowercase() != value
663}
664
665fn not_terminal_suffix(value: &str) -> bool {
666 value.chars().count() < 2 || value.contains(['/', '\\']) || value[1..].contains('.')
667}
668
669fn not_path_component(value: &str) -> bool {
670 value.is_empty() || value == "." || value == ".." || value.contains(['/', '\\'])
671}
672
673fn refusal(kind: &'static str, value: &str, hint: &str) -> Error {
674 Error::InvalidValue { kind, value: value.to_owned(), hint: hint.to_owned() }
675}
676
677fn check_value(kind: &'static str, value: &str, rules: &[Rule]) -> Result<()> {
679 match rules.iter().find(|(breaks, _)| breaks(value)) {
680 Some((_, hint)) => Err(refusal(kind, value, hint)),
681 None => Ok(()),
682 }
683}
684
685fn check_list(kind: &'static str, values: &[String], unique: &str, rules: &[Rule]) -> Result<()> {
688 let mut seen = std::collections::HashSet::with_capacity(values.len());
689 if let Some(repeated) = values.iter().find(|value| !seen.insert(value.as_str())) {
690 return Err(refusal(kind, repeated, unique));
691 }
692 for (breaks, hint) in rules {
693 if let Some(value) = values.iter().find(|value| breaks(value)) {
694 return Err(refusal(kind, value, hint));
695 }
696 }
697 Ok(())
698}
699
700fn retained_strings(values: &[String], capacity: usize) -> usize {
701 capacity.saturating_mul(std::mem::size_of::<String>()).saturating_add(
702 values.iter().fold(0_usize, |total, value| total.saturating_add(value.capacity())),
703 )
704}
705
706fn terminal_suffix(name: &str) -> Option<&str> {
707 let dot = name.rfind('.')?;
708 (dot > 0 && dot + 1 < name.len()).then_some(&name[dot..])
709}
710
711#[cfg(test)]
712mod tests {
713 #[test]
714 fn decimal_shares_compare_exactly_at_and_below_a_boundary() {
715 use super::ShareThreshold;
716 let share = ShareThreshold::parse("1%").expect("percentage");
717 assert!(share.admits(1, 100));
718 assert!(!share.admits(1, 101));
719 assert!(share.admits(u64::MAX / 100, u64::MAX / 100));
720 assert!(!share.admits(0, 0));
721 assert!(ShareThreshold::parse("0%").expect("zero").admits(0, 0));
722 assert!(
723 ShareThreshold::parse("0.0000000000000000001%")
724 .expect("fine precision")
725 .admits(1, u64::MAX)
726 );
727 for invalid in ["-1%", "101%", "100.1%", "NaN%", "1", "1.%", "1e1%"] {
728 assert!(ShareThreshold::parse(invalid).is_none(), "{invalid}");
729 }
730 }
731
732 #[test]
736 fn no_partition_admits_more_parts_than_the_share_bounds() {
737 use super::ShareThreshold;
738
739 fn partitions(whole: u64, largest: u64, prefix: &mut Vec<u64>, out: &mut Vec<Vec<u64>>) {
741 out.push(prefix.clone());
742 if prefix.len() == 12 {
743 return;
744 }
745 for part in (0..=largest.min(whole)).rev() {
746 prefix.push(part);
747 partitions(whole - part, part, prefix, out);
748 prefix.pop();
749 }
750 }
751
752 let bound =
753 |share: &str| ShareThreshold::parse(share).expect("percentage").admitted_parts_bound();
754 for (share, expected) in [
755 ("100%", Some(1)),
756 ("10%", Some(10)),
757 ("3%", Some(34)),
758 ("1%", Some(100)),
759 ("1.0%", Some(100)),
760 ("0.5%", Some(200)),
761 ("0.01%", Some(10_000)),
762 ("0.00152587890625%", Some(65_536)),
763 ("0.0015258%", Some(65_540)),
764 ("0%", None),
765 ("0.000%", None),
766 ("0.0000000000000000001%", None),
767 ] {
768 assert_eq!(bound(share), expected, "{share}");
769 }
770 assert_eq!(bound("0.0010000000000000000001%"), Some(100_000));
772
773 for share in ["100%", "50%", "33%", "33.4%", "25%", "12.5%", "10%", "9.99%"] {
774 let threshold = ShareThreshold::parse(share).expect("percentage");
775 let bound = threshold.admitted_parts_bound().expect("a positive share bounds parts");
776 for whole in 0..=12 {
777 let mut all = Vec::new();
778 partitions(whole, whole, &mut Vec::new(), &mut all);
779 for parts in all {
780 let total = parts.iter().sum::<u64>();
781 let admitted =
782 parts.iter().filter(|part| threshold.admits(**part, total)).count();
783 assert!(
784 u64::try_from(admitted).expect("small") <= bound,
785 "{share}: {parts:?} admits {admitted} > {bound}"
786 );
787 }
788 }
789 }
790 }
791 use super::*;
792 use std::path::PathBuf;
793
794 fn candidate(path: &str, kind: EntryKind, bytes: u64, mtime_ns: i64) -> (PathBuf, String) {
795 let relative = PathBuf::from(path);
796 let name = relative
797 .file_name()
798 .map(|name| name.to_string_lossy().into_owned())
799 .unwrap_or_default();
800 let _ = (kind, bytes, mtime_ns);
801 (relative, name)
802 }
803
804 fn entry_admits(
805 selection: &EntrySelection,
806 path: &str,
807 kind: EntryKind,
808 bytes: u64,
809 mtime: i64,
810 ignored: bool,
811 ) -> bool {
812 let (relative, name) = candidate(path, kind, bytes, mtime);
813 selection.admits(&Candidate {
814 relative: &relative,
815 name: &name,
816 kind,
817 bytes,
818 allocated: bytes.div_ceil(512) * 512,
819 mtime_ns: mtime,
820 ignored,
821 })
822 }
823
824 fn admits(selection: &Selection, path: &str, kind: EntryKind, bytes: u64, mtime: i64) -> bool {
825 classified_admits(selection, path, kind, bytes, mtime, false)
826 }
827
828 fn classified_admits(
829 selection: &Selection,
830 path: &str,
831 kind: EntryKind,
832 bytes: u64,
833 mtime: i64,
834 ignored: bool,
835 ) -> bool {
836 let (relative, name) = candidate(path, kind, bytes, mtime);
837 selection.admits(&Candidate {
838 relative: &relative,
839 name: &name,
840 kind,
841 bytes,
842 allocated: bytes.div_ceil(512) * 512,
843 mtime_ns: mtime,
844 ignored,
845 })
846 }
847
848 fn pattern(source: &str) -> Pattern {
849 Pattern::parse(source).expect("pattern compiles")
850 }
851
852 #[test]
853 fn a_default_selection_admits_everything_and_reads_the_fast_tier() {
854 let selection = Selection::default();
855 assert!(selection.is_unfiltered());
856 assert!(admits(&selection, "src/main.rs", EntryKind::File, 10, 5));
857 assert!(admits(&selection, "src", EntryKind::Dir, 0, 5));
858 }
859
860 #[test]
861 fn include_patterns_narrow_and_exclude_patterns_win() {
862 let mut selection = Selection { include: vec![pattern("*.rs")], ..Selection::default() };
863 assert!(!selection.is_unfiltered());
864 assert!(admits(&selection, "src/main.rs", EntryKind::File, 10, 5));
865 assert!(!admits(&selection, "src/main.toml", EntryKind::File, 10, 5));
866
867 selection.exclude.push(pattern("**/generated/**"));
870 assert!(!admits(&selection, "src/generated/api.rs", EntryKind::File, 10, 5));
871 assert!(admits(&selection, "src/hand/api.rs", EntryKind::File, 10, 5));
872 }
873
874 #[test]
875 fn min_size_follows_the_selected_metric() {
876 let apparent = Selection { min_size: Some(600), ..Selection::default() };
877 assert!(!admits(&apparent, "a.bin", EntryKind::File, 100, 0));
879
880 let allocated =
881 Selection { min_size: Some(600), size: SizeMetric::Allocated, ..Selection::default() };
882 assert!(admits(&allocated, "a.bin", EntryKind::File, 600, 0));
884 assert!(!admits(&allocated, "b.bin", EntryKind::File, 100, 0));
885 }
886
887 #[test]
888 fn the_modified_window_is_half_open() {
889 let selection = Selection {
890 modified: ModifiedWindow { since: Some(100), before: Some(200) },
891 ..Selection::default()
892 };
893 assert!(admits(&selection, "a", EntryKind::File, 1, 100));
896 assert!(admits(&selection, "b", EntryKind::File, 1, 150));
897 assert!(!admits(&selection, "c", EntryKind::File, 1, 200));
899 assert!(!admits(&selection, "d", EntryKind::File, 1, 99));
900 }
901
902 #[test]
903 fn kinds_filter_and_an_empty_list_means_every_kind() {
904 let files = Selection { kinds: vec![EntryKind::File], ..Selection::default() };
905 assert!(admits(&files, "a.rs", EntryKind::File, 1, 0));
906 assert!(!admits(&files, "src", EntryKind::Dir, 0, 0));
907
908 let both =
909 Selection { kinds: vec![EntryKind::File, EntryKind::Dir], ..Selection::default() };
910 assert!(admits(&both, "src", EntryKind::Dir, 0, 0));
911 assert!(!admits(&both, "link", EntryKind::Symlink, 0, 0));
912 }
913
914 #[test]
917 fn ignored_entries_select_one_partition_or_both() {
918 let include = Selection::default();
919 let exclude = Selection { ignored: IgnoredEntries::Exclude, ..Selection::default() };
920 let only = Selection { ignored: IgnoredEntries::Only, ..Selection::default() };
921 assert!(!exclude.is_unfiltered() && !only.is_unfiltered());
922 for (selection, admits_unignored, admits_ignored) in
923 [(&include, true, true), (&exclude, true, false), (&only, false, true)]
924 {
925 assert_eq!(
926 classified_admits(selection, "src/lib.rs", EntryKind::File, 1, 0, false),
927 admits_unignored,
928 "{:?} on an unignored entry",
929 selection.ignored
930 );
931 assert_eq!(
932 classified_admits(selection, "dist", EntryKind::Dir, 0, 0, true),
933 admits_ignored,
934 "{:?} on an ignored entry",
935 selection.ignored
936 );
937 }
938 for mode in [IgnoredEntries::Include, IgnoredEntries::Exclude, IgnoredEntries::Only] {
939 assert_eq!(IgnoredEntries::parse(mode.label()), Ok(mode));
940 }
941 assert_eq!(
942 IgnoredEntries::parse("some"),
943 Err("expected one of include, exclude, only".to_string())
944 );
945 }
946
947 #[test]
948 fn portable_catalog_predicates_compose_without_client_side_filtering() {
949 let selection = EntrySelection {
950 query: Selection { size: SizeMetric::Apparent, ..Selection::default() },
952 max_size: Some(10),
953 exclude_ignored: true,
954 terminal_extensions: vec![".rs".to_string(), ".md".to_string()],
955 ancestor_names: vec!["src".to_string(), "docs".to_string()],
956 ..EntrySelection::default()
957 };
958 assert!(entry_admits(&selection, "src/lib.rs", EntryKind::File, 10, 0, false));
959 assert!(entry_admits(&selection, "docs/readme.md", EntryKind::File, 9, 0, false));
960 assert!(!entry_admits(&selection, "src/lib.RS", EntryKind::File, 11, 0, false));
961 assert!(!entry_admits(&selection, "tests/lib.rs", EntryKind::File, 9, 0, false));
962 assert!(!entry_admits(&selection, "src/lib.rs", EntryKind::File, 9, 0, true));
963 assert!(!entry_admits(&selection, "src/.gitignore", EntryKind::File, 1, 0, false));
964 }
965
966 #[test]
967 fn logical_extensions_and_exact_names_form_one_name_identity_filter() {
968 let selection = EntrySelection {
969 logical_extensions: vec![".v2.zip".to_string()],
970 exact_names: vec!["makefile".to_string()],
971 ..EntrySelection::default()
972 };
973 assert!(entry_admits(&selection, "release.v2.zip", EntryKind::File, 1, 0, false));
974 assert!(entry_admits(&selection, "Makefile", EntryKind::File, 1, 0, false));
975 assert!(!entry_admits(&selection, "plain.zip", EntryKind::File, 1, 0, false));
976 assert!(!entry_admits(&selection, "README", EntryKind::File, 1, 0, false));
977 }
978
979 #[test]
980 fn terminal_extensions_and_ancestor_names_refuse_what_could_never_match() {
981 let refused = |hint: &str, outcome: Result<()>| match outcome {
982 Err(Error::InvalidValue { hint: actual, .. }) => {
983 assert!(actual.contains(hint), "{actual:?} names {hint:?}");
984 }
985 other => panic!("expected a refusal naming {hint:?}, got {other:?}"),
986 };
987 for (value, hint) in [
988 ("rs", "start with a dot"),
989 (".RS", "lowercase"),
990 (".Ée", "lowercase"),
991 (".", "canonical terminal suffixes"),
992 (".tar.gz", "canonical terminal suffixes"),
993 ("..", "canonical terminal suffixes"),
994 (".a/b", "canonical terminal suffixes"),
995 (".a\\b", "canonical terminal suffixes"),
996 ] {
997 let mut selection = EntrySelection::default();
998 refused(hint, selection.admit_terminal_extension(value));
999 assert!(selection.terminal_extensions.is_empty(), "{value:?} was not added");
1000 let written = EntrySelection {
1001 terminal_extensions: vec![value.to_string()],
1002 ..Default::default()
1003 };
1004 refused(hint, written.validate());
1005 }
1006 for value in ["", ".", "..", "a/b", "a\\b"] {
1007 let mut selection = EntrySelection::default();
1008 refused("exact path-component names", selection.admit_ancestor_name(value));
1009 let written =
1010 EntrySelection { ancestor_names: vec![value.to_string()], ..Default::default() };
1011 refused("exact path-component names", written.validate());
1012 }
1013
1014 let mut selection = EntrySelection::default();
1015 selection.admit_terminal_extension(".rs").expect("a canonical suffix");
1016 selection.admit_terminal_extension(".c++").expect("a non-alphanumeric suffix");
1017 refused("unique", selection.admit_terminal_extension(".rs"));
1018 selection.admit_ancestor_name("src").expect("a component");
1019 selection.admit_ancestor_name("x%FF").expect("an escaped component");
1020 selection.admit_ancestor_name("..foo").expect("dots inside a name");
1021 refused("unique", selection.admit_ancestor_name("src"));
1022 assert_eq!(selection.terminal_extensions, [".rs", ".c++"]);
1023 assert_eq!(selection.ancestor_names, ["src", "x%FF", "..foo"]);
1024 selection.validate().expect("admitted values validate");
1025 refused(
1026 "unique",
1027 EntrySelection {
1028 terminal_extensions: vec![".md".to_string(), ".md".to_string()],
1029 ..Default::default()
1030 }
1031 .validate(),
1032 );
1033 refused(
1034 "unique",
1035 EntrySelection {
1036 ancestor_names: vec!["docs".to_string(), "docs".to_string()],
1037 ..Default::default()
1038 }
1039 .validate(),
1040 );
1041 }
1042
1043 #[test]
1047 fn a_list_wrong_twice_is_refused_in_the_catalog_query_order() {
1048 let hint_of = |selection: EntrySelection| match selection.validate() {
1049 Err(Error::InvalidValue { hint, .. }) => hint,
1050 other => panic!("expected a refusal, got {other:?}"),
1051 };
1052 let terminal = |values: &[&str]| EntrySelection {
1053 terminal_extensions: values.iter().map(ToString::to_string).collect(),
1054 ..Default::default()
1055 };
1056 let ancestors = |values: &[&str]| EntrySelection {
1057 ancestor_names: values.iter().map(ToString::to_string).collect(),
1058 ..Default::default()
1059 };
1060 for (selection, hint) in [
1061 (terminal(&["rs", "rs"]), TERMINAL_UNIQUE),
1062 (terminal(&[".RS", ".RS"]), TERMINAL_UNIQUE),
1063 (terminal(&[".RS", "rs"]), "terminal_extensions entries must start with a dot"),
1064 (terminal(&[".tar.gz", ".RS"]), "terminal_extensions entries must be lowercase"),
1065 (ancestors(&["..", ".."]), ANCESTOR_UNIQUE),
1066 (
1067 EntrySelection {
1068 terminal_extensions: vec!["rs".to_string()],
1069 ancestor_names: vec!["src".to_string(), "src".to_string()],
1070 ..Default::default()
1071 },
1072 "terminal_extensions entries must start with a dot",
1073 ),
1074 ] {
1075 assert_eq!(hint_of(selection), hint);
1076 }
1077 }
1078
1079 #[test]
1080 fn bounds_admit_by_index_and_report_their_limit() {
1081 assert!(Bound::All.admits(1_000_000));
1082 assert_eq!(Bound::All.limit(), None);
1083 assert!(Bound::Limit(2).admits(0));
1084 assert!(Bound::Limit(2).admits(1));
1085 assert!(!Bound::Limit(2).admits(2));
1086 assert_eq!(Bound::Limit(2).limit(), Some(2));
1087 assert!(!Bound::Limit(0).admits(0));
1089 }
1090
1091 #[test]
1092 fn an_unbounded_window_does_not_constrain() {
1093 assert!(ModifiedWindow::default().is_unbounded());
1094 assert!(ModifiedWindow::default().contains(i64::MIN));
1095 assert!(ModifiedWindow::default().contains(i64::MAX));
1096 }
1097}