pristine/tui/lens.rs
1//! What is on screen: two independent axes, with presets on top.
2//!
3//! # Two axes, not four modes
4//!
5//! The set of views a reader asked for was "default, all-ignored, vendor, all", and those four
6//! names mix two things that vary independently:
7//!
8//! - **Tier** — whether a claim is one a *rule* named ([`Tier::Named`]) or one only the
9//! gitignore fallback found ([`Tier::Ignored`]). "all-ignored" is a statement about this axis
10//! and nothing else.
11//! - **Kind** — the closed vocabulary #623 established and #652 extended at both ends, ordered
12//! by what it costs to lose: [`Kind::Unrecoverable`], [`Kind::Dependencies`], [`Kind::Build`],
13//! [`Kind::Cache`], [`Kind::Noise`]. "vendor" is a statement about this one.
14//!
15//! Modelled as four opaque modes, "show me every cache that a rule named" is not expressible
16//! and never becomes expressible without a fifth mode. Modelled as two axes it already is, and
17//! the presets are a convenience over the top rather than the vocabulary itself.
18//!
19//! # The presets are a path through the lattice, one axis per step
20//!
21//! `f`/`F` walk `default → dependencies → all-ignored → all`, and each step moves exactly one
22//! axis while carrying the other forward — so `all-ignored` is "what I am looking at, **plus**
23//! the gitignored tier" rather than a jump back to everything. That is what makes four names
24//! four distinct views. See [`Preset`] for the table.
25//!
26//! # Expressible has to mean expressible BY A READER
27//!
28//! Two axes inside a struct that no key can move is a model with a claim it cannot cash. So the
29//! presets are shortcuts and the axes have keys of their own: `t` moves the tier axis on its
30//! own, and one key per [`Kind`] toggles that member of the other. Every combination of the two
31//! is reachable — "every cache a rule named" is `f` to default, then `d` and `b` — and none of
32//! them had to be anticipated as a mode.
33//!
34//! # `default` narrows, so the header says what it left out
35//!
36//! A run opens on [`Preset::Default`], which shows what rules named and hides the gitignore
37//! fallback. That is a filter that is on without having been asked for, which is the shape the
38//! age floor was resolved against — "silently keeps" is "silently deletes" seen from the other
39//! side. What makes it honest rather than silent is that the count it hides is on the header,
40//! beside the number it qualifies, from the first frame. See [`super::state::View::out_of_view`].
41//!
42//! # Gitignored FILES are a third axis, not a third tier
43//!
44//! A gitignored file is claimed by tier two, so the obvious home for it is a third value on the
45//! tier axis — and that does not survive the presets. Every step of `f`'s cycle moves exactly
46//! one axis, and `all` would then have to widen the tier axis *and* the kind axis in one step
47//! to reach files. The `/` pattern already had this shape and was settled the same way: it
48//! decides what is on screen, it is orthogonal to both axes, and no preset touches it.
49//!
50//! So files get an axis and a key of their own, `i`, and the request's actual sentence — that
51//! ignored files be includable and excludable independently of ignored directories — falls
52//! out of that rather than being arranged for. It starts **off**, because a real `~/repos`
53//! holds tens of thousands of them; what makes that honest is the same thing that makes
54//! `default` honest, a count of what is out of view on the header from the first frame.
55//!
56//! # The pattern is part of the lens
57//!
58//! `/`'s regex is a third thing that decides whether a claim is on screen, so it lives here
59//! rather than beside here. That matters because a **mark stores the lens it was made
60//! through** ([`super::state::View`]), and a mark made under `/nx` has to keep meaning
61//! `/nx` when the pattern is cleared, exactly as a mark made under Dependencies keeps meaning
62//! Dependencies.
63
64use std::fmt;
65
66use regex::Regex;
67
68use crate::rules::Kind;
69use crate::walk::Hit;
70
71/// Which tier claimed a directory.
72///
73/// The asymmetry is information rather than an accident of implementation: a named row is a
74/// directory whose cost to lose is known, and an unnamed one is a leap. That is worth being
75/// able to filter on in both directions.
76#[derive(Clone, Copy, Debug, PartialEq, Eq)]
77pub enum Tier {
78 /// A rule named it, so it carries an ecosystem and a [`Kind`].
79 Named,
80 /// Only the gitignore fallback found it. Nothing knows what it is.
81 Ignored,
82}
83
84impl Tier {
85 /// Which member of the tier axis judges this hit, or `None` when the **files** axis does.
86 ///
87 /// An `Option` rather than a third value, and that shape is the finding rather than a
88 /// detail. A gitignored file is claimed by tier two, so a three-valued tier reads
89 /// naturally — but it would then have to move when [`Preset::All`] widens, and every
90 /// preset moves exactly one axis per step. Files are therefore orthogonal to both axes,
91 /// exactly as the `/` pattern is, and this says so by declining to answer rather than by
92 /// answering `Ignored` and leaving a caller to remember not to ask.
93 ///
94 /// It also no longer reads the *kind*, which it used to: a kind used to imply a rule, and
95 /// since a gitignored file can carry one it does not.
96 #[must_use]
97 pub fn of(hit: &Hit) -> Option<Self> {
98 if hit.is_ignored_file() {
99 return None;
100 }
101 Some(match hit.rule() {
102 Some(_) => Self::Named,
103 None => Self::Ignored,
104 })
105 }
106}
107
108/// Which tiers are on screen. Both axes are sets, which is what makes an unanticipated
109/// combination expressible without a new mode.
110#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
111pub struct Tiers {
112 /// Whether rule-named claims are shown.
113 pub named: bool,
114 /// Whether gitignore-fallback claims are shown.
115 pub ignored: bool,
116}
117
118impl Tiers {
119 /// Both.
120 #[must_use]
121 pub const fn both() -> Self {
122 Self {
123 named: true,
124 ignored: true,
125 }
126 }
127
128 /// Only what a rule named.
129 #[must_use]
130 pub const fn named() -> Self {
131 Self {
132 named: true,
133 ignored: false,
134 }
135 }
136
137 /// Only what the gitignore fallback found.
138 #[must_use]
139 pub const fn ignored() -> Self {
140 Self {
141 named: false,
142 ignored: true,
143 }
144 }
145
146 /// Whether this tier survives.
147 #[must_use]
148 pub const fn has(self, tier: Tier) -> bool {
149 match tier {
150 Tier::Named => self.named,
151 Tier::Ignored => self.ignored,
152 }
153 }
154
155 /// Every state this axis can be in, in the order `t` walks them.
156 ///
157 /// Three rather than four: the empty set shows no claims at all, which is not a view but a
158 /// blank screen, so the key steps over it rather than offering it. Every *other* combination
159 /// of the two axes is reachable, because the kind axis is toggled member by member.
160 pub const ALL: [Self; 3] = [Self::named(), Self::both(), Self::ignored()];
161
162 /// The next state of the axis.
163 #[must_use]
164 pub fn next(self) -> Self {
165 let at = Self::ALL
166 .iter()
167 .position(|&other| other == self)
168 .unwrap_or(0);
169 Self::ALL[(at + 1) % Self::ALL.len()]
170 }
171
172 /// What the footer calls this state of the axis.
173 #[must_use]
174 pub fn label(self) -> &'static str {
175 match (self.named, self.ignored) {
176 (true, true) => "named + gitignored",
177 (true, false) => "named",
178 (false, true) => "gitignored",
179 (false, false) => "no tier",
180 }
181 }
182}
183
184/// Which kinds are on screen.
185///
186/// Spelled as one named boolean per member rather than as a bitset, because the vocabulary is
187/// closed and a member that arrived without being written out here is a member no key could
188/// reach — which is #626's own finding, that a model whose expressiveness no interface exposes
189/// has not been built yet.
190// One bool per member of a closed vocabulary is the shape, not an accident of it: the lint is
191// about a struct that has grown flags, and this is a set over five known things.
192#[allow(clippy::struct_excessive_bools)]
193#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
194pub struct Kinds {
195 /// Things nothing brings back.
196 pub unrecoverable: bool,
197 /// Installed third-party code.
198 pub dependencies: bool,
199 /// Compiled output.
200 pub build: bool,
201 /// Regenerated automatically.
202 pub cache: bool,
203 /// Logs and the cruft an operating system leaves behind.
204 pub noise: bool,
205}
206
207impl Kinds {
208 /// Every kind.
209 #[must_use]
210 pub const fn all() -> Self {
211 Self {
212 unrecoverable: true,
213 dependencies: true,
214 build: true,
215 cache: true,
216 noise: true,
217 }
218 }
219
220 /// None of them, which is what a tier-two-only view wants: it is not narrowing the named
221 /// claims, it is leaving them out.
222 #[must_use]
223 pub const fn none() -> Self {
224 Self {
225 unrecoverable: false,
226 dependencies: false,
227 build: false,
228 cache: false,
229 noise: false,
230 }
231 }
232
233 /// Just one.
234 #[must_use]
235 pub const fn only(kind: Kind) -> Self {
236 Self {
237 unrecoverable: matches!(kind, Kind::Unrecoverable),
238 dependencies: matches!(kind, Kind::Dependencies),
239 build: matches!(kind, Kind::Build),
240 cache: matches!(kind, Kind::Cache),
241 noise: matches!(kind, Kind::Noise),
242 }
243 }
244
245 /// Whether this kind survives.
246 #[must_use]
247 pub const fn has(self, kind: Kind) -> bool {
248 match kind {
249 Kind::Unrecoverable => self.unrecoverable,
250 Kind::Dependencies => self.dependencies,
251 Kind::Build => self.build,
252 Kind::Cache => self.cache,
253 Kind::Noise => self.noise,
254 }
255 }
256
257 /// The same set with one member turned on or off — one key, one axis member.
258 #[must_use]
259 pub const fn toggling(mut self, kind: Kind) -> Self {
260 match kind {
261 Kind::Unrecoverable => self.unrecoverable = !self.unrecoverable,
262 Kind::Dependencies => self.dependencies = !self.dependencies,
263 Kind::Build => self.build = !self.build,
264 Kind::Cache => self.cache = !self.cache,
265 Kind::Noise => self.noise = !self.noise,
266 }
267 self
268 }
269
270 /// The kinds on screen, named, or `none` when the axis is empty.
271 ///
272 /// The full set says `every kind` rather than listing five words, and that is the honest
273 /// spelling as well as the short one: this line sits on a header and inside a confirmation
274 /// warning, and an axis that is not narrowing anything should not take a line to say so.
275 #[must_use]
276 pub fn label(self) -> String {
277 if self == Self::all() {
278 return "every kind".to_owned();
279 }
280 let said: Vec<&str> = Kind::ALL
281 .into_iter()
282 .filter(|&kind| self.has(kind))
283 .map(Kind::short)
284 .collect();
285 if said.is_empty() {
286 return "none".to_owned();
287 }
288 said.join(" + ")
289 }
290}
291
292/// A named point on the two axes, which is what one key cycles through.
293///
294/// # The cycle is a path through the lattice, one axis at a time
295///
296/// `default → dependencies → all-ignored → all`, and **each step moves exactly one axis while
297/// retaining the other**:
298///
299/// | | tier | kind |
300/// |---|---|---|
301/// | `default` | named | every kind |
302/// | `dependencies` | named | dependencies ← *kind narrows* |
303/// | `all-ignored` | named + gitignored ← *tier widens* | dependencies |
304/// | `all` | named + gitignored | every kind ← *kind widens* |
305///
306/// That is what makes them four distinct views rather than three wearing four names, and it is
307/// what the asked-for order is *for*: read as a path, `all-ignored` is "the view I am on, plus
308/// the gitignored tier", which is precisely the sentence the tier axis makes available.
309///
310/// An earlier pass mapped `all-ignored` and `all` to one point and told them apart by having
311/// `all` clear the `/` pattern. Adversarial review rejected that and was right twice over: it
312/// left a step of the cycle doing nothing to the axes, and it reached for the pattern — which is
313/// **orthogonal to both axes** — to manufacture a difference. The pattern is never touched by a
314/// preset now.
315#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
316pub enum Preset {
317 /// What a rule could put a name to: tier one, every kind. Where a run starts.
318 ///
319 /// It hides the gitignored tier, so [`super::render`] says how many claims that is on the
320 /// header — a view that narrows without saying what it dropped is the "silently keeps"
321 /// failure the age floor was already resolved against.
322 #[default]
323 Default,
324 /// Installed third-party code, and only that: npkill's `vendor`. The kind axis narrowed,
325 /// the tier axis exactly as `default` had it.
326 Dependencies,
327 /// The step before it, **and also** the gitignore fallback: the tier axis widened, the kind
328 /// narrowing retained.
329 AllIgnored,
330 /// Everything the scan found: both tiers, every kind.
331 All,
332}
333
334impl Preset {
335 /// Every preset, in the order `f` walks them — the order the request names them in.
336 pub const ALL: [Self; 4] = [
337 Self::Default,
338 Self::Dependencies,
339 Self::AllIgnored,
340 Self::All,
341 ];
342
343 /// The next one round.
344 #[must_use]
345 pub fn next(self) -> Self {
346 Self::step(self, 1)
347 }
348
349 /// The one before.
350 #[must_use]
351 pub fn prev(self) -> Self {
352 Self::step(self, Self::ALL.len() - 1)
353 }
354
355 fn step(self, by: usize) -> Self {
356 let at = Self::ALL
357 .iter()
358 .position(|&other| other == self)
359 .unwrap_or(0);
360 Self::ALL[(at + by) % Self::ALL.len()]
361 }
362
363 /// What the footer calls this view.
364 #[must_use]
365 pub fn label(self) -> &'static str {
366 match self {
367 Self::Default => "default",
368 Self::Dependencies => "dependencies",
369 Self::AllIgnored => "all-ignored",
370 Self::All => "all",
371 }
372 }
373
374 /// The sentence the footer says when the view changes, which has to name what is now
375 /// *missing*: a reader who cannot see a claim has no way to notice it was hidden rather
376 /// than never found.
377 #[must_use]
378 pub fn what(self) -> &'static str {
379 match self {
380 Self::Default => "only what a rule named — the gitignored tier is hidden",
381 Self::Dependencies => "only installed dependencies a rule named",
382 Self::AllIgnored => "installed dependencies, and the gitignored tier beside them",
383 // Not "everything the scan found", which it was and no longer is: gitignored files
384 // are an axis no preset touches, so this is everything on the two axes it moves.
385 // A view that overstated itself here would be the "silently keeps" failure wearing
386 // the label of its opposite.
387 Self::All => "every directory the scan found — `i` adds gitignored files",
388 }
389 }
390
391 /// Where this preset sits on the two axes.
392 ///
393 /// One axis moves per step and the other is retained — see the type's own docs for the
394 /// table, and note that no preset touches the `/` pattern.
395 #[must_use]
396 pub fn axes(self) -> (Tiers, Kinds) {
397 match self {
398 Self::Default => (Tiers::named(), Kinds::all()),
399 Self::Dependencies => (Tiers::named(), Kinds::only(Kind::Dependencies)),
400 Self::AllIgnored => (Tiers::both(), Kinds::only(Kind::Dependencies)),
401 Self::All => (Tiers::both(), Kinds::all()),
402 }
403 }
404}
405
406impl fmt::Display for Preset {
407 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
408 f.write_str(self.label())
409 }
410}
411
412/// Everything that decides whether a claim is on screen.
413///
414/// Cloned into every mark, which is why it is a value rather than a handle into the view: a
415/// mark has to keep meaning what the reader could see when they made it, and a mark holding a
416/// reference to the *current* view would mean the opposite of that.
417#[derive(Clone, Debug)]
418pub struct Lens {
419 tiers: Tiers,
420 kinds: Kinds,
421 /// Whether gitignored **files** are on screen. See [`Lens::files`].
422 files: bool,
423 /// The `/` prompt's regex over the whole path, when there is one.
424 pattern: Option<Regex>,
425}
426
427impl Default for Lens {
428 fn default() -> Self {
429 Self::showing(Preset::default())
430 }
431}
432
433impl PartialEq for Lens {
434 /// By what it shows. [`Regex`] has no equality of its own, and the pattern is the only
435 /// honest stand-in — two engines compiled from one string accept the same paths.
436 fn eq(&self, other: &Self) -> bool {
437 self.tiers == other.tiers
438 && self.kinds == other.kinds
439 && self.files == other.files
440 && self.pattern.as_ref().map(Regex::as_str) == other.pattern.as_ref().map(Regex::as_str)
441 }
442}
443
444impl Eq for Lens {}
445
446impl std::hash::Hash for Lens {
447 /// Exactly what [`Lens::eq`] reads, in the same order, because a hash that disagreed with
448 /// equality is a lens that changed without anybody watching it being told — and
449 /// [`super::treemap`] watches this one to decide whether a picture is still the right
450 /// one. The pattern goes in as its source text for equality's own reason.
451 fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
452 self.tiers.hash(state);
453 self.kinds.hash(state);
454 self.files.hash(state);
455 self.pattern.as_ref().map(Regex::as_str).hash(state);
456 }
457}
458
459impl Lens {
460 /// A lens on the two axes directly, which is the general door: [`Lens::showing`] is the
461 /// preset shorthand over it.
462 #[must_use]
463 pub fn of(tiers: Tiers, kinds: Kinds) -> Self {
464 Self {
465 tiers,
466 kinds,
467 // Off, which is the request's own answer and the header's job to declare. A real
468 // `~/repos` holds tens of thousands of gitignored files, and a sweep that showed
469 // them unasked would bury the 40 GB `node_modules` this tool exists to find under
470 // `.DS_Store` rows.
471 files: false,
472 pattern: None,
473 }
474 }
475
476 /// The lens a preset names.
477 #[must_use]
478 pub fn showing(preset: Preset) -> Self {
479 let (tiers, kinds) = preset.axes();
480 Self::of(tiers, kinds)
481 }
482
483 /// The same lens with `pattern` over it, or with none.
484 #[must_use]
485 pub fn matching(mut self, pattern: Option<Regex>) -> Self {
486 self.pattern = pattern;
487 self
488 }
489
490 /// Which tiers are on screen.
491 #[must_use]
492 pub fn tiers(&self) -> Tiers {
493 self.tiers
494 }
495
496 /// Which kinds are.
497 #[must_use]
498 pub fn kinds(&self) -> Kinds {
499 self.kinds
500 }
501
502 /// The same lens with the tier axis moved, and the kind axis untouched.
503 ///
504 /// The two editors exist so that the axes are independently reachable **by a reader**, not
505 /// only inside this file: a model that can express "every cache a rule named" while no key
506 /// can reach it is not expressible in any sense the request meant.
507 #[must_use]
508 pub fn with_tiers(mut self, tiers: Tiers) -> Self {
509 self.tiers = tiers;
510 self
511 }
512
513 /// The same lens with the kind axis moved, and the tier axis untouched.
514 #[must_use]
515 pub fn with_kinds(mut self, kinds: Kinds) -> Self {
516 self.kinds = kinds;
517 self
518 }
519
520 /// Whether gitignored files are on screen.
521 ///
522 /// # Why an axis of its own rather than a third tier
523 ///
524 /// A gitignored file *is* a tier-two claim, so a third value on the tier axis is the first
525 /// idea and it does not survive the presets. Each step of `f`'s cycle moves exactly one
526 /// axis, and `all` — "everything the scan found" — would have to widen the tier axis and
527 /// the kind axis together to reach files. The `/` pattern already had this shape and was
528 /// resolved the same way: it decides what is on screen, it is orthogonal to both axes, and
529 /// **no preset touches it**. Files are the same, which also means turning them on is not
530 /// undone by cycling the view.
531 ///
532 /// It is independently reachable, which is what the request asked for in so many words:
533 /// `i` toggles this and nothing else, so gitignored files come and go without disturbing
534 /// gitignored directories.
535 #[must_use]
536 pub fn files(&self) -> bool {
537 self.files
538 }
539
540 /// The same lens showing, or not showing, gitignored files. Both other axes untouched.
541 #[must_use]
542 pub fn with_files(mut self, files: bool) -> Self {
543 self.files = files;
544 self
545 }
546
547 /// Which preset this lens sits on, if it sits on one.
548 ///
549 /// Unambiguous, because the four presets occupy four distinct points — which is what the
550 /// footer needs in order to name a view honestly, and it is why nothing has to remember
551 /// which step of the cycle the reader took to get here. A lens the axis keys built lands
552 /// off all four and says so.
553 ///
554 /// The pattern is deliberately not consulted, and neither is the files axis: both narrow
555 /// or widen *whatever the two axes left*, so `dependencies` with a pattern over it and
556 /// files showing beside it is still `dependencies`.
557 #[must_use]
558 pub fn preset(&self) -> Option<Preset> {
559 Preset::ALL
560 .into_iter()
561 .find(|preset| preset.axes() == (self.tiers, self.kinds))
562 }
563
564 /// How the footer spells the axes when the reader has moved off every preset.
565 ///
566 /// The files axis is named only when it is *on*, and that asymmetry is deliberate: off is
567 /// where every view starts, so saying it everywhere would spend a third of the line on the
568 /// absence of something. When it is on it changes what a row means, so it is said.
569 #[must_use]
570 pub fn axes_label(&self) -> String {
571 let axes = format!("{} · {}", self.tiers.label(), self.kinds.label());
572 if self.files {
573 format!("{axes} · files")
574 } else {
575 axes
576 }
577 }
578
579 /// The pattern in force, if any.
580 #[must_use]
581 pub fn pattern(&self) -> Option<&str> {
582 self.pattern.as_ref().map(Regex::as_str)
583 }
584
585 /// Whether this lens hides nothing at all, which is the fast path the whole front end
586 /// takes when a reader has not narrowed anything.
587 ///
588 /// Note that no preset reaches it: `all` widens both axes and leaves files where the
589 /// reader put them, so a run that has never pressed `i` is narrowed however far `f` has
590 /// been cycled. That is what the header's out-of-view count is for.
591 #[must_use]
592 pub fn is_everything(&self) -> bool {
593 self.tiers == Tiers::both()
594 && self.kinds == Kinds::all()
595 && self.files
596 && self.pattern.is_none()
597 }
598
599 /// Whether this claim is on screen.
600 ///
601 /// The three axes are visibly independent here, which is the whole reason for the shape.
602 /// **Where a claim came from** decides one of them — a rule, the gitignore fallback, or the
603 /// fallback on a file — and **what it is** decides the kind axis, whoever found it. That
604 /// second half is what lets `.env` files be narrowed to on their own without a mode
605 /// anybody had to anticipate, and it leaves the tier-two *directory* judged by the tier
606 /// axis and never by the kind axis, since it has no kind to judge.
607 #[must_use]
608 pub fn matches(&self, hit: &Hit) -> bool {
609 let by_source = match Tier::of(hit) {
610 Some(tier) => self.tiers.has(tier),
611 None => self.files,
612 };
613 let by_kind = hit.kind().is_none_or(|kind| self.kinds.has(kind));
614 by_source && by_kind && self.says_yes_to(&hit.path.to_string_lossy())
615 }
616
617 /// Whether the pattern, if there is one, accepts this path.
618 fn says_yes_to(&self, path: &str) -> bool {
619 self.pattern
620 .as_ref()
621 .is_none_or(|pattern| pattern.is_match(path))
622 }
623
624 /// How the confirmation names this lens, when it has to say what a hidden entry is hidden
625 /// *by*. Both halves, because either can be the one doing the hiding.
626 #[must_use]
627 pub fn describe(&self) -> String {
628 // The axes rather than a preset name, because this sentence has to be actionable: a
629 // reader told "hidden by all-ignored" still has to work out what that leaves out, and a
630 // reader told "named · dependencies + cache" does not.
631 let view = self.axes_label();
632 match self.pattern() {
633 Some(pattern) => format!("{view} · /{pattern}"),
634 None => view,
635 }
636 }
637}
638
639#[cfg(test)]
640mod tests {
641 use super::{Kinds, Lens, Preset, Tier, Tiers};
642 use crate::fixture::{gitignored, gitignored_file, of_kind};
643 use crate::rules::Kind;
644 use regex::Regex;
645
646 /// One claim of each kind, plus one only git knows about.
647 fn claims() -> (
648 crate::walk::Hit,
649 crate::walk::Hit,
650 crate::walk::Hit,
651 crate::walk::Hit,
652 ) {
653 (
654 of_kind("/scan/a/node_modules", Kind::Dependencies),
655 of_kind("/scan/a/dist", Kind::Build),
656 of_kind("/scan/a/.nx/cache", Kind::Cache),
657 gitignored("/scan/a/out"),
658 )
659 }
660
661 #[test]
662 fn the_presets_are_the_four_that_were_asked_for_in_the_order_they_were_asked_for() {
663 assert_eq!(
664 Preset::ALL.map(Preset::label),
665 ["default", "dependencies", "all-ignored", "all"]
666 );
667 let (deps, build, cache, ignored) = claims();
668 let shows = |preset: Preset| {
669 let lens = Lens::showing(preset);
670 [
671 lens.matches(&deps),
672 lens.matches(&build),
673 lens.matches(&cache),
674 lens.matches(&ignored),
675 ]
676 };
677
678 // default: everything a rule could put a name to, and not the fallback tier.
679 assert_eq!(shows(Preset::Default), [true, true, true, false]);
680 // dependencies: the kind axis narrowed, the tier axis as default had it.
681 assert_eq!(shows(Preset::Dependencies), [true, false, false, false]);
682 // all-ignored: the tier axis widened, and the kind narrowing RETAINED — the fallback
683 // tier alongside what the previous step was showing rather than instead of it.
684 assert_eq!(shows(Preset::AllIgnored), [true, false, false, true]);
685 // all: the kind axis widened too, which is everything.
686 assert_eq!(shows(Preset::All), [true, true, true, true]);
687 }
688
689 #[test]
690 fn the_four_presets_are_four_distinct_points_on_the_two_axes() {
691 // Four names for three points would leave a step of the cycle doing nothing, and an
692 // earlier pass had exactly that — told apart by clearing the `/` pattern, which is
693 // orthogonal to both axes and so cannot carry a difference between two views.
694 for (nth, preset) in Preset::ALL.into_iter().enumerate() {
695 for other in Preset::ALL.into_iter().skip(nth + 1) {
696 assert_ne!(preset.axes(), other.axes(), "{preset} and {other}");
697 }
698 }
699 }
700
701 #[test]
702 fn every_step_of_the_cycle_moves_exactly_one_axis() {
703 // What makes the asked-for order a *path* rather than four unrelated points: each key
704 // press is one sentence about one axis, and the other is carried forward. That is what
705 // lets `all-ignored` mean "what I am looking at, plus the gitignored tier".
706 let mut at = Preset::Default;
707 for _ in 1..Preset::ALL.len() {
708 let next = at.next();
709 let (tiers, kinds) = at.axes();
710 let (moved_tiers, moved_kinds) = next.axes();
711 assert_ne!(
712 (tiers == moved_tiers, kinds == moved_kinds),
713 (true, true),
714 "{at} → {next} moved nothing"
715 );
716 assert!(
717 (tiers == moved_tiers) || (kinds == moved_kinds),
718 "{at} → {next} moved both axes at once"
719 );
720 at = next;
721 }
722 }
723
724 #[test]
725 fn a_preset_never_touches_the_pattern() {
726 // The pattern narrows whatever the axes leave, so it is orthogonal to both of them and
727 // no preset gets to mean anything by it. `dependencies` with a pattern over it is still
728 // `dependencies`.
729 let pattern = Regex::new("nx").expect("a literal pattern compiles");
730 for preset in Preset::ALL {
731 let lens = Lens::showing(preset).matching(Some(pattern.clone()));
732 assert_eq!(lens.pattern(), Some("nx"), "{preset}");
733 assert_eq!(lens.preset(), Some(preset), "{preset}");
734 }
735 }
736
737 #[test]
738 fn the_cycle_comes_back_round_and_goes_both_ways() {
739 let mut at = Preset::Default;
740 for _ in Preset::ALL {
741 at = at.next();
742 }
743 assert_eq!(at, Preset::Default);
744 assert_eq!(Preset::Default.prev(), Preset::All);
745 assert_eq!(Preset::All.next(), Preset::Default);
746 }
747
748 #[test]
749 fn the_axes_are_independent_of_each_other() {
750 // The property the whole model exists for, and the one the presets alone cannot give:
751 // narrowing the kind axis says nothing about the tier axis, so "every cache a rule
752 // named" and "every cache, plus the gitignored tier" are both expressible without
753 // either being a mode somebody had to anticipate. Neither is a preset.
754 let caches = Lens::of(Tiers::named(), Kinds::only(Kind::Cache));
755 let caches_and_ignored = Lens::of(Tiers::both(), Kinds::only(Kind::Cache));
756 assert_eq!(caches.preset(), None);
757 assert_eq!(caches_and_ignored.preset(), None);
758
759 let (deps, _build, cache, ignored) = claims();
760
761 assert!(caches.matches(&cache));
762 assert!(!caches.matches(&deps));
763 assert!(!caches.matches(&ignored));
764
765 assert!(caches_and_ignored.matches(&cache));
766 assert!(!caches_and_ignored.matches(&deps));
767 assert!(caches_and_ignored.matches(&ignored));
768 }
769
770 #[test]
771 fn each_axis_moves_without_disturbing_the_other() {
772 // What the two editing keys are built on. Moving the tier axis must leave the kinds
773 // exactly as they were and the other way round, or "independent" is a word rather than
774 // a property.
775 let start = Lens::of(Tiers::named(), Kinds::only(Kind::Cache));
776
777 let widened = start.clone().with_tiers(Tiers::both());
778 assert_eq!(widened.kinds(), start.kinds());
779 assert_eq!(widened.tiers(), Tiers::both());
780
781 let narrowed = start
782 .clone()
783 .with_kinds(start.kinds().toggling(Kind::Build));
784 assert_eq!(narrowed.tiers(), start.tiers());
785 assert!(narrowed.kinds().has(Kind::Build));
786 assert!(narrowed.kinds().has(Kind::Cache));
787 assert!(!narrowed.kinds().has(Kind::Dependencies));
788 }
789
790 #[test]
791 fn the_tier_axis_walks_its_three_states_and_never_the_empty_one() {
792 // An empty tier set shows no claims at all, which is a blank screen rather than a
793 // view, so the key steps over it. Every other combination stays reachable, because the
794 // kind axis is toggled member by member rather than cycled.
795 let mut at = Tiers::named();
796 let mut seen = Vec::new();
797 for _ in Tiers::ALL {
798 seen.push(at);
799 at = at.next();
800 }
801 assert_eq!(seen, Tiers::ALL);
802 assert_eq!(at, Tiers::named(), "the cycle does not come back round");
803 for tiers in Tiers::ALL {
804 assert!(tiers.named || tiers.ignored, "{tiers:?} shows nothing");
805 }
806 }
807
808 #[test]
809 fn a_tier_two_claim_is_judged_by_the_tier_axis_and_never_by_the_kind_axis() {
810 // It has no kind to judge — that asymmetry is the tier's whole content — so narrowing
811 // the kinds must not be able to hide it by accident, and widening them must not be able
812 // to bring it back.
813 let ignored = gitignored("/scan/a/out");
814 let deps = of_kind("/scan/a/node_modules", Kind::Dependencies);
815
816 let no_kinds_at_all = Lens::of(Tiers::both(), Kinds::none());
817 assert!(no_kinds_at_all.matches(&ignored));
818 assert!(!no_kinds_at_all.matches(&deps));
819
820 let every_kind_but_no_fallback = Lens::of(Tiers::named(), Kinds::all());
821 assert!(!every_kind_but_no_fallback.matches(&ignored));
822 assert!(every_kind_but_no_fallback.matches(&deps));
823 }
824
825 #[test]
826 fn a_pattern_narrows_whatever_the_axes_left() {
827 let lens = Lens::showing(Preset::All)
828 .matching(Some(Regex::new("nx").expect("a literal pattern compiles")));
829 assert!(lens.matches(&gitignored("/scan/nx/dist")));
830 assert!(!lens.matches(&gitignored("/scan/pua/dist")));
831 assert!(!lens.is_everything());
832 assert_eq!(lens.describe(), "named + gitignored · every kind · /nx");
833 }
834
835 #[test]
836 fn two_lenses_are_the_same_when_they_show_the_same_things() {
837 // Marks are deduplicated by lens, so this is load-bearing rather than a formality: a
838 // lens that never compares equal to itself would leave a mark per keystroke.
839 let one = Lens::showing(Preset::Default)
840 .matching(Some(Regex::new("nx").expect("a literal pattern compiles")));
841 let two = Lens::showing(Preset::Default)
842 .matching(Some(Regex::new("nx").expect("a literal pattern compiles")));
843 assert_eq!(one, two);
844 assert_ne!(one, Lens::showing(Preset::Default));
845 }
846
847 #[test]
848 fn a_hits_tier_is_read_off_whether_a_rule_named_it_and_never_off_its_kind() {
849 // It used to be read off the kind, and it cannot be any more: a gitignored FILE
850 // carries one. Reading the claim is what keeps `.env` out of the *named* tier while
851 // still letting the kind axis narrow to it.
852 assert_eq!(
853 Tier::of(&of_kind("/scan/a/target", Kind::Build)),
854 Some(Tier::Named)
855 );
856 assert_eq!(Tier::of(&gitignored("/scan/a/dist")), Some(Tier::Ignored));
857 assert_eq!(
858 Tier::of(&gitignored_file("/scan/a/.env", Some(Kind::Unrecoverable))),
859 None,
860 "a file is judged by the files axis, and the tier axis declines to answer"
861 );
862 }
863
864 #[test]
865 fn a_gitignored_file_is_off_screen_until_its_own_key_says_otherwise() {
866 // The request's whole sentence, as one assertion: includable and excludable
867 // independently of ignored directories. A real `~/repos` holds tens of thousands of
868 // these, so no preset may drag them in and none may push them out.
869 let env = gitignored_file("/scan/a/.env", Some(Kind::Unrecoverable));
870 let dir = gitignored("/scan/a/dist");
871
872 for preset in Preset::ALL {
873 assert!(
874 !Lens::showing(preset).matches(&env),
875 "{preset} showed a gitignored file"
876 );
877 }
878
879 let showing = Lens::showing(Preset::Default).with_files(true);
880 assert!(showing.matches(&env));
881 assert!(
882 !showing.matches(&dir),
883 "turning files on must not turn the gitignored TIER on"
884 );
885
886 let tier_only = Lens::of(Tiers::ignored(), Kinds::none());
887 assert!(tier_only.matches(&dir));
888 assert!(
889 !tier_only.matches(&env),
890 "turning the gitignored tier on must not turn files on"
891 );
892 }
893
894 #[test]
895 fn the_files_axis_is_carried_through_every_preset_the_way_the_pattern_is() {
896 // Orthogonal means orthogonal: `f` moves the two axes and leaves this alone, exactly
897 // as it leaves the `/` pattern alone. Anything else and turning files on would be
898 // undone by a keystroke about something else.
899 let showing = Lens::showing(Preset::Default).with_files(true);
900 assert!(showing.files());
901 for preset in Preset::ALL {
902 let cycled = Lens::showing(preset).with_files(true);
903 assert!(cycled.files(), "{preset}");
904 assert_eq!(cycled.preset(), Some(preset), "{preset}");
905 }
906 }
907
908 #[test]
909 fn the_kind_axis_judges_a_file_because_a_file_has_a_kind() {
910 // What the vocabulary's two new members are *for*: "show me every unrecoverable file"
911 // is a question the axes can already answer, without a mode anybody had to anticipate.
912 let env = gitignored_file("/scan/a/.env", Some(Kind::Unrecoverable));
913 let log = gitignored_file("/scan/a/build.log", Some(Kind::Noise));
914 let scratch = gitignored_file("/scan/a/dump.sql", None);
915
916 let precious = Lens::of(Tiers::named(), Kinds::only(Kind::Unrecoverable)).with_files(true);
917 assert!(precious.matches(&env));
918 assert!(!precious.matches(&log));
919 assert!(
920 precious.matches(&scratch),
921 "a file with no kind has nothing for the kind axis to refuse, exactly as a \
922 tier-two directory does not"
923 );
924 }
925
926 #[test]
927 fn nothing_is_everything_until_the_files_axis_is_on_too() {
928 // The fast path has to mean what it says. `all` is not everything any more, because
929 // an axis it does not touch is still narrowing.
930 assert!(!Lens::showing(Preset::All).is_everything());
931 assert!(Lens::showing(Preset::All).with_files(true).is_everything());
932 }
933
934 #[test]
935 fn the_footer_says_the_files_axis_only_when_it_is_on() {
936 let off = Lens::of(Tiers::named(), Kinds::only(Kind::Cache));
937 assert_eq!(off.axes_label(), "named · cache");
938 assert_eq!(off.with_files(true).axes_label(), "named · cache · files");
939 }
940}