Skip to main content

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}