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    /// Distinct contents by identity. A list, because an equal length and FNV-1a digest do
267    /// not prove equal bytes, and a collision must never share another source's matcher.
268    shared: HashMap<ControlIdentity, Vec<Holding>>,
269    /// Every refused source, by governing directory. Kept whole, not bounded like issue
270    /// details, so removing a refused file keeps the refused count exact.
271    refused: BTreeMap<PathBuf, ControlRefusalReason>,
272    limits: ControlLimits,
273    source_bytes: usize,
274    retained_cost: usize,
275}
276
277impl Default for ControlTable {
278    fn default() -> Self {
279        Self::with_limits(ControlLimits::default())
280    }
281}
282
283impl ControlTable {
284    /// An empty table that refuses sources past either of `limits`.
285    pub(crate) fn with_limits(limits: ControlLimits) -> Self {
286        Self {
287            by_directory: BTreeMap::new(),
288            shared: HashMap::new(),
289            refused: BTreeMap::new(),
290            limits,
291            source_bytes: 0,
292            retained_cost: 0,
293        }
294    }
295
296    /// Insert or replace one verified control source.
297    ///
298    /// `path` names the control file relative to the index root. The source is retained
299    /// exactly, while matching state is derived once per distinct content rather than per
300    /// directory or per entry. A source the limits cannot admit is refused and recorded,
301    /// and a source it replaces is dropped with it: rules no longer on disk must not keep
302    /// applying. The line limit is checked first, so a source both limits refuse is
303    /// refused for its line.
304    ///
305    /// # Errors
306    ///
307    /// [`crate::Error::InvalidControlPath`] when `path` does not name a control file.
308    pub fn upsert(&mut self, path: &Path, source: Vec<u8>) -> crate::Result<ControlAdmission> {
309        let identity = identity(&source);
310        self.upsert_identified(path, source, identity)
311    }
312
313    fn upsert_identified(
314        &mut self,
315        path: &Path,
316        source: Vec<u8>,
317        identity: ControlIdentity,
318    ) -> crate::Result<ControlAdmission> {
319        let directory = control_directory(path)?;
320        match self.verdict(directory, &source, identity) {
321            Verdict::Unchanged => Ok(ControlAdmission::Retained { changed: false }),
322            Verdict::Refuse(reason) => {
323                crate::counters::bump(|counts| {
324                    counts.control_refused = counts.control_refused.saturating_add(1);
325                });
326                Ok(self.refuse(directory, reason))
327            }
328            Verdict::Admit { retained_cost } => {
329                self.detach(directory);
330                self.attach(directory, source, identity);
331                self.refused.remove(directory);
332                debug_assert_eq!(self.retained_cost, retained_cost);
333                Ok(ControlAdmission::Retained { changed: true })
334            }
335        }
336    }
337
338    /// What upserting `source` at `directory` would do, decided before anything moves.
339    ///
340    /// The decision is a pure function of this table, so a caller can ask whether an
341    /// operation would change anything without projecting a copy of the table to find out.
342    fn verdict(&self, directory: &Path, source: &[u8], identity: ControlIdentity) -> Verdict {
343        if self.by_directory.get(directory).is_some_and(|current| current.bytes == source) {
344            return Verdict::Unchanged;
345        }
346        if self.limits.line_limit.is_some_and(|line_limit| {
347            source.split(|byte| *byte == b'\n').any(|line| line.len() > line_limit)
348        }) {
349            return Verdict::Refuse(ControlRefusalReason::LineLimit);
350        }
351        let content_charge =
352            if self.holding(identity, source).is_some() { 0 } else { content_cost(source) };
353        // Saturating: every retained charge is a sum of real allocations, so only a source
354        // no budget could admit reaches the ceiling, and an unbounded table never refuses.
355        let retained_cost = self
356            .retained_cost
357            .saturating_sub(self.release_charge(directory))
358            .saturating_add(directory_cost(directory))
359            .saturating_add(content_charge);
360        if self.limits.budget.is_some_and(|budget| retained_cost > budget) {
361            return Verdict::Refuse(ControlRefusalReason::Budget);
362        }
363        Verdict::Admit { retained_cost }
364    }
365
366    /// Whether upserting `source` at `path` would leave this table exactly as it is.
367    ///
368    /// True when the directory already retains those exact bytes, and when it already
369    /// records a refusal this source would earn again: refusing an already-refused
370    /// directory for the same limit writes the same record. A warm revalidate of a tree
371    /// past its budget re-reads every refused file, and this is what tells the index that
372    /// those reads change nothing (fdu-hzm5).
373    pub(crate) fn upsert_is_inert(&self, path: &Path, source: &[u8]) -> bool {
374        let Ok(directory) = control_directory(path) else {
375            return false;
376        };
377        match self.verdict(directory, source, identity(source)) {
378            Verdict::Unchanged => true,
379            Verdict::Refuse(reason) => self.refused.get(directory) == Some(&reason),
380            Verdict::Admit { .. } => false,
381        }
382    }
383
384    /// Whether removing the control file at `path` would leave this table as it is.
385    pub(crate) fn remove_is_inert(&self, path: &Path) -> bool {
386        control_directory(path).is_ok_and(|directory| {
387            !self.by_directory.contains_key(directory) && !self.refused.contains_key(directory)
388        })
389    }
390
391    /// Whether any directory at or below `subtree` retains a source or records a refusal.
392    ///
393    /// What a structural removal of `subtree` would prune, so a batch that removes nothing
394    /// the table records leaves it alone.
395    pub(crate) fn has_record_at_or_below(&self, subtree: &Path) -> bool {
396        has_key_at_or_below(&self.by_directory, subtree)
397            || has_key_at_or_below(&self.refused, subtree)
398    }
399
400    /// Restore a refusal a snapshot recorded, without the source that was refused.
401    pub(crate) fn record_refusal(
402        &mut self,
403        path: &Path,
404        reason: ControlRefusalReason,
405    ) -> crate::Result<()> {
406        let directory = control_directory(path)?;
407        self.refuse(directory, reason);
408        Ok(())
409    }
410
411    fn refuse(&mut self, directory: &Path, reason: ControlRefusalReason) -> ControlAdmission {
412        self.detach(directory);
413        self.refused.insert(directory.to_path_buf(), reason);
414        ControlAdmission::Refused(reason)
415    }
416
417    /// Remove one control source, or the record of its refusal. Missing sources are no-ops.
418    ///
419    /// # Errors
420    ///
421    /// [`crate::Error::InvalidControlPath`] when `path` does not name a control file.
422    pub fn remove(&mut self, path: &Path) -> crate::Result<bool> {
423        let directory = control_directory(path)?;
424        let retained = self.detach(directory);
425        let refused = self.refused.remove(directory).is_some();
426        Ok(retained || refused)
427    }
428
429    /// Remove every control file, and every refusal, at or below `subtree`.
430    pub(crate) fn remove_subtree(&mut self, subtree: &Path) {
431        let directories: Vec<PathBuf> = self
432            .by_directory
433            .keys()
434            .filter(|directory| directory.starts_with(subtree))
435            .cloned()
436            .collect();
437        for directory in directories {
438            self.detach(&directory);
439        }
440        self.refused.retain(|directory, _| !directory.starts_with(subtree));
441    }
442
443    /// The holding for exactly `source`, when some directory already retains it.
444    fn holding(&self, identity: ControlIdentity, source: &[u8]) -> Option<&Holding> {
445        self.shared
446            .get(&identity)?
447            .iter()
448            .find(|holding| holding.content.bytes.as_slice() == source)
449    }
450
451    /// The charge that dropping `directory`'s current source would release.
452    fn release_charge(&self, directory: &Path) -> usize {
453        let Some(content) = self.by_directory.get(directory) else {
454            return 0;
455        };
456        let last_holder = self.shared.get(&content.identity).is_some_and(|holdings| {
457            holdings
458                .iter()
459                .any(|holding| Arc::ptr_eq(&holding.content, content) && holding.holders == 1)
460        });
461        directory_cost(directory).saturating_add(if last_holder { content.content_cost } else { 0 })
462    }
463
464    /// Drop `directory`'s source, releasing its content when this was the last holder.
465    fn detach(&mut self, directory: &Path) -> bool {
466        let Some(content) = self.by_directory.remove(directory) else {
467            return false;
468        };
469        let holdings = self.shared.get_mut(&content.identity).expect("a retained content is held");
470        let position = holdings
471            .iter()
472            .position(|holding| Arc::ptr_eq(&holding.content, &content))
473            .expect("a retained content is listed under its own identity");
474        holdings[position].holders -= 1;
475        if holdings[position].holders == 0 {
476            holdings.swap_remove(position);
477            self.retained_cost -= content.content_cost;
478            if holdings.is_empty() {
479                self.shared.remove(&content.identity);
480            }
481        }
482        self.retained_cost -= directory_cost(directory);
483        self.source_bytes -= content.bytes.len();
484        true
485    }
486
487    /// Hold `source` at `directory`, sharing an identical retained content when one exists.
488    fn attach(&mut self, directory: &Path, source: Vec<u8>, identity: ControlIdentity) {
489        let holdings = self.shared.entry(identity).or_default();
490        let content = if let Some(holding) =
491            holdings.iter_mut().find(|holding| holding.content.bytes == source)
492        {
493            holding.holders += 1;
494            crate::counters::bump(|counts| {
495                counts.control_sources_shared = counts.control_sources_shared.saturating_add(1);
496            });
497            Arc::clone(&holding.content)
498        } else {
499            let content_cost = content_cost(&source);
500            let matcher = Gitignore::parse(&source);
501            let content =
502                Arc::new(SharedContent { bytes: source, identity, matcher, content_cost });
503            holdings.push(Holding { content: Arc::clone(&content), holders: 1 });
504            self.retained_cost += content_cost;
505            content
506        };
507        self.retained_cost += directory_cost(directory);
508        self.source_bytes += content.bytes.len();
509        self.by_directory.insert(directory.to_path_buf(), content);
510    }
511
512    /// Matcher view for one retained path.
513    pub fn matcher_for<'a>(&'a self, path: &'a Path) -> ControlMatcher<'a> {
514        ControlMatcher { table: self, path }
515    }
516
517    /// The controls governing every child of `directory`, resolved once for all of them.
518    ///
519    /// [`ControlMatcher::is_ignored`] looks each ancestor up in the table for every entry
520    /// it classifies. A listing's children share those ancestors, so a caller classifying
521    /// a whole listing resolves them here once and matches each child against the chain
522    /// (H163). The chain owns its sources, so the table may change while it is held; it
523    /// then answers for the table as it was when resolved.
524    ///
525    /// `directory` must be a normalized relative path, as every walked or event path is:
526    /// the chain counts one component per ancestor, which a `..` would break.
527    pub(crate) fn chain_for(&self, directory: &Path) -> ControlChain {
528        debug_assert!(
529            directory.components().all(|component| matches!(component, Component::Normal(_))),
530            "control chains are resolved for normalized relative directories: {}",
531            directory.display()
532        );
533        let mut governing = Vec::new();
534        if !self.by_directory.is_empty() {
535            let depth = gitignore::with_components(directory, None, |components| components.len());
536            for (up, ancestor) in directory.ancestors().enumerate() {
537                if let Some(source) = self.by_directory.get(ancestor) {
538                    governing.push((depth.saturating_sub(up), Arc::clone(source)));
539                }
540            }
541        }
542        ControlChain { governing }
543    }
544
545    /// Evaluate complete ignore semantics without relying on retained parent facts.
546    ///
547    /// The index hot path uses [`ControlMatcher::is_ignored`] with the parent's stored
548    /// classification. This standalone form evaluates each directory prefix so callers
549    /// and tests receive the same answer even without an index entry in hand.
550    pub fn is_ignored(&self, path: &Path, is_dir: bool) -> bool {
551        let components: Vec<_> = path.components().collect();
552        let mut current = PathBuf::new();
553        let mut parent_ignored = false;
554        for (position, component) in components.iter().enumerate() {
555            current.push(component.as_os_str());
556            if parent_ignored {
557                return true;
558            }
559            let current_is_dir = position + 1 < components.len() || is_dir;
560            parent_ignored = self.matcher_for(&current).is_ignored(current_is_dir);
561        }
562        parent_ignored
563    }
564
565    /// Relative subtree whose classification may move when `path` changes.
566    pub fn affected_subtree(path: &Path) -> crate::Result<PathBuf> {
567        Ok(control_directory(path)?.to_path_buf())
568    }
569
570    /// Stable identities changed between two complete table states.
571    pub(crate) fn changes_from(
572        &self,
573        previous: &Self,
574    ) -> Vec<(PathBuf, Option<ControlIdentity>, Option<ControlIdentity>)> {
575        let directories: BTreeSet<&Path> = previous
576            .by_directory
577            .keys()
578            .chain(self.by_directory.keys())
579            .map(PathBuf::as_path)
580            .collect();
581        directories
582            .into_iter()
583            .filter_map(|directory| {
584                let before = previous.by_directory.get(directory);
585                let after = self.by_directory.get(directory);
586                let changed = match (before, after) {
587                    (Some(before), Some(after)) => {
588                        !Arc::ptr_eq(before, after) && before.bytes != after.bytes
589                    }
590                    (None, None) => false,
591                    (Some(_), None) | (None, Some(_)) => true,
592                };
593                changed.then(|| {
594                    (
595                        control_path(directory),
596                        before.map(|source| source.identity),
597                        after.map(|source| source.identity),
598                    )
599                })
600            })
601            .collect()
602    }
603
604    /// Refusals recorded or lifted between two complete table states.
605    pub(crate) fn refusal_changes_from(
606        &self,
607        previous: &Self,
608    ) -> Vec<(PathBuf, Option<ControlRefusalReason>, Option<ControlRefusalReason>)> {
609        let directories: BTreeSet<&Path> =
610            previous.refused.keys().chain(self.refused.keys()).map(PathBuf::as_path).collect();
611        directories
612            .into_iter()
613            .filter_map(|directory| {
614                let before = previous.refused.get(directory).copied();
615                let after = self.refused.get(directory).copied();
616                (before != after).then(|| (control_path(directory), before, after))
617            })
618            .collect()
619    }
620
621    /// Exact sources in deterministic governing-directory order.
622    pub(crate) fn sources(&self) -> impl ExactSizeIterator<Item = (PathBuf, &[u8])> {
623        self.by_directory
624            .iter()
625            .map(|(directory, source)| (control_path(directory), source.bytes.as_slice()))
626    }
627
628    /// Every refused control file and its reason, in governing-directory order.
629    pub fn refusals(&self) -> impl ExactSizeIterator<Item = RefusedControl> + '_ {
630        self.refused.iter().map(|(directory, reason)| RefusedControl {
631            path: control_path(directory),
632            reason: *reason,
633        })
634    }
635
636    /// Whether every control source that could govern `path` was admitted.
637    ///
638    /// A refused source may contain ignore or negation rules, so its descendants have
639    /// unknown classification even if the admitted rules currently say otherwise.
640    pub(crate) fn classification_known(&self, path: &Path) -> bool {
641        !path
642            .parent()
643            .into_iter()
644            .flat_map(Path::ancestors)
645            .any(|directory| self.refused.contains_key(directory))
646    }
647
648    /// Number of refused control files.
649    pub fn refused_len(&self) -> usize {
650        self.refused.len()
651    }
652
653    /// The limits this table admits sources under.
654    pub const fn limits(&self) -> ControlLimits {
655        self.limits
656    }
657
658    /// This table's coverage, listing at most [`crate::MAX_RETAINED_ISSUES`] refusals.
659    pub fn observation(&self) -> ControlObservation {
660        ControlObservation {
661            limits: self.limits,
662            applied: u64::try_from(self.len()).unwrap_or(u64::MAX),
663            rules: self
664                .by_directory
665                .values()
666                .fold(0_u64, |total, content| total.saturating_add(content.matcher.rule_count())),
667            refused: u64::try_from(self.refused_len()).unwrap_or(u64::MAX),
668            refusals: self.refusals().take(crate::MAX_RETAINED_ISSUES).collect(),
669        }
670    }
671
672    /// Exact retained source bytes across the whole table.
673    pub const fn source_bytes(&self) -> usize {
674        self.source_bytes
675    }
676
677    /// Bounded retained charge for the complete table.
678    pub const fn retained_cost(&self) -> usize {
679        self.retained_cost
680    }
681
682    /// Whether `path` already retains exactly `source`.
683    pub fn source_is(&self, path: &Path, source: &[u8]) -> bool {
684        control_directory(path)
685            .ok()
686            .and_then(|directory| self.by_directory.get(directory))
687            .is_some_and(|current| current.bytes == source)
688    }
689
690    /// Whether the table holds a record at `path`: a retained source or a refusal.
691    ///
692    /// Reconciliation asks this to decide whether a control file missing from a listing
693    /// needs a removal, and a refused file that disappears must lift its refusal.
694    pub(crate) fn contains(&self, path: &Path) -> bool {
695        control_directory(path).ok().is_some_and(|directory| {
696            self.by_directory.contains_key(directory) || self.refused.contains_key(directory)
697        })
698    }
699
700    /// Number of retained control files.
701    pub fn len(&self) -> usize {
702        self.by_directory.len()
703    }
704
705    /// Whether no control file is retained. A table may still record refusals.
706    pub fn is_empty(&self) -> bool {
707        self.by_directory.is_empty()
708    }
709
710    /// Whether the table records nothing at all: no retained source and no refusal.
711    pub(crate) fn is_vacant(&self) -> bool {
712        self.by_directory.is_empty() && self.refused.is_empty()
713    }
714}
715
716/// A path-bound view over the controls that may govern it.
717pub struct ControlMatcher<'a> {
718    table: &'a ControlTable,
719    path: &'a Path,
720}
721
722impl ControlMatcher<'_> {
723    /// Decide this path assuming its retained parent is not ignored.
724    ///
725    /// A caller with retained facts already knows the parent's effective classification,
726    /// so it can stop immediately when that parent is ignored. Otherwise every control
727    /// directory on this path is active and the deepest matching opinion wins.
728    pub fn is_ignored(&self, is_dir: bool) -> bool {
729        // The empty table answers without collecting the ancestor list. Every entry of
730        // a scan that observes no control state asks this question exactly once, so the
731        // allocation below would be the last per-entry cost of a machinery the scan
732        // opted out of (fdu-etfj).
733        if self.table.by_directory.is_empty() {
734            return false;
735        }
736        // A deeper control source overrides every matching ancestor. Walk from the
737        // immediate directory toward the root and return the first opinion instead of
738        // allocating and reversing an ancestor vector for every classified entry.
739        for directory in self.path.parent().into_iter().flat_map(Path::ancestors) {
740            let Some(source) = self.table.by_directory.get(directory) else {
741                continue;
742            };
743            let relative = self.path.strip_prefix(directory).unwrap_or(self.path);
744            if let Some(ignored) = source.matcher.matches(relative, is_dir) {
745                return ignored;
746            }
747        }
748        false
749    }
750}
751
752/// The controls that govern one directory's children, deepest first, with how many of the
753/// directory's path components lead to each one's own directory.
754#[derive(Clone, Debug, Default)]
755pub(crate) struct ControlChain {
756    governing: Vec<(usize, Arc<SharedContent>)>,
757}
758
759impl ControlChain {
760    /// Decide the child `name` of `directory`, the directory this chain was resolved for,
761    /// assuming that directory is not ignored.
762    ///
763    /// The answer is [`ControlMatcher::is_ignored`]'s for `directory/name`: the deepest
764    /// control with an opinion wins, each matching the path relative to its own directory.
765    pub(crate) fn is_ignored(&self, directory: &Path, name: &[u8], is_dir: bool) -> bool {
766        if self.governing.is_empty() {
767            return false;
768        }
769        gitignore::with_components(directory, Some(name), |components| {
770            self.governing
771                .iter()
772                .find_map(|(leading, source)| {
773                    let relative = components.get(*leading..).unwrap_or_default();
774                    source.matcher.matches_components(relative, is_dir)
775                })
776                .unwrap_or(false)
777        })
778    }
779}
780
781/// Whether any key of `directories` is `subtree` or lies below it.
782///
783/// One lookup rather than a scan: [`Path`] orders component by component, so every
784/// descendant of `subtree` sorts immediately after it and before any other key, and the
785/// first key at or after `subtree` decides.
786fn has_key_at_or_below<V>(directories: &BTreeMap<PathBuf, V>, subtree: &Path) -> bool {
787    directories
788        .range::<Path, _>((std::ops::Bound::Included(subtree), std::ops::Bound::Unbounded))
789        .next()
790        .is_some_and(|(directory, _)| directory.starts_with(subtree))
791}
792
793/// Whether a relative path names the fixed control file: the canonical path every control
794/// operation, and the table, name a directory's rules by.
795///
796/// A directory's rules may be held by a `.GITIGNORE` on a case-insensitive volume, but
797/// they are still recorded under `<dir>/.gitignore` (see the module documentation), so a
798/// control operation naming any other spelling is malformed.
799pub fn is_control_file(path: &Path) -> bool {
800    path.file_name().is_some_and(|name| name == CONTROL_FILE_NAME)
801}
802
803/// How a listed name relates to its directory's control file.
804#[derive(Clone, Copy, PartialEq, Eq, Debug)]
805pub(crate) enum ControlSpelling {
806    /// Exactly [`CONTROL_FILE_NAME`]: the entry a lookup of the directory's control opens
807    /// on every filesystem, read through its own path.
808    Exact,
809    /// [`CONTROL_FILE_NAME`] in another ASCII case, such as `.GITIGNORE`: the directory's
810    /// control only where the filesystem resolves `.gitignore` to it, which a lookup of the
811    /// canonical path decides.
812    Variant,
813}
814
815/// Whether `name` spells the control file name, and how.
816///
817/// Every listed entry asks this, so it is a length test and, for the rare ten-byte name,
818/// an ASCII case-insensitive comparison: no allocation and no system call.
819pub(crate) fn control_spelling(name: &std::ffi::OsStr) -> Option<ControlSpelling> {
820    let bytes = name.as_encoded_bytes();
821    let exact = CONTROL_FILE_NAME.as_bytes();
822    if bytes.len() != exact.len() {
823        None
824    } else if bytes == exact {
825        Some(ControlSpelling::Exact)
826    } else if bytes.eq_ignore_ascii_case(exact) {
827        Some(ControlSpelling::Variant)
828    } else {
829        None
830    }
831}
832
833/// The spelling of the control name a relative path's last component uses, if any.
834pub(crate) fn path_control_spelling(path: &Path) -> Option<ControlSpelling> {
835    path.file_name().and_then(control_spelling)
836}
837
838/// The canonical control path of the directory `path` sits in: `<parent>/.gitignore`.
839pub(crate) fn sibling_control_path(path: &Path) -> PathBuf {
840    control_path(path.parent().unwrap_or_else(|| Path::new("")))
841}
842
843/// The control file a walk error under `root` names, relative to it, when there is one,
844/// by its canonical path.
845///
846/// A control file the walk could not read leaves the rules it holds unknown, so the
847/// ignored split it governs cannot be verified. That includes a listed case variant whose
848/// own metadata could not be read: on a case-insensitive volume it may hold the rules, and
849/// nothing looked the canonical path up. The index and the transient summary both ask this
850/// of the same normalized walk errors, so they withhold the same shares.
851pub(crate) fn unreadable_control(root: &Path, error: &crate::Error) -> Option<PathBuf> {
852    let crate::Error::Io { .. } = error else {
853        return None;
854    };
855    crate::Issue::from_error_under(root, error).path.and_then(|path| governing_control(&path))
856}
857
858/// The canonical control path of the directory whose control an entry at `path` may hold,
859/// when its name spells the control name in any case.
860pub(crate) fn governing_control(path: &Path) -> Option<PathBuf> {
861    path_control_spelling(path).map(|_| sibling_control_path(path))
862}
863
864fn control_directory(path: &Path) -> crate::Result<&Path> {
865    if !is_control_file(path) {
866        return Err(crate::Error::InvalidControlPath(path.to_path_buf()));
867    }
868    Ok(path.parent().unwrap_or_else(|| Path::new("")))
869}
870
871fn control_path(directory: &Path) -> PathBuf {
872    directory.join(CONTROL_FILE_NAME)
873}
874
875fn identity(bytes: &[u8]) -> ControlIdentity {
876    const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
877    const FNV_PRIME: u64 = 0x100_0000_01b3;
878
879    let mut fingerprint = FNV_OFFSET_BASIS;
880    for byte in bytes {
881        fingerprint ^= u64::from(*byte);
882        fingerprint = fingerprint.wrapping_mul(FNV_PRIME);
883    }
884    ControlIdentity { bytes: u64::try_from(bytes.len()).unwrap_or(u64::MAX), fingerprint }
885}
886
887/// Charge for one directory's key and identity, whatever content it holds.
888fn directory_cost(directory: &Path) -> usize {
889    CONTROL_SOURCE_OVERHEAD.saturating_add(directory.as_os_str().as_encoded_bytes().len())
890}
891
892/// Charge for one distinct content: its exact bytes and parsed glob bytes, plus the
893/// matcher's per-pattern and per-segment shells.
894fn content_cost(source: &[u8]) -> usize {
895    let (newlines, segment_shells) = source.iter().fold((0usize, 0usize), |counts, byte| {
896        (counts.0 + usize::from(*byte == b'\n'), counts.1 + usize::from(*byte == b'/'))
897    });
898    let pattern_shells = newlines.saturating_add(1);
899    source
900        .len()
901        .saturating_mul(2)
902        .saturating_add(pattern_shells.saturating_mul(64))
903        .saturating_add(segment_shells.saturating_mul(24))
904}
905
906/// Charge for one source whose content no other directory holds.
907#[cfg(test)]
908fn retained_source_cost(directory: &Path, source: &[u8]) -> usize {
909    directory_cost(directory).saturating_add(content_cost(source))
910}
911
912#[cfg(test)]
913pub(crate) fn source_at_test_limit() -> Vec<u8> {
914    let mut source = Vec::new();
915    loop {
916        let previous_len = source.len();
917        source.extend(std::iter::repeat_n(b'a', DEFAULT_CONTROL_LINE_LIMIT));
918        source.push(b'\n');
919        if retained_source_cost(Path::new(""), &source) > DEFAULT_CONTROL_BUDGET {
920            source.truncate(previous_len);
921            break;
922        }
923    }
924    let remaining = DEFAULT_CONTROL_BUDGET - retained_source_cost(Path::new(""), &source);
925    source.extend(std::iter::repeat_n(b'a', (remaining / 2).min(DEFAULT_CONTROL_LINE_LIMIT)));
926    assert_eq!(retained_source_cost(Path::new(""), &source), DEFAULT_CONTROL_BUDGET);
927    source
928}
929
930#[cfg(test)]
931impl ControlTable {
932    /// Recompute every charge and holder count from the directories alone.
933    fn assert_consistent(&self) {
934        let mut distinct: Vec<&Arc<SharedContent>> = Vec::new();
935        let mut directory_charges = 0;
936        let mut source_bytes = 0;
937        for (directory, content) in &self.by_directory {
938            directory_charges += directory_cost(directory);
939            source_bytes += content.bytes.len();
940            if !distinct.iter().any(|seen| Arc::ptr_eq(seen, content)) {
941                distinct.push(content);
942            }
943        }
944        let content_charges: usize = distinct.iter().map(|content| content.content_cost).sum();
945        assert_eq!(self.retained_cost, directory_charges + content_charges, "retained cost");
946        assert_eq!(self.source_bytes, source_bytes, "source bytes");
947        let holdings: usize = self.shared.values().map(Vec::len).sum();
948        assert_eq!(holdings, distinct.len(), "one holding per distinct content");
949        for (identity, holdings) in &self.shared {
950            assert!(!holdings.is_empty(), "no empty identity list survives");
951            for holding in holdings {
952                assert_eq!(holding.content.identity, *identity);
953                let holders = self
954                    .by_directory
955                    .values()
956                    .filter(|content| Arc::ptr_eq(content, &holding.content))
957                    .count();
958                assert_eq!(holding.holders, holders, "holder count");
959            }
960            for (index, left) in holdings.iter().enumerate() {
961                for right in &holdings[index + 1..] {
962                    assert_ne!(left.content.bytes, right.content.bytes, "equal bytes are shared");
963                }
964            }
965        }
966        assert!(
967            self.refused.keys().all(|directory| !self.by_directory.contains_key(directory)),
968            "a refused directory retains no source"
969        );
970        assert!(
971            self.limits.budget.is_none_or(|budget| self.retained_cost <= budget),
972            "within budget"
973        );
974        assert!(
975            self.limits.line_limit.is_none_or(|line_limit| {
976                distinct.iter().all(|content| {
977                    content.bytes.split(|byte| *byte == b'\n').all(|line| line.len() <= line_limit)
978                })
979            }),
980            "within the line limit"
981        );
982    }
983}
984
985#[cfg(test)]
986mod tests {
987    use super::*;
988
989    /// Deterministic `SplitMix64`, so a failing sequence replays from its printed seed.
990    struct SplitMix(u64);
991
992    impl SplitMix {
993        fn next(&mut self) -> u64 {
994            self.0 = self.0.wrapping_add(0x9e37_79b9_7f4a_7c15);
995            let mut value = self.0;
996            value = (value ^ (value >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
997            value = (value ^ (value >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
998            value ^ (value >> 31)
999        }
1000
1001        fn below(&mut self, bound: usize) -> usize {
1002            usize::try_from(self.next() % u64::try_from(bound).expect("small bound")).expect("fits")
1003        }
1004    }
1005
1006    /// Every directory's last operation decides whether it holds a record, whatever the
1007    /// limits decided: an upsert leaves exactly one of a source or a refusal, and a removal
1008    /// leaves neither. Charges and holder counts are recomputed after every step, under
1009    /// each combination of a bounded or unbounded budget and line limit.
1010    #[test]
1011    fn rule_totals_count_accepted_patterns_per_governing_location() {
1012        let mut table = ControlTable::default();
1013        let source = b"# comment\n\n*.log\n!important.log\n*.log\n[bad\n";
1014        table.upsert(Path::new(".gitignore"), source.to_vec()).expect("root control");
1015        table.upsert(Path::new("nested/.gitignore"), source.to_vec()).expect("nested control");
1016        assert_eq!(table.observation().applied, 2);
1017        assert_eq!(table.observation().rules, 6);
1018        table
1019            .upsert(Path::new("nested/.gitignore"), b"# empty\n".to_vec())
1020            .expect("replacement control");
1021        assert_eq!(table.observation().applied, 2);
1022        assert_eq!(table.observation().rules, 3);
1023    }
1024
1025    #[test]
1026    fn charges_and_refusals_stay_exact_through_random_upserts_and_removals() {
1027        const DIRECTORIES: [&str; 6] = ["", "a", "a/b", "b", "b/c/d", "c"];
1028        let long_line = [vec![b'x'; DEFAULT_CONTROL_LINE_LIMIT + 1], b"\n".to_vec()].concat();
1029        let large = b"pattern/\n".repeat(40);
1030        let contents: [&[u8]; 6] =
1031            [b"*.log\n", b"target/\n", b"!keep\n*.tmp\n", b"", &large, &long_line];
1032        // Room for a few small sources and one large one, so the budget refuses often.
1033        let budget = 2 * retained_source_cost(Path::new("b/c/d"), &large);
1034        for seed in 0..64 {
1035            let mut random = SplitMix(seed);
1036            let limits = ControlLimits {
1037                budget: (seed % 2 == 0).then_some(budget),
1038                line_limit: (seed % 4 < 2).then_some(DEFAULT_CONTROL_LINE_LIMIT),
1039            };
1040            let mut table = ControlTable::with_limits(limits);
1041            let mut holds_record: BTreeMap<&Path, bool> = BTreeMap::new();
1042            for step in 0..300 {
1043                let directory = Path::new(DIRECTORIES[random.below(DIRECTORIES.len())]);
1044                let path = directory.join(CONTROL_FILE_NAME);
1045                match random.below(8) {
1046                    0 => {
1047                        table.remove_subtree(directory);
1048                        for (held, record) in &mut holds_record {
1049                            if held.starts_with(directory) {
1050                                *record = false;
1051                            }
1052                        }
1053                    }
1054                    1 | 2 => {
1055                        table.remove(&path).expect("control path");
1056                        holds_record.insert(directory, false);
1057                    }
1058                    _ => {
1059                        let content = contents[random.below(contents.len())].to_vec();
1060                        let admission = table.upsert(&path, content.clone()).expect("control path");
1061                        if content == long_line && limits.line_limit.is_some() {
1062                            assert_eq!(
1063                                admission,
1064                                ControlAdmission::Refused(ControlRefusalReason::LineLimit)
1065                            );
1066                        }
1067                        if limits.budget.is_none() && limits.line_limit.is_none() {
1068                            assert!(
1069                                matches!(admission, ControlAdmission::Retained { .. }),
1070                                "an unbounded table refuses nothing"
1071                            );
1072                        }
1073                        holds_record.insert(directory, true);
1074                    }
1075                }
1076                std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1077                    table.assert_consistent();
1078                    for (directory, record) in &holds_record {
1079                        let path = directory.join(CONTROL_FILE_NAME);
1080                        assert_eq!(table.contains(&path), *record, "{}", path.display());
1081                    }
1082                    let records = holds_record.values().filter(|record| **record).count();
1083                    assert_eq!(table.len() + table.refused_len(), records);
1084                }))
1085                .unwrap_or_else(|_| panic!("seed {seed}, step {step}: inconsistent table"));
1086            }
1087            for directory in DIRECTORIES {
1088                table.remove(&Path::new(directory).join(CONTROL_FILE_NAME)).expect("control path");
1089            }
1090            assert_eq!(table.retained_cost(), 0, "seed {seed}");
1091            assert_eq!(table.source_bytes(), 0, "seed {seed}");
1092            assert!(table.shared.is_empty(), "seed {seed}");
1093            assert!(table.is_vacant(), "seed {seed}");
1094        }
1095    }
1096
1097    #[test]
1098    fn a_resolved_chain_answers_as_the_per_entry_matcher_does() {
1099        let mut table = ControlTable::default();
1100        for (path, source) in [
1101            (".gitignore", &b"*.log\n/build/\n!keep.log\nsub/*.tmp\n"[..]),
1102            ("a/.gitignore", b"!*.log\n*.o\n/deep/**\n"),
1103            ("a/b/.gitignore", b"*.log\n!x.o\n"),
1104            ("c/.gitignore", b"# comment only\n"),
1105        ] {
1106            table.upsert(Path::new(path), source.to_vec()).expect("fixture control");
1107        }
1108        let directories =
1109            ["", "a", "a/b", "a/b/c", "a/deep", "a/deep/er", "build", "c", "c/sub", "sub", "x/y"];
1110        let names = ["x.log", "keep.log", "x.o", "y.o", "build", "deep", "t.tmp", "plain"];
1111        for directory in directories {
1112            let chain = table.chain_for(Path::new(directory));
1113            for name in names {
1114                for is_dir in [false, true] {
1115                    let path = Path::new(directory).join(name);
1116                    assert_eq!(
1117                        chain.is_ignored(Path::new(directory), name.as_bytes(), is_dir),
1118                        table.matcher_for(&path).is_ignored(is_dir),
1119                        "{} (dir {is_dir})",
1120                        path.display()
1121                    );
1122                }
1123            }
1124        }
1125        assert!(!ControlTable::default().chain_for(Path::new("a")).is_ignored(
1126            Path::new("a"),
1127            b"x.log",
1128            false
1129        ));
1130    }
1131
1132    #[test]
1133    fn a_chain_agrees_past_the_inline_buffers_and_beside_unrelated_controls() {
1134        let mut table = ControlTable::default();
1135        table.upsert(Path::new("z/.gitignore"), b"*.log\n".to_vec()).expect("unrelated");
1136        let unrelated = table.chain_for(Path::new("a/b"));
1137        assert!(!unrelated.is_ignored(Path::new("a/b"), b"x.log", false));
1138        assert!(!table.matcher_for(Path::new("a/b/x.log")).is_ignored(false));
1139
1140        // A control 33 directories down, and directories of 31 to 34 components, cross the
1141        // 32-component inline buffer both in the chain's key and in the matched path.
1142        let deep: PathBuf = (0..33).map(|at| format!("d{at}")).collect();
1143        table.upsert(Path::new(".gitignore"), b"**/x.log\n/d0/**/y.log\n".to_vec()).expect("root");
1144        table.upsert(&deep.join(".gitignore"), b"!x.log\n*.tmp\n".to_vec()).expect("deep");
1145        for depth in [31usize, 32, 33, 34] {
1146            let directory: PathBuf = (0..depth).map(|at| format!("d{at}")).collect();
1147            let chain = table.chain_for(&directory);
1148            for name in ["x.log", "y.log", "z.tmp", "plain"] {
1149                let path = directory.join(name);
1150                assert_eq!(
1151                    chain.is_ignored(&directory, name.as_bytes(), false),
1152                    table.matcher_for(&path).is_ignored(false),
1153                    "{name} at depth {depth}"
1154                );
1155            }
1156        }
1157    }
1158
1159    #[test]
1160    fn a_fingerprint_collision_never_shares_a_matcher() {
1161        let collision = ControlIdentity { bytes: 6, fingerprint: 7 };
1162        let mut table = ControlTable::default();
1163        table
1164            .upsert_identified(Path::new("a/.gitignore"), b"*.log\n".to_vec(), collision)
1165            .expect("first");
1166        table
1167            .upsert_identified(Path::new("b/.gitignore"), b"*.tmp\n".to_vec(), collision)
1168            .expect("second");
1169        table.assert_consistent();
1170
1171        assert_eq!(table.shared[&collision].len(), 2);
1172        assert!(table.is_ignored(Path::new("a/x.log"), false));
1173        assert!(!table.is_ignored(Path::new("a/x.tmp"), false));
1174        assert!(table.is_ignored(Path::new("b/x.tmp"), false));
1175        assert!(!table.is_ignored(Path::new("b/x.log"), false));
1176        assert_eq!(
1177            table.retained_cost(),
1178            retained_source_cost(Path::new("a"), b"*.log\n")
1179                + retained_source_cost(Path::new("b"), b"*.tmp\n")
1180        );
1181
1182        table.remove(Path::new("a/.gitignore")).expect("remove");
1183        table.assert_consistent();
1184        assert!(table.is_ignored(Path::new("b/x.tmp"), false));
1185    }
1186
1187    const CHANGED: ControlAdmission = ControlAdmission::Retained { changed: true };
1188    const UNCHANGED: ControlAdmission = ControlAdmission::Retained { changed: false };
1189    const OVER_BUDGET: ControlAdmission = ControlAdmission::Refused(ControlRefusalReason::Budget);
1190    const OVER_LINE_LIMIT: ControlAdmission =
1191        ControlAdmission::Refused(ControlRefusalReason::LineLimit);
1192
1193    /// A table under `budget` and the default line limit.
1194    fn budgeted(budget: Option<usize>) -> ControlTable {
1195        ControlTable::with_limits(ControlLimits { budget, ..ControlLimits::default() })
1196    }
1197
1198    #[test]
1199    fn replacing_the_last_holder_releases_its_content_for_the_bound() {
1200        let mut table = ControlTable::default();
1201        let first = source_at_test_limit();
1202        assert_eq!(
1203            table.upsert(Path::new(".gitignore"), first.clone()).expect("control path"),
1204            CHANGED
1205        );
1206        // The same content elsewhere costs only a key, which still crosses a full table.
1207        assert_eq!(table.upsert(Path::new("copy/.gitignore"), first).expect("path"), OVER_BUDGET);
1208        assert_eq!(
1209            table.upsert(Path::new(".gitignore"), b"small\n".to_vec()).expect("control path"),
1210            CHANGED
1211        );
1212        table.assert_consistent();
1213        assert_eq!(table.retained_cost(), retained_source_cost(Path::new(""), b"small\n"));
1214    }
1215
1216    #[test]
1217    fn creation_edit_and_last_removal_are_exact() {
1218        let mut table = ControlTable::default();
1219        assert_eq!(
1220            table.upsert(Path::new(".gitignore"), b"*.log\n".to_vec()).expect("control path"),
1221            CHANGED
1222        );
1223        let original = table.clone();
1224        assert_eq!(
1225            table.upsert(Path::new(".gitignore"), b"*.log\n".to_vec()).expect("control path"),
1226            UNCHANGED
1227        );
1228        assert_eq!(
1229            table.upsert(Path::new(".gitignore"), b"*.tmp\n".to_vec()).expect("control path"),
1230            CHANGED
1231        );
1232        assert_eq!(table.changes_from(&original).len(), 1);
1233        assert!(table.remove(Path::new(".gitignore")).expect("remove"));
1234        assert!(table.is_empty());
1235        assert_eq!(table.source_bytes(), 0);
1236        assert!(!table.remove(Path::new(".gitignore")).expect("missing is a no-op"));
1237    }
1238
1239    /// The budget admits a table charged exactly to it and refuses one byte more.
1240    #[test]
1241    fn the_budget_admits_its_own_size_and_refuses_one_byte_over_it() {
1242        let source = b"*.log\n".to_vec();
1243        let exact = retained_source_cost(Path::new("a"), &source);
1244        let mut at_budget = budgeted(Some(exact));
1245        assert_eq!(
1246            at_budget.upsert(Path::new("a/.gitignore"), source.clone()).expect("control path"),
1247            CHANGED
1248        );
1249        assert_eq!(at_budget.retained_cost(), exact);
1250
1251        let mut under_budget = budgeted(Some(exact - 1));
1252        assert_eq!(
1253            under_budget.upsert(Path::new("a/.gitignore"), source).expect("control path"),
1254            OVER_BUDGET
1255        );
1256        assert_eq!(under_budget.retained_cost(), 0);
1257        assert!(under_budget.contains(Path::new("a/.gitignore")));
1258        assert_eq!(
1259            under_budget.observation(),
1260            ControlObservation {
1261                limits: ControlLimits {
1262                    budget: Some(exact - 1),
1263                    line_limit: Some(DEFAULT_CONTROL_LINE_LIMIT),
1264                },
1265                applied: 0,
1266                rules: 0,
1267                refused: 1,
1268                refusals: vec![RefusedControl {
1269                    path: PathBuf::from("a/.gitignore"),
1270                    reason: ControlRefusalReason::Budget,
1271                }],
1272            }
1273        );
1274    }
1275
1276    #[test]
1277    fn a_refused_source_drops_the_rules_it_replaces_and_freed_budget_admits_it_later() {
1278        let mut table = ControlTable::default();
1279        let first = source_at_test_limit();
1280        assert_eq!(
1281            table.upsert(Path::new(".gitignore"), first.clone()).expect("control path"),
1282            CHANGED
1283        );
1284        assert_eq!(
1285            table
1286                .upsert(Path::new("nested/.gitignore"), b"*.log\n".to_vec())
1287                .expect("control path"),
1288            OVER_BUDGET
1289        );
1290        let before = table.clone();
1291
1292        // Replacing the root's source with one that no longer fits drops the old rules
1293        // rather than keeping rules that are no longer on disk.
1294        let mut grown = first;
1295        grown.push(b'\n');
1296        assert_eq!(
1297            table.upsert(Path::new(".gitignore"), grown).expect("control path"),
1298            OVER_BUDGET
1299        );
1300        assert!(table.is_empty());
1301        assert_eq!(table.changes_from(&before).len(), 1);
1302        assert_eq!(
1303            table.refusal_changes_from(&before),
1304            vec![(PathBuf::from(".gitignore"), None, Some(ControlRefusalReason::Budget))]
1305        );
1306
1307        // With the budget free, reading the nested file again admits it and lifts its refusal.
1308        assert_eq!(
1309            table
1310                .upsert(Path::new("nested/.gitignore"), b"*.log\n".to_vec())
1311                .expect("control path"),
1312            CHANGED
1313        );
1314        assert_eq!(table.refused_len(), 1);
1315        // Removing a refused file lifts its refusal too.
1316        assert!(table.remove(Path::new(".gitignore")).expect("remove refused"));
1317        assert_eq!(table.refused_len(), 0);
1318        table.assert_consistent();
1319    }
1320
1321    #[test]
1322    fn identical_sources_share_one_content_charge() {
1323        let source = b"target/\n*.log\nnode_modules/\n".to_vec();
1324        let mut table = ControlTable::default();
1325        table.upsert(Path::new("a/.gitignore"), source.clone()).expect("first holder");
1326        let one = table.retained_cost();
1327        table.upsert(Path::new("bb/.gitignore"), source.clone()).expect("second holder");
1328
1329        // The second directory pays for its key and nothing for content it shares.
1330        assert_eq!(table.retained_cost() - one, CONTROL_SOURCE_OVERHEAD + "bb".len());
1331        assert_eq!(table.source_bytes(), 2 * source.len());
1332        assert!(table.is_ignored(Path::new("a/debug.log"), false));
1333        assert!(table.is_ignored(Path::new("bb/debug.log"), false));
1334    }
1335
1336    /// One line at the limit applies; one byte longer refuses the whole source, before any
1337    /// parsing, so a hostile rule costs no matching work.
1338    #[test]
1339    fn the_line_limit_admits_its_own_length_and_refuses_one_byte_over_it() {
1340        let mut table = ControlTable::default();
1341        let at_limit = vec![b'a'; DEFAULT_CONTROL_LINE_LIMIT];
1342        assert_eq!(
1343            table.upsert(Path::new("a/.gitignore"), at_limit).expect("control path"),
1344            CHANGED
1345        );
1346        let over = [b"*.log\n".as_slice(), &vec![b'a'; DEFAULT_CONTROL_LINE_LIMIT + 1]].concat();
1347        assert_eq!(
1348            table.upsert(Path::new("b/.gitignore"), over).expect("control path"),
1349            OVER_LINE_LIMIT
1350        );
1351        assert!(!table.is_ignored(Path::new("b/debug.log"), false), "no rule of it applies");
1352        assert_eq!(
1353            table.refusals().collect::<Vec<_>>(),
1354            vec![RefusedControl {
1355                path: PathBuf::from("b/.gitignore"),
1356                reason: ControlRefusalReason::LineLimit,
1357            }]
1358        );
1359    }
1360
1361    /// Raising or lifting one limit never moves the other: a larger budget still refuses a
1362    /// long line, and no line limit still refuses a table past its budget.
1363    #[test]
1364    fn the_budget_and_the_line_limit_lift_independently() {
1365        let long = [b"*.log\n".as_slice(), &vec![b'a'; DEFAULT_CONTROL_LINE_LIMIT + 1]].concat();
1366        let large = source_at_test_limit();
1367
1368        let mut no_budget = budgeted(None);
1369        assert_eq!(
1370            no_budget.upsert(Path::new("big/.gitignore"), large.clone()).expect("path"),
1371            CHANGED
1372        );
1373        assert_eq!(
1374            no_budget.upsert(Path::new("c/.gitignore"), b"*.tmp\n".to_vec()).expect("path"),
1375            CHANGED
1376        );
1377        assert!(no_budget.retained_cost() > DEFAULT_CONTROL_BUDGET);
1378        assert_eq!(
1379            no_budget.upsert(Path::new(".gitignore"), long.clone()).expect("path"),
1380            OVER_LINE_LIMIT
1381        );
1382
1383        let mut raised_budget = budgeted(Some(16 * DEFAULT_CONTROL_BUDGET));
1384        assert_eq!(
1385            raised_budget.upsert(Path::new(".gitignore"), long.clone()).expect("path"),
1386            OVER_LINE_LIMIT
1387        );
1388
1389        let mut no_line_limit = ControlTable::with_limits(ControlLimits {
1390            line_limit: None,
1391            ..ControlLimits::default()
1392        });
1393        assert_eq!(no_line_limit.upsert(Path::new(".gitignore"), long).expect("path"), CHANGED);
1394        assert!(no_line_limit.is_ignored(Path::new("debug.log"), false));
1395        assert_eq!(
1396            no_line_limit.upsert(Path::new("big/.gitignore"), large).expect("path"),
1397            OVER_BUDGET
1398        );
1399        no_line_limit.assert_consistent();
1400    }
1401
1402    #[test]
1403    fn a_listing_of_refusals_is_bounded_and_says_when_it_is_truncated() {
1404        let mut table = budgeted(Some(0));
1405        let refused = crate::MAX_RETAINED_ISSUES + 1;
1406        for directory in 0..refused {
1407            let path = PathBuf::from(format!("d{directory:03}/.gitignore"));
1408            assert_eq!(table.upsert(&path, b"*\n".to_vec()).expect("control path"), OVER_BUDGET);
1409        }
1410        let observation = table.observation();
1411        assert_eq!(observation.refused, u64::try_from(refused).expect("small"));
1412        assert_eq!(observation.refusals.len(), crate::MAX_RETAINED_ISSUES);
1413        assert_eq!(observation.refusals[0].path, Path::new("d000/.gitignore"));
1414        assert!(!observation.lists_every_refusal());
1415        assert!(!observation.is_complete());
1416    }
1417
1418    #[test]
1419    fn control_identity_uses_standard_fnv1a_vectors() {
1420        assert_eq!(identity(b"").fingerprint, 0xcbf2_9ce4_8422_2325);
1421        assert_eq!(identity(b"a").fingerprint, 0xaf63_dc4c_8601_ec8c);
1422        assert_eq!(identity(b"foobar").fingerprint, 0x8594_4171_f739_67e8);
1423    }
1424
1425    #[test]
1426    fn nested_negation_and_control_removal_change_the_governed_subtree() {
1427        let mut table = ControlTable::default();
1428        table.upsert(Path::new(".gitignore"), b"*.log\n".to_vec()).expect("root");
1429        table.upsert(Path::new("docs/.gitignore"), b"!keep.log\n".to_vec()).expect("nested");
1430
1431        assert!(table.is_ignored(Path::new("debug.log"), false));
1432        assert!(table.is_ignored(Path::new("docs/other.log"), false));
1433        assert!(!table.is_ignored(Path::new("docs/keep.log"), false));
1434        assert_eq!(
1435            ControlTable::affected_subtree(Path::new("docs/.gitignore")).expect("scope"),
1436            Path::new("docs")
1437        );
1438
1439        table.remove(Path::new("docs/.gitignore")).expect("remove nested");
1440        assert!(table.is_ignored(Path::new("docs/keep.log"), false));
1441    }
1442
1443    #[test]
1444    fn ignored_parent_cannot_be_reincluded_from_inside_it() {
1445        let mut table = ControlTable::default();
1446        table.upsert(Path::new(".gitignore"), b"vendor/\n".to_vec()).expect("root");
1447        table
1448            .upsert(Path::new("vendor/.gitignore"), b"!keep.txt\n".to_vec())
1449            .expect("retained but inactive nested control");
1450
1451        assert!(table.is_ignored(Path::new("vendor"), true));
1452        assert!(table.is_ignored(Path::new("vendor/keep.txt"), false));
1453    }
1454
1455    /// Only `.gitignore` spelled in some ASCII case is a spelling of the control name; the
1456    /// canonical path every spelling is recorded under is its directory's `.gitignore`, and
1457    /// only that path is a valid control path (fdu-0w1b).
1458    #[test]
1459    fn a_control_name_is_spelled_in_any_ascii_case_and_recorded_by_one_path() {
1460        use std::ffi::OsStr;
1461
1462        assert_eq!(control_spelling(OsStr::new(".gitignore")), Some(ControlSpelling::Exact));
1463        for variant in [".GITIGNORE", ".GitIgnore", ".gitIGNORE", ".gitignorE"] {
1464            assert_eq!(control_spelling(OsStr::new(variant)), Some(ControlSpelling::Variant));
1465        }
1466        // A non-ASCII letter some filesystem folds to `i` is not looked up, and neither is a
1467        // name of another length or with other characters.
1468        for other in [".gıtıgnore", ".gitignor", ".gitignore~", "gitignore.", ".gitignor3", ""] {
1469            assert_eq!(control_spelling(OsStr::new(other)), None, "{other:?}");
1470        }
1471        #[cfg(unix)]
1472        {
1473            use std::os::unix::ffi::OsStrExt as _;
1474            assert_eq!(control_spelling(OsStr::from_bytes(b".gitignor\xff")), None);
1475        }
1476
1477        assert_eq!(
1478            governing_control(Path::new("a/.GITIGNORE")),
1479            Some(PathBuf::from("a/.gitignore"))
1480        );
1481        assert_eq!(governing_control(Path::new(".gitignore")), Some(PathBuf::from(".gitignore")));
1482        assert_eq!(governing_control(Path::new("a/README")), None);
1483        assert!(is_control_file(Path::new("a/.gitignore")));
1484        assert!(!is_control_file(Path::new("a/.GITIGNORE")), "operations name one path");
1485        let mut table = ControlTable::default();
1486        assert!(table.upsert(Path::new("a/.GITIGNORE"), b"*.log\n".to_vec()).is_err());
1487    }
1488}