Skip to main content

fdu_core/
control.rs

1//! Bounded, removal-aware control state used to classify retained filesystem facts.
2//!
3//! A control file is producer input, not something the index discovers by walking the
4//! filesystem. The table stores the exact bytes a producer verified and the matcher
5//! derived from them. That keeps cold discovery, refresh, observation, and snapshot load
6//! on one semantic path and makes deleting the last control file an ordinary state
7//! transition rather than a special rebuild.
8//!
9//! **Which of git's ignore inputs count.** Exactly one: each directory's
10//! [`CONTROL_FILE_NAME`] inside the scanned root, governing its own directory and
11//! everything below it, with deeper files taking precedence. Nothing else git consults is
12//! read. `.git/info/exclude` and `core.excludesFile` are ignored, so a `.DS_Store` excluded
13//! only globally lands in the unignored partition. A nested repository is not a boundary:
14//! its `.gitignore` files join the outer ones as if the tree were one repository. And
15//! unlike git, which never looks inside an ignored directory, the walk reads a
16//! `.gitignore` there too; the ignored partition is still right, because git's rule that
17//! an excluded parent cannot be re-included is applied when matching, but the file's
18//! bytes are retained against the table bound. Matching is case-sensitive regardless of
19//! `core.ignorecase`.
20//!
21//! **Which file is a directory's control.** Whatever a lookup of `<dir>/.gitignore`
22//! resolves to, because that is the path git opens. On a case-sensitive directory that is
23//! only an entry named exactly `.gitignore`; on a case-insensitive one (APFS and NTFS by
24//! default, an ext4 casefold directory) it is the one entry the filesystem folds to that
25//! name, so a `.GITIGNORE` governs there as it does for git, and nowhere else. The rules
26//! are recorded under the canonical path `<dir>/.gitignore` whatever spelling holds them:
27//! every control operation, table key, refusal, and change names that path, as
28//! `git check-ignore -v` does, so [`is_control_file`] accepts only that path. A walk pays
29//! for this only on a listed name spelled `.gitignore` in another ASCII case, which it
30//! resolves with one lookup of the canonical path; the exact name is read by its own path
31//! as before, and every other name costs a length comparison. A name some filesystem
32//! folds to `.gitignore` through a non-ASCII character is not looked up.
33
34mod gitignore;
35
36use std::collections::{BTreeMap, BTreeSet, HashMap};
37use std::path::{Component, Path, PathBuf};
38use std::sync::Arc;
39
40use gitignore::Gitignore;
41
42/// Name of the fixed control file understood by the first engine version.
43pub const CONTROL_FILE_NAME: &str = ".gitignore";
44
45/// Default retained control-table charge for one index, in bytes.
46///
47/// The charge includes a fixed amount per directory as well as each distinct source's
48/// bytes and matcher, so a hostile tree of empty control files cannot evade it. Four MiB is
49/// far above ordinary repositories while remaining small relative to the inventory it
50/// governs; a source past it is refused, not an error. Callers set it with
51/// [`ControlLimits::budget`], which the command line spells `--gitignore-budget`.
52pub const DEFAULT_CONTROL_BUDGET: usize = 4 * 1024 * 1024;
53
54/// Default longest line a control source may hold, in bytes.
55///
56/// A control file may hold many ordinary rules up to the budget, but a single adversarial
57/// rule must not impose unbounded matching work on every entry, so a source with a longer
58/// line is refused whole. Callers set it with [`ControlLimits::line_limit`], which the
59/// command line spells `--gitignore-line-limit`.
60pub const DEFAULT_CONTROL_LINE_LIMIT: usize = 16 * 1024;
61
62/// The two bounds on the control state one index retains, each liftable on its own.
63///
64/// They bound different things. The budget bounds the memory the whole table retains; the
65/// line limit bounds what one pattern costs to match against every entry. So raising the
66/// budget admits more files without admitting longer lines, and lifting the line limit
67/// admits long lines without retaining more. A source either bound cannot admit is
68/// refused, and its [`ControlRefusalReason`] names the one that fired.
69///
70/// Both are part of the scan scope: they decide which rules apply, so a snapshot taken
71/// under other limits never serves a request for these.
72#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
73pub struct ControlLimits {
74    /// Bytes of retained charge before further sources are refused, or `None` for no
75    /// bound.
76    ///
77    /// Each directory's key and each distinct source's bytes and matcher are charged, so
78    /// identical files count once. It also bounds the read: a control file is read to one
79    /// byte past the budget, so `None` reads every control file whole, however large.
80    pub budget: Option<usize>,
81    /// Longest line in bytes a source may hold before it is refused whole, or `None` for
82    /// no bound.
83    pub line_limit: Option<usize>,
84}
85
86impl Default for ControlLimits {
87    /// [`DEFAULT_CONTROL_BUDGET`] and [`DEFAULT_CONTROL_LINE_LIMIT`].
88    fn default() -> Self {
89        Self { budget: Some(DEFAULT_CONTROL_BUDGET), line_limit: Some(DEFAULT_CONTROL_LINE_LIMIT) }
90    }
91}
92
93impl ControlLimits {
94    /// The limit a refusal for `reason` crossed, or `None` when that limit is unbounded.
95    pub const fn limit_for(self, reason: ControlRefusalReason) -> Option<usize> {
96        match reason {
97            ControlRefusalReason::Budget => self.budget,
98            ControlRefusalReason::LineLimit => self.line_limit,
99        }
100    }
101}
102
103/// `budget 4.0 MiB, line limit 16 KiB`, with `all` for an unbounded limit, the word every
104/// surface accepts back.
105impl std::fmt::Display for ControlLimits {
106    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
107        write!(
108            formatter,
109            "budget {}, line limit {}",
110            limit_display(self.budget),
111            limit_display(self.line_limit)
112        )
113    }
114}
115
116/// One control limit as a person reads it: a size, or `all` when unbounded.
117pub(crate) fn limit_display(limit: Option<usize>) -> String {
118    limit.map_or_else(
119        || "all".to_string(),
120        |bytes| crate::report_format::human_bytes(u64::try_from(bytes).unwrap_or(u64::MAX)),
121    )
122}
123
124/// Conservative retained charge for one key, identity, and matcher shell.
125pub(crate) const CONTROL_SOURCE_OVERHEAD: usize = 64;
126
127/// Stable, non-sensitive identity of one retained control source.
128#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
129pub struct ControlIdentity {
130    /// Exact source length.
131    pub bytes: u64,
132    /// Stable FNV-1a digest of the source bytes.
133    pub fingerprint: u64,
134}
135
136/// One distinct control content, parsed once and shared by every directory holding it.
137#[derive(Debug)]
138struct SharedContent {
139    bytes: Vec<u8>,
140    identity: ControlIdentity,
141    matcher: Gitignore,
142    /// Charge for the exact bytes and the parsed matcher, paid once per distinct content.
143    content_cost: usize,
144}
145
146/// A distinct content and how many directories of one table hold it.
147///
148/// The count belongs to the table, not to the `Arc`: a projected clone of a table shares
149/// every content with it, so the reference count cannot say which holders one table has.
150#[derive(Clone, Debug)]
151struct Holding {
152    content: Arc<SharedContent>,
153    holders: usize,
154}
155
156/// Which of the [`ControlLimits`] refused a control source instead of applying its rules.
157#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
158pub enum ControlRefusalReason {
159    /// Retaining the source would have taken the table past [`ControlLimits::budget`].
160    Budget,
161    /// A line of the source is longer than [`ControlLimits::line_limit`].
162    LineLimit,
163}
164
165impl ControlRefusalReason {
166    /// The stable name every structured output and binding uses for this reason, which is
167    /// also the name of the limit that fired.
168    pub const fn label(self) -> &'static str {
169        match self {
170            Self::Budget => "budget",
171            Self::LineLimit => "line_limit",
172        }
173    }
174}
175
176/// What a control table did with one verified control source.
177#[derive(Clone, Copy, PartialEq, Eq, Debug)]
178pub enum ControlAdmission {
179    /// The rules apply. `changed` is false when the directory already retained exactly
180    /// these bytes.
181    Retained {
182        /// Whether the table's retained sources changed.
183        changed: bool,
184    },
185    /// The rules do not apply, and the directory retains no source. The refusal is
186    /// recorded, so the table's coverage names it.
187    Refused(ControlRefusalReason),
188}
189
190/// What one upsert would do to a control table, decided before anything moves.
191#[derive(Clone, Copy, PartialEq, Eq, Debug)]
192enum Verdict {
193    /// The directory already retains exactly these bytes.
194    Unchanged,
195    /// The source applies, leaving the table charged `retained_cost` bytes.
196    Admit { retained_cost: usize },
197    /// A limit cannot admit the source.
198    Refuse(ControlRefusalReason),
199}
200
201/// One control file whose rules an index refused, relative to the index root.
202#[derive(Clone, PartialEq, Eq, Debug, Hash)]
203pub struct RefusedControl {
204    /// The refused `.gitignore`.
205    pub path: PathBuf,
206    /// Which limit refused it.
207    pub reason: ControlRefusalReason,
208}
209
210/// Whether an index's ignore classification applies every control file in its scope.
211///
212/// Sizes and counts never depend on this: a refused control file costs only the
213/// ignored and unignored split. Below a refused file that split is not exact in either
214/// direction, because the file may have held negations as well as ignore rules.
215#[derive(Clone, PartialEq, Eq, Debug)]
216pub enum ControlCoverage {
217    /// The index read no control file, so it classifies nothing as ignored or unignored.
218    NotObserved,
219    /// The index read every control file in its scope and applied the ones it admitted.
220    Observed(ControlObservation),
221}
222
223/// The control files an observing index applied and refused.
224#[derive(Clone, PartialEq, Eq, Debug)]
225pub struct ControlObservation {
226    /// The limits the index applied control files under.
227    pub limits: ControlLimits,
228    /// Control files whose rules apply.
229    pub applied: u64,
230    /// Accepted rules summed once per governing directory, including repeated sources.
231    pub rules: u64,
232    /// Control files refused, counted exactly.
233    pub refused: u64,
234    /// Refused control files in path order, at most [`crate::MAX_RETAINED_ISSUES`] of
235    /// them. A list shorter than [`Self::refused`] is truncated.
236    pub refusals: Vec<RefusedControl>,
237}
238
239impl ControlObservation {
240    /// Whether every control file in scope applies, so ignore classification is exact.
241    pub const fn is_complete(&self) -> bool {
242        self.refused == 0
243    }
244
245    /// Whether [`Self::refusals`] names every refused control file.
246    pub fn lists_every_refusal(&self) -> bool {
247        u64::try_from(self.refusals.len()).is_ok_and(|listed| listed == self.refused)
248    }
249}
250
251/// Exact `.gitignore` sources and parsed matchers, keyed by governing directory.
252///
253/// Identical sources are stored and parsed once. A tree of package checkouts repeats a few
254/// `.gitignore` files thousands of times, and charging every copy for its bytes and matcher
255/// crossed the bound at a fraction of the distinct rules the tree holds (fdu-szkg). Each
256/// directory still pays for its own key, so a tree of empty control files cannot evade the
257/// bound, and removing the last holder of a content releases the content's charge.
258///
259/// A source the limits cannot admit is refused, not an error: the table records the
260/// refusal and keeps no rules for that directory, and the scan that read it continues
261/// (fdu-1onj). A refusal ends when the directory's control file is removed or a later
262/// read admits it.
263#[derive(Clone, Debug)]
264pub struct ControlTable {
265    by_directory: BTreeMap<PathBuf, Arc<SharedContent>>,
266    /// The same sources by the bytes of their directory (H188). [`Self::chain_for`] probes
267    /// every ancestor of a listing's directory, and a `PathBuf` key compares component by
268    /// component at each probe: 90M of the summary fold's instructions on `linux-v6.12`,
269    /// where the bytes answer the same question. One extra copy of each retained key,
270    /// bounded by the count `retained_cost` already charges a key for.
271    by_directory_bytes: HashMap<std::ffi::OsString, Arc<SharedContent>>,
272    /// Distinct contents by identity. A list, because an equal length and FNV-1a digest do
273    /// not prove equal bytes, and a collision must never share another source's matcher.
274    shared: HashMap<ControlIdentity, Vec<Holding>>,
275    /// Every refused source, by governing directory. Kept whole, not bounded like issue
276    /// details, so removing a refused file keeps the refused count exact.
277    refused: BTreeMap<PathBuf, ControlRefusalReason>,
278    limits: ControlLimits,
279    source_bytes: usize,
280    retained_cost: usize,
281}
282
283impl Default for ControlTable {
284    fn default() -> Self {
285        Self::with_limits(ControlLimits::default())
286    }
287}
288
289impl ControlTable {
290    /// An empty table that refuses sources past either of `limits`.
291    pub(crate) fn with_limits(limits: ControlLimits) -> Self {
292        Self {
293            by_directory: BTreeMap::new(),
294            by_directory_bytes: HashMap::new(),
295            shared: HashMap::new(),
296            refused: BTreeMap::new(),
297            limits,
298            source_bytes: 0,
299            retained_cost: 0,
300        }
301    }
302
303    /// Insert or replace one verified control source.
304    ///
305    /// `path` names the control file relative to the index root. The source is retained
306    /// exactly, while matching state is derived once per distinct content rather than per
307    /// directory or per entry. A source the limits cannot admit is refused and recorded,
308    /// and a source it replaces is dropped with it: rules no longer on disk must not keep
309    /// applying. The line limit is checked first, so a source both limits refuse is
310    /// refused for its line.
311    ///
312    /// # Errors
313    ///
314    /// [`crate::Error::InvalidControlPath`] when `path` does not name a control file.
315    pub fn upsert(&mut self, path: &Path, source: Vec<u8>) -> crate::Result<ControlAdmission> {
316        let identity = identity(&source);
317        self.upsert_identified(path, source, identity)
318    }
319
320    fn upsert_identified(
321        &mut self,
322        path: &Path,
323        source: Vec<u8>,
324        identity: ControlIdentity,
325    ) -> crate::Result<ControlAdmission> {
326        let directory = control_directory(path)?;
327        match self.verdict(directory, &source, identity) {
328            Verdict::Unchanged => Ok(ControlAdmission::Retained { changed: false }),
329            Verdict::Refuse(reason) => {
330                crate::counters::bump(|counts| {
331                    counts.control_refused = counts.control_refused.saturating_add(1);
332                });
333                Ok(self.refuse(directory, reason))
334            }
335            Verdict::Admit { retained_cost } => {
336                self.detach(directory);
337                self.attach(directory, source, identity);
338                self.refused.remove(directory);
339                debug_assert_eq!(self.retained_cost, retained_cost);
340                Ok(ControlAdmission::Retained { changed: true })
341            }
342        }
343    }
344
345    /// What upserting `source` at `directory` would do, decided before anything moves.
346    ///
347    /// The decision is a pure function of this table, so a caller can ask whether an
348    /// operation would change anything without projecting a copy of the table to find out.
349    fn verdict(&self, directory: &Path, source: &[u8], identity: ControlIdentity) -> Verdict {
350        if self.by_directory.get(directory).is_some_and(|current| current.bytes == source) {
351            return Verdict::Unchanged;
352        }
353        if self.limits.line_limit.is_some_and(|line_limit| {
354            source.split(|byte| *byte == b'\n').any(|line| line.len() > line_limit)
355        }) {
356            return Verdict::Refuse(ControlRefusalReason::LineLimit);
357        }
358        let content_charge =
359            if self.holding(identity, source).is_some() { 0 } else { content_cost(source) };
360        // Saturating: every retained charge is a sum of real allocations, so only a source
361        // no budget could admit reaches the ceiling, and an unbounded table never refuses.
362        let retained_cost = self
363            .retained_cost
364            .saturating_sub(self.release_charge(directory))
365            .saturating_add(directory_cost(directory))
366            .saturating_add(content_charge);
367        if self.limits.budget.is_some_and(|budget| retained_cost > budget) {
368            return Verdict::Refuse(ControlRefusalReason::Budget);
369        }
370        Verdict::Admit { retained_cost }
371    }
372
373    /// Whether upserting `source` at `path` would leave this table exactly as it is.
374    ///
375    /// True when the directory already retains those exact bytes, and when it already
376    /// records a refusal this source would earn again: refusing an already-refused
377    /// directory for the same limit writes the same record. A warm revalidate of a tree
378    /// past its budget re-reads every refused file, and this is what tells the index that
379    /// those reads change nothing (fdu-hzm5).
380    pub(crate) fn upsert_is_inert(&self, path: &Path, source: &[u8]) -> bool {
381        let Ok(directory) = control_directory(path) else {
382            return false;
383        };
384        match self.verdict(directory, source, identity(source)) {
385            Verdict::Unchanged => true,
386            Verdict::Refuse(reason) => self.refused.get(directory) == Some(&reason),
387            Verdict::Admit { .. } => false,
388        }
389    }
390
391    /// Whether removing the control file at `path` would leave this table as it is.
392    pub(crate) fn remove_is_inert(&self, path: &Path) -> bool {
393        control_directory(path).is_ok_and(|directory| {
394            !self.by_directory.contains_key(directory) && !self.refused.contains_key(directory)
395        })
396    }
397
398    /// Whether any directory at or below `subtree` retains a source or records a refusal.
399    ///
400    /// What a structural removal of `subtree` would prune, so a batch that removes nothing
401    /// the table records leaves it alone.
402    pub(crate) fn has_record_at_or_below(&self, subtree: &Path) -> bool {
403        has_key_at_or_below(&self.by_directory, subtree)
404            || has_key_at_or_below(&self.refused, subtree)
405    }
406
407    /// Restore a refusal a snapshot recorded, without the source that was refused.
408    pub(crate) fn record_refusal(
409        &mut self,
410        path: &Path,
411        reason: ControlRefusalReason,
412    ) -> crate::Result<()> {
413        let directory = control_directory(path)?;
414        self.refuse(directory, reason);
415        Ok(())
416    }
417
418    fn refuse(&mut self, directory: &Path, reason: ControlRefusalReason) -> ControlAdmission {
419        self.detach(directory);
420        self.refused.insert(directory.to_path_buf(), reason);
421        ControlAdmission::Refused(reason)
422    }
423
424    /// Remove one control source, or the record of its refusal. Missing sources are no-ops.
425    ///
426    /// # Errors
427    ///
428    /// [`crate::Error::InvalidControlPath`] when `path` does not name a control file.
429    pub fn remove(&mut self, path: &Path) -> crate::Result<bool> {
430        let directory = control_directory(path)?;
431        let retained = self.detach(directory);
432        let refused = self.refused.remove(directory).is_some();
433        Ok(retained || refused)
434    }
435
436    /// Remove every control file, and every refusal, at or below `subtree`.
437    pub(crate) fn remove_subtree(&mut self, subtree: &Path) {
438        let directories: Vec<PathBuf> = self
439            .by_directory
440            .keys()
441            .filter(|directory| directory.starts_with(subtree))
442            .cloned()
443            .collect();
444        for directory in directories {
445            self.detach(&directory);
446        }
447        self.refused.retain(|directory, _| !directory.starts_with(subtree));
448    }
449
450    /// The holding for exactly `source`, when some directory already retains it.
451    fn holding(&self, identity: ControlIdentity, source: &[u8]) -> Option<&Holding> {
452        self.shared
453            .get(&identity)?
454            .iter()
455            .find(|holding| holding.content.bytes.as_slice() == source)
456    }
457
458    /// The charge that dropping `directory`'s current source would release.
459    fn release_charge(&self, directory: &Path) -> usize {
460        let Some(content) = self.by_directory.get(directory) else {
461            return 0;
462        };
463        let last_holder = self.shared.get(&content.identity).is_some_and(|holdings| {
464            holdings
465                .iter()
466                .any(|holding| Arc::ptr_eq(&holding.content, content) && holding.holders == 1)
467        });
468        directory_cost(directory).saturating_add(if last_holder { content.content_cost } else { 0 })
469    }
470
471    /// Drop `directory`'s source, releasing its content when this was the last holder.
472    fn detach(&mut self, directory: &Path) -> bool {
473        let Some(content) = self.by_directory.remove(directory) else {
474            return false;
475        };
476        self.by_directory_bytes.remove(directory.as_os_str());
477        let holdings = self.shared.get_mut(&content.identity).expect("a retained content is held");
478        let position = holdings
479            .iter()
480            .position(|holding| Arc::ptr_eq(&holding.content, &content))
481            .expect("a retained content is listed under its own identity");
482        holdings[position].holders -= 1;
483        if holdings[position].holders == 0 {
484            holdings.swap_remove(position);
485            self.retained_cost -= content.content_cost;
486            if holdings.is_empty() {
487                self.shared.remove(&content.identity);
488            }
489        }
490        self.retained_cost -= directory_cost(directory);
491        self.source_bytes -= content.bytes.len();
492        true
493    }
494
495    /// Hold `source` at `directory`, sharing an identical retained content when one exists.
496    fn attach(&mut self, directory: &Path, source: Vec<u8>, identity: ControlIdentity) {
497        let holdings = self.shared.entry(identity).or_default();
498        let content = if let Some(holding) =
499            holdings.iter_mut().find(|holding| holding.content.bytes == source)
500        {
501            holding.holders += 1;
502            crate::counters::bump(|counts| {
503                counts.control_sources_shared = counts.control_sources_shared.saturating_add(1);
504            });
505            Arc::clone(&holding.content)
506        } else {
507            let content_cost = content_cost(&source);
508            let matcher = Gitignore::parse(&source);
509            let content =
510                Arc::new(SharedContent { bytes: source, identity, matcher, content_cost });
511            holdings.push(Holding { content: Arc::clone(&content), holders: 1 });
512            self.retained_cost += content_cost;
513            content
514        };
515        self.retained_cost += directory_cost(directory);
516        self.source_bytes += content.bytes.len();
517        self.by_directory_bytes.insert(directory.as_os_str().to_os_string(), Arc::clone(&content));
518        self.by_directory.insert(directory.to_path_buf(), content);
519    }
520
521    /// Matcher view for one retained path.
522    pub fn matcher_for<'a>(&'a self, path: &'a Path) -> ControlMatcher<'a> {
523        ControlMatcher { table: self, path }
524    }
525
526    /// The controls governing every child of `directory`, resolved once for all of them.
527    ///
528    /// [`ControlMatcher::is_ignored`] looks each ancestor up in the table for every entry
529    /// it classifies. A listing's children share those ancestors, so a caller classifying
530    /// a whole listing resolves them here once and matches each child against the chain
531    /// (H163). The chain owns its sources, so the table may change while it is held; it
532    /// then answers for the table as it was when resolved.
533    ///
534    /// `directory` must be a normalized relative path, as every walked or event path is:
535    /// the chain counts one component per ancestor, which a `..` would break.
536    pub(crate) fn chain_for(&self, directory: &Path) -> ControlChain {
537        debug_assert!(
538            directory.components().all(|component| matches!(component, Component::Normal(_))),
539            "control chains are resolved for normalized relative directories: {}",
540            directory.display()
541        );
542        let mut governing = Vec::new();
543        if !self.by_directory_bytes.is_empty() {
544            let depth = gitignore::with_components(directory, None, |components| components.len());
545            for (up, ancestor) in ancestors(directory.as_os_str()).enumerate() {
546                if let Some(source) = self.by_directory_bytes.get(ancestor) {
547                    governing.push((depth.saturating_sub(up), Arc::clone(source)));
548                }
549            }
550        }
551        ControlChain { governing }
552    }
553
554    /// The chain for `directory`'s children, derived from `above`, the chain resolved for
555    /// its parent's children, instead of resolved from the table again (H175).
556    ///
557    /// It is [`Self::chain_for`]'s answer whenever no control of a directory above
558    /// `directory` has changed since `above` was resolved: `above` then names every control
559    /// above `directory`, deepest first, and only `directory`'s own can be new, which goes
560    /// first. A parent-first build meets that condition, because each listing applies only
561    /// its own directory's control, before any of its children is listed. It costs one
562    /// lookup, and an allocation only when `directory` holds a control; a caller that
563    /// knows `directory` holds none, because its listing carried no control, can share
564    /// `above` without asking.
565    pub(crate) fn chain_below(
566        &self,
567        above: &Arc<ControlChain>,
568        directory: &Path,
569    ) -> Arc<ControlChain> {
570        let Some(source) = self.by_directory_bytes.get(directory.as_os_str()) else {
571            return Arc::clone(above);
572        };
573        let depth = gitignore::with_components(directory, None, |components| components.len());
574        let mut governing = Vec::with_capacity(above.governing.len() + 1);
575        governing.push((depth, Arc::clone(source)));
576        governing.extend(above.governing.iter().cloned());
577        Arc::new(ControlChain { governing })
578    }
579
580    /// Evaluate complete ignore semantics without relying on retained parent facts.
581    ///
582    /// The index hot path uses [`ControlMatcher::is_ignored`] with the parent's stored
583    /// classification. This standalone form evaluates each directory prefix so callers
584    /// and tests receive the same answer even without an index entry in hand.
585    pub fn is_ignored(&self, path: &Path, is_dir: bool) -> bool {
586        let components: Vec<_> = path.components().collect();
587        let mut current = PathBuf::new();
588        let mut parent_ignored = false;
589        for (position, component) in components.iter().enumerate() {
590            current.push(component.as_os_str());
591            if parent_ignored {
592                return true;
593            }
594            let current_is_dir = position + 1 < components.len() || is_dir;
595            parent_ignored = self.matcher_for(&current).is_ignored(current_is_dir);
596        }
597        parent_ignored
598    }
599
600    /// Relative subtree whose classification may move when `path` changes.
601    pub fn affected_subtree(path: &Path) -> crate::Result<PathBuf> {
602        Ok(control_directory(path)?.to_path_buf())
603    }
604
605    /// Stable identities changed between two complete table states.
606    pub(crate) fn changes_from(
607        &self,
608        previous: &Self,
609    ) -> Vec<(PathBuf, Option<ControlIdentity>, Option<ControlIdentity>)> {
610        let directories: BTreeSet<&Path> = previous
611            .by_directory
612            .keys()
613            .chain(self.by_directory.keys())
614            .map(PathBuf::as_path)
615            .collect();
616        directories
617            .into_iter()
618            .filter_map(|directory| {
619                let before = previous.by_directory.get(directory);
620                let after = self.by_directory.get(directory);
621                let changed = match (before, after) {
622                    (Some(before), Some(after)) => {
623                        !Arc::ptr_eq(before, after) && before.bytes != after.bytes
624                    }
625                    (None, None) => false,
626                    (Some(_), None) | (None, Some(_)) => true,
627                };
628                changed.then(|| {
629                    (
630                        control_path(directory),
631                        before.map(|source| source.identity),
632                        after.map(|source| source.identity),
633                    )
634                })
635            })
636            .collect()
637    }
638
639    /// Refusals recorded or lifted between two complete table states.
640    pub(crate) fn refusal_changes_from(
641        &self,
642        previous: &Self,
643    ) -> Vec<(PathBuf, Option<ControlRefusalReason>, Option<ControlRefusalReason>)> {
644        let directories: BTreeSet<&Path> =
645            previous.refused.keys().chain(self.refused.keys()).map(PathBuf::as_path).collect();
646        directories
647            .into_iter()
648            .filter_map(|directory| {
649                let before = previous.refused.get(directory).copied();
650                let after = self.refused.get(directory).copied();
651                (before != after).then(|| (control_path(directory), before, after))
652            })
653            .collect()
654    }
655
656    /// Exact sources in deterministic governing-directory order.
657    pub(crate) fn sources(&self) -> impl ExactSizeIterator<Item = (PathBuf, &[u8])> {
658        self.by_directory
659            .iter()
660            .map(|(directory, source)| (control_path(directory), source.bytes.as_slice()))
661    }
662
663    /// Every refused control file and its reason, in governing-directory order.
664    pub fn refusals(&self) -> impl ExactSizeIterator<Item = RefusedControl> + '_ {
665        self.refused.iter().map(|(directory, reason)| RefusedControl {
666            path: control_path(directory),
667            reason: *reason,
668        })
669    }
670
671    /// Whether every control source that could govern `path` was admitted.
672    ///
673    /// A refused source may contain ignore or negation rules, so its descendants have
674    /// unknown classification even if the admitted rules currently say otherwise.
675    pub(crate) fn classification_known(&self, path: &Path) -> bool {
676        path.parent().is_none_or(|directory| self.children_classification_known(directory))
677    }
678
679    /// [`Self::classification_known`] for every entry directly in `directory`.
680    pub(crate) fn children_classification_known(&self, directory: &Path) -> bool {
681        !directory.ancestors().any(|ancestor| self.refused.contains_key(ancestor))
682    }
683
684    /// Number of refused control files.
685    pub fn refused_len(&self) -> usize {
686        self.refused.len()
687    }
688
689    /// The limits this table admits sources under.
690    pub const fn limits(&self) -> ControlLimits {
691        self.limits
692    }
693
694    /// This table's coverage, listing at most [`crate::MAX_RETAINED_ISSUES`] refusals.
695    pub fn observation(&self) -> ControlObservation {
696        ControlObservation {
697            limits: self.limits,
698            applied: u64::try_from(self.len()).unwrap_or(u64::MAX),
699            rules: self
700                .by_directory
701                .values()
702                .fold(0_u64, |total, content| total.saturating_add(content.matcher.rule_count())),
703            refused: u64::try_from(self.refused_len()).unwrap_or(u64::MAX),
704            refusals: self.refusals().take(crate::MAX_RETAINED_ISSUES).collect(),
705        }
706    }
707
708    /// Exact retained source bytes across the whole table.
709    pub const fn source_bytes(&self) -> usize {
710        self.source_bytes
711    }
712
713    /// Bounded retained charge for the complete table.
714    pub const fn retained_cost(&self) -> usize {
715        self.retained_cost
716    }
717
718    /// Whether `path` already retains exactly `source`.
719    pub fn source_is(&self, path: &Path, source: &[u8]) -> bool {
720        control_directory(path)
721            .ok()
722            .and_then(|directory| self.by_directory.get(directory))
723            .is_some_and(|current| current.bytes == source)
724    }
725
726    /// Whether the table holds a record at `path`: a retained source or a refusal.
727    ///
728    /// Reconciliation asks this to decide whether a control file missing from a listing
729    /// needs a removal, and a refused file that disappears must lift its refusal.
730    pub(crate) fn contains(&self, path: &Path) -> bool {
731        control_directory(path).ok().is_some_and(|directory| {
732            self.by_directory.contains_key(directory) || self.refused.contains_key(directory)
733        })
734    }
735
736    /// Number of retained control files.
737    pub fn len(&self) -> usize {
738        self.by_directory.len()
739    }
740
741    /// Whether no control file is retained. A table may still record refusals.
742    pub fn is_empty(&self) -> bool {
743        self.by_directory.is_empty()
744    }
745
746    /// Whether the table records nothing at all: no retained source and no refusal.
747    pub(crate) fn is_vacant(&self) -> bool {
748        self.by_directory.is_empty() && self.refused.is_empty()
749    }
750}
751
752/// A path-bound view over the controls that may govern it.
753pub struct ControlMatcher<'a> {
754    table: &'a ControlTable,
755    path: &'a Path,
756}
757
758impl ControlMatcher<'_> {
759    /// Decide this path assuming its retained parent is not ignored.
760    ///
761    /// A caller with retained facts already knows the parent's effective classification,
762    /// so it can stop immediately when that parent is ignored. Otherwise every control
763    /// directory on this path is active and the deepest matching opinion wins.
764    pub fn is_ignored(&self, is_dir: bool) -> bool {
765        // The empty table answers without collecting the ancestor list. Every entry of
766        // a scan that observes no control state asks this question exactly once, so the
767        // allocation below would be the last per-entry cost of a machinery the scan
768        // opted out of (fdu-etfj).
769        if self.table.by_directory.is_empty() {
770            return false;
771        }
772        // A deeper control source overrides every matching ancestor. Walk from the
773        // immediate directory toward the root and return the first opinion instead of
774        // allocating and reversing an ancestor vector for every classified entry.
775        for directory in self.path.parent().into_iter().flat_map(Path::ancestors) {
776            let Some(source) = self.table.by_directory.get(directory) else {
777                continue;
778            };
779            let relative = self.path.strip_prefix(directory).unwrap_or(self.path);
780            if let Some(ignored) = source.matcher.matches(relative, is_dir) {
781                return ignored;
782            }
783        }
784        false
785    }
786}
787
788/// The controls that govern one directory's children, deepest first, with how many of the
789/// directory's path components lead to each one's own directory.
790#[derive(Clone, Debug, Default)]
791pub(crate) struct ControlChain {
792    governing: Vec<(usize, Arc<SharedContent>)>,
793}
794
795impl ControlChain {
796    /// Whether no control governs the directory, so none of its children is ignored.
797    pub(crate) fn is_empty(&self) -> bool {
798        self.governing.is_empty()
799    }
800
801    /// Whether two chains name the same controls, as the same retained contents, at the
802    /// same depths and in the same order.
803    pub(crate) fn same_as(&self, other: &Self) -> bool {
804        self.governing.len() == other.governing.len()
805            && self
806                .governing
807                .iter()
808                .zip(&other.governing)
809                .all(|(left, right)| left.0 == right.0 && Arc::ptr_eq(&left.1, &right.1))
810    }
811
812    /// Decide the child `name` of `directory`, the directory this chain was resolved for,
813    /// assuming that directory is not ignored.
814    #[cfg(test)]
815    pub(crate) fn is_ignored(&self, directory: &Path, name: &[u8], is_dir: bool) -> bool {
816        with_directory_components(directory, |components| {
817            self.is_ignored_within(components, name, is_dir)
818        })
819    }
820
821    /// Decide the child `name` of the directory this chain was resolved for, given that
822    /// directory's normal components, assuming that directory is not ignored.
823    ///
824    /// The answer is [`ControlMatcher::is_ignored`]'s for `directory/name`: the deepest
825    /// control with an opinion wins, each matching the path relative to its own directory.
826    /// A caller classifying a whole listing splits the directory once, with
827    /// [`with_directory_components`], and the name is hashed once here for every control
828    /// that governs it (H171), as the set of its byte classes is collected (H183).
829    pub(crate) fn is_ignored_within(&self, directory: &[&[u8]], name: &[u8], is_dir: bool) -> bool {
830        if self.governing.is_empty() {
831            return false;
832        }
833        let name = gitignore::Name::new(name);
834        let mut tally = gitignore::Tally::default();
835        let ignored = self
836            .governing
837            .iter()
838            .find_map(|(leading, source)| {
839                let relative = directory.get(*leading..).unwrap_or_default();
840                source.matcher.decide(relative, &name, is_dir, &mut tally)
841            })
842            .unwrap_or(false);
843        tally.record();
844        ignored
845    }
846}
847
848/// Call `each` with the normal components of `directory`, split once for every child of
849/// one listing, without a heap allocation for a path of ordinary depth.
850pub(crate) fn with_directory_components<R>(
851    directory: &Path,
852    each: impl FnOnce(&[&[u8]]) -> R,
853) -> R {
854    gitignore::with_components(directory, None, each)
855}
856
857/// A directory's normal components, copied once, for a caller that keeps them across
858/// calls and classifies each child of the directory as it arrives.
859///
860/// [`Path::components`] parses the whole path each time it is asked, which costs a
861/// deep entry more than matching its name does once the rules are indexed (H171).
862#[derive(Debug)]
863pub(crate) struct SplitDirectory {
864    bytes: Vec<u8>,
865    /// Where each component ends in `bytes`.
866    ends: Vec<usize>,
867}
868
869impl SplitDirectory {
870    pub(crate) fn new(directory: &Path) -> Self {
871        with_directory_components(directory, |components| {
872            let mut split = Self {
873                bytes: Vec::with_capacity(components.iter().map(|component| component.len()).sum()),
874                ends: Vec::with_capacity(components.len()),
875            };
876            for component in components {
877                split.bytes.extend_from_slice(component);
878                split.ends.push(split.bytes.len());
879            }
880            split
881        })
882    }
883
884    /// Call `each` with the components, without a heap allocation for a path of ordinary
885    /// depth.
886    pub(crate) fn with_components<R>(&self, each: impl FnOnce(&[&[u8]]) -> R) -> R {
887        let components = self.ends.iter().scan(0, |start, &end| {
888            let component = &self.bytes[*start..end];
889            *start = end;
890            Some(component)
891        });
892        gitignore::with_collected(components, each)
893    }
894}
895
896/// `path.parent()` and `path.file_name()` of a normalized relative path, as a walk emits
897/// them, by the bytes before and after its last separator (H188); `""` for a missing
898/// parent or name, as the fold reads either.
899pub(crate) fn split_parent(path: &std::path::Path) -> (&std::ffi::OsStr, &std::ffi::OsStr) {
900    let parsed = || {
901        (
902            path.parent().map_or(std::ffi::OsStr::new(""), std::path::Path::as_os_str),
903            path.file_name().unwrap_or(std::ffi::OsStr::new("")),
904        )
905    };
906    #[cfg(unix)]
907    let split = {
908        use std::os::unix::ffi::OsStrExt as _;
909        let bytes = path.as_os_str().as_bytes();
910        match bytes.iter().rposition(|byte| *byte == b'/') {
911            Some(at) => (
912                std::ffi::OsStr::from_bytes(&bytes[..at]),
913                std::ffi::OsStr::from_bytes(&bytes[at + 1..]),
914            ),
915            None => (std::ffi::OsStr::new(""), path.as_os_str()),
916        }
917    };
918    #[cfg(not(unix))]
919    let split = parsed();
920    debug_assert_eq!(split, parsed(), "a walked path is normalized and relative");
921    split
922}
923
924/// `Path::ancestors` of a normalized relative directory, by its separators (H188): the
925/// directory, each directory above it, and the root, `""`.
926#[cfg(unix)]
927pub(crate) fn ancestors(directory: &std::ffi::OsStr) -> impl Iterator<Item = &std::ffi::OsStr> {
928    use std::os::unix::ffi::OsStrExt as _;
929    let bytes = directory.as_bytes();
930    let mut next = Some(bytes.len());
931    std::iter::from_fn(move || {
932        let end = next?;
933        next = (end > 0).then(|| bytes[..end].iter().rposition(|byte| *byte == b'/').unwrap_or(0));
934        Some(std::ffi::OsStr::from_bytes(&bytes[..end]))
935    })
936}
937
938#[cfg(not(unix))]
939pub(crate) fn ancestors(directory: &std::ffi::OsStr) -> impl Iterator<Item = &std::ffi::OsStr> {
940    std::path::Path::new(directory).ancestors().map(std::path::Path::as_os_str)
941}
942
943/// Whether any key of `directories` is `subtree` or lies below it.
944///
945/// One lookup rather than a scan: [`Path`] orders component by component, so every
946/// descendant of `subtree` sorts immediately after it and before any other key, and the
947/// first key at or after `subtree` decides.
948fn has_key_at_or_below<V>(directories: &BTreeMap<PathBuf, V>, subtree: &Path) -> bool {
949    directories
950        .range::<Path, _>((std::ops::Bound::Included(subtree), std::ops::Bound::Unbounded))
951        .next()
952        .is_some_and(|(directory, _)| directory.starts_with(subtree))
953}
954
955/// Whether a relative path names the fixed control file: the canonical path every control
956/// operation, and the table, name a directory's rules by.
957///
958/// A directory's rules may be held by a `.GITIGNORE` on a case-insensitive volume, but
959/// they are still recorded under `<dir>/.gitignore` (see the module documentation), so a
960/// control operation naming any other spelling is malformed.
961pub fn is_control_file(path: &Path) -> bool {
962    path.file_name().is_some_and(|name| name == CONTROL_FILE_NAME)
963}
964
965/// How a listed name relates to its directory's control file.
966#[derive(Clone, Copy, PartialEq, Eq, Debug)]
967pub(crate) enum ControlSpelling {
968    /// Exactly [`CONTROL_FILE_NAME`]: the entry a lookup of the directory's control opens
969    /// on every filesystem, read through its own path.
970    Exact,
971    /// [`CONTROL_FILE_NAME`] in another ASCII case, such as `.GITIGNORE`: the directory's
972    /// control only where the filesystem resolves `.gitignore` to it, which a lookup of the
973    /// canonical path decides.
974    Variant,
975}
976
977/// Whether `name` spells the control file name, and how.
978///
979/// Every listed entry asks this, so it is a length test and, for the rare ten-byte name,
980/// an ASCII case-insensitive comparison: no allocation and no system call.
981pub(crate) fn control_spelling(name: &std::ffi::OsStr) -> Option<ControlSpelling> {
982    let bytes = name.as_encoded_bytes();
983    let exact = CONTROL_FILE_NAME.as_bytes();
984    if bytes.len() != exact.len() {
985        None
986    } else if bytes == exact {
987        Some(ControlSpelling::Exact)
988    } else if bytes.eq_ignore_ascii_case(exact) {
989        Some(ControlSpelling::Variant)
990    } else {
991        None
992    }
993}
994
995/// The spelling of the control name a relative path's last component uses, if any.
996pub(crate) fn path_control_spelling(path: &Path) -> Option<ControlSpelling> {
997    path.file_name().and_then(control_spelling)
998}
999
1000/// The canonical control path of the directory `path` sits in: `<parent>/.gitignore`.
1001pub(crate) fn sibling_control_path(path: &Path) -> PathBuf {
1002    control_path(path.parent().unwrap_or_else(|| Path::new("")))
1003}
1004
1005/// The control file a walk error under `root` names, relative to it, when there is one,
1006/// by its canonical path.
1007///
1008/// A control file the walk could not read leaves the rules it holds unknown, so the
1009/// ignored split it governs cannot be verified. That includes a listed case variant whose
1010/// own metadata could not be read: on a case-insensitive volume it may hold the rules, and
1011/// nothing looked the canonical path up. The index and the transient summary both ask this
1012/// of the same normalized walk errors, so they withhold the same shares.
1013pub(crate) fn unreadable_control(root: &Path, error: &crate::Error) -> Option<PathBuf> {
1014    let crate::Error::Io { .. } = error else {
1015        return None;
1016    };
1017    crate::Issue::from_error_under(root, error).path.and_then(|path| governing_control(&path))
1018}
1019
1020/// The canonical control path of the directory whose control an entry at `path` may hold,
1021/// when its name spells the control name in any case.
1022pub(crate) fn governing_control(path: &Path) -> Option<PathBuf> {
1023    path_control_spelling(path).map(|_| sibling_control_path(path))
1024}
1025
1026fn control_directory(path: &Path) -> crate::Result<&Path> {
1027    if !is_control_file(path) {
1028        return Err(crate::Error::InvalidControlPath(path.to_path_buf()));
1029    }
1030    Ok(path.parent().unwrap_or_else(|| Path::new("")))
1031}
1032
1033fn control_path(directory: &Path) -> PathBuf {
1034    directory.join(CONTROL_FILE_NAME)
1035}
1036
1037fn identity(bytes: &[u8]) -> ControlIdentity {
1038    const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
1039    const FNV_PRIME: u64 = 0x100_0000_01b3;
1040
1041    let mut fingerprint = FNV_OFFSET_BASIS;
1042    for byte in bytes {
1043        fingerprint ^= u64::from(*byte);
1044        fingerprint = fingerprint.wrapping_mul(FNV_PRIME);
1045    }
1046    ControlIdentity { bytes: u64::try_from(bytes.len()).unwrap_or(u64::MAX), fingerprint }
1047}
1048
1049/// Charge for one directory's key and identity, whatever content it holds.
1050fn directory_cost(directory: &Path) -> usize {
1051    CONTROL_SOURCE_OVERHEAD.saturating_add(directory.as_os_str().as_encoded_bytes().len())
1052}
1053
1054/// Charge for one distinct content: its exact bytes and parsed glob bytes, plus the
1055/// matcher's per-pattern and per-segment shells.
1056fn content_cost(source: &[u8]) -> usize {
1057    let (newlines, segment_shells) = source.iter().fold((0usize, 0usize), |counts, byte| {
1058        (counts.0 + usize::from(*byte == b'\n'), counts.1 + usize::from(*byte == b'/'))
1059    });
1060    let pattern_shells = newlines.saturating_add(1);
1061    source
1062        .len()
1063        .saturating_mul(2)
1064        .saturating_add(pattern_shells.saturating_mul(64))
1065        .saturating_add(segment_shells.saturating_mul(24))
1066}
1067
1068/// Charge for one source whose content no other directory holds.
1069#[cfg(test)]
1070fn retained_source_cost(directory: &Path, source: &[u8]) -> usize {
1071    directory_cost(directory).saturating_add(content_cost(source))
1072}
1073
1074#[cfg(test)]
1075pub(crate) fn source_at_test_limit() -> Vec<u8> {
1076    let mut source = Vec::new();
1077    loop {
1078        let previous_len = source.len();
1079        source.extend(std::iter::repeat_n(b'a', DEFAULT_CONTROL_LINE_LIMIT));
1080        source.push(b'\n');
1081        if retained_source_cost(Path::new(""), &source) > DEFAULT_CONTROL_BUDGET {
1082            source.truncate(previous_len);
1083            break;
1084        }
1085    }
1086    let remaining = DEFAULT_CONTROL_BUDGET - retained_source_cost(Path::new(""), &source);
1087    source.extend(std::iter::repeat_n(b'a', (remaining / 2).min(DEFAULT_CONTROL_LINE_LIMIT)));
1088    assert_eq!(retained_source_cost(Path::new(""), &source), DEFAULT_CONTROL_BUDGET);
1089    source
1090}
1091
1092#[cfg(test)]
1093impl ControlTable {
1094    /// Recompute every charge and holder count from the directories alone.
1095    fn assert_consistent(&self) {
1096        let mut distinct: Vec<&Arc<SharedContent>> = Vec::new();
1097        let mut directory_charges = 0;
1098        let mut source_bytes = 0;
1099        for (directory, content) in &self.by_directory {
1100            directory_charges += directory_cost(directory);
1101            source_bytes += content.bytes.len();
1102            if !distinct.iter().any(|seen| Arc::ptr_eq(seen, content)) {
1103                distinct.push(content);
1104            }
1105        }
1106        let content_charges: usize = distinct.iter().map(|content| content.content_cost).sum();
1107        assert_eq!(self.retained_cost, directory_charges + content_charges, "retained cost");
1108        assert_eq!(self.source_bytes, source_bytes, "source bytes");
1109        let holdings: usize = self.shared.values().map(Vec::len).sum();
1110        assert_eq!(holdings, distinct.len(), "one holding per distinct content");
1111        for (identity, holdings) in &self.shared {
1112            assert!(!holdings.is_empty(), "no empty identity list survives");
1113            for holding in holdings {
1114                assert_eq!(holding.content.identity, *identity);
1115                let holders = self
1116                    .by_directory
1117                    .values()
1118                    .filter(|content| Arc::ptr_eq(content, &holding.content))
1119                    .count();
1120                assert_eq!(holding.holders, holders, "holder count");
1121            }
1122            for (index, left) in holdings.iter().enumerate() {
1123                for right in &holdings[index + 1..] {
1124                    assert_ne!(left.content.bytes, right.content.bytes, "equal bytes are shared");
1125                }
1126            }
1127        }
1128        assert!(
1129            self.refused.keys().all(|directory| !self.by_directory.contains_key(directory)),
1130            "a refused directory retains no source"
1131        );
1132        // The byte-keyed index (H188) holds the same relation: one key per directory,
1133        // sharing the directory's content.
1134        assert_eq!(
1135            self.by_directory_bytes.len(),
1136            self.by_directory.len(),
1137            "one byte key per directory"
1138        );
1139        for (directory, content) in &self.by_directory {
1140            let by_bytes = self
1141                .by_directory_bytes
1142                .get(directory.as_os_str())
1143                .expect("every directory is keyed by its bytes");
1144            assert!(
1145                Arc::ptr_eq(by_bytes, content),
1146                "{}: the byte-keyed index shares the directory's content",
1147                directory.display()
1148            );
1149        }
1150        assert!(
1151            self.limits.budget.is_none_or(|budget| self.retained_cost <= budget),
1152            "within budget"
1153        );
1154        assert!(
1155            self.limits.line_limit.is_none_or(|line_limit| {
1156                distinct.iter().all(|content| {
1157                    content.bytes.split(|byte| *byte == b'\n').all(|line| line.len() <= line_limit)
1158                })
1159            }),
1160            "within the line limit"
1161        );
1162    }
1163}
1164
1165#[cfg(test)]
1166mod tests {
1167    use super::*;
1168
1169    /// Deterministic `SplitMix64`, so a failing sequence replays from its printed seed.
1170    struct SplitMix(u64);
1171
1172    impl SplitMix {
1173        fn next(&mut self) -> u64 {
1174            self.0 = self.0.wrapping_add(0x9e37_79b9_7f4a_7c15);
1175            let mut value = self.0;
1176            value = (value ^ (value >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
1177            value = (value ^ (value >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
1178            value ^ (value >> 31)
1179        }
1180
1181        fn below(&mut self, bound: usize) -> usize {
1182            usize::try_from(self.next() % u64::try_from(bound).expect("small bound")).expect("fits")
1183        }
1184    }
1185
1186    /// Every directory's last operation decides whether it holds a record, whatever the
1187    /// limits decided: an upsert leaves exactly one of a source or a refusal, and a removal
1188    /// leaves neither. Charges and holder counts are recomputed after every step, under
1189    /// each combination of a bounded or unbounded budget and line limit.
1190    #[test]
1191    fn rule_totals_count_accepted_patterns_per_governing_location() {
1192        let mut table = ControlTable::default();
1193        let source = b"# comment\n\n*.log\n!important.log\n*.log\n[bad\n";
1194        table.upsert(Path::new(".gitignore"), source.to_vec()).expect("root control");
1195        table.upsert(Path::new("nested/.gitignore"), source.to_vec()).expect("nested control");
1196        assert_eq!(table.observation().applied, 2);
1197        assert_eq!(table.observation().rules, 6);
1198        table
1199            .upsert(Path::new("nested/.gitignore"), b"# empty\n".to_vec())
1200            .expect("replacement control");
1201        assert_eq!(table.observation().applied, 2);
1202        assert_eq!(table.observation().rules, 3);
1203    }
1204
1205    #[test]
1206    fn charges_and_refusals_stay_exact_through_random_upserts_and_removals() {
1207        const DIRECTORIES: [&str; 6] = ["", "a", "a/b", "b", "b/c/d", "c"];
1208        let long_line = [vec![b'x'; DEFAULT_CONTROL_LINE_LIMIT + 1], b"\n".to_vec()].concat();
1209        let large = b"pattern/\n".repeat(40);
1210        let contents: [&[u8]; 6] =
1211            [b"*.log\n", b"target/\n", b"!keep\n*.tmp\n", b"", &large, &long_line];
1212        // Room for a few small sources and one large one, so the budget refuses often.
1213        let budget = 2 * retained_source_cost(Path::new("b/c/d"), &large);
1214        for seed in 0..64 {
1215            let mut random = SplitMix(seed);
1216            let limits = ControlLimits {
1217                budget: (seed % 2 == 0).then_some(budget),
1218                line_limit: (seed % 4 < 2).then_some(DEFAULT_CONTROL_LINE_LIMIT),
1219            };
1220            let mut table = ControlTable::with_limits(limits);
1221            let mut holds_record: BTreeMap<&Path, bool> = BTreeMap::new();
1222            for step in 0..300 {
1223                let directory = Path::new(DIRECTORIES[random.below(DIRECTORIES.len())]);
1224                let path = directory.join(CONTROL_FILE_NAME);
1225                match random.below(8) {
1226                    0 => {
1227                        table.remove_subtree(directory);
1228                        for (held, record) in &mut holds_record {
1229                            if held.starts_with(directory) {
1230                                *record = false;
1231                            }
1232                        }
1233                    }
1234                    1 | 2 => {
1235                        table.remove(&path).expect("control path");
1236                        holds_record.insert(directory, false);
1237                    }
1238                    _ => {
1239                        let content = contents[random.below(contents.len())].to_vec();
1240                        let admission = table.upsert(&path, content.clone()).expect("control path");
1241                        if content == long_line && limits.line_limit.is_some() {
1242                            assert_eq!(
1243                                admission,
1244                                ControlAdmission::Refused(ControlRefusalReason::LineLimit)
1245                            );
1246                        }
1247                        if limits.budget.is_none() && limits.line_limit.is_none() {
1248                            assert!(
1249                                matches!(admission, ControlAdmission::Retained { .. }),
1250                                "an unbounded table refuses nothing"
1251                            );
1252                        }
1253                        holds_record.insert(directory, true);
1254                    }
1255                }
1256                std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1257                    table.assert_consistent();
1258                    for (directory, record) in &holds_record {
1259                        let path = directory.join(CONTROL_FILE_NAME);
1260                        assert_eq!(table.contains(&path), *record, "{}", path.display());
1261                    }
1262                    let records = holds_record.values().filter(|record| **record).count();
1263                    assert_eq!(table.len() + table.refused_len(), records);
1264                }))
1265                .unwrap_or_else(|_| panic!("seed {seed}, step {step}: inconsistent table"));
1266            }
1267            for directory in DIRECTORIES {
1268                table.remove(&Path::new(directory).join(CONTROL_FILE_NAME)).expect("control path");
1269            }
1270            assert_eq!(table.retained_cost(), 0, "seed {seed}");
1271            assert_eq!(table.source_bytes(), 0, "seed {seed}");
1272            assert!(table.shared.is_empty(), "seed {seed}");
1273            assert!(table.is_vacant(), "seed {seed}");
1274        }
1275    }
1276
1277    #[test]
1278    fn a_resolved_chain_answers_as_the_per_entry_matcher_does() {
1279        let mut table = ControlTable::default();
1280        for (path, source) in [
1281            (".gitignore", &b"*.log\n/build/\n!keep.log\nsub/*.tmp\n"[..]),
1282            ("a/.gitignore", b"!*.log\n*.o\n/deep/**\n"),
1283            ("a/b/.gitignore", b"*.log\n!x.o\n"),
1284            ("c/.gitignore", b"# comment only\n"),
1285        ] {
1286            table.upsert(Path::new(path), source.to_vec()).expect("fixture control");
1287        }
1288        let directories =
1289            ["", "a", "a/b", "a/b/c", "a/deep", "a/deep/er", "build", "c", "c/sub", "sub", "x/y"];
1290        let names = ["x.log", "keep.log", "x.o", "y.o", "build", "deep", "t.tmp", "plain"];
1291        for directory in directories {
1292            let chain = table.chain_for(Path::new(directory));
1293            for name in names {
1294                for is_dir in [false, true] {
1295                    let path = Path::new(directory).join(name);
1296                    assert_eq!(
1297                        chain.is_ignored(Path::new(directory), name.as_bytes(), is_dir),
1298                        table.matcher_for(&path).is_ignored(is_dir),
1299                        "{} (dir {is_dir})",
1300                        path.display()
1301                    );
1302                }
1303            }
1304        }
1305        assert!(!ControlTable::default().chain_for(Path::new("a")).is_ignored(
1306            Path::new("a"),
1307            b"x.log",
1308            false
1309        ));
1310    }
1311
1312    /// A chain derived parent-first, each directory adding only its own control, names the
1313    /// same controls as the chain the table resolves, whatever each directory's control
1314    /// did: applied, shared with another directory, refused for the budget or for a line,
1315    /// removed, or never listed (H175).
1316    #[test]
1317    fn chains_derived_parent_first_are_the_ones_the_table_resolves() {
1318        let long_line = [vec![b'x'; DEFAULT_CONTROL_LINE_LIMIT + 1], b"\n".to_vec()].concat();
1319        let large = b"pattern/\n".repeat(40);
1320        let contents: [&[u8]; 6] =
1321            [b"*.log\n", b"!keep\n*.tmp\n", b"", b"/a/x.log\n!*.log\n", &large, &long_line];
1322        let budget = 3 * retained_source_cost(Path::new("a/b/c"), &large);
1323        for seed in 0..200 {
1324            let mut random = SplitMix(seed);
1325            let mut table = ControlTable::with_limits(ControlLimits {
1326                budget: Some(budget),
1327                ..ControlLimits::default()
1328            });
1329            let mut listings = std::collections::VecDeque::from([(
1330                PathBuf::new(),
1331                Arc::<ControlChain>::default(),
1332            )]);
1333            while let Some((directory, above)) = listings.pop_front() {
1334                let control = directory.join(CONTROL_FILE_NAME);
1335                let listed = match random.below(4) {
1336                    0 | 1 => {
1337                        let content = contents[random.below(contents.len())].to_vec();
1338                        table.upsert(&control, content).expect("control path");
1339                        true
1340                    }
1341                    2 => {
1342                        table.remove(&control).expect("control path");
1343                        true
1344                    }
1345                    _ => false,
1346                };
1347                let chain = if listed { table.chain_below(&above, &directory) } else { above };
1348                let resolved = table.chain_for(&directory);
1349                assert!(chain.same_as(&resolved), "seed {seed}: {}", directory.display());
1350                for name in ["x.log", "keep", "x.tmp", "pattern", "a"] {
1351                    for is_dir in [false, true] {
1352                        assert_eq!(
1353                            chain.is_ignored(&directory, name.as_bytes(), is_dir),
1354                            resolved.is_ignored(&directory, name.as_bytes(), is_dir),
1355                            "seed {seed}: {}/{name}",
1356                            directory.display()
1357                        );
1358                    }
1359                }
1360                if directory.components().count() < 4 {
1361                    for name in ["a", "b", "c"] {
1362                        if random.below(3) > 0 {
1363                            listings.push_back((directory.join(name), Arc::clone(&chain)));
1364                        }
1365                    }
1366                }
1367            }
1368            table.assert_consistent();
1369        }
1370    }
1371
1372    #[test]
1373    fn a_chain_agrees_past_the_inline_buffers_and_beside_unrelated_controls() {
1374        let mut table = ControlTable::default();
1375        table.upsert(Path::new("z/.gitignore"), b"*.log\n".to_vec()).expect("unrelated");
1376        let unrelated = table.chain_for(Path::new("a/b"));
1377        assert!(!unrelated.is_ignored(Path::new("a/b"), b"x.log", false));
1378        assert!(!table.matcher_for(Path::new("a/b/x.log")).is_ignored(false));
1379
1380        // A control 33 directories down, and directories of 31 to 34 components, cross the
1381        // 32-component inline buffer both in the chain's key and in the matched path.
1382        let deep: PathBuf = (0..33).map(|at| format!("d{at}")).collect();
1383        table.upsert(Path::new(".gitignore"), b"**/x.log\n/d0/**/y.log\n".to_vec()).expect("root");
1384        table.upsert(&deep.join(".gitignore"), b"!x.log\n*.tmp\n".to_vec()).expect("deep");
1385        for depth in [31usize, 32, 33, 34] {
1386            let directory: PathBuf = (0..depth).map(|at| format!("d{at}")).collect();
1387            let chain = table.chain_for(&directory);
1388            for name in ["x.log", "y.log", "z.tmp", "plain"] {
1389                let path = directory.join(name);
1390                assert_eq!(
1391                    chain.is_ignored(&directory, name.as_bytes(), false),
1392                    table.matcher_for(&path).is_ignored(false),
1393                    "{name} at depth {depth}"
1394                );
1395            }
1396        }
1397    }
1398
1399    /// The matching counters see each lookup, each lookup that found a rule, and each rule
1400    /// whose glob had to run, on the thread that classified.
1401    #[test]
1402    fn matching_counts_lookups_hits_and_rules_tested() {
1403        let _serial = crate::counters::test_serial();
1404        crate::counters::enable(true);
1405        crate::counters::test_thread_reset();
1406        let mut table = ControlTable::default();
1407        table
1408            .upsert(Path::new(".gitignore"), b"*.o\nMakefile\n/build\n*.c.[01]*\n".to_vec())
1409            .expect("root control");
1410        let chain = table.chain_for(Path::new(""));
1411        // A name lookup, and an extension lookup that finds `*.o`; `/build` and the
1412        // wildcard rule are ruled out by their length and literal checks.
1413        assert!(chain.is_ignored_within(&[], b"main.o", false));
1414        // Two lookups that find nothing, and the wildcard rule tested in full.
1415        assert!(chain.is_ignored_within(&[], b"a.c.0x", false));
1416        let counts = crate::counters::test_thread_snapshot();
1417        crate::counters::enable(false);
1418        assert_eq!(
1419            (counts.ignore_bucket_probes, counts.ignore_bucket_hits, counts.ignore_patterns_tested),
1420            (4, 1, 1)
1421        );
1422    }
1423
1424    #[test]
1425    fn a_fingerprint_collision_never_shares_a_matcher() {
1426        let collision = ControlIdentity { bytes: 6, fingerprint: 7 };
1427        let mut table = ControlTable::default();
1428        table
1429            .upsert_identified(Path::new("a/.gitignore"), b"*.log\n".to_vec(), collision)
1430            .expect("first");
1431        table
1432            .upsert_identified(Path::new("b/.gitignore"), b"*.tmp\n".to_vec(), collision)
1433            .expect("second");
1434        table.assert_consistent();
1435
1436        assert_eq!(table.shared[&collision].len(), 2);
1437        assert!(table.is_ignored(Path::new("a/x.log"), false));
1438        assert!(!table.is_ignored(Path::new("a/x.tmp"), false));
1439        assert!(table.is_ignored(Path::new("b/x.tmp"), false));
1440        assert!(!table.is_ignored(Path::new("b/x.log"), false));
1441        assert_eq!(
1442            table.retained_cost(),
1443            retained_source_cost(Path::new("a"), b"*.log\n")
1444                + retained_source_cost(Path::new("b"), b"*.tmp\n")
1445        );
1446
1447        table.remove(Path::new("a/.gitignore")).expect("remove");
1448        table.assert_consistent();
1449        assert!(table.is_ignored(Path::new("b/x.tmp"), false));
1450    }
1451
1452    const CHANGED: ControlAdmission = ControlAdmission::Retained { changed: true };
1453    const UNCHANGED: ControlAdmission = ControlAdmission::Retained { changed: false };
1454    const OVER_BUDGET: ControlAdmission = ControlAdmission::Refused(ControlRefusalReason::Budget);
1455    const OVER_LINE_LIMIT: ControlAdmission =
1456        ControlAdmission::Refused(ControlRefusalReason::LineLimit);
1457
1458    /// A table under `budget` and the default line limit.
1459    fn budgeted(budget: Option<usize>) -> ControlTable {
1460        ControlTable::with_limits(ControlLimits { budget, ..ControlLimits::default() })
1461    }
1462
1463    #[test]
1464    fn replacing_the_last_holder_releases_its_content_for_the_bound() {
1465        let mut table = ControlTable::default();
1466        let first = source_at_test_limit();
1467        assert_eq!(
1468            table.upsert(Path::new(".gitignore"), first.clone()).expect("control path"),
1469            CHANGED
1470        );
1471        // The same content elsewhere costs only a key, which still crosses a full table.
1472        assert_eq!(table.upsert(Path::new("copy/.gitignore"), first).expect("path"), OVER_BUDGET);
1473        assert_eq!(
1474            table.upsert(Path::new(".gitignore"), b"small\n".to_vec()).expect("control path"),
1475            CHANGED
1476        );
1477        table.assert_consistent();
1478        assert_eq!(table.retained_cost(), retained_source_cost(Path::new(""), b"small\n"));
1479    }
1480
1481    #[test]
1482    fn creation_edit_and_last_removal_are_exact() {
1483        let mut table = ControlTable::default();
1484        assert_eq!(
1485            table.upsert(Path::new(".gitignore"), b"*.log\n".to_vec()).expect("control path"),
1486            CHANGED
1487        );
1488        let original = table.clone();
1489        assert_eq!(
1490            table.upsert(Path::new(".gitignore"), b"*.log\n".to_vec()).expect("control path"),
1491            UNCHANGED
1492        );
1493        assert_eq!(
1494            table.upsert(Path::new(".gitignore"), b"*.tmp\n".to_vec()).expect("control path"),
1495            CHANGED
1496        );
1497        assert_eq!(table.changes_from(&original).len(), 1);
1498        assert!(table.remove(Path::new(".gitignore")).expect("remove"));
1499        assert!(table.is_empty());
1500        assert_eq!(table.source_bytes(), 0);
1501        assert!(!table.remove(Path::new(".gitignore")).expect("missing is a no-op"));
1502    }
1503
1504    /// The budget admits a table charged exactly to it and refuses one byte more.
1505    #[test]
1506    fn the_budget_admits_its_own_size_and_refuses_one_byte_over_it() {
1507        let source = b"*.log\n".to_vec();
1508        let exact = retained_source_cost(Path::new("a"), &source);
1509        let mut at_budget = budgeted(Some(exact));
1510        assert_eq!(
1511            at_budget.upsert(Path::new("a/.gitignore"), source.clone()).expect("control path"),
1512            CHANGED
1513        );
1514        assert_eq!(at_budget.retained_cost(), exact);
1515
1516        let mut under_budget = budgeted(Some(exact - 1));
1517        assert_eq!(
1518            under_budget.upsert(Path::new("a/.gitignore"), source).expect("control path"),
1519            OVER_BUDGET
1520        );
1521        assert_eq!(under_budget.retained_cost(), 0);
1522        assert!(under_budget.contains(Path::new("a/.gitignore")));
1523        assert_eq!(
1524            under_budget.observation(),
1525            ControlObservation {
1526                limits: ControlLimits {
1527                    budget: Some(exact - 1),
1528                    line_limit: Some(DEFAULT_CONTROL_LINE_LIMIT),
1529                },
1530                applied: 0,
1531                rules: 0,
1532                refused: 1,
1533                refusals: vec![RefusedControl {
1534                    path: PathBuf::from("a/.gitignore"),
1535                    reason: ControlRefusalReason::Budget,
1536                }],
1537            }
1538        );
1539    }
1540
1541    #[test]
1542    fn a_refused_source_drops_the_rules_it_replaces_and_freed_budget_admits_it_later() {
1543        let mut table = ControlTable::default();
1544        let first = source_at_test_limit();
1545        assert_eq!(
1546            table.upsert(Path::new(".gitignore"), first.clone()).expect("control path"),
1547            CHANGED
1548        );
1549        assert_eq!(
1550            table
1551                .upsert(Path::new("nested/.gitignore"), b"*.log\n".to_vec())
1552                .expect("control path"),
1553            OVER_BUDGET
1554        );
1555        let before = table.clone();
1556
1557        // Replacing the root's source with one that no longer fits drops the old rules
1558        // rather than keeping rules that are no longer on disk.
1559        let mut grown = first;
1560        grown.push(b'\n');
1561        assert_eq!(
1562            table.upsert(Path::new(".gitignore"), grown).expect("control path"),
1563            OVER_BUDGET
1564        );
1565        assert!(table.is_empty());
1566        assert_eq!(table.changes_from(&before).len(), 1);
1567        assert_eq!(
1568            table.refusal_changes_from(&before),
1569            vec![(PathBuf::from(".gitignore"), None, Some(ControlRefusalReason::Budget))]
1570        );
1571
1572        // With the budget free, reading the nested file again admits it and lifts its refusal.
1573        assert_eq!(
1574            table
1575                .upsert(Path::new("nested/.gitignore"), b"*.log\n".to_vec())
1576                .expect("control path"),
1577            CHANGED
1578        );
1579        assert_eq!(table.refused_len(), 1);
1580        // Removing a refused file lifts its refusal too.
1581        assert!(table.remove(Path::new(".gitignore")).expect("remove refused"));
1582        assert_eq!(table.refused_len(), 0);
1583        table.assert_consistent();
1584    }
1585
1586    #[test]
1587    fn identical_sources_share_one_content_charge() {
1588        let source = b"target/\n*.log\nnode_modules/\n".to_vec();
1589        let mut table = ControlTable::default();
1590        table.upsert(Path::new("a/.gitignore"), source.clone()).expect("first holder");
1591        let one = table.retained_cost();
1592        table.upsert(Path::new("bb/.gitignore"), source.clone()).expect("second holder");
1593
1594        // The second directory pays for its key and nothing for content it shares.
1595        assert_eq!(table.retained_cost() - one, CONTROL_SOURCE_OVERHEAD + "bb".len());
1596        assert_eq!(table.source_bytes(), 2 * source.len());
1597        assert!(table.is_ignored(Path::new("a/debug.log"), false));
1598        assert!(table.is_ignored(Path::new("bb/debug.log"), false));
1599    }
1600
1601    /// One line at the limit applies; one byte longer refuses the whole source, before any
1602    /// parsing, so a hostile rule costs no matching work.
1603    #[test]
1604    fn the_line_limit_admits_its_own_length_and_refuses_one_byte_over_it() {
1605        let mut table = ControlTable::default();
1606        let at_limit = vec![b'a'; DEFAULT_CONTROL_LINE_LIMIT];
1607        assert_eq!(
1608            table.upsert(Path::new("a/.gitignore"), at_limit).expect("control path"),
1609            CHANGED
1610        );
1611        let over = [b"*.log\n".as_slice(), &vec![b'a'; DEFAULT_CONTROL_LINE_LIMIT + 1]].concat();
1612        assert_eq!(
1613            table.upsert(Path::new("b/.gitignore"), over).expect("control path"),
1614            OVER_LINE_LIMIT
1615        );
1616        assert!(!table.is_ignored(Path::new("b/debug.log"), false), "no rule of it applies");
1617        assert_eq!(
1618            table.refusals().collect::<Vec<_>>(),
1619            vec![RefusedControl {
1620                path: PathBuf::from("b/.gitignore"),
1621                reason: ControlRefusalReason::LineLimit,
1622            }]
1623        );
1624    }
1625
1626    /// Raising or lifting one limit never moves the other: a larger budget still refuses a
1627    /// long line, and no line limit still refuses a table past its budget.
1628    #[test]
1629    fn the_budget_and_the_line_limit_lift_independently() {
1630        let long = [b"*.log\n".as_slice(), &vec![b'a'; DEFAULT_CONTROL_LINE_LIMIT + 1]].concat();
1631        let large = source_at_test_limit();
1632
1633        let mut no_budget = budgeted(None);
1634        assert_eq!(
1635            no_budget.upsert(Path::new("big/.gitignore"), large.clone()).expect("path"),
1636            CHANGED
1637        );
1638        assert_eq!(
1639            no_budget.upsert(Path::new("c/.gitignore"), b"*.tmp\n".to_vec()).expect("path"),
1640            CHANGED
1641        );
1642        assert!(no_budget.retained_cost() > DEFAULT_CONTROL_BUDGET);
1643        assert_eq!(
1644            no_budget.upsert(Path::new(".gitignore"), long.clone()).expect("path"),
1645            OVER_LINE_LIMIT
1646        );
1647
1648        let mut raised_budget = budgeted(Some(16 * DEFAULT_CONTROL_BUDGET));
1649        assert_eq!(
1650            raised_budget.upsert(Path::new(".gitignore"), long.clone()).expect("path"),
1651            OVER_LINE_LIMIT
1652        );
1653
1654        let mut no_line_limit = ControlTable::with_limits(ControlLimits {
1655            line_limit: None,
1656            ..ControlLimits::default()
1657        });
1658        assert_eq!(no_line_limit.upsert(Path::new(".gitignore"), long).expect("path"), CHANGED);
1659        assert!(no_line_limit.is_ignored(Path::new("debug.log"), false));
1660        assert_eq!(
1661            no_line_limit.upsert(Path::new("big/.gitignore"), large).expect("path"),
1662            OVER_BUDGET
1663        );
1664        no_line_limit.assert_consistent();
1665    }
1666
1667    #[test]
1668    fn a_listing_of_refusals_is_bounded_and_says_when_it_is_truncated() {
1669        let mut table = budgeted(Some(0));
1670        let refused = crate::MAX_RETAINED_ISSUES + 1;
1671        for directory in 0..refused {
1672            let path = PathBuf::from(format!("d{directory:03}/.gitignore"));
1673            assert_eq!(table.upsert(&path, b"*\n".to_vec()).expect("control path"), OVER_BUDGET);
1674        }
1675        let observation = table.observation();
1676        assert_eq!(observation.refused, u64::try_from(refused).expect("small"));
1677        assert_eq!(observation.refusals.len(), crate::MAX_RETAINED_ISSUES);
1678        assert_eq!(observation.refusals[0].path, Path::new("d000/.gitignore"));
1679        assert!(!observation.lists_every_refusal());
1680        assert!(!observation.is_complete());
1681    }
1682
1683    #[test]
1684    fn control_identity_uses_standard_fnv1a_vectors() {
1685        assert_eq!(identity(b"").fingerprint, 0xcbf2_9ce4_8422_2325);
1686        assert_eq!(identity(b"a").fingerprint, 0xaf63_dc4c_8601_ec8c);
1687        assert_eq!(identity(b"foobar").fingerprint, 0x8594_4171_f739_67e8);
1688    }
1689
1690    #[test]
1691    fn nested_negation_and_control_removal_change_the_governed_subtree() {
1692        let mut table = ControlTable::default();
1693        table.upsert(Path::new(".gitignore"), b"*.log\n".to_vec()).expect("root");
1694        table.upsert(Path::new("docs/.gitignore"), b"!keep.log\n".to_vec()).expect("nested");
1695
1696        assert!(table.is_ignored(Path::new("debug.log"), false));
1697        assert!(table.is_ignored(Path::new("docs/other.log"), false));
1698        assert!(!table.is_ignored(Path::new("docs/keep.log"), false));
1699        assert_eq!(
1700            ControlTable::affected_subtree(Path::new("docs/.gitignore")).expect("scope"),
1701            Path::new("docs")
1702        );
1703
1704        table.remove(Path::new("docs/.gitignore")).expect("remove nested");
1705        assert!(table.is_ignored(Path::new("docs/keep.log"), false));
1706    }
1707
1708    #[test]
1709    fn ignored_parent_cannot_be_reincluded_from_inside_it() {
1710        let mut table = ControlTable::default();
1711        table.upsert(Path::new(".gitignore"), b"vendor/\n".to_vec()).expect("root");
1712        table
1713            .upsert(Path::new("vendor/.gitignore"), b"!keep.txt\n".to_vec())
1714            .expect("retained but inactive nested control");
1715
1716        assert!(table.is_ignored(Path::new("vendor"), true));
1717        assert!(table.is_ignored(Path::new("vendor/keep.txt"), false));
1718    }
1719
1720    /// Only `.gitignore` spelled in some ASCII case is a spelling of the control name; the
1721    /// canonical path every spelling is recorded under is its directory's `.gitignore`, and
1722    /// only that path is a valid control path (fdu-0w1b).
1723    #[test]
1724    fn a_control_name_is_spelled_in_any_ascii_case_and_recorded_by_one_path() {
1725        use std::ffi::OsStr;
1726
1727        assert_eq!(control_spelling(OsStr::new(".gitignore")), Some(ControlSpelling::Exact));
1728        for variant in [".GITIGNORE", ".GitIgnore", ".gitIGNORE", ".gitignorE"] {
1729            assert_eq!(control_spelling(OsStr::new(variant)), Some(ControlSpelling::Variant));
1730        }
1731        // A non-ASCII letter some filesystem folds to `i` is not looked up, and neither is a
1732        // name of another length or with other characters.
1733        for other in [".gıtıgnore", ".gitignor", ".gitignore~", "gitignore.", ".gitignor3", ""] {
1734            assert_eq!(control_spelling(OsStr::new(other)), None, "{other:?}");
1735        }
1736        #[cfg(unix)]
1737        {
1738            use std::os::unix::ffi::OsStrExt as _;
1739            assert_eq!(control_spelling(OsStr::from_bytes(b".gitignor\xff")), None);
1740        }
1741
1742        assert_eq!(
1743            governing_control(Path::new("a/.GITIGNORE")),
1744            Some(PathBuf::from("a/.gitignore"))
1745        );
1746        assert_eq!(governing_control(Path::new(".gitignore")), Some(PathBuf::from(".gitignore")));
1747        assert_eq!(governing_control(Path::new("a/README")), None);
1748        assert!(is_control_file(Path::new("a/.gitignore")));
1749        assert!(!is_control_file(Path::new("a/.GITIGNORE")), "operations name one path");
1750        let mut table = ControlTable::default();
1751        assert!(table.upsert(Path::new("a/.GITIGNORE"), b"*.log\n".to_vec()).is_err());
1752    }
1753}