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
37impl Default for SizeMetric {
43 fn default() -> Self {
44 crate::query::Request::DEFAULTS.size
45 }
46}
47
48#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
50pub enum SortKey {
51 #[default]
53 Size,
54 Count,
56 Mtime,
58 Name,
60 Metric(&'static str),
62}
63
64#[derive(Clone, Copy, Debug, Default)]
71pub struct ModifiedWindow {
72 pub since: Option<i64>,
74 pub before: Option<i64>,
76}
77
78impl ModifiedWindow {
79 pub fn contains(&self, mtime_ns: i64) -> bool {
81 self.since.is_none_or(|since| mtime_ns >= since)
82 && self.before.is_none_or(|before| mtime_ns < before)
83 }
84
85 pub fn is_unbounded(&self) -> bool {
87 self.since.is_none() && self.before.is_none()
88 }
89}
90
91#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, Hash)]
98pub enum IgnoredEntries {
99 #[default]
101 Include,
102 Exclude,
104 Only,
106}
107
108impl IgnoredEntries {
109 pub const fn admits(self, ignored: bool) -> bool {
111 match self {
112 Self::Include => true,
113 Self::Exclude => !ignored,
114 Self::Only => ignored,
115 }
116 }
117
118 pub fn parse(value: &str) -> std::result::Result<Self, String> {
123 match value.trim().to_ascii_lowercase().as_str() {
124 "include" => Ok(Self::Include),
125 "exclude" => Ok(Self::Exclude),
126 "only" => Ok(Self::Only),
127 _ => Err("expected one of include, exclude, only".to_string()),
128 }
129 }
130
131 pub const fn label(self) -> &'static str {
133 match self {
134 Self::Include => "include",
135 Self::Exclude => "exclude",
136 Self::Only => "only",
137 }
138 }
139}
140
141#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
146pub enum Bound {
147 #[default]
149 All,
150 Limit(usize),
152}
153
154impl Bound {
155 pub fn admits(self, index: usize) -> bool {
157 match self {
158 Self::All => true,
159 Self::Limit(limit) => index < limit,
160 }
161 }
162
163 pub fn limit(self) -> Option<usize> {
165 match self {
166 Self::All => None,
167 Self::Limit(limit) => Some(limit),
168 }
169 }
170}
171
172#[derive(Clone, Debug, PartialEq, Eq)]
177pub struct ShareThreshold {
178 whole: u8,
179 fractional: Vec<u8>,
180}
181
182impl ShareThreshold {
183 pub fn one_percent() -> Self {
185 Self { whole: 1, fractional: Vec::new() }
186 }
187 pub fn parse(value: &str) -> Option<Self> {
189 let digits = value.trim().strip_suffix('%')?;
190 let (whole, fraction) = digits.split_once('.').unwrap_or((digits, ""));
191 if whole.is_empty()
192 || !whole.bytes().all(|byte| byte.is_ascii_digit())
193 || !fraction.bytes().all(|byte| byte.is_ascii_digit())
194 || (digits.contains('.') && fraction.is_empty())
195 {
196 return None;
197 }
198 let whole: u8 = whole.parse().ok()?;
199 if whole > 100 || (whole == 100 && fraction.bytes().any(|digit| digit != b'0')) {
200 return None;
201 }
202 Some(Self { whole, fractional: fraction.bytes().map(|digit| digit - b'0').collect() })
203 }
204
205 pub fn admits(&self, part: u64, whole: u64) -> bool {
207 if self.whole == 0 && self.fractional.iter().all(|digit| *digit == 0) {
208 return true;
209 }
210 if whole == 0 {
211 return false;
212 }
213 let numerator = u128::from(part) * 100;
214 let denominator = u128::from(whole);
215 let integral = numerator / denominator;
216 if integral != u128::from(self.whole) {
217 return integral > u128::from(self.whole);
218 }
219 let mut remainder = numerator % denominator;
220 for digit in &self.fractional {
221 remainder *= 10;
222 let actual = remainder / denominator;
223 if actual != u128::from(*digit) {
224 return actual > u128::from(*digit);
225 }
226 remainder %= denominator;
227 }
228 true
229 }
230
231 pub fn label(&self) -> String {
233 let mut out = self.whole.to_string();
234 if !self.fractional.is_empty() {
235 out.push('.');
236 for digit in &self.fractional {
237 out.push(char::from(b'0' + *digit));
238 }
239 }
240 out.push('%');
241 out
242 }
243}
244
245#[derive(Clone, Debug, Default)]
247pub struct Selection {
248 pub include: Vec<Pattern>,
250 pub exclude: Vec<Pattern>,
252 pub min_size: Option<u64>,
254 pub kinds: Vec<EntryKind>,
256 pub modified: ModifiedWindow,
258 pub ignored: IgnoredEntries,
263 pub depth: Option<Bound>,
271 pub min_share: Option<ShareThreshold>,
273 pub breadth: Option<Bound>,
275 pub limit: Option<Bound>,
282 pub sort: Option<SortKey>,
288 pub reverse: bool,
290 pub size: SizeMetric,
292}
293
294#[derive(Clone, Copy, Debug)]
305pub struct Candidate<'a> {
306 pub relative: &'a Path,
308 pub name: &'a str,
310 pub kind: EntryKind,
312 pub bytes: u64,
314 pub allocated: u64,
316 pub mtime_ns: i64,
318 pub ignored: bool,
323}
324
325#[derive(Clone, Debug, Default)]
348pub struct EntrySelection {
349 pub query: Selection,
351 pub max_size: Option<u64>,
355 pub exclude_ignored: bool,
357 pub logical_extensions: Vec<String>,
361 pub exact_names: Vec<String>,
366 pub terminal_extensions: Vec<String>,
372 pub ancestor_names: Vec<String>,
377}
378
379#[derive(Clone, Copy, PartialEq, Eq, Debug)]
381pub(crate) enum NameIdentity {
382 Native,
384 Portable,
386}
387
388impl From<Selection> for EntrySelection {
389 fn from(query: Selection) -> Self {
390 Self { query, ..Self::default() }
391 }
392}
393
394impl Selection {
395 pub(crate) fn retained_heap_bytes(&self) -> usize {
397 let pattern_bytes =
398 self.include.iter().chain(&self.exclude).fold(0_usize, |total, pattern| {
399 total.saturating_add(pattern.retained_heap_bytes())
400 });
401 self.include
402 .capacity()
403 .saturating_add(self.exclude.capacity())
404 .saturating_mul(std::mem::size_of::<Pattern>())
405 .saturating_add(pattern_bytes)
406 .saturating_add(self.kinds.capacity().saturating_mul(std::mem::size_of::<EntryKind>()))
407 }
408
409 pub fn is_unfiltered(&self) -> bool {
415 self.include.is_empty()
416 && self.exclude.is_empty()
417 && self.min_size.is_none()
418 && self.kinds.is_empty()
419 && self.modified.is_unbounded()
420 && self.ignored == IgnoredEntries::Include
421 }
422
423 pub fn admits(&self, candidate: &Candidate<'_>) -> bool {
425 if !self.kinds.is_empty() && !self.kinds.contains(&candidate.kind) {
426 return false;
427 }
428 if !self.ignored.admits(candidate.ignored) {
429 return false;
430 }
431 if let Some(min_size) = self.min_size {
432 if self.size_of(candidate) < min_size {
433 return false;
434 }
435 }
436 if !self.modified.contains(candidate.mtime_ns) {
437 return false;
438 }
439 if self.exclude.iter().any(|p| p.matches(candidate.relative, candidate.name)) {
442 return false;
443 }
444 if self.include.is_empty() {
445 return true;
446 }
447 self.include.iter().any(|p| p.matches(candidate.relative, candidate.name))
448 }
449
450 pub fn size_of(&self, candidate: &Candidate<'_>) -> u64 {
452 match self.size {
453 SizeMetric::Apparent => candidate.bytes,
454 SizeMetric::Allocated => candidate.allocated,
455 }
456 }
457}
458
459impl EntrySelection {
460 pub fn admit_terminal_extension(&mut self, value: impl Into<String>) -> Result<()> {
467 let value = value.into();
468 if self.terminal_extensions.contains(&value) {
469 return Err(refusal(TERMINAL_KIND, &value, TERMINAL_UNIQUE));
470 }
471 check_value(TERMINAL_KIND, &value, TERMINAL_RULES)?;
472 self.terminal_extensions.push(value);
473 Ok(())
474 }
475
476 pub fn admit_ancestor_name(&mut self, value: impl Into<String>) -> Result<()> {
483 let value = value.into();
484 if self.ancestor_names.contains(&value) {
485 return Err(refusal(ANCESTOR_KIND, &value, ANCESTOR_UNIQUE));
486 }
487 check_value(ANCESTOR_KIND, &value, ANCESTOR_RULES)?;
488 self.ancestor_names.push(value);
489 Ok(())
490 }
491
492 pub fn validate(&self) -> Result<()> {
504 check_list(TERMINAL_KIND, &self.terminal_extensions, TERMINAL_UNIQUE, TERMINAL_RULES)?;
505 check_list(ANCESTOR_KIND, &self.ancestor_names, ANCESTOR_UNIQUE, ANCESTOR_RULES)
506 }
507
508 pub(crate) fn retained_heap_bytes(&self) -> usize {
510 self.query
511 .retained_heap_bytes()
512 .saturating_add(retained_strings(
513 &self.logical_extensions,
514 self.logical_extensions.capacity(),
515 ))
516 .saturating_add(retained_strings(&self.exact_names, self.exact_names.capacity()))
517 .saturating_add(retained_strings(
518 &self.terminal_extensions,
519 self.terminal_extensions.capacity(),
520 ))
521 .saturating_add(retained_strings(&self.ancestor_names, self.ancestor_names.capacity()))
522 }
523
524 pub fn is_unfiltered(&self) -> bool {
526 self.query.is_unfiltered()
527 && self.max_size.is_none()
528 && !self.exclude_ignored
529 && self.logical_extensions.is_empty()
530 && self.exact_names.is_empty()
531 && self.terminal_extensions.is_empty()
532 && self.ancestor_names.is_empty()
533 }
534
535 pub fn admits(&self, candidate: &Candidate<'_>) -> bool {
537 if !self.query.admits(candidate) {
538 return false;
539 }
540 if let Some(max_size) = self.max_size {
541 if self.query.size_of(candidate) > max_size {
542 return false;
543 }
544 }
545 if self.exclude_ignored && candidate.ignored {
546 return false;
547 }
548 if !self.logical_extensions.is_empty() || !self.exact_names.is_empty() {
549 if candidate.kind != EntryKind::File {
550 return false;
551 }
552 let extension_matches = crate::classify::logical_ext(candidate.name.as_ref())
553 .is_some_and(|extension| {
554 self.logical_extensions
555 .iter()
556 .any(|expected| extension.eq_ignore_ascii_case(expected))
557 });
558 let name_matches = self
559 .exact_names
560 .iter()
561 .any(|expected| candidate.name.eq_ignore_ascii_case(expected));
562 if !extension_matches && !name_matches {
563 return false;
564 }
565 }
566 if !self.terminal_extensions.is_empty() {
567 if candidate.kind != EntryKind::File {
568 return false;
569 }
570 let Some(suffix) = terminal_suffix(candidate.name) else {
571 return false;
572 };
573 if !self
574 .terminal_extensions
575 .iter()
576 .any(|expected| suffix.eq_ignore_ascii_case(expected))
577 {
578 return false;
579 }
580 }
581 if !self.ancestor_names.is_empty()
582 && !candidate.relative.parent().is_some_and(|parent| {
583 parent.components().any(|component| {
584 let std::path::Component::Normal(name) = component else {
585 return false;
586 };
587 self.ancestor_names.iter().any(|expected| name == expected.as_str())
588 })
589 })
590 {
591 return false;
592 }
593 true
594 }
595}
596
597const TERMINAL_KIND: &str = "terminal extension";
598const ANCESTOR_KIND: &str = "ancestor name";
599const TERMINAL_UNIQUE: &str = "terminal_extensions entries must be unique";
600const ANCESTOR_UNIQUE: &str = "ancestor_names entries must be unique";
601
602type Rule = (fn(&str) -> bool, &'static str);
604
605const TERMINAL_RULES: &[Rule] = &[
610 (undotted, "terminal_extensions entries must start with a dot"),
611 (not_lowercase, "terminal_extensions entries must be lowercase"),
612 (not_terminal_suffix, "terminal_extensions entries must be canonical terminal suffixes"),
613];
614
615const ANCESTOR_RULES: &[Rule] =
617 &[(not_path_component, "ancestor_names entries must be exact path-component names")];
618
619fn undotted(value: &str) -> bool {
620 !value.starts_with('.')
621}
622
623fn not_lowercase(value: &str) -> bool {
626 value.to_lowercase() != value
627}
628
629fn not_terminal_suffix(value: &str) -> bool {
630 value.chars().count() < 2 || value.contains(['/', '\\']) || value[1..].contains('.')
631}
632
633fn not_path_component(value: &str) -> bool {
634 value.is_empty() || value == "." || value == ".." || value.contains(['/', '\\'])
635}
636
637fn refusal(kind: &'static str, value: &str, hint: &str) -> Error {
638 Error::InvalidValue { kind, value: value.to_owned(), hint: hint.to_owned() }
639}
640
641fn check_value(kind: &'static str, value: &str, rules: &[Rule]) -> Result<()> {
643 match rules.iter().find(|(breaks, _)| breaks(value)) {
644 Some((_, hint)) => Err(refusal(kind, value, hint)),
645 None => Ok(()),
646 }
647}
648
649fn check_list(kind: &'static str, values: &[String], unique: &str, rules: &[Rule]) -> Result<()> {
652 let mut seen = std::collections::HashSet::with_capacity(values.len());
653 if let Some(repeated) = values.iter().find(|value| !seen.insert(value.as_str())) {
654 return Err(refusal(kind, repeated, unique));
655 }
656 for (breaks, hint) in rules {
657 if let Some(value) = values.iter().find(|value| breaks(value)) {
658 return Err(refusal(kind, value, hint));
659 }
660 }
661 Ok(())
662}
663
664fn retained_strings(values: &[String], capacity: usize) -> usize {
665 capacity.saturating_mul(std::mem::size_of::<String>()).saturating_add(
666 values.iter().fold(0_usize, |total, value| total.saturating_add(value.capacity())),
667 )
668}
669
670fn terminal_suffix(name: &str) -> Option<&str> {
671 let dot = name.rfind('.')?;
672 (dot > 0 && dot + 1 < name.len()).then_some(&name[dot..])
673}
674
675#[cfg(test)]
676mod tests {
677 #[test]
678 fn decimal_shares_compare_exactly_at_and_below_a_boundary() {
679 use super::ShareThreshold;
680 let share = ShareThreshold::parse("1%").expect("percentage");
681 assert!(share.admits(1, 100));
682 assert!(!share.admits(1, 101));
683 assert!(share.admits(u64::MAX / 100, u64::MAX / 100));
684 assert!(!share.admits(0, 0));
685 assert!(ShareThreshold::parse("0%").expect("zero").admits(0, 0));
686 assert!(
687 ShareThreshold::parse("0.0000000000000000001%")
688 .expect("fine precision")
689 .admits(1, u64::MAX)
690 );
691 for invalid in ["-1%", "101%", "100.1%", "NaN%", "1", "1.%", "1e1%"] {
692 assert!(ShareThreshold::parse(invalid).is_none(), "{invalid}");
693 }
694 }
695 use super::*;
696 use std::path::PathBuf;
697
698 fn candidate(path: &str, kind: EntryKind, bytes: u64, mtime_ns: i64) -> (PathBuf, String) {
699 let relative = PathBuf::from(path);
700 let name = relative
701 .file_name()
702 .map(|name| name.to_string_lossy().into_owned())
703 .unwrap_or_default();
704 let _ = (kind, bytes, mtime_ns);
705 (relative, name)
706 }
707
708 fn entry_admits(
709 selection: &EntrySelection,
710 path: &str,
711 kind: EntryKind,
712 bytes: u64,
713 mtime: i64,
714 ignored: bool,
715 ) -> bool {
716 let (relative, name) = candidate(path, kind, bytes, mtime);
717 selection.admits(&Candidate {
718 relative: &relative,
719 name: &name,
720 kind,
721 bytes,
722 allocated: bytes.div_ceil(512) * 512,
723 mtime_ns: mtime,
724 ignored,
725 })
726 }
727
728 fn admits(selection: &Selection, path: &str, kind: EntryKind, bytes: u64, mtime: i64) -> bool {
729 classified_admits(selection, path, kind, bytes, mtime, false)
730 }
731
732 fn classified_admits(
733 selection: &Selection,
734 path: &str,
735 kind: EntryKind,
736 bytes: u64,
737 mtime: i64,
738 ignored: bool,
739 ) -> bool {
740 let (relative, name) = candidate(path, kind, bytes, mtime);
741 selection.admits(&Candidate {
742 relative: &relative,
743 name: &name,
744 kind,
745 bytes,
746 allocated: bytes.div_ceil(512) * 512,
747 mtime_ns: mtime,
748 ignored,
749 })
750 }
751
752 fn pattern(source: &str) -> Pattern {
753 Pattern::parse(source).expect("pattern compiles")
754 }
755
756 #[test]
757 fn a_default_selection_admits_everything_and_reads_the_fast_tier() {
758 let selection = Selection::default();
759 assert!(selection.is_unfiltered());
760 assert!(admits(&selection, "src/main.rs", EntryKind::File, 10, 5));
761 assert!(admits(&selection, "src", EntryKind::Dir, 0, 5));
762 }
763
764 #[test]
765 fn include_patterns_narrow_and_exclude_patterns_win() {
766 let mut selection = Selection { include: vec![pattern("*.rs")], ..Selection::default() };
767 assert!(!selection.is_unfiltered());
768 assert!(admits(&selection, "src/main.rs", EntryKind::File, 10, 5));
769 assert!(!admits(&selection, "src/main.toml", EntryKind::File, 10, 5));
770
771 selection.exclude.push(pattern("**/generated/**"));
774 assert!(!admits(&selection, "src/generated/api.rs", EntryKind::File, 10, 5));
775 assert!(admits(&selection, "src/hand/api.rs", EntryKind::File, 10, 5));
776 }
777
778 #[test]
779 fn min_size_follows_the_selected_metric() {
780 let apparent = Selection { min_size: Some(600), ..Selection::default() };
781 assert!(!admits(&apparent, "a.bin", EntryKind::File, 100, 0));
783
784 let allocated =
785 Selection { min_size: Some(600), size: SizeMetric::Allocated, ..Selection::default() };
786 assert!(admits(&allocated, "a.bin", EntryKind::File, 600, 0));
788 assert!(!admits(&allocated, "b.bin", EntryKind::File, 100, 0));
789 }
790
791 #[test]
792 fn the_modified_window_is_half_open() {
793 let selection = Selection {
794 modified: ModifiedWindow { since: Some(100), before: Some(200) },
795 ..Selection::default()
796 };
797 assert!(admits(&selection, "a", EntryKind::File, 1, 100));
800 assert!(admits(&selection, "b", EntryKind::File, 1, 150));
801 assert!(!admits(&selection, "c", EntryKind::File, 1, 200));
803 assert!(!admits(&selection, "d", EntryKind::File, 1, 99));
804 }
805
806 #[test]
807 fn kinds_filter_and_an_empty_list_means_every_kind() {
808 let files = Selection { kinds: vec![EntryKind::File], ..Selection::default() };
809 assert!(admits(&files, "a.rs", EntryKind::File, 1, 0));
810 assert!(!admits(&files, "src", EntryKind::Dir, 0, 0));
811
812 let both =
813 Selection { kinds: vec![EntryKind::File, EntryKind::Dir], ..Selection::default() };
814 assert!(admits(&both, "src", EntryKind::Dir, 0, 0));
815 assert!(!admits(&both, "link", EntryKind::Symlink, 0, 0));
816 }
817
818 #[test]
821 fn ignored_entries_select_one_partition_or_both() {
822 let include = Selection::default();
823 let exclude = Selection { ignored: IgnoredEntries::Exclude, ..Selection::default() };
824 let only = Selection { ignored: IgnoredEntries::Only, ..Selection::default() };
825 assert!(!exclude.is_unfiltered() && !only.is_unfiltered());
826 for (selection, admits_unignored, admits_ignored) in
827 [(&include, true, true), (&exclude, true, false), (&only, false, true)]
828 {
829 assert_eq!(
830 classified_admits(selection, "src/lib.rs", EntryKind::File, 1, 0, false),
831 admits_unignored,
832 "{:?} on an unignored entry",
833 selection.ignored
834 );
835 assert_eq!(
836 classified_admits(selection, "dist", EntryKind::Dir, 0, 0, true),
837 admits_ignored,
838 "{:?} on an ignored entry",
839 selection.ignored
840 );
841 }
842 for mode in [IgnoredEntries::Include, IgnoredEntries::Exclude, IgnoredEntries::Only] {
843 assert_eq!(IgnoredEntries::parse(mode.label()), Ok(mode));
844 }
845 assert_eq!(
846 IgnoredEntries::parse("some"),
847 Err("expected one of include, exclude, only".to_string())
848 );
849 }
850
851 #[test]
852 fn portable_catalog_predicates_compose_without_client_side_filtering() {
853 let selection = EntrySelection {
854 query: Selection { size: SizeMetric::Apparent, ..Selection::default() },
856 max_size: Some(10),
857 exclude_ignored: true,
858 terminal_extensions: vec![".rs".to_string(), ".md".to_string()],
859 ancestor_names: vec!["src".to_string(), "docs".to_string()],
860 ..EntrySelection::default()
861 };
862 assert!(entry_admits(&selection, "src/lib.rs", EntryKind::File, 10, 0, false));
863 assert!(entry_admits(&selection, "docs/readme.md", EntryKind::File, 9, 0, false));
864 assert!(!entry_admits(&selection, "src/lib.RS", EntryKind::File, 11, 0, false));
865 assert!(!entry_admits(&selection, "tests/lib.rs", EntryKind::File, 9, 0, false));
866 assert!(!entry_admits(&selection, "src/lib.rs", EntryKind::File, 9, 0, true));
867 assert!(!entry_admits(&selection, "src/.gitignore", EntryKind::File, 1, 0, false));
868 }
869
870 #[test]
871 fn logical_extensions_and_exact_names_form_one_name_identity_filter() {
872 let selection = EntrySelection {
873 logical_extensions: vec![".v2.zip".to_string()],
874 exact_names: vec!["makefile".to_string()],
875 ..EntrySelection::default()
876 };
877 assert!(entry_admits(&selection, "release.v2.zip", EntryKind::File, 1, 0, false));
878 assert!(entry_admits(&selection, "Makefile", EntryKind::File, 1, 0, false));
879 assert!(!entry_admits(&selection, "plain.zip", EntryKind::File, 1, 0, false));
880 assert!(!entry_admits(&selection, "README", EntryKind::File, 1, 0, false));
881 }
882
883 #[test]
884 fn terminal_extensions_and_ancestor_names_refuse_what_could_never_match() {
885 let refused = |hint: &str, outcome: Result<()>| match outcome {
886 Err(Error::InvalidValue { hint: actual, .. }) => {
887 assert!(actual.contains(hint), "{actual:?} names {hint:?}");
888 }
889 other => panic!("expected a refusal naming {hint:?}, got {other:?}"),
890 };
891 for (value, hint) in [
892 ("rs", "start with a dot"),
893 (".RS", "lowercase"),
894 (".Ée", "lowercase"),
895 (".", "canonical terminal suffixes"),
896 (".tar.gz", "canonical terminal suffixes"),
897 ("..", "canonical terminal suffixes"),
898 (".a/b", "canonical terminal suffixes"),
899 (".a\\b", "canonical terminal suffixes"),
900 ] {
901 let mut selection = EntrySelection::default();
902 refused(hint, selection.admit_terminal_extension(value));
903 assert!(selection.terminal_extensions.is_empty(), "{value:?} was not added");
904 let written = EntrySelection {
905 terminal_extensions: vec![value.to_string()],
906 ..Default::default()
907 };
908 refused(hint, written.validate());
909 }
910 for value in ["", ".", "..", "a/b", "a\\b"] {
911 let mut selection = EntrySelection::default();
912 refused("exact path-component names", selection.admit_ancestor_name(value));
913 let written =
914 EntrySelection { ancestor_names: vec![value.to_string()], ..Default::default() };
915 refused("exact path-component names", written.validate());
916 }
917
918 let mut selection = EntrySelection::default();
919 selection.admit_terminal_extension(".rs").expect("a canonical suffix");
920 selection.admit_terminal_extension(".c++").expect("a non-alphanumeric suffix");
921 refused("unique", selection.admit_terminal_extension(".rs"));
922 selection.admit_ancestor_name("src").expect("a component");
923 selection.admit_ancestor_name("x%FF").expect("an escaped component");
924 selection.admit_ancestor_name("..foo").expect("dots inside a name");
925 refused("unique", selection.admit_ancestor_name("src"));
926 assert_eq!(selection.terminal_extensions, [".rs", ".c++"]);
927 assert_eq!(selection.ancestor_names, ["src", "x%FF", "..foo"]);
928 selection.validate().expect("admitted values validate");
929 refused(
930 "unique",
931 EntrySelection {
932 terminal_extensions: vec![".md".to_string(), ".md".to_string()],
933 ..Default::default()
934 }
935 .validate(),
936 );
937 refused(
938 "unique",
939 EntrySelection {
940 ancestor_names: vec!["docs".to_string(), "docs".to_string()],
941 ..Default::default()
942 }
943 .validate(),
944 );
945 }
946
947 #[test]
951 fn a_list_wrong_twice_is_refused_in_the_catalog_query_order() {
952 let hint_of = |selection: EntrySelection| match selection.validate() {
953 Err(Error::InvalidValue { hint, .. }) => hint,
954 other => panic!("expected a refusal, got {other:?}"),
955 };
956 let terminal = |values: &[&str]| EntrySelection {
957 terminal_extensions: values.iter().map(ToString::to_string).collect(),
958 ..Default::default()
959 };
960 let ancestors = |values: &[&str]| EntrySelection {
961 ancestor_names: values.iter().map(ToString::to_string).collect(),
962 ..Default::default()
963 };
964 for (selection, hint) in [
965 (terminal(&["rs", "rs"]), TERMINAL_UNIQUE),
966 (terminal(&[".RS", ".RS"]), TERMINAL_UNIQUE),
967 (terminal(&[".RS", "rs"]), "terminal_extensions entries must start with a dot"),
968 (terminal(&[".tar.gz", ".RS"]), "terminal_extensions entries must be lowercase"),
969 (ancestors(&["..", ".."]), ANCESTOR_UNIQUE),
970 (
971 EntrySelection {
972 terminal_extensions: vec!["rs".to_string()],
973 ancestor_names: vec!["src".to_string(), "src".to_string()],
974 ..Default::default()
975 },
976 "terminal_extensions entries must start with a dot",
977 ),
978 ] {
979 assert_eq!(hint_of(selection), hint);
980 }
981 }
982
983 #[test]
984 fn bounds_admit_by_index_and_report_their_limit() {
985 assert!(Bound::All.admits(1_000_000));
986 assert_eq!(Bound::All.limit(), None);
987 assert!(Bound::Limit(2).admits(0));
988 assert!(Bound::Limit(2).admits(1));
989 assert!(!Bound::Limit(2).admits(2));
990 assert_eq!(Bound::Limit(2).limit(), Some(2));
991 assert!(!Bound::Limit(0).admits(0));
993 }
994
995 #[test]
996 fn an_unbounded_window_does_not_constrain() {
997 assert!(ModifiedWindow::default().is_unbounded());
998 assert!(ModifiedWindow::default().contains(i64::MIN));
999 assert!(ModifiedWindow::default().contains(i64::MAX));
1000 }
1001}