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: every regular file named
10//! [`CONTROL_FILE_NAME`] inside the scanned root, each 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
21mod gitignore;
22
23use std::collections::{BTreeMap, BTreeSet, HashMap};
24use std::path::{Path, PathBuf};
25use std::sync::Arc;
26
27use gitignore::Gitignore;
28
29/// Name of the fixed control file understood by the first engine version.
30pub const CONTROL_FILE_NAME: &str = ".gitignore";
31
32/// Default retained control-table charge for one index, in bytes.
33///
34/// The charge includes a fixed amount per directory as well as each distinct source's
35/// bytes and matcher, so a hostile tree of empty control files cannot evade it. Four MiB is
36/// far above ordinary repositories while remaining small relative to the inventory it
37/// governs; a source past it is refused, not an error. Callers set it with
38/// [`ControlLimits::budget`], which the command line spells `--gitignore-budget`.
39pub const DEFAULT_CONTROL_BUDGET: usize = 4 * 1024 * 1024;
40
41/// Default longest line a control source may hold, in bytes.
42///
43/// A control file may hold many ordinary rules up to the budget, but a single adversarial
44/// rule must not impose unbounded matching work on every entry, so a source with a longer
45/// line is refused whole. Callers set it with [`ControlLimits::line_limit`], which the
46/// command line spells `--gitignore-line-limit`.
47pub const DEFAULT_CONTROL_LINE_LIMIT: usize = 16 * 1024;
48
49/// The two bounds on the control state one index retains, each liftable on its own.
50///
51/// They bound different things. The budget bounds the memory the whole table retains; the
52/// line limit bounds what one pattern costs to match against every entry. So raising the
53/// budget admits more files without admitting longer lines, and lifting the line limit
54/// admits long lines without retaining more. A source either bound cannot admit is
55/// refused, and its [`ControlRefusalReason`] names the one that fired.
56///
57/// Both are part of the scan scope: they decide which rules apply, so a snapshot taken
58/// under other limits never serves a request for these.
59#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
60pub struct ControlLimits {
61    /// Bytes of retained charge before further sources are refused, or `None` for no
62    /// bound.
63    ///
64    /// Each directory's key and each distinct source's bytes and matcher are charged, so
65    /// identical files count once. It also bounds the read: a control file is read to one
66    /// byte past the budget, so `None` reads every control file whole, however large.
67    pub budget: Option<usize>,
68    /// Longest line in bytes a source may hold before it is refused whole, or `None` for
69    /// no bound.
70    pub line_limit: Option<usize>,
71}
72
73impl Default for ControlLimits {
74    /// [`DEFAULT_CONTROL_BUDGET`] and [`DEFAULT_CONTROL_LINE_LIMIT`].
75    fn default() -> Self {
76        Self { budget: Some(DEFAULT_CONTROL_BUDGET), line_limit: Some(DEFAULT_CONTROL_LINE_LIMIT) }
77    }
78}
79
80impl ControlLimits {
81    /// The limit a refusal for `reason` crossed, or `None` when that limit is unbounded.
82    pub const fn limit_for(self, reason: ControlRefusalReason) -> Option<usize> {
83        match reason {
84            ControlRefusalReason::Budget => self.budget,
85            ControlRefusalReason::LineLimit => self.line_limit,
86        }
87    }
88}
89
90/// `budget 4.0 MiB, line limit 16 KiB`, with `all` for an unbounded limit, the word every
91/// surface accepts back.
92impl std::fmt::Display for ControlLimits {
93    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94        write!(
95            formatter,
96            "budget {}, line limit {}",
97            limit_display(self.budget),
98            limit_display(self.line_limit)
99        )
100    }
101}
102
103/// One control limit as a person reads it: a size, or `all` when unbounded.
104pub(crate) fn limit_display(limit: Option<usize>) -> String {
105    limit.map_or_else(
106        || "all".to_string(),
107        |bytes| crate::report_format::human_bytes(u64::try_from(bytes).unwrap_or(u64::MAX)),
108    )
109}
110
111/// Conservative retained charge for one key, identity, and matcher shell.
112pub(crate) const CONTROL_SOURCE_OVERHEAD: usize = 64;
113
114/// Stable, non-sensitive identity of one retained control source.
115#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
116pub struct ControlIdentity {
117    /// Exact source length.
118    pub bytes: u64,
119    /// Stable FNV-1a digest of the source bytes.
120    pub fingerprint: u64,
121}
122
123/// One distinct control content, parsed once and shared by every directory holding it.
124#[derive(Debug)]
125struct SharedContent {
126    bytes: Vec<u8>,
127    identity: ControlIdentity,
128    matcher: Gitignore,
129    /// Charge for the exact bytes and the parsed matcher, paid once per distinct content.
130    content_cost: usize,
131}
132
133/// A distinct content and how many directories of one table hold it.
134///
135/// The count belongs to the table, not to the `Arc`: a projected clone of a table shares
136/// every content with it, so the reference count cannot say which holders one table has.
137#[derive(Clone, Debug)]
138struct Holding {
139    content: Arc<SharedContent>,
140    holders: usize,
141}
142
143/// Which of the [`ControlLimits`] refused a control source instead of applying its rules.
144#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
145pub enum ControlRefusalReason {
146    /// Retaining the source would have taken the table past [`ControlLimits::budget`].
147    Budget,
148    /// A line of the source is longer than [`ControlLimits::line_limit`].
149    LineLimit,
150}
151
152impl ControlRefusalReason {
153    /// The stable name every structured output and binding uses for this reason, which is
154    /// also the name of the limit that fired.
155    pub const fn label(self) -> &'static str {
156        match self {
157            Self::Budget => "budget",
158            Self::LineLimit => "line_limit",
159        }
160    }
161}
162
163/// What a control table did with one verified control source.
164#[derive(Clone, Copy, PartialEq, Eq, Debug)]
165pub enum ControlAdmission {
166    /// The rules apply. `changed` is false when the directory already retained exactly
167    /// these bytes.
168    Retained {
169        /// Whether the table's retained sources changed.
170        changed: bool,
171    },
172    /// The rules do not apply, and the directory retains no source. The refusal is
173    /// recorded, so the table's coverage names it.
174    Refused(ControlRefusalReason),
175}
176
177/// What one upsert would do to a control table, decided before anything moves.
178#[derive(Clone, Copy, PartialEq, Eq, Debug)]
179enum Verdict {
180    /// The directory already retains exactly these bytes.
181    Unchanged,
182    /// The source applies, leaving the table charged `retained_cost` bytes.
183    Admit { retained_cost: usize },
184    /// A limit cannot admit the source.
185    Refuse(ControlRefusalReason),
186}
187
188/// One control file whose rules an index refused, relative to the index root.
189#[derive(Clone, PartialEq, Eq, Debug, Hash)]
190pub struct RefusedControl {
191    /// The refused `.gitignore`.
192    pub path: PathBuf,
193    /// Which limit refused it.
194    pub reason: ControlRefusalReason,
195}
196
197/// Whether an index's ignore classification applies every control file in its scope.
198///
199/// Sizes and counts never depend on this: a refused control file costs only the
200/// ignored and unignored split. Below a refused file that split is not exact in either
201/// direction, because the file may have held negations as well as ignore rules.
202#[derive(Clone, PartialEq, Eq, Debug)]
203pub enum ControlCoverage {
204    /// The index read no control file, so it classifies nothing as ignored or unignored.
205    NotObserved,
206    /// The index read every control file in its scope and applied the ones it admitted.
207    Observed(ControlObservation),
208}
209
210/// The control files an observing index applied and refused.
211#[derive(Clone, PartialEq, Eq, Debug)]
212pub struct ControlObservation {
213    /// The limits the index applied control files under.
214    pub limits: ControlLimits,
215    /// Control files whose rules apply.
216    pub applied: u64,
217    /// Control files refused, counted exactly.
218    pub refused: u64,
219    /// Refused control files in path order, at most [`crate::MAX_RETAINED_ISSUES`] of
220    /// them. A list shorter than [`Self::refused`] is truncated.
221    pub refusals: Vec<RefusedControl>,
222}
223
224impl ControlObservation {
225    /// Whether every control file in scope applies, so ignore classification is exact.
226    pub const fn is_complete(&self) -> bool {
227        self.refused == 0
228    }
229
230    /// Whether [`Self::refusals`] names every refused control file.
231    pub fn lists_every_refusal(&self) -> bool {
232        u64::try_from(self.refusals.len()).is_ok_and(|listed| listed == self.refused)
233    }
234}
235
236/// Exact `.gitignore` sources and parsed matchers, keyed by governing directory.
237///
238/// Identical sources are stored and parsed once. A tree of package checkouts repeats a few
239/// `.gitignore` files thousands of times, and charging every copy for its bytes and matcher
240/// crossed the bound at a fraction of the distinct rules the tree holds (fdu-szkg). Each
241/// directory still pays for its own key, so a tree of empty control files cannot evade the
242/// bound, and removing the last holder of a content releases the content's charge.
243///
244/// A source the limits cannot admit is refused, not an error: the table records the
245/// refusal and keeps no rules for that directory, and the scan that read it continues
246/// (fdu-1onj). A refusal ends when the directory's control file is removed or a later
247/// read admits it.
248#[derive(Clone, Debug)]
249pub struct ControlTable {
250    by_directory: BTreeMap<PathBuf, Arc<SharedContent>>,
251    /// Distinct contents by identity. A list, because an equal length and FNV-1a digest do
252    /// not prove equal bytes, and a collision must never share another source's matcher.
253    shared: HashMap<ControlIdentity, Vec<Holding>>,
254    /// Every refused source, by governing directory. Kept whole, not bounded like issue
255    /// details, so removing a refused file keeps the refused count exact.
256    refused: BTreeMap<PathBuf, ControlRefusalReason>,
257    limits: ControlLimits,
258    source_bytes: usize,
259    retained_cost: usize,
260}
261
262impl Default for ControlTable {
263    fn default() -> Self {
264        Self::with_limits(ControlLimits::default())
265    }
266}
267
268impl ControlTable {
269    /// An empty table that refuses sources past either of `limits`.
270    pub(crate) fn with_limits(limits: ControlLimits) -> Self {
271        Self {
272            by_directory: BTreeMap::new(),
273            shared: HashMap::new(),
274            refused: BTreeMap::new(),
275            limits,
276            source_bytes: 0,
277            retained_cost: 0,
278        }
279    }
280
281    /// Insert or replace one verified control source.
282    ///
283    /// `path` names the control file relative to the index root. The source is retained
284    /// exactly, while matching state is derived once per distinct content rather than per
285    /// directory or per entry. A source the limits cannot admit is refused and recorded,
286    /// and a source it replaces is dropped with it: rules no longer on disk must not keep
287    /// applying. The line limit is checked first, so a source both limits refuse is
288    /// refused for its line.
289    ///
290    /// # Errors
291    ///
292    /// [`crate::Error::InvalidControlPath`] when `path` does not name a control file.
293    pub fn upsert(&mut self, path: &Path, source: Vec<u8>) -> crate::Result<ControlAdmission> {
294        let identity = identity(&source);
295        self.upsert_identified(path, source, identity)
296    }
297
298    fn upsert_identified(
299        &mut self,
300        path: &Path,
301        source: Vec<u8>,
302        identity: ControlIdentity,
303    ) -> crate::Result<ControlAdmission> {
304        let directory = control_directory(path)?;
305        match self.verdict(directory, &source, identity) {
306            Verdict::Unchanged => Ok(ControlAdmission::Retained { changed: false }),
307            Verdict::Refuse(reason) => {
308                crate::counters::bump(|counts| {
309                    counts.control_refused = counts.control_refused.saturating_add(1);
310                });
311                Ok(self.refuse(directory, reason))
312            }
313            Verdict::Admit { retained_cost } => {
314                self.detach(directory);
315                self.attach(directory, source, identity);
316                self.refused.remove(directory);
317                debug_assert_eq!(self.retained_cost, retained_cost);
318                Ok(ControlAdmission::Retained { changed: true })
319            }
320        }
321    }
322
323    /// What upserting `source` at `directory` would do, decided before anything moves.
324    ///
325    /// The decision is a pure function of this table, so a caller can ask whether an
326    /// operation would change anything without projecting a copy of the table to find out.
327    fn verdict(&self, directory: &Path, source: &[u8], identity: ControlIdentity) -> Verdict {
328        if self.by_directory.get(directory).is_some_and(|current| current.bytes == source) {
329            return Verdict::Unchanged;
330        }
331        if self.limits.line_limit.is_some_and(|line_limit| {
332            source.split(|byte| *byte == b'\n').any(|line| line.len() > line_limit)
333        }) {
334            return Verdict::Refuse(ControlRefusalReason::LineLimit);
335        }
336        let content_charge =
337            if self.holding(identity, source).is_some() { 0 } else { content_cost(source) };
338        // Saturating: every retained charge is a sum of real allocations, so only a source
339        // no budget could admit reaches the ceiling, and an unbounded table never refuses.
340        let retained_cost = self
341            .retained_cost
342            .saturating_sub(self.release_charge(directory))
343            .saturating_add(directory_cost(directory))
344            .saturating_add(content_charge);
345        if self.limits.budget.is_some_and(|budget| retained_cost > budget) {
346            return Verdict::Refuse(ControlRefusalReason::Budget);
347        }
348        Verdict::Admit { retained_cost }
349    }
350
351    /// Whether upserting `source` at `path` would leave this table exactly as it is.
352    ///
353    /// True when the directory already retains those exact bytes, and when it already
354    /// records a refusal this source would earn again: refusing an already-refused
355    /// directory for the same limit writes the same record. A warm revalidate of a tree
356    /// past its budget re-reads every refused file, and this is what tells the index that
357    /// those reads change nothing (fdu-hzm5).
358    pub(crate) fn upsert_is_inert(&self, path: &Path, source: &[u8]) -> bool {
359        let Ok(directory) = control_directory(path) else {
360            return false;
361        };
362        match self.verdict(directory, source, identity(source)) {
363            Verdict::Unchanged => true,
364            Verdict::Refuse(reason) => self.refused.get(directory) == Some(&reason),
365            Verdict::Admit { .. } => false,
366        }
367    }
368
369    /// Whether removing the control file at `path` would leave this table as it is.
370    pub(crate) fn remove_is_inert(&self, path: &Path) -> bool {
371        control_directory(path).is_ok_and(|directory| {
372            !self.by_directory.contains_key(directory) && !self.refused.contains_key(directory)
373        })
374    }
375
376    /// Whether any directory at or below `subtree` retains a source or records a refusal.
377    ///
378    /// What a structural removal of `subtree` would prune, so a batch that removes nothing
379    /// the table records leaves it alone.
380    pub(crate) fn has_record_at_or_below(&self, subtree: &Path) -> bool {
381        has_key_at_or_below(&self.by_directory, subtree)
382            || has_key_at_or_below(&self.refused, subtree)
383    }
384
385    /// Restore a refusal a snapshot recorded, without the source that was refused.
386    pub(crate) fn record_refusal(
387        &mut self,
388        path: &Path,
389        reason: ControlRefusalReason,
390    ) -> crate::Result<()> {
391        let directory = control_directory(path)?;
392        self.refuse(directory, reason);
393        Ok(())
394    }
395
396    fn refuse(&mut self, directory: &Path, reason: ControlRefusalReason) -> ControlAdmission {
397        self.detach(directory);
398        self.refused.insert(directory.to_path_buf(), reason);
399        ControlAdmission::Refused(reason)
400    }
401
402    /// Remove one control source, or the record of its refusal. Missing sources are no-ops.
403    ///
404    /// # Errors
405    ///
406    /// [`crate::Error::InvalidControlPath`] when `path` does not name a control file.
407    pub fn remove(&mut self, path: &Path) -> crate::Result<bool> {
408        let directory = control_directory(path)?;
409        let retained = self.detach(directory);
410        let refused = self.refused.remove(directory).is_some();
411        Ok(retained || refused)
412    }
413
414    /// Remove every control file, and every refusal, at or below `subtree`.
415    pub(crate) fn remove_subtree(&mut self, subtree: &Path) {
416        let directories: Vec<PathBuf> = self
417            .by_directory
418            .keys()
419            .filter(|directory| directory.starts_with(subtree))
420            .cloned()
421            .collect();
422        for directory in directories {
423            self.detach(&directory);
424        }
425        self.refused.retain(|directory, _| !directory.starts_with(subtree));
426    }
427
428    /// The holding for exactly `source`, when some directory already retains it.
429    fn holding(&self, identity: ControlIdentity, source: &[u8]) -> Option<&Holding> {
430        self.shared
431            .get(&identity)?
432            .iter()
433            .find(|holding| holding.content.bytes.as_slice() == source)
434    }
435
436    /// The charge that dropping `directory`'s current source would release.
437    fn release_charge(&self, directory: &Path) -> usize {
438        let Some(content) = self.by_directory.get(directory) else {
439            return 0;
440        };
441        let last_holder = self.shared.get(&content.identity).is_some_and(|holdings| {
442            holdings
443                .iter()
444                .any(|holding| Arc::ptr_eq(&holding.content, content) && holding.holders == 1)
445        });
446        directory_cost(directory).saturating_add(if last_holder { content.content_cost } else { 0 })
447    }
448
449    /// Drop `directory`'s source, releasing its content when this was the last holder.
450    fn detach(&mut self, directory: &Path) -> bool {
451        let Some(content) = self.by_directory.remove(directory) else {
452            return false;
453        };
454        let holdings = self.shared.get_mut(&content.identity).expect("a retained content is held");
455        let position = holdings
456            .iter()
457            .position(|holding| Arc::ptr_eq(&holding.content, &content))
458            .expect("a retained content is listed under its own identity");
459        holdings[position].holders -= 1;
460        if holdings[position].holders == 0 {
461            holdings.swap_remove(position);
462            self.retained_cost -= content.content_cost;
463            if holdings.is_empty() {
464                self.shared.remove(&content.identity);
465            }
466        }
467        self.retained_cost -= directory_cost(directory);
468        self.source_bytes -= content.bytes.len();
469        true
470    }
471
472    /// Hold `source` at `directory`, sharing an identical retained content when one exists.
473    fn attach(&mut self, directory: &Path, source: Vec<u8>, identity: ControlIdentity) {
474        let holdings = self.shared.entry(identity).or_default();
475        let content = if let Some(holding) =
476            holdings.iter_mut().find(|holding| holding.content.bytes == source)
477        {
478            holding.holders += 1;
479            crate::counters::bump(|counts| {
480                counts.control_sources_shared = counts.control_sources_shared.saturating_add(1);
481            });
482            Arc::clone(&holding.content)
483        } else {
484            let content_cost = content_cost(&source);
485            let matcher = Gitignore::parse(&source);
486            let content =
487                Arc::new(SharedContent { bytes: source, identity, matcher, content_cost });
488            holdings.push(Holding { content: Arc::clone(&content), holders: 1 });
489            self.retained_cost += content_cost;
490            content
491        };
492        self.retained_cost += directory_cost(directory);
493        self.source_bytes += content.bytes.len();
494        self.by_directory.insert(directory.to_path_buf(), content);
495    }
496
497    /// Matcher view for one retained path.
498    pub fn matcher_for<'a>(&'a self, path: &'a Path) -> ControlMatcher<'a> {
499        ControlMatcher { table: self, path }
500    }
501
502    /// Evaluate complete ignore semantics without relying on retained parent facts.
503    ///
504    /// The index hot path uses [`ControlMatcher::is_ignored`] with the parent's stored
505    /// classification. This standalone form evaluates each directory prefix so callers
506    /// and tests receive the same answer even without an index entry in hand.
507    pub fn is_ignored(&self, path: &Path, is_dir: bool) -> bool {
508        let components: Vec<_> = path.components().collect();
509        let mut current = PathBuf::new();
510        let mut parent_ignored = false;
511        for (position, component) in components.iter().enumerate() {
512            current.push(component.as_os_str());
513            if parent_ignored {
514                return true;
515            }
516            let current_is_dir = position + 1 < components.len() || is_dir;
517            parent_ignored = self.matcher_for(&current).is_ignored(current_is_dir);
518        }
519        parent_ignored
520    }
521
522    /// Relative subtree whose classification may move when `path` changes.
523    pub fn affected_subtree(path: &Path) -> crate::Result<PathBuf> {
524        Ok(control_directory(path)?.to_path_buf())
525    }
526
527    /// Stable identities changed between two complete table states.
528    pub(crate) fn changes_from(
529        &self,
530        previous: &Self,
531    ) -> Vec<(PathBuf, Option<ControlIdentity>, Option<ControlIdentity>)> {
532        let directories: BTreeSet<&Path> = previous
533            .by_directory
534            .keys()
535            .chain(self.by_directory.keys())
536            .map(PathBuf::as_path)
537            .collect();
538        directories
539            .into_iter()
540            .filter_map(|directory| {
541                let before = previous.by_directory.get(directory);
542                let after = self.by_directory.get(directory);
543                let changed = match (before, after) {
544                    (Some(before), Some(after)) => {
545                        !Arc::ptr_eq(before, after) && before.bytes != after.bytes
546                    }
547                    (None, None) => false,
548                    (Some(_), None) | (None, Some(_)) => true,
549                };
550                changed.then(|| {
551                    (
552                        control_path(directory),
553                        before.map(|source| source.identity),
554                        after.map(|source| source.identity),
555                    )
556                })
557            })
558            .collect()
559    }
560
561    /// Refusals recorded or lifted between two complete table states.
562    pub(crate) fn refusal_changes_from(
563        &self,
564        previous: &Self,
565    ) -> Vec<(PathBuf, Option<ControlRefusalReason>, Option<ControlRefusalReason>)> {
566        let directories: BTreeSet<&Path> =
567            previous.refused.keys().chain(self.refused.keys()).map(PathBuf::as_path).collect();
568        directories
569            .into_iter()
570            .filter_map(|directory| {
571                let before = previous.refused.get(directory).copied();
572                let after = self.refused.get(directory).copied();
573                (before != after).then(|| (control_path(directory), before, after))
574            })
575            .collect()
576    }
577
578    /// Exact sources in deterministic governing-directory order.
579    pub(crate) fn sources(&self) -> impl ExactSizeIterator<Item = (PathBuf, &[u8])> {
580        self.by_directory
581            .iter()
582            .map(|(directory, source)| (control_path(directory), source.bytes.as_slice()))
583    }
584
585    /// Every refused control file and its reason, in governing-directory order.
586    pub fn refusals(&self) -> impl ExactSizeIterator<Item = RefusedControl> + '_ {
587        self.refused.iter().map(|(directory, reason)| RefusedControl {
588            path: control_path(directory),
589            reason: *reason,
590        })
591    }
592
593    /// Number of refused control files.
594    pub fn refused_len(&self) -> usize {
595        self.refused.len()
596    }
597
598    /// The limits this table admits sources under.
599    pub const fn limits(&self) -> ControlLimits {
600        self.limits
601    }
602
603    /// This table's coverage, listing at most [`crate::MAX_RETAINED_ISSUES`] refusals.
604    pub fn observation(&self) -> ControlObservation {
605        ControlObservation {
606            limits: self.limits,
607            applied: u64::try_from(self.len()).unwrap_or(u64::MAX),
608            refused: u64::try_from(self.refused_len()).unwrap_or(u64::MAX),
609            refusals: self.refusals().take(crate::MAX_RETAINED_ISSUES).collect(),
610        }
611    }
612
613    /// Exact retained source bytes across the whole table.
614    pub const fn source_bytes(&self) -> usize {
615        self.source_bytes
616    }
617
618    /// Bounded retained charge for the complete table.
619    pub const fn retained_cost(&self) -> usize {
620        self.retained_cost
621    }
622
623    /// Whether `path` already retains exactly `source`.
624    pub fn source_is(&self, path: &Path, source: &[u8]) -> bool {
625        control_directory(path)
626            .ok()
627            .and_then(|directory| self.by_directory.get(directory))
628            .is_some_and(|current| current.bytes == source)
629    }
630
631    /// Whether the table holds a record at `path`: a retained source or a refusal.
632    ///
633    /// Reconciliation asks this to decide whether a control file missing from a listing
634    /// needs a removal, and a refused file that disappears must lift its refusal.
635    pub(crate) fn contains(&self, path: &Path) -> bool {
636        control_directory(path).ok().is_some_and(|directory| {
637            self.by_directory.contains_key(directory) || self.refused.contains_key(directory)
638        })
639    }
640
641    /// Number of retained control files.
642    pub fn len(&self) -> usize {
643        self.by_directory.len()
644    }
645
646    /// Whether no control file is retained. A table may still record refusals.
647    pub fn is_empty(&self) -> bool {
648        self.by_directory.is_empty()
649    }
650
651    /// Whether the table records nothing at all: no retained source and no refusal.
652    pub(crate) fn is_vacant(&self) -> bool {
653        self.by_directory.is_empty() && self.refused.is_empty()
654    }
655}
656
657/// A path-bound view over the controls that may govern it.
658pub struct ControlMatcher<'a> {
659    table: &'a ControlTable,
660    path: &'a Path,
661}
662
663impl ControlMatcher<'_> {
664    /// Decide this path assuming its retained parent is not ignored.
665    ///
666    /// A caller with retained facts already knows the parent's effective classification,
667    /// so it can stop immediately when that parent is ignored. Otherwise every control
668    /// directory on this path is active and the deepest matching opinion wins.
669    pub fn is_ignored(&self, is_dir: bool) -> bool {
670        // The empty table answers without collecting the ancestor list. Every entry of
671        // a scan that observes no control state asks this question exactly once, so the
672        // allocation below would be the last per-entry cost of a machinery the scan
673        // opted out of (fdu-etfj).
674        if self.table.by_directory.is_empty() {
675            return false;
676        }
677        // A deeper control source overrides every matching ancestor. Walk from the
678        // immediate directory toward the root and return the first opinion instead of
679        // allocating and reversing an ancestor vector for every classified entry.
680        for directory in self.path.parent().into_iter().flat_map(Path::ancestors) {
681            let Some(source) = self.table.by_directory.get(directory) else {
682                continue;
683            };
684            let relative = self.path.strip_prefix(directory).unwrap_or(self.path);
685            if let Some(ignored) = source.matcher.matches(relative, is_dir) {
686                return ignored;
687            }
688        }
689        false
690    }
691}
692
693/// Whether any key of `directories` is `subtree` or lies below it.
694///
695/// One lookup rather than a scan: [`Path`] orders component by component, so every
696/// descendant of `subtree` sorts immediately after it and before any other key, and the
697/// first key at or after `subtree` decides.
698fn has_key_at_or_below<V>(directories: &BTreeMap<PathBuf, V>, subtree: &Path) -> bool {
699    directories
700        .range::<Path, _>((std::ops::Bound::Included(subtree), std::ops::Bound::Unbounded))
701        .next()
702        .is_some_and(|(directory, _)| directory.starts_with(subtree))
703}
704
705/// Whether a relative path names the fixed control file.
706pub fn is_control_file(path: &Path) -> bool {
707    path.file_name().is_some_and(|name| name == CONTROL_FILE_NAME)
708}
709
710fn control_directory(path: &Path) -> crate::Result<&Path> {
711    if !is_control_file(path) {
712        return Err(crate::Error::InvalidControlPath(path.to_path_buf()));
713    }
714    Ok(path.parent().unwrap_or_else(|| Path::new("")))
715}
716
717fn control_path(directory: &Path) -> PathBuf {
718    directory.join(CONTROL_FILE_NAME)
719}
720
721fn identity(bytes: &[u8]) -> ControlIdentity {
722    const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
723    const FNV_PRIME: u64 = 0x100_0000_01b3;
724
725    let mut fingerprint = FNV_OFFSET_BASIS;
726    for byte in bytes {
727        fingerprint ^= u64::from(*byte);
728        fingerprint = fingerprint.wrapping_mul(FNV_PRIME);
729    }
730    ControlIdentity { bytes: u64::try_from(bytes.len()).unwrap_or(u64::MAX), fingerprint }
731}
732
733/// Charge for one directory's key and identity, whatever content it holds.
734fn directory_cost(directory: &Path) -> usize {
735    CONTROL_SOURCE_OVERHEAD.saturating_add(directory.as_os_str().as_encoded_bytes().len())
736}
737
738/// Charge for one distinct content: its exact bytes and parsed glob bytes, plus the
739/// matcher's per-pattern and per-segment shells.
740fn content_cost(source: &[u8]) -> usize {
741    let (newlines, segment_shells) = source.iter().fold((0usize, 0usize), |counts, byte| {
742        (counts.0 + usize::from(*byte == b'\n'), counts.1 + usize::from(*byte == b'/'))
743    });
744    let pattern_shells = newlines.saturating_add(1);
745    source
746        .len()
747        .saturating_mul(2)
748        .saturating_add(pattern_shells.saturating_mul(64))
749        .saturating_add(segment_shells.saturating_mul(24))
750}
751
752/// Charge for one source whose content no other directory holds.
753#[cfg(test)]
754fn retained_source_cost(directory: &Path, source: &[u8]) -> usize {
755    directory_cost(directory).saturating_add(content_cost(source))
756}
757
758#[cfg(test)]
759pub(crate) fn source_at_test_limit() -> Vec<u8> {
760    let mut source = Vec::new();
761    loop {
762        let previous_len = source.len();
763        source.extend(std::iter::repeat_n(b'a', DEFAULT_CONTROL_LINE_LIMIT));
764        source.push(b'\n');
765        if retained_source_cost(Path::new(""), &source) > DEFAULT_CONTROL_BUDGET {
766            source.truncate(previous_len);
767            break;
768        }
769    }
770    let remaining = DEFAULT_CONTROL_BUDGET - retained_source_cost(Path::new(""), &source);
771    source.extend(std::iter::repeat_n(b'a', (remaining / 2).min(DEFAULT_CONTROL_LINE_LIMIT)));
772    assert_eq!(retained_source_cost(Path::new(""), &source), DEFAULT_CONTROL_BUDGET);
773    source
774}
775
776#[cfg(test)]
777impl ControlTable {
778    /// Recompute every charge and holder count from the directories alone.
779    fn assert_consistent(&self) {
780        let mut distinct: Vec<&Arc<SharedContent>> = Vec::new();
781        let mut directory_charges = 0;
782        let mut source_bytes = 0;
783        for (directory, content) in &self.by_directory {
784            directory_charges += directory_cost(directory);
785            source_bytes += content.bytes.len();
786            if !distinct.iter().any(|seen| Arc::ptr_eq(seen, content)) {
787                distinct.push(content);
788            }
789        }
790        let content_charges: usize = distinct.iter().map(|content| content.content_cost).sum();
791        assert_eq!(self.retained_cost, directory_charges + content_charges, "retained cost");
792        assert_eq!(self.source_bytes, source_bytes, "source bytes");
793        let holdings: usize = self.shared.values().map(Vec::len).sum();
794        assert_eq!(holdings, distinct.len(), "one holding per distinct content");
795        for (identity, holdings) in &self.shared {
796            assert!(!holdings.is_empty(), "no empty identity list survives");
797            for holding in holdings {
798                assert_eq!(holding.content.identity, *identity);
799                let holders = self
800                    .by_directory
801                    .values()
802                    .filter(|content| Arc::ptr_eq(content, &holding.content))
803                    .count();
804                assert_eq!(holding.holders, holders, "holder count");
805            }
806            for (index, left) in holdings.iter().enumerate() {
807                for right in &holdings[index + 1..] {
808                    assert_ne!(left.content.bytes, right.content.bytes, "equal bytes are shared");
809                }
810            }
811        }
812        assert!(
813            self.refused.keys().all(|directory| !self.by_directory.contains_key(directory)),
814            "a refused directory retains no source"
815        );
816        assert!(
817            self.limits.budget.is_none_or(|budget| self.retained_cost <= budget),
818            "within budget"
819        );
820        assert!(
821            self.limits.line_limit.is_none_or(|line_limit| {
822                distinct.iter().all(|content| {
823                    content.bytes.split(|byte| *byte == b'\n').all(|line| line.len() <= line_limit)
824                })
825            }),
826            "within the line limit"
827        );
828    }
829}
830
831#[cfg(test)]
832mod tests {
833    use super::*;
834
835    /// Deterministic `SplitMix64`, so a failing sequence replays from its printed seed.
836    struct SplitMix(u64);
837
838    impl SplitMix {
839        fn next(&mut self) -> u64 {
840            self.0 = self.0.wrapping_add(0x9e37_79b9_7f4a_7c15);
841            let mut value = self.0;
842            value = (value ^ (value >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
843            value = (value ^ (value >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
844            value ^ (value >> 31)
845        }
846
847        fn below(&mut self, bound: usize) -> usize {
848            usize::try_from(self.next() % u64::try_from(bound).expect("small bound")).expect("fits")
849        }
850    }
851
852    /// Every directory's last operation decides whether it holds a record, whatever the
853    /// limits decided: an upsert leaves exactly one of a source or a refusal, and a removal
854    /// leaves neither. Charges and holder counts are recomputed after every step, under
855    /// each combination of a bounded or unbounded budget and line limit.
856    #[test]
857    fn charges_and_refusals_stay_exact_through_random_upserts_and_removals() {
858        const DIRECTORIES: [&str; 6] = ["", "a", "a/b", "b", "b/c/d", "c"];
859        let long_line = [vec![b'x'; DEFAULT_CONTROL_LINE_LIMIT + 1], b"\n".to_vec()].concat();
860        let large = b"pattern/\n".repeat(40);
861        let contents: [&[u8]; 6] =
862            [b"*.log\n", b"target/\n", b"!keep\n*.tmp\n", b"", &large, &long_line];
863        // Room for a few small sources and one large one, so the budget refuses often.
864        let budget = 2 * retained_source_cost(Path::new("b/c/d"), &large);
865        for seed in 0..64 {
866            let mut random = SplitMix(seed);
867            let limits = ControlLimits {
868                budget: (seed % 2 == 0).then_some(budget),
869                line_limit: (seed % 4 < 2).then_some(DEFAULT_CONTROL_LINE_LIMIT),
870            };
871            let mut table = ControlTable::with_limits(limits);
872            let mut holds_record: BTreeMap<&Path, bool> = BTreeMap::new();
873            for step in 0..300 {
874                let directory = Path::new(DIRECTORIES[random.below(DIRECTORIES.len())]);
875                let path = directory.join(CONTROL_FILE_NAME);
876                match random.below(8) {
877                    0 => {
878                        table.remove_subtree(directory);
879                        for (held, record) in &mut holds_record {
880                            if held.starts_with(directory) {
881                                *record = false;
882                            }
883                        }
884                    }
885                    1 | 2 => {
886                        table.remove(&path).expect("control path");
887                        holds_record.insert(directory, false);
888                    }
889                    _ => {
890                        let content = contents[random.below(contents.len())].to_vec();
891                        let admission = table.upsert(&path, content.clone()).expect("control path");
892                        if content == long_line && limits.line_limit.is_some() {
893                            assert_eq!(
894                                admission,
895                                ControlAdmission::Refused(ControlRefusalReason::LineLimit)
896                            );
897                        }
898                        if limits.budget.is_none() && limits.line_limit.is_none() {
899                            assert!(
900                                matches!(admission, ControlAdmission::Retained { .. }),
901                                "an unbounded table refuses nothing"
902                            );
903                        }
904                        holds_record.insert(directory, true);
905                    }
906                }
907                std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
908                    table.assert_consistent();
909                    for (directory, record) in &holds_record {
910                        let path = directory.join(CONTROL_FILE_NAME);
911                        assert_eq!(table.contains(&path), *record, "{}", path.display());
912                    }
913                    let records = holds_record.values().filter(|record| **record).count();
914                    assert_eq!(table.len() + table.refused_len(), records);
915                }))
916                .unwrap_or_else(|_| panic!("seed {seed}, step {step}: inconsistent table"));
917            }
918            for directory in DIRECTORIES {
919                table.remove(&Path::new(directory).join(CONTROL_FILE_NAME)).expect("control path");
920            }
921            assert_eq!(table.retained_cost(), 0, "seed {seed}");
922            assert_eq!(table.source_bytes(), 0, "seed {seed}");
923            assert!(table.shared.is_empty(), "seed {seed}");
924            assert!(table.is_vacant(), "seed {seed}");
925        }
926    }
927
928    #[test]
929    fn a_fingerprint_collision_never_shares_a_matcher() {
930        let collision = ControlIdentity { bytes: 6, fingerprint: 7 };
931        let mut table = ControlTable::default();
932        table
933            .upsert_identified(Path::new("a/.gitignore"), b"*.log\n".to_vec(), collision)
934            .expect("first");
935        table
936            .upsert_identified(Path::new("b/.gitignore"), b"*.tmp\n".to_vec(), collision)
937            .expect("second");
938        table.assert_consistent();
939
940        assert_eq!(table.shared[&collision].len(), 2);
941        assert!(table.is_ignored(Path::new("a/x.log"), false));
942        assert!(!table.is_ignored(Path::new("a/x.tmp"), false));
943        assert!(table.is_ignored(Path::new("b/x.tmp"), false));
944        assert!(!table.is_ignored(Path::new("b/x.log"), false));
945        assert_eq!(
946            table.retained_cost(),
947            retained_source_cost(Path::new("a"), b"*.log\n")
948                + retained_source_cost(Path::new("b"), b"*.tmp\n")
949        );
950
951        table.remove(Path::new("a/.gitignore")).expect("remove");
952        table.assert_consistent();
953        assert!(table.is_ignored(Path::new("b/x.tmp"), false));
954    }
955
956    const CHANGED: ControlAdmission = ControlAdmission::Retained { changed: true };
957    const UNCHANGED: ControlAdmission = ControlAdmission::Retained { changed: false };
958    const OVER_BUDGET: ControlAdmission = ControlAdmission::Refused(ControlRefusalReason::Budget);
959    const OVER_LINE_LIMIT: ControlAdmission =
960        ControlAdmission::Refused(ControlRefusalReason::LineLimit);
961
962    /// A table under `budget` and the default line limit.
963    fn budgeted(budget: Option<usize>) -> ControlTable {
964        ControlTable::with_limits(ControlLimits { budget, ..ControlLimits::default() })
965    }
966
967    #[test]
968    fn replacing_the_last_holder_releases_its_content_for_the_bound() {
969        let mut table = ControlTable::default();
970        let first = source_at_test_limit();
971        assert_eq!(
972            table.upsert(Path::new(".gitignore"), first.clone()).expect("control path"),
973            CHANGED
974        );
975        // The same content elsewhere costs only a key, which still crosses a full table.
976        assert_eq!(table.upsert(Path::new("copy/.gitignore"), first).expect("path"), OVER_BUDGET);
977        assert_eq!(
978            table.upsert(Path::new(".gitignore"), b"small\n".to_vec()).expect("control path"),
979            CHANGED
980        );
981        table.assert_consistent();
982        assert_eq!(table.retained_cost(), retained_source_cost(Path::new(""), b"small\n"));
983    }
984
985    #[test]
986    fn creation_edit_and_last_removal_are_exact() {
987        let mut table = ControlTable::default();
988        assert_eq!(
989            table.upsert(Path::new(".gitignore"), b"*.log\n".to_vec()).expect("control path"),
990            CHANGED
991        );
992        let original = table.clone();
993        assert_eq!(
994            table.upsert(Path::new(".gitignore"), b"*.log\n".to_vec()).expect("control path"),
995            UNCHANGED
996        );
997        assert_eq!(
998            table.upsert(Path::new(".gitignore"), b"*.tmp\n".to_vec()).expect("control path"),
999            CHANGED
1000        );
1001        assert_eq!(table.changes_from(&original).len(), 1);
1002        assert!(table.remove(Path::new(".gitignore")).expect("remove"));
1003        assert!(table.is_empty());
1004        assert_eq!(table.source_bytes(), 0);
1005        assert!(!table.remove(Path::new(".gitignore")).expect("missing is a no-op"));
1006    }
1007
1008    /// The budget admits a table charged exactly to it and refuses one byte more.
1009    #[test]
1010    fn the_budget_admits_its_own_size_and_refuses_one_byte_over_it() {
1011        let source = b"*.log\n".to_vec();
1012        let exact = retained_source_cost(Path::new("a"), &source);
1013        let mut at_budget = budgeted(Some(exact));
1014        assert_eq!(
1015            at_budget.upsert(Path::new("a/.gitignore"), source.clone()).expect("control path"),
1016            CHANGED
1017        );
1018        assert_eq!(at_budget.retained_cost(), exact);
1019
1020        let mut under_budget = budgeted(Some(exact - 1));
1021        assert_eq!(
1022            under_budget.upsert(Path::new("a/.gitignore"), source).expect("control path"),
1023            OVER_BUDGET
1024        );
1025        assert_eq!(under_budget.retained_cost(), 0);
1026        assert!(under_budget.contains(Path::new("a/.gitignore")));
1027        assert_eq!(
1028            under_budget.observation(),
1029            ControlObservation {
1030                limits: ControlLimits {
1031                    budget: Some(exact - 1),
1032                    line_limit: Some(DEFAULT_CONTROL_LINE_LIMIT),
1033                },
1034                applied: 0,
1035                refused: 1,
1036                refusals: vec![RefusedControl {
1037                    path: PathBuf::from("a/.gitignore"),
1038                    reason: ControlRefusalReason::Budget,
1039                }],
1040            }
1041        );
1042    }
1043
1044    #[test]
1045    fn a_refused_source_drops_the_rules_it_replaces_and_freed_budget_admits_it_later() {
1046        let mut table = ControlTable::default();
1047        let first = source_at_test_limit();
1048        assert_eq!(
1049            table.upsert(Path::new(".gitignore"), first.clone()).expect("control path"),
1050            CHANGED
1051        );
1052        assert_eq!(
1053            table
1054                .upsert(Path::new("nested/.gitignore"), b"*.log\n".to_vec())
1055                .expect("control path"),
1056            OVER_BUDGET
1057        );
1058        let before = table.clone();
1059
1060        // Replacing the root's source with one that no longer fits drops the old rules
1061        // rather than keeping rules that are no longer on disk.
1062        let mut grown = first;
1063        grown.push(b'\n');
1064        assert_eq!(
1065            table.upsert(Path::new(".gitignore"), grown).expect("control path"),
1066            OVER_BUDGET
1067        );
1068        assert!(table.is_empty());
1069        assert_eq!(table.changes_from(&before).len(), 1);
1070        assert_eq!(
1071            table.refusal_changes_from(&before),
1072            vec![(PathBuf::from(".gitignore"), None, Some(ControlRefusalReason::Budget))]
1073        );
1074
1075        // With the budget free, reading the nested file again admits it and lifts its refusal.
1076        assert_eq!(
1077            table
1078                .upsert(Path::new("nested/.gitignore"), b"*.log\n".to_vec())
1079                .expect("control path"),
1080            CHANGED
1081        );
1082        assert_eq!(table.refused_len(), 1);
1083        // Removing a refused file lifts its refusal too.
1084        assert!(table.remove(Path::new(".gitignore")).expect("remove refused"));
1085        assert_eq!(table.refused_len(), 0);
1086        table.assert_consistent();
1087    }
1088
1089    #[test]
1090    fn identical_sources_share_one_content_charge() {
1091        let source = b"target/\n*.log\nnode_modules/\n".to_vec();
1092        let mut table = ControlTable::default();
1093        table.upsert(Path::new("a/.gitignore"), source.clone()).expect("first holder");
1094        let one = table.retained_cost();
1095        table.upsert(Path::new("bb/.gitignore"), source.clone()).expect("second holder");
1096
1097        // The second directory pays for its key and nothing for content it shares.
1098        assert_eq!(table.retained_cost() - one, CONTROL_SOURCE_OVERHEAD + "bb".len());
1099        assert_eq!(table.source_bytes(), 2 * source.len());
1100        assert!(table.is_ignored(Path::new("a/debug.log"), false));
1101        assert!(table.is_ignored(Path::new("bb/debug.log"), false));
1102    }
1103
1104    /// One line at the limit applies; one byte longer refuses the whole source, before any
1105    /// parsing, so a hostile rule costs no matching work.
1106    #[test]
1107    fn the_line_limit_admits_its_own_length_and_refuses_one_byte_over_it() {
1108        let mut table = ControlTable::default();
1109        let at_limit = vec![b'a'; DEFAULT_CONTROL_LINE_LIMIT];
1110        assert_eq!(
1111            table.upsert(Path::new("a/.gitignore"), at_limit).expect("control path"),
1112            CHANGED
1113        );
1114        let over = [b"*.log\n".as_slice(), &vec![b'a'; DEFAULT_CONTROL_LINE_LIMIT + 1]].concat();
1115        assert_eq!(
1116            table.upsert(Path::new("b/.gitignore"), over).expect("control path"),
1117            OVER_LINE_LIMIT
1118        );
1119        assert!(!table.is_ignored(Path::new("b/debug.log"), false), "no rule of it applies");
1120        assert_eq!(
1121            table.refusals().collect::<Vec<_>>(),
1122            vec![RefusedControl {
1123                path: PathBuf::from("b/.gitignore"),
1124                reason: ControlRefusalReason::LineLimit,
1125            }]
1126        );
1127    }
1128
1129    /// Raising or lifting one limit never moves the other: a larger budget still refuses a
1130    /// long line, and no line limit still refuses a table past its budget.
1131    #[test]
1132    fn the_budget_and_the_line_limit_lift_independently() {
1133        let long = [b"*.log\n".as_slice(), &vec![b'a'; DEFAULT_CONTROL_LINE_LIMIT + 1]].concat();
1134        let large = source_at_test_limit();
1135
1136        let mut no_budget = budgeted(None);
1137        assert_eq!(
1138            no_budget.upsert(Path::new("big/.gitignore"), large.clone()).expect("path"),
1139            CHANGED
1140        );
1141        assert_eq!(
1142            no_budget.upsert(Path::new("c/.gitignore"), b"*.tmp\n".to_vec()).expect("path"),
1143            CHANGED
1144        );
1145        assert!(no_budget.retained_cost() > DEFAULT_CONTROL_BUDGET);
1146        assert_eq!(
1147            no_budget.upsert(Path::new(".gitignore"), long.clone()).expect("path"),
1148            OVER_LINE_LIMIT
1149        );
1150
1151        let mut raised_budget = budgeted(Some(16 * DEFAULT_CONTROL_BUDGET));
1152        assert_eq!(
1153            raised_budget.upsert(Path::new(".gitignore"), long.clone()).expect("path"),
1154            OVER_LINE_LIMIT
1155        );
1156
1157        let mut no_line_limit = ControlTable::with_limits(ControlLimits {
1158            line_limit: None,
1159            ..ControlLimits::default()
1160        });
1161        assert_eq!(no_line_limit.upsert(Path::new(".gitignore"), long).expect("path"), CHANGED);
1162        assert!(no_line_limit.is_ignored(Path::new("debug.log"), false));
1163        assert_eq!(
1164            no_line_limit.upsert(Path::new("big/.gitignore"), large).expect("path"),
1165            OVER_BUDGET
1166        );
1167        no_line_limit.assert_consistent();
1168    }
1169
1170    #[test]
1171    fn a_listing_of_refusals_is_bounded_and_says_when_it_is_truncated() {
1172        let mut table = budgeted(Some(0));
1173        let refused = crate::MAX_RETAINED_ISSUES + 1;
1174        for directory in 0..refused {
1175            let path = PathBuf::from(format!("d{directory:03}/.gitignore"));
1176            assert_eq!(table.upsert(&path, b"*\n".to_vec()).expect("control path"), OVER_BUDGET);
1177        }
1178        let observation = table.observation();
1179        assert_eq!(observation.refused, u64::try_from(refused).expect("small"));
1180        assert_eq!(observation.refusals.len(), crate::MAX_RETAINED_ISSUES);
1181        assert_eq!(observation.refusals[0].path, Path::new("d000/.gitignore"));
1182        assert!(!observation.lists_every_refusal());
1183        assert!(!observation.is_complete());
1184    }
1185
1186    #[test]
1187    fn control_identity_uses_standard_fnv1a_vectors() {
1188        assert_eq!(identity(b"").fingerprint, 0xcbf2_9ce4_8422_2325);
1189        assert_eq!(identity(b"a").fingerprint, 0xaf63_dc4c_8601_ec8c);
1190        assert_eq!(identity(b"foobar").fingerprint, 0x8594_4171_f739_67e8);
1191    }
1192
1193    #[test]
1194    fn nested_negation_and_control_removal_change_the_governed_subtree() {
1195        let mut table = ControlTable::default();
1196        table.upsert(Path::new(".gitignore"), b"*.log\n".to_vec()).expect("root");
1197        table.upsert(Path::new("docs/.gitignore"), b"!keep.log\n".to_vec()).expect("nested");
1198
1199        assert!(table.is_ignored(Path::new("debug.log"), false));
1200        assert!(table.is_ignored(Path::new("docs/other.log"), false));
1201        assert!(!table.is_ignored(Path::new("docs/keep.log"), false));
1202        assert_eq!(
1203            ControlTable::affected_subtree(Path::new("docs/.gitignore")).expect("scope"),
1204            Path::new("docs")
1205        );
1206
1207        table.remove(Path::new("docs/.gitignore")).expect("remove nested");
1208        assert!(table.is_ignored(Path::new("docs/keep.log"), false));
1209    }
1210
1211    #[test]
1212    fn ignored_parent_cannot_be_reincluded_from_inside_it() {
1213        let mut table = ControlTable::default();
1214        table.upsert(Path::new(".gitignore"), b"vendor/\n".to_vec()).expect("root");
1215        table
1216            .upsert(Path::new("vendor/.gitignore"), b"!keep.txt\n".to_vec())
1217            .expect("retained but inactive nested control");
1218
1219        assert!(table.is_ignored(Path::new("vendor"), true));
1220        assert!(table.is_ignored(Path::new("vendor/keep.txt"), false));
1221    }
1222}