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