use std::fmt;
use regex::Regex;
use crate::rules::Kind;
use crate::walk::Hit;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Tier {
Named,
Ignored,
}
impl Tier {
#[must_use]
pub fn of(hit: &Hit) -> Option<Self> {
if hit.is_ignored_file() {
return None;
}
Some(match hit.rule() {
Some(_) => Self::Named,
None => Self::Ignored,
})
}
}
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
pub struct Tiers {
pub named: bool,
pub ignored: bool,
}
impl Tiers {
#[must_use]
pub const fn both() -> Self {
Self {
named: true,
ignored: true,
}
}
#[must_use]
pub const fn named() -> Self {
Self {
named: true,
ignored: false,
}
}
#[must_use]
pub const fn ignored() -> Self {
Self {
named: false,
ignored: true,
}
}
#[must_use]
pub const fn has(self, tier: Tier) -> bool {
match tier {
Tier::Named => self.named,
Tier::Ignored => self.ignored,
}
}
pub const ALL: [Self; 3] = [Self::named(), Self::both(), Self::ignored()];
#[must_use]
pub fn next(self) -> Self {
let at = Self::ALL
.iter()
.position(|&other| other == self)
.unwrap_or(0);
Self::ALL[(at + 1) % Self::ALL.len()]
}
#[must_use]
pub fn label(self) -> &'static str {
match (self.named, self.ignored) {
(true, true) => "named + gitignored",
(true, false) => "named",
(false, true) => "gitignored",
(false, false) => "no tier",
}
}
}
#[allow(clippy::struct_excessive_bools)]
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
pub struct Kinds {
pub unrecoverable: bool,
pub dependencies: bool,
pub build: bool,
pub cache: bool,
pub noise: bool,
}
impl Kinds {
#[must_use]
pub const fn all() -> Self {
Self {
unrecoverable: true,
dependencies: true,
build: true,
cache: true,
noise: true,
}
}
#[must_use]
pub const fn none() -> Self {
Self {
unrecoverable: false,
dependencies: false,
build: false,
cache: false,
noise: false,
}
}
#[must_use]
pub const fn only(kind: Kind) -> Self {
Self {
unrecoverable: matches!(kind, Kind::Unrecoverable),
dependencies: matches!(kind, Kind::Dependencies),
build: matches!(kind, Kind::Build),
cache: matches!(kind, Kind::Cache),
noise: matches!(kind, Kind::Noise),
}
}
#[must_use]
pub const fn has(self, kind: Kind) -> bool {
match kind {
Kind::Unrecoverable => self.unrecoverable,
Kind::Dependencies => self.dependencies,
Kind::Build => self.build,
Kind::Cache => self.cache,
Kind::Noise => self.noise,
}
}
#[must_use]
pub const fn toggling(mut self, kind: Kind) -> Self {
match kind {
Kind::Unrecoverable => self.unrecoverable = !self.unrecoverable,
Kind::Dependencies => self.dependencies = !self.dependencies,
Kind::Build => self.build = !self.build,
Kind::Cache => self.cache = !self.cache,
Kind::Noise => self.noise = !self.noise,
}
self
}
#[must_use]
pub fn label(self) -> String {
if self == Self::all() {
return "every kind".to_owned();
}
let said: Vec<&str> = Kind::ALL
.into_iter()
.filter(|&kind| self.has(kind))
.map(Kind::short)
.collect();
if said.is_empty() {
return "none".to_owned();
}
said.join(" + ")
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Preset {
#[default]
Default,
Dependencies,
AllIgnored,
All,
}
impl Preset {
pub const ALL: [Self; 4] = [
Self::Default,
Self::Dependencies,
Self::AllIgnored,
Self::All,
];
#[must_use]
pub fn next(self) -> Self {
Self::step(self, 1)
}
#[must_use]
pub fn prev(self) -> Self {
Self::step(self, Self::ALL.len() - 1)
}
fn step(self, by: usize) -> Self {
let at = Self::ALL
.iter()
.position(|&other| other == self)
.unwrap_or(0);
Self::ALL[(at + by) % Self::ALL.len()]
}
#[must_use]
pub fn label(self) -> &'static str {
match self {
Self::Default => "default",
Self::Dependencies => "dependencies",
Self::AllIgnored => "all-ignored",
Self::All => "all",
}
}
#[must_use]
pub fn what(self) -> &'static str {
match self {
Self::Default => "only what a rule named — the gitignored tier is hidden",
Self::Dependencies => "only installed dependencies a rule named",
Self::AllIgnored => "installed dependencies, and the gitignored tier beside them",
Self::All => "every directory the scan found — `i` adds gitignored files",
}
}
#[must_use]
pub fn axes(self) -> (Tiers, Kinds) {
match self {
Self::Default => (Tiers::named(), Kinds::all()),
Self::Dependencies => (Tiers::named(), Kinds::only(Kind::Dependencies)),
Self::AllIgnored => (Tiers::both(), Kinds::only(Kind::Dependencies)),
Self::All => (Tiers::both(), Kinds::all()),
}
}
}
impl fmt::Display for Preset {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.label())
}
}
#[derive(Clone, Debug)]
pub struct Lens {
tiers: Tiers,
kinds: Kinds,
files: bool,
pattern: Option<Regex>,
}
impl Default for Lens {
fn default() -> Self {
Self::showing(Preset::default())
}
}
impl PartialEq for Lens {
fn eq(&self, other: &Self) -> bool {
self.tiers == other.tiers
&& self.kinds == other.kinds
&& self.files == other.files
&& self.pattern.as_ref().map(Regex::as_str) == other.pattern.as_ref().map(Regex::as_str)
}
}
impl Eq for Lens {}
impl std::hash::Hash for Lens {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.tiers.hash(state);
self.kinds.hash(state);
self.files.hash(state);
self.pattern.as_ref().map(Regex::as_str).hash(state);
}
}
impl Lens {
#[must_use]
pub fn of(tiers: Tiers, kinds: Kinds) -> Self {
Self {
tiers,
kinds,
files: false,
pattern: None,
}
}
#[must_use]
pub fn showing(preset: Preset) -> Self {
let (tiers, kinds) = preset.axes();
Self::of(tiers, kinds)
}
#[must_use]
pub fn matching(mut self, pattern: Option<Regex>) -> Self {
self.pattern = pattern;
self
}
#[must_use]
pub fn tiers(&self) -> Tiers {
self.tiers
}
#[must_use]
pub fn kinds(&self) -> Kinds {
self.kinds
}
#[must_use]
pub fn with_tiers(mut self, tiers: Tiers) -> Self {
self.tiers = tiers;
self
}
#[must_use]
pub fn with_kinds(mut self, kinds: Kinds) -> Self {
self.kinds = kinds;
self
}
#[must_use]
pub fn files(&self) -> bool {
self.files
}
#[must_use]
pub fn with_files(mut self, files: bool) -> Self {
self.files = files;
self
}
#[must_use]
pub fn preset(&self) -> Option<Preset> {
Preset::ALL
.into_iter()
.find(|preset| preset.axes() == (self.tiers, self.kinds))
}
#[must_use]
pub fn axes_label(&self) -> String {
let axes = format!("{} · {}", self.tiers.label(), self.kinds.label());
if self.files {
format!("{axes} · files")
} else {
axes
}
}
#[must_use]
pub fn pattern(&self) -> Option<&str> {
self.pattern.as_ref().map(Regex::as_str)
}
#[must_use]
pub fn is_everything(&self) -> bool {
self.tiers == Tiers::both()
&& self.kinds == Kinds::all()
&& self.files
&& self.pattern.is_none()
}
#[must_use]
pub fn matches(&self, hit: &Hit) -> bool {
let by_source = match Tier::of(hit) {
Some(tier) => self.tiers.has(tier),
None => self.files,
};
let by_kind = hit.kind().is_none_or(|kind| self.kinds.has(kind));
by_source && by_kind && self.says_yes_to(&hit.path.to_string_lossy())
}
fn says_yes_to(&self, path: &str) -> bool {
self.pattern
.as_ref()
.is_none_or(|pattern| pattern.is_match(path))
}
#[must_use]
pub fn describe(&self) -> String {
let view = self.axes_label();
match self.pattern() {
Some(pattern) => format!("{view} · /{pattern}"),
None => view,
}
}
}
#[cfg(test)]
mod tests {
use super::{Kinds, Lens, Preset, Tier, Tiers};
use crate::fixture::{gitignored, gitignored_file, of_kind};
use crate::rules::Kind;
use regex::Regex;
fn claims() -> (
crate::walk::Hit,
crate::walk::Hit,
crate::walk::Hit,
crate::walk::Hit,
) {
(
of_kind("/scan/a/node_modules", Kind::Dependencies),
of_kind("/scan/a/dist", Kind::Build),
of_kind("/scan/a/.nx/cache", Kind::Cache),
gitignored("/scan/a/out"),
)
}
#[test]
fn the_presets_are_the_four_that_were_asked_for_in_the_order_they_were_asked_for() {
assert_eq!(
Preset::ALL.map(Preset::label),
["default", "dependencies", "all-ignored", "all"]
);
let (deps, build, cache, ignored) = claims();
let shows = |preset: Preset| {
let lens = Lens::showing(preset);
[
lens.matches(&deps),
lens.matches(&build),
lens.matches(&cache),
lens.matches(&ignored),
]
};
assert_eq!(shows(Preset::Default), [true, true, true, false]);
assert_eq!(shows(Preset::Dependencies), [true, false, false, false]);
assert_eq!(shows(Preset::AllIgnored), [true, false, false, true]);
assert_eq!(shows(Preset::All), [true, true, true, true]);
}
#[test]
fn the_four_presets_are_four_distinct_points_on_the_two_axes() {
for (nth, preset) in Preset::ALL.into_iter().enumerate() {
for other in Preset::ALL.into_iter().skip(nth + 1) {
assert_ne!(preset.axes(), other.axes(), "{preset} and {other}");
}
}
}
#[test]
fn every_step_of_the_cycle_moves_exactly_one_axis() {
let mut at = Preset::Default;
for _ in 1..Preset::ALL.len() {
let next = at.next();
let (tiers, kinds) = at.axes();
let (moved_tiers, moved_kinds) = next.axes();
assert_ne!(
(tiers == moved_tiers, kinds == moved_kinds),
(true, true),
"{at} → {next} moved nothing"
);
assert!(
(tiers == moved_tiers) || (kinds == moved_kinds),
"{at} → {next} moved both axes at once"
);
at = next;
}
}
#[test]
fn a_preset_never_touches_the_pattern() {
let pattern = Regex::new("nx").expect("a literal pattern compiles");
for preset in Preset::ALL {
let lens = Lens::showing(preset).matching(Some(pattern.clone()));
assert_eq!(lens.pattern(), Some("nx"), "{preset}");
assert_eq!(lens.preset(), Some(preset), "{preset}");
}
}
#[test]
fn the_cycle_comes_back_round_and_goes_both_ways() {
let mut at = Preset::Default;
for _ in Preset::ALL {
at = at.next();
}
assert_eq!(at, Preset::Default);
assert_eq!(Preset::Default.prev(), Preset::All);
assert_eq!(Preset::All.next(), Preset::Default);
}
#[test]
fn the_axes_are_independent_of_each_other() {
let caches = Lens::of(Tiers::named(), Kinds::only(Kind::Cache));
let caches_and_ignored = Lens::of(Tiers::both(), Kinds::only(Kind::Cache));
assert_eq!(caches.preset(), None);
assert_eq!(caches_and_ignored.preset(), None);
let (deps, _build, cache, ignored) = claims();
assert!(caches.matches(&cache));
assert!(!caches.matches(&deps));
assert!(!caches.matches(&ignored));
assert!(caches_and_ignored.matches(&cache));
assert!(!caches_and_ignored.matches(&deps));
assert!(caches_and_ignored.matches(&ignored));
}
#[test]
fn each_axis_moves_without_disturbing_the_other() {
let start = Lens::of(Tiers::named(), Kinds::only(Kind::Cache));
let widened = start.clone().with_tiers(Tiers::both());
assert_eq!(widened.kinds(), start.kinds());
assert_eq!(widened.tiers(), Tiers::both());
let narrowed = start
.clone()
.with_kinds(start.kinds().toggling(Kind::Build));
assert_eq!(narrowed.tiers(), start.tiers());
assert!(narrowed.kinds().has(Kind::Build));
assert!(narrowed.kinds().has(Kind::Cache));
assert!(!narrowed.kinds().has(Kind::Dependencies));
}
#[test]
fn the_tier_axis_walks_its_three_states_and_never_the_empty_one() {
let mut at = Tiers::named();
let mut seen = Vec::new();
for _ in Tiers::ALL {
seen.push(at);
at = at.next();
}
assert_eq!(seen, Tiers::ALL);
assert_eq!(at, Tiers::named(), "the cycle does not come back round");
for tiers in Tiers::ALL {
assert!(tiers.named || tiers.ignored, "{tiers:?} shows nothing");
}
}
#[test]
fn a_tier_two_claim_is_judged_by_the_tier_axis_and_never_by_the_kind_axis() {
let ignored = gitignored("/scan/a/out");
let deps = of_kind("/scan/a/node_modules", Kind::Dependencies);
let no_kinds_at_all = Lens::of(Tiers::both(), Kinds::none());
assert!(no_kinds_at_all.matches(&ignored));
assert!(!no_kinds_at_all.matches(&deps));
let every_kind_but_no_fallback = Lens::of(Tiers::named(), Kinds::all());
assert!(!every_kind_but_no_fallback.matches(&ignored));
assert!(every_kind_but_no_fallback.matches(&deps));
}
#[test]
fn a_pattern_narrows_whatever_the_axes_left() {
let lens = Lens::showing(Preset::All)
.matching(Some(Regex::new("nx").expect("a literal pattern compiles")));
assert!(lens.matches(&gitignored("/scan/nx/dist")));
assert!(!lens.matches(&gitignored("/scan/pua/dist")));
assert!(!lens.is_everything());
assert_eq!(lens.describe(), "named + gitignored · every kind · /nx");
}
#[test]
fn two_lenses_are_the_same_when_they_show_the_same_things() {
let one = Lens::showing(Preset::Default)
.matching(Some(Regex::new("nx").expect("a literal pattern compiles")));
let two = Lens::showing(Preset::Default)
.matching(Some(Regex::new("nx").expect("a literal pattern compiles")));
assert_eq!(one, two);
assert_ne!(one, Lens::showing(Preset::Default));
}
#[test]
fn a_hits_tier_is_read_off_whether_a_rule_named_it_and_never_off_its_kind() {
assert_eq!(
Tier::of(&of_kind("/scan/a/target", Kind::Build)),
Some(Tier::Named)
);
assert_eq!(Tier::of(&gitignored("/scan/a/dist")), Some(Tier::Ignored));
assert_eq!(
Tier::of(&gitignored_file("/scan/a/.env", Some(Kind::Unrecoverable))),
None,
"a file is judged by the files axis, and the tier axis declines to answer"
);
}
#[test]
fn a_gitignored_file_is_off_screen_until_its_own_key_says_otherwise() {
let env = gitignored_file("/scan/a/.env", Some(Kind::Unrecoverable));
let dir = gitignored("/scan/a/dist");
for preset in Preset::ALL {
assert!(
!Lens::showing(preset).matches(&env),
"{preset} showed a gitignored file"
);
}
let showing = Lens::showing(Preset::Default).with_files(true);
assert!(showing.matches(&env));
assert!(
!showing.matches(&dir),
"turning files on must not turn the gitignored TIER on"
);
let tier_only = Lens::of(Tiers::ignored(), Kinds::none());
assert!(tier_only.matches(&dir));
assert!(
!tier_only.matches(&env),
"turning the gitignored tier on must not turn files on"
);
}
#[test]
fn the_files_axis_is_carried_through_every_preset_the_way_the_pattern_is() {
let showing = Lens::showing(Preset::Default).with_files(true);
assert!(showing.files());
for preset in Preset::ALL {
let cycled = Lens::showing(preset).with_files(true);
assert!(cycled.files(), "{preset}");
assert_eq!(cycled.preset(), Some(preset), "{preset}");
}
}
#[test]
fn the_kind_axis_judges_a_file_because_a_file_has_a_kind() {
let env = gitignored_file("/scan/a/.env", Some(Kind::Unrecoverable));
let log = gitignored_file("/scan/a/build.log", Some(Kind::Noise));
let scratch = gitignored_file("/scan/a/dump.sql", None);
let precious = Lens::of(Tiers::named(), Kinds::only(Kind::Unrecoverable)).with_files(true);
assert!(precious.matches(&env));
assert!(!precious.matches(&log));
assert!(
precious.matches(&scratch),
"a file with no kind has nothing for the kind axis to refuse, exactly as a \
tier-two directory does not"
);
}
#[test]
fn nothing_is_everything_until_the_files_axis_is_on_too() {
assert!(!Lens::showing(Preset::All).is_everything());
assert!(Lens::showing(Preset::All).with_files(true).is_everything());
}
#[test]
fn the_footer_says_the_files_axis_only_when_it_is_on() {
let off = Lens::of(Tiers::named(), Kinds::only(Kind::Cache));
assert_eq!(off.axes_label(), "named · cache");
assert_eq!(off.with_files(true).axes_label(), "named · cache · files");
}
}