Skip to main content

fdu_core/
classify.rs

1//! File-type recognition.
2//!
3//! Exact filenames and extensions are resolved from a [`TypeRegistry`]; the repository
4//! default is compiled at build time, and callers may parse a replacement once at setup.
5//! Classification may also use a caller-supplied bounded prefix for binary signatures,
6//! shebangs, modelines, ambiguous headers, and origin flags. Classification never parses
7//! rules or opens a file; the caller owns the optional read.
8//!
9//! # Extension levels
10//!
11//! A file name has up to three extensions, one per question, and each has its own
12//! functions:
13//!
14//! - **Raw**: [`derive_ext`], and [`ext_bucket`], which labels its `None` as
15//!   [`NO_EXTENSION`]. Any final dotted component counts if that extension is valid
16//!   Unicode, with no character or length restriction; a `.tar` before it is kept.
17//!   An invalid native extension has no bucketable string and maps to `None`.
18//!   The extension view, per-directory extension tallies,
19//!   and an unrecognized type's label use this level. It is the answer fdu gave before
20//!   registries existed.
21//! - **Logical**: [`logical_ext`] and [`NameClassification::logical_extension`]. This is
22//!   File Rollup Format's name-owned extension: up to two trailing components, each ASCII
23//!   alphanumeric and at most twelve bytes. A portable entry row reports it.
24//! - **Canonical**: [`TypeRegistry::canonical_ext`] and
25//!   [`NameClassification::canonical_extension`], from [`TypeRegistry::classify_name`].
26//!   This is the declared extension the logical one matched, whole or by its final
27//!   component. It is `None` when nothing declared matches, or when an exact filename wins
28//!   first.
29//!
30//! Where the levels part, with the compiled registry (`none` is `None`):
31//!
32//! | Name             | Raw       | Bucket     | Logical   | Canonical |
33//! | ---------------- | --------- | ---------- | --------- | --------- |
34//! | `archive.tar.gz` | `.tar.gz` | `.tar.gz`  | `.tar.gz` | `.tar.gz` |
35//! | `release.v2.zip` | `.zip`    | `.zip`     | `.v2.zip` | `.zip`    |
36//! | `file.c++`       | `.c++`    | `.c++`     | none      | none      |
37//! | `.gitignore`     | none      | `(none)`   | none      | none      |
38//! | `notes.`         | none      | `(none)`   | none      | none      |
39//!
40//! A unit test reads this table and checks every cell against the functions.
41
42use std::borrow::Cow;
43use std::collections::HashMap;
44use std::ffi::OsStr;
45use std::fmt;
46use std::path::Path;
47use std::sync::{Arc, LazyLock};
48
49mod file_rollup_manifest;
50mod file_type_detection;
51
52// The TOML cursor both manifest dialects read with. `include!`d by `build.rs` beside
53// `type_rule_manifest`, so it follows that file's rules for sharing with a build script.
54mod manifest_toml;
55
56// Compiled into the crate and `include!`d by `build.rs`, so rules supplied at run time
57// are read by exactly the code that read this repository's own manifest at build time.
58// This is an implementation detail of `TypeRegistry::from_manifest`, not a second public
59// configuration surface.
60mod type_rule_manifest;
61
62/// Broad analysis family for a recognized file type.
63#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
64pub enum ContentFamily {
65    /// Programming and configuration languages with code-like comment syntax.
66    Code,
67    /// Human-authored prose.
68    Prose,
69    /// Mixed markup whose reader-visible text may need projection.
70    Markup,
71    /// Structured textual or binary data.
72    Data,
73    /// Known binary formats that text analyzers must not open.
74    Binary,
75    /// No family was established by the bounded cascade.
76    Unknown,
77}
78
79impl ContentFamily {
80    /// Stable machine label used by reports and caches.
81    pub const fn as_str(self) -> &'static str {
82        match self {
83            Self::Code => "code",
84            Self::Prose => "prose",
85            Self::Markup => "markup",
86            Self::Data => "data",
87            Self::Binary => "binary",
88            Self::Unknown => "unknown",
89        }
90    }
91}
92
93/// Stable identifier for a known or preserved unknown file type.
94#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
95pub struct FileTypeId(String);
96
97impl FileTypeId {
98    /// Borrow the stable machine label.
99    pub fn as_str(&self) -> &str {
100        &self.0
101    }
102
103    pub(crate) fn from_cache(value: String) -> Self {
104        Self(value)
105    }
106}
107
108impl fmt::Display for FileTypeId {
109    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
110        formatter.write_str(&self.0)
111    }
112}
113
114/// Which bounded step established a classification.
115#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
116pub enum DetectionSource {
117    /// An exact basename rule matched.
118    ExactFilename,
119    /// A compound extension such as `.tar.gz` matched.
120    CompoundExtension,
121    /// An ordinary extension matched.
122    Extension,
123    /// An unresolved text file's interpreter matched a shebang rule.
124    Shebang,
125    /// A modeline in an unresolved text file named the language.
126    Modeline,
127    /// A required literal resolved an explicitly ambiguous extension.
128    AmbiguousContent,
129    /// A named binary or textual format signature matched.
130    FormatSignature,
131    /// A bounded prefix established a binary family.
132    ContentProbe,
133    /// No known rule matched; an extension, when present, is preserved in the type id.
134    Unknown,
135}
136
137impl DetectionSource {
138    /// Stable machine label used by reports and caches.
139    pub const fn as_str(self) -> &'static str {
140        match self {
141            Self::ExactFilename => "exact_filename",
142            Self::CompoundExtension => "compound_extension",
143            Self::Extension => "extension",
144            Self::Shebang => "shebang",
145            Self::Modeline => "modeline",
146            Self::AmbiguousContent => "ambiguous_content",
147            Self::FormatSignature => "format_signature",
148            Self::ContentProbe => "content_probe",
149            Self::Unknown => "unknown",
150        }
151    }
152}
153
154/// Coarse confidence attached to a classification decision.
155#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
156pub enum DetectionConfidence {
157    /// Exact filename, extension, or known binary format.
158    Certain,
159    /// A conventional shebang interpreter.
160    High,
161    /// A bounded content heuristic rather than a named format rule.
162    Heuristic,
163}
164
165impl DetectionConfidence {
166    /// Stable machine label used by reports and caches.
167    pub const fn as_str(self) -> &'static str {
168        match self {
169            Self::Certain => "certain",
170            Self::High => "high",
171            Self::Heuristic => "heuristic",
172        }
173    }
174}
175
176/// Orthogonal attributes discovered from bounded path and prefix checks.
177#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
178pub struct ClassificationFlags {
179    /// The prefix contains a conventional generated-file marker.
180    pub generated: bool,
181    /// A path component names a conventional vendored dependency tree.
182    pub vendored: bool,
183    /// The path names a conventional documentation tree or document basename.
184    pub documentation: bool,
185}
186
187/// Result of the cheapest-first file-type cascade.
188#[derive(Clone, PartialEq, Eq, Debug)]
189pub struct Classification {
190    /// Stable known id or `unknown:.ext` for an unrecognized extension.
191    pub file_type: FileTypeId,
192    /// Broad analyzer family.
193    pub family: ContentFamily,
194    /// Rule tier that produced this result.
195    pub source: DetectionSource,
196    /// Strength of the evidence used.
197    pub confidence: DetectionConfidence,
198    /// Orthogonal origin and purpose attributes.
199    pub flags: ClassificationFlags,
200}
201
202#[derive(Clone, Copy)]
203struct GeneratedRule {
204    id: &'static str,
205    family: ContentFamily,
206    extensions: &'static [&'static str],
207    filenames: &'static [&'static str],
208    shebangs: &'static [&'static str],
209    priority: u16,
210}
211
212include!(concat!(env!("OUT_DIR"), "/file_type_rules.rs"));
213
214/// The engine family a manifest's `family` name selects.
215///
216/// The manifest carries a name because it is a text file; the engine carries an enum
217/// because the analyzer set is closed. This is the one place the two meet, and
218/// `validate_manifest` has already rejected any name it does not admit.
219fn family_from_name(name: &str) -> Option<ContentFamily> {
220    match name {
221        "code" => Some(ContentFamily::Code),
222        "prose" => Some(ContentFamily::Prose),
223        "markup" => Some(ContentFamily::Markup),
224        "data" => Some(ContentFamily::Data),
225        "binary" => Some(ContentFamily::Binary),
226        "unknown" => Some(ContentFamily::Unknown),
227        _ => None,
228    }
229}
230
231/// One rule's identity, after its keys have been indexed.
232///
233/// Extensions and filenames are not retained: they exist to build the two indexes, and
234/// the cascade reads the indexes rather than the lists. `Cow` lets the compiled default
235/// borrow all rendered rule text while a caller-supplied registry owns its text.
236#[derive(Clone, PartialEq, Eq, Debug)]
237struct TypeRule {
238    id: Cow<'static, str>,
239    family: ContentFamily,
240    display_family: Option<Cow<'static, str>>,
241    display_group: Option<Cow<'static, str>>,
242    shebangs: Vec<Cow<'static, str>>,
243    priority: u16,
244}
245
246/// One ordered browsing group from a File Rollup registry.
247#[derive(Clone, PartialEq, Eq, Debug)]
248pub struct TypeGroup {
249    id: String,
250    label: String,
251    order: u32,
252}
253
254impl TypeGroup {
255    /// Stable machine identity.
256    pub fn id(&self) -> &str {
257        &self.id
258    }
259
260    /// Human-facing label.
261    pub fn label(&self) -> &str {
262        &self.label
263    }
264
265    /// Registry display order.
266    pub fn order(&self) -> u32 {
267        self.order
268    }
269}
270
271/// One ordered display family from a File Rollup registry.
272#[derive(Clone, PartialEq, Eq, Debug)]
273pub struct TypeFamily {
274    id: String,
275    label: String,
276    group_id: String,
277    order: u32,
278    extensions: Vec<String>,
279}
280
281impl TypeFamily {
282    /// Stable machine identity.
283    pub fn id(&self) -> &str {
284        &self.id
285    }
286
287    /// Human-facing label.
288    pub fn label(&self) -> &str {
289        &self.label
290    }
291
292    /// Owning browsing group identity.
293    pub fn group_id(&self) -> &str {
294        &self.group_id
295    }
296
297    /// Registry display order.
298    pub fn order(&self) -> u32 {
299        self.order
300    }
301
302    /// Complete canonical extensions declared by member kinds, with leading dots.
303    pub fn extensions(&self) -> &[String] {
304        &self.extensions
305    }
306}
307
308/// Registry-derived portable identity for one basename.
309#[derive(Clone, PartialEq, Eq, Debug)]
310pub struct NameClassification {
311    logical_extension: Option<String>,
312    canonical_extension: Option<String>,
313    kind_id: Option<String>,
314    family_id: Option<String>,
315    group_id: Option<String>,
316    content_family: ContentFamily,
317}
318
319impl NameClassification {
320    /// Name-owned logical extension.
321    pub fn logical_extension(&self) -> Option<&str> {
322        self.logical_extension.as_deref()
323    }
324
325    /// The declared extension that matched, or `None` when none did.
326    pub fn canonical_extension(&self) -> Option<&str> {
327        self.canonical_extension.as_deref()
328    }
329
330    /// Winning kind identity.
331    pub fn kind_id(&self) -> Option<&str> {
332        self.kind_id.as_deref()
333    }
334
335    /// Winning display family identity.
336    pub fn family_id(&self) -> Option<&str> {
337        self.family_id.as_deref()
338    }
339
340    /// Winning browsing group identity.
341    pub fn group_id(&self) -> Option<&str> {
342        self.group_id.as_deref()
343    }
344
345    /// Analyzer-oriented family.
346    pub fn content_family(&self) -> ContentFamily {
347        self.content_family
348    }
349}
350
351/// A set of file-type rules, indexed for the classification cascade.
352///
353/// The registry is a *value*, not a compiled-in table, so a consumer whose taxonomy
354/// differs from this repository's can supply its own without rebuilding the crate or
355/// reclassifying in its own language. The compiled default stays the default and the fast
356/// path: no file to find, no startup parse, and all compiled rule text is borrowed.
357///
358/// Its `fingerprint` is what makes a rule change safe. A snapshot and a content sidecar
359/// both record the fingerprint of the registry that produced them, and both refuse a
360/// cached answer when it moved — a classification change can move a file between
361/// families, which invalidates the metrics rather than merely their labels.
362#[derive(Clone, PartialEq, Eq, Debug)]
363pub struct TypeRegistry {
364    rules: Vec<TypeRule>,
365    /// Exact-basename lookup table storing indexes into `rules`.
366    by_filename: HashMap<Cow<'static, str>, u32>,
367    /// Extension lookup table storing indexes into `rules`.
368    by_extension: HashMap<Cow<'static, str>, u32>,
369    groups: Vec<TypeGroup>,
370    families: Vec<TypeFamily>,
371    registry_revision: Option<u32>,
372    case_insensitive_filenames: bool,
373    fingerprint: u64,
374}
375
376/// The registry compiled from this repository's manifest.
377///
378/// Built once, lazily, on first classification.
379static COMPILED_REGISTRY: LazyLock<Arc<TypeRegistry>> =
380    LazyLock::new(|| Arc::new(TypeRegistry::from_generated()));
381
382impl TypeRegistry {
383    /// The registry compiled from this repository's manifest.
384    pub fn compiled() -> &'static Self {
385        COMPILED_REGISTRY.as_ref()
386    }
387
388    /// Share the compiled registry with an owner that must retain it.
389    pub(crate) fn compiled_shared() -> Arc<Self> {
390        Arc::clone(&COMPILED_REGISTRY)
391    }
392
393    /// Build a registry from the `[[kind]]` manifest dialect.
394    ///
395    /// Validated by the same code that validates this repository's manifest at build
396    /// time, so a manifest this accepts would have compiled and one it rejects would have
397    /// failed the build with the same message.
398    pub fn from_manifest(source: &str) -> crate::Result<Self> {
399        let reject = |message: String| crate::Error::InvalidValue {
400            kind: "type rules",
401            value: String::new(),
402            hint: message,
403        };
404        if file_rollup_manifest::looks_like_registry(source) {
405            let parsed = file_rollup_manifest::parse(source).map_err(reject)?;
406            let mut groups: Vec<_> = parsed
407                .groups
408                .iter()
409                .map(|group| TypeGroup {
410                    id: group.id.clone(),
411                    label: group.label.clone(),
412                    order: group.order,
413                })
414                .collect();
415            groups.sort_by(|left, right| {
416                left.order.cmp(&right.order).then_with(|| left.id.cmp(&right.id))
417            });
418            let group_order: HashMap<_, _> =
419                groups.iter().map(|group| (group.id.as_str(), group.order)).collect();
420            let mut families: Vec<_> = parsed
421                .families
422                .iter()
423                .map(|family| TypeFamily {
424                    id: family.id.clone(),
425                    label: family.label.clone(),
426                    group_id: family.group.clone(),
427                    order: family.order,
428                    extensions: parsed
429                        .kinds
430                        .iter()
431                        .filter(|kind| kind.family == family.id)
432                        .flat_map(|kind| kind.extensions.iter().map(|value| format!(".{value}")))
433                        .collect(),
434                })
435                .collect();
436            families.sort_by(|left, right| {
437                group_order[&left.group_id.as_str()]
438                    .cmp(&group_order[&right.group_id.as_str()])
439                    .then_with(|| left.order.cmp(&right.order))
440                    .then_with(|| left.id.cmp(&right.id))
441            });
442            let family_groups: HashMap<_, _> = parsed
443                .families
444                .iter()
445                .map(|family| (family.id.as_str(), family.group.as_str()))
446                .collect();
447            let rules = parsed
448                .kinds
449                .iter()
450                .map(|kind| TypeRule {
451                    id: Cow::Owned(kind.id.clone()),
452                    family: family_from_name(&kind.content_family)
453                        .expect("validated content family"),
454                    display_family: (!kind.family.is_empty())
455                        .then(|| Cow::Owned(kind.family.clone())),
456                    display_group: Some(Cow::Owned(if kind.group.is_empty() {
457                        family_groups[&kind.family.as_str()].to_string()
458                    } else {
459                        kind.group.clone()
460                    })),
461                    shebangs: kind.shebangs.iter().cloned().map(Cow::Owned).collect(),
462                    priority: kind.priority,
463                })
464                .collect();
465            let mut registry = Self::indexed(
466                rules,
467                parsed.kinds.iter().map(|kind| kind.filenames.iter().cloned().map(Cow::Owned)),
468                parsed.kinds.iter().map(|kind| kind.extensions.iter().cloned().map(Cow::Owned)),
469                file_rollup_manifest::fingerprint(&parsed),
470            );
471            registry.groups = groups;
472            registry.families = families;
473            registry.registry_revision = Some(parsed.revision);
474            registry.case_insensitive_filenames = true;
475            return Ok(registry);
476        }
477        let parsed = type_rule_manifest::parse_manifest(source).map_err(reject)?;
478        type_rule_manifest::validate_manifest(&parsed).map_err(reject)?;
479        let rules = parsed
480            .iter()
481            .map(|rule| TypeRule {
482                id: Cow::Owned(rule.id.clone()),
483                family: family_from_name(&rule.family).expect("validated family"),
484                display_family: None,
485                display_group: None,
486                shebangs: rule.shebangs.iter().cloned().map(Cow::Owned).collect(),
487                priority: rule.priority,
488            })
489            .collect();
490        Ok(Self::indexed(
491            rules,
492            parsed.iter().map(|rule| rule.filenames.iter().cloned().map(Cow::Owned)),
493            parsed.iter().map(|rule| rule.extensions.iter().cloned().map(Cow::Owned)),
494            type_rule_manifest::manifest_fingerprint(&parsed),
495        ))
496    }
497
498    fn from_generated() -> Self {
499        let rules = GENERATED_RULES
500            .iter()
501            .map(|rule| TypeRule {
502                id: Cow::Borrowed(rule.id),
503                family: rule.family,
504                display_family: None,
505                display_group: None,
506                shebangs: rule.shebangs.iter().copied().map(Cow::Borrowed).collect(),
507                priority: rule.priority,
508            })
509            .collect();
510        Self::indexed(
511            rules,
512            GENERATED_RULES.iter().map(|rule| rule.filenames.iter().copied().map(Cow::Borrowed)),
513            GENERATED_RULES.iter().map(|rule| rule.extensions.iter().copied().map(Cow::Borrowed)),
514            TYPE_RULE_FINGERPRINT,
515        )
516    }
517
518    /// Index the exact-basename and extension tiers.
519    fn indexed(
520        rules: Vec<TypeRule>,
521        filenames: impl Iterator<Item = impl Iterator<Item = Cow<'static, str>>>,
522        extensions: impl Iterator<Item = impl Iterator<Item = Cow<'static, str>>>,
523        fingerprint: u64,
524    ) -> Self {
525        let by_filename = index_keys(filenames);
526        let by_extension = index_keys(extensions);
527        Self {
528            rules,
529            by_filename,
530            by_extension,
531            groups: Vec::new(),
532            families: Vec::new(),
533            registry_revision: None,
534            case_insensitive_filenames: false,
535            fingerprint,
536        }
537    }
538
539    /// Identity of the rules this registry holds.
540    pub fn fingerprint(&self) -> u64 {
541        self.fingerprint
542    }
543
544    /// File Rollup registry revision, absent for the compact fdu analyzer manifest.
545    pub fn registry_revision(&self) -> Option<u32> {
546        self.registry_revision
547    }
548
549    /// Ordered File Rollup browsing groups.
550    pub fn groups(&self) -> impl Iterator<Item = &TypeGroup> {
551        self.groups.iter()
552    }
553
554    /// Ordered File Rollup display families.
555    pub fn families(&self) -> impl Iterator<Item = &TypeFamily> {
556        self.families.iter()
557    }
558
559    /// Look up one File Rollup display family.
560    pub fn family(&self, id: &str) -> Option<&TypeFamily> {
561        self.families.iter().find(|family| family.id == id)
562    }
563
564    /// How many `[[kind]]` rules it holds.
565    pub fn rule_count(&self) -> usize {
566        self.rules.len()
567    }
568
569    /// Distinct exact basenames it claims.
570    pub fn filename_count(&self) -> usize {
571        self.by_filename.len()
572    }
573
574    /// Exact basename keys declared by this registry.
575    ///
576    /// The serving index uses this closed vocabulary to maintain bounded basename
577    /// tallies without changing the classification cascade or retaining arbitrary file
578    /// names. Callers that need case-insensitive presentation may normalize these keys;
579    /// classification itself keeps its existing exact spelling contract.
580    pub(crate) fn exact_filenames(&self) -> impl Iterator<Item = &str> {
581        self.by_filename.keys().map(AsRef::as_ref)
582    }
583
584    /// Distinct extensions it claims.
585    pub fn extension_count(&self) -> usize {
586        self.by_extension.len()
587    }
588
589    /// The declared extension a name matched, per File Rollup Format: `None` when no
590    /// extension declaration wins, including when an exact basename wins first.
591    ///
592    /// A file name owns its [`logical_ext`], which may retain two trailing components.
593    /// The registry owns the canonical level: a logical extension declared whole stays
594    /// whole, and otherwise its longest declared suffix matches. Thus `archive.tar.gz`
595    /// remains `.tar.gz`, while `release.v2.zip` becomes `.zip` when the registry declares
596    /// `zip` but not `v2.zip`. An undeclared extension has no canonical form:
597    /// `release.v2.widget` answers `None` here exactly as its
598    /// [`NameClassification::canonical_extension`] does, so a row and a registry lookup
599    /// cannot disagree about the same name.
600    pub fn canonical_ext(&self, name: &OsStr) -> Option<String> {
601        if name.to_str().and_then(|name| self.by_filename(name)).is_some() {
602            return None;
603        }
604        self.extension_match(name).map(|(extension, _)| extension.as_str().to_string())
605    }
606
607    /// The declared extension a name's logical extension matches, and its rule: the whole
608    /// logical extension first, then its final component.
609    ///
610    /// Held inline rather than allocated, because every classified file asks and most
611    /// files in a tree match nothing; see [`InlineExtension`].
612    fn extension_match(&self, name: &OsStr) -> Option<(InlineExtension, &TypeRule)> {
613        let logical = InlineExtension::logical(name)?;
614        let key =
615            logical.as_str().strip_prefix('.').expect("a logical extension starts with a dot");
616        if let Some(rule) = self.by_extension(key) {
617            return Some((logical, rule));
618        }
619        let (_, suffix) = key.rsplit_once('.')?;
620        self.by_extension(suffix).map(|rule| (InlineExtension::dotted(suffix), rule))
621    }
622
623    /// Every stable type identifier it can produce, in manifest order.
624    pub fn type_ids(&self) -> impl Iterator<Item = &str> {
625        self.rules.iter().map(|rule| rule.id.as_ref())
626    }
627
628    /// Classify one basename into portable File Rollup identity without opening a file.
629    pub fn classify_name(&self, name: &OsStr) -> NameClassification {
630        let logical_extension = logical_ext(name);
631        let filename_rule = name.to_str().and_then(|name| self.by_filename(name));
632        let extension_match = filename_rule.is_none().then(|| self.extension_match(name));
633        let (canonical_extension, rule) = match (filename_rule, extension_match.flatten()) {
634            (Some(rule), _) => (None, Some(rule)),
635            (None, Some((extension, rule))) => (Some(extension.as_str().to_string()), Some(rule)),
636            (None, None) => (None, None),
637        };
638        let fallback_group = self
639            .registry_revision
640            .is_some()
641            .then(|| self.groups.iter().find(|group| group.id == "other"))
642            .flatten()
643            .map(|group| group.id.clone());
644        NameClassification {
645            logical_extension,
646            canonical_extension,
647            kind_id: rule.map(|rule| rule.id.to_string()),
648            family_id: rule.and_then(|rule| rule.display_family.as_ref().map(ToString::to_string)),
649            group_id: rule
650                .and_then(|rule| rule.display_group.as_ref().map(ToString::to_string))
651                .or(fallback_group),
652            content_family: rule.map_or(ContentFamily::Unknown, |rule| rule.family),
653        }
654    }
655
656    fn by_filename(&self, name: &str) -> Option<&TypeRule> {
657        self.by_filename
658            .get(name)
659            .or_else(|| {
660                self.case_insensitive_filenames
661                    .then(|| name.to_ascii_lowercase())
662                    .and_then(|name| self.by_filename.get(name.as_str()))
663            })
664            .map(|index| &self.rules[*index as usize])
665    }
666
667    fn by_extension(&self, key: &str) -> Option<&TypeRule> {
668        self.by_extension.get(key).map(|index| &self.rules[*index as usize])
669    }
670
671    fn by_id(&self, id: &str) -> Option<&TypeRule> {
672        self.rules.iter().find(|rule| rule.id == id)
673    }
674}
675
676/// Resolve one validated, collision-free key tier into rule indexes.
677fn index_keys(
678    keys: impl Iterator<Item = impl Iterator<Item = Cow<'static, str>>>,
679) -> HashMap<Cow<'static, str>, u32> {
680    let mut table: HashMap<Cow<'static, str>, u32> = HashMap::new();
681    for (position, rule_keys) in keys.enumerate() {
682        let index = u32::try_from(position).expect("a manifest holds fewer than 4 billion rules");
683        for key in rule_keys {
684            let previous = table.insert(key, index);
685            debug_assert!(previous.is_none(), "validated registry keys are unique");
686        }
687    }
688    table
689}
690
691/// Fingerprint of the repository-owned rule manifest compiled into this build.
692pub const fn type_rule_fingerprint() -> u64 {
693    TYPE_RULE_FINGERPRINT
694}
695
696/// Human-facing name for a stable code-type identifier.
697///
698/// Reports and caches retain the identifier; only terminal language views use this
699/// presentation layer. Unknown identifiers are returned unchanged.
700pub(crate) fn human_language_name(id: &str) -> &str {
701    match id {
702        "rust" => "Rust",
703        "python" => "Python",
704        "javascript" => "JavaScript",
705        "typescript" => "TypeScript",
706        "go" => "Go",
707        "c" => "C",
708        "cpp" => "C++",
709        "csharp" => "C#",
710        "java" => "Java",
711        "kotlin" => "Kotlin",
712        "swift" => "Swift",
713        "ruby" => "Ruby",
714        "php" => "PHP",
715        "shell" => "Shell",
716        "powershell" => "PowerShell",
717        "lua" => "Lua",
718        "perl" => "Perl",
719        "r" => "R",
720        "dart" => "Dart",
721        "scala" => "Scala",
722        "haskell" => "Haskell",
723        "elixir" => "Elixir",
724        "erlang" => "Erlang",
725        "clojure" => "Clojure",
726        "fsharp" => "F#",
727        "ocaml" => "OCaml",
728        "objective-c" => "Objective-C",
729        "julia" => "Julia",
730        "zig" => "Zig",
731        "nim" => "Nim",
732        "solidity" => "Solidity",
733        "assembly" => "Assembly",
734        "sql" => "SQL",
735        "make" => "Make",
736        "dockerfile" => "Dockerfile",
737        "cmake" => "CMake",
738        "protobuf" => "Protocol Buffers",
739        "terraform" => "Terraform",
740        "nix" => "Nix",
741        "css" => "CSS",
742        _ => id,
743    }
744}
745
746/// Classify from path metadata only, against the compiled default rules.
747pub fn classify_path(path: &Path) -> Classification {
748    classify_path_with_prefix(path, None)
749}
750
751/// Classify with an optional bounded content prefix, against the compiled default rules.
752pub fn classify_path_with_prefix(path: &Path, prefix: Option<&[u8]>) -> Classification {
753    classify_with(TypeRegistry::compiled(), path, prefix)
754}
755
756/// Classify against a specific registry, with an optional bounded content prefix.
757///
758/// Known exact-name and ordinary extension matches avoid deep detection. The `.h`
759/// ambiguity and generated-file flag alone inspect their documented bounded prefix.
760/// Callers may pass a larger buffer; all content-dependent helpers enforce smaller
761/// internal limits.
762pub fn classify_with(
763    registry: &TypeRegistry,
764    path: &Path,
765    prefix: Option<&[u8]>,
766) -> Classification {
767    let name = path.file_name().unwrap_or_else(|| OsStr::new(""));
768    // The rules table is pure ASCII, so a name that is not UTF-8 matched no rule
769    // filename under the byte comparison this replaces either.
770    if let Some(rule) = name.to_str().and_then(|name| registry.by_filename(name)) {
771        return with_flags(
772            path,
773            prefix,
774            classified(rule, DetectionSource::ExactFilename, DetectionConfidence::Certain),
775        );
776    }
777
778    if let Some((extension, rule)) = registry.extension_match(name) {
779        let key =
780            extension.as_str().strip_prefix('.').expect("derived extensions start with a dot");
781        let source = if key.contains('.') {
782            DetectionSource::CompoundExtension
783        } else {
784            DetectionSource::Extension
785        };
786        let classification = if key == "h" {
787            prefix
788                .and_then(file_type_detection::resolve_c_header)
789                .and_then(|id| registry.by_id(id))
790                .map_or_else(
791                    || classified(rule, source, DetectionConfidence::Certain),
792                    |cpp| {
793                        classified(
794                            cpp,
795                            DetectionSource::AmbiguousContent,
796                            DetectionConfidence::High,
797                        )
798                    },
799                )
800        } else {
801            classified(rule, source, DetectionConfidence::Certain)
802        };
803        return with_flags(path, prefix, classification);
804    }
805
806    // An unrecognized type keeps the name's raw extension in its label, as it always has:
807    // `unknown:.c++` and `unknown:.tar.lz4` are the piles the types view and the content
808    // cache already know. Only declared matches follow the File Rollup eligibility rule.
809    let extension = derive_ext(name);
810    let unknown = || Classification {
811        file_type: FileTypeId(
812            extension
813                .as_deref()
814                .map_or_else(|| "unknown".to_string(), |ext| format!("unknown:{ext}")),
815        ),
816        family: ContentFamily::Unknown,
817        source: DetectionSource::Unknown,
818        confidence: DetectionConfidence::Heuristic,
819        flags: ClassificationFlags::default(),
820    };
821    let Some(prefix) = prefix else {
822        return with_flags(path, None, unknown());
823    };
824    let probed = file_type_detection::probe_unresolved(prefix);
825    match probed {
826        Some(file_type_detection::PrefixMatch::UnknownBinary) => {
827            return with_flags(
828                path,
829                Some(prefix),
830                Classification {
831                    family: ContentFamily::Binary,
832                    source: DetectionSource::ContentProbe,
833                    ..unknown()
834                },
835            );
836        }
837        Some(file_type_detection::PrefixMatch::Rule(id, source))
838            if registry.by_id(id).is_some_and(|rule| rule.family == ContentFamily::Binary) =>
839        {
840            let rule = registry.by_id(id).expect("guard established a registry rule");
841            return with_flags(
842                path,
843                Some(prefix),
844                classified(rule, source, DetectionConfidence::High),
845            );
846        }
847        _ => {}
848    }
849    if let Some(interpreter) = file_type_detection::shebang_interpreter(prefix) {
850        if let Some(rule) = registry
851            .rules
852            .iter()
853            .filter(|rule| rule.shebangs.iter().any(|shebang| shebang == interpreter))
854            .max_by_key(|rule| rule.priority)
855        {
856            return with_flags(
857                path,
858                Some(prefix),
859                classified(rule, DetectionSource::Shebang, DetectionConfidence::High),
860            );
861        }
862    }
863    let classification = match probed {
864        Some(file_type_detection::PrefixMatch::Rule(id, source)) => registry
865            .by_id(id)
866            .map_or_else(unknown, |rule| classified(rule, source, DetectionConfidence::High)),
867        Some(file_type_detection::PrefixMatch::UnknownBinary) => {
868            unreachable!("returned above")
869        }
870        None => unknown(),
871    };
872    with_flags(path, Some(prefix), classification)
873}
874
875fn classified(
876    rule: &TypeRule,
877    source: DetectionSource,
878    confidence: DetectionConfidence,
879) -> Classification {
880    Classification {
881        file_type: FileTypeId(rule.id.to_string()),
882        family: rule.family,
883        source,
884        confidence,
885        flags: ClassificationFlags::default(),
886    }
887}
888
889fn with_flags(
890    path: &Path,
891    prefix: Option<&[u8]>,
892    mut classification: Classification,
893) -> Classification {
894    classification.flags = file_type_detection::flags(path, prefix);
895    classification
896}
897
898/// Return the logical extension of a file name, lowercased and including its leading dot.
899///
900/// The logical level is name-owned rather than registry-owned. It retains at most two
901/// trailing dotted components when each is nonempty, ASCII alphanumeric, and at most
902/// twelve characters. A leading dot belongs to the basename, and an ineligible final
903/// component means the name has no logical extension.
904///
905/// This is the value a portable entry row reports, and the one declared extensions are
906/// matched against ([`TypeRegistry::canonical_ext`]). The extension view's aggregate
907/// buckets and an unrecognized type's label use the raw [`derive_ext`] instead.
908///
909/// ```
910/// use std::ffi::OsStr;
911/// use fdu_core::classify::logical_ext;
912///
913/// assert_eq!(logical_ext(OsStr::new("archive.tar.gz")).as_deref(), Some(".tar.gz"));
914/// assert_eq!(logical_ext(OsStr::new("release.v2.zip")).as_deref(), Some(".v2.zip"));
915/// assert_eq!(logical_ext(OsStr::new(".gitignore")), None);
916/// ```
917pub fn logical_ext(name: &OsStr) -> Option<String> {
918    logical_ext_native(name)
919}
920
921/// Extract the compound-tail extension from a file name, lowercased and including the
922/// leading dot.
923///
924/// "Compound tail" means `archive.tar.gz` yields `.tar.gz` rather than `.gz`, because
925/// the pair is what a human means by the file's type. Only `.tar` is folded this way;
926/// generalizing to an arbitrary set of compound stems belongs in the rule dialect, not
927/// in a hand-maintained list here.
928///
929/// Returns `None` for names with no usable extension, including dotfiles like
930/// `.gitignore` — a leading dot marks a hidden file, it does not introduce an extension.
931///
932/// This is the raw extension, and it is deliberately not the File Rollup one: any final
933/// valid-Unicode component counts without a character or length restriction, so
934/// `file.c++` is `.c++` here while
935/// [`logical_ext`] and [`TypeRegistry::canonical_ext`] give it none. It names the extension
936/// view's piles and the label of an unrecognized type, and it keeps the answer it had
937/// before registries existed for the detached and command-line callers that depend on it.
938/// An invalid UTF-8 or UTF-16 extension returns `None`. On Unix and Windows an invalid
939/// native stem does not prevent a valid extension from being extracted.
940///
941/// ```
942/// use std::ffi::OsStr;
943/// use fdu_core::classify::derive_ext;
944///
945/// assert_eq!(derive_ext(OsStr::new("archive.tar.gz")).as_deref(), Some(".tar.gz"));
946/// assert_eq!(derive_ext(OsStr::new("notes.MD")).as_deref(), Some(".md"));
947/// assert_eq!(derive_ext(OsStr::new("file.c++")).as_deref(), Some(".c++"));
948/// assert_eq!(derive_ext(OsStr::new(".gitignore")), None);
949/// assert_eq!(derive_ext(OsStr::new("README")), None);
950/// ```
951pub fn derive_ext(name: &OsStr) -> Option<String> {
952    derive_ext_native(name)
953}
954
955/// Label of the extension bucket a file belongs to, including the one for no extension.
956///
957/// This is the raw-extension pile every index tallies, whatever registry it classifies
958/// with: canonical extensions are a classification concept, derived per row at the read
959/// boundary from [`TypeRegistry::canonical_ext`] rather than retained as a second tally.
960///
961/// [`derive_ext`] answers "what is this name's extension", and `None` is the right answer
962/// for `Makefile`. A roll-up asks a different question — "which pile does this file's
963/// bytes go on" — and every file belongs on some pile. Dropping the `None` case meant the
964/// extension view's rows did not sum to the tree it was reporting on: a 263-byte fixture
965/// came back as three rows totalling 235, the missing 28 being a `Makefile`, and nothing
966/// in the output said so.
967///
968/// [`derive_ext`] always yields a leading dot, so this label cannot collide with a real
969/// extension however the tree is named.
970///
971/// ```
972/// use std::ffi::OsStr;
973/// use fdu_core::classify::{NO_EXTENSION, ext_bucket};
974///
975/// assert_eq!(ext_bucket(OsStr::new("archive.tar.gz")), ".tar.gz");
976/// assert_eq!(ext_bucket(OsStr::new("Makefile")), NO_EXTENSION);
977/// assert_eq!(ext_bucket(OsStr::new(".gitignore")), NO_EXTENSION);
978/// ```
979pub fn ext_bucket(name: &OsStr) -> String {
980    derive_ext(name).unwrap_or_else(|| NO_EXTENSION.to_string())
981}
982
983/// Extension-view label for files whose name carries no extension.
984///
985/// Parenthesised so it reads as a category rather than as a filename, and dot-free so it
986/// cannot be mistaken for — or collide with — an extension [`derive_ext`] produced.
987pub const NO_EXTENSION: &str = "(none)";
988
989#[cfg(unix)]
990fn derive_ext_native(name: &OsStr) -> Option<String> {
991    use std::os::unix::ffi::OsStrExt;
992
993    derive_ext_units(name.as_bytes(), b'.', |unit| unit.to_ascii_lowercase())
994        .and_then(|units| String::from_utf8(units).ok())
995}
996
997#[cfg(windows)]
998fn derive_ext_native(name: &OsStr) -> Option<String> {
999    use std::os::windows::ffi::OsStrExt;
1000
1001    let units: Vec<u16> = name.encode_wide().collect();
1002    derive_ext_units(&units, u16::from(b'.'), |unit| {
1003        // Lowercase the units that are single bytes and leave the rest alone. Asking
1004        // `try_from` whether it fits says that directly, where a comparison plus an
1005        // `expect` made the caller argue the bound was already checked.
1006        match u8::try_from(unit) {
1007            Ok(byte) => u16::from(byte.to_ascii_lowercase()),
1008            Err(_) => unit,
1009        }
1010    })
1011    .and_then(|extension| String::from_utf16(&extension).ok())
1012}
1013
1014#[cfg(not(any(unix, windows)))]
1015fn derive_ext_native(name: &OsStr) -> Option<String> {
1016    derive_ext_str(name.to_str()?)
1017}
1018
1019fn derive_ext_units<T: Copy + Eq + From<u8>>(
1020    name: &[T],
1021    dot: T,
1022    lowercase: impl Fn(T) -> T,
1023) -> Option<Vec<T>> {
1024    let searchable = if name.first() == Some(&dot) { &name[1..] } else { name };
1025    let dot_index = searchable.iter().rposition(|unit| *unit == dot)?;
1026    let (stem, last) = searchable.split_at(dot_index);
1027    if last.len() <= 1 {
1028        return None;
1029    }
1030
1031    let mut extension = Vec::new();
1032    if let Some(inner_dot) = stem.iter().rposition(|unit| *unit == dot) {
1033        let inner = &stem[inner_dot..];
1034        let tar = [
1035            dot,
1036            lowercase_ascii_unit(b't', &lowercase),
1037            lowercase_ascii_unit(b'a', &lowercase),
1038            lowercase_ascii_unit(b'r', &lowercase),
1039        ];
1040        if inner.len() == tar.len() && inner.iter().copied().map(&lowercase).eq(tar) {
1041            extension.extend(inner.iter().copied().map(&lowercase));
1042        }
1043    }
1044    extension.extend(last.iter().copied().map(lowercase));
1045    Some(extension)
1046}
1047
1048fn lowercase_ascii_unit<T: Copy + From<u8>>(byte: u8, lowercase: &impl Fn(T) -> T) -> T {
1049    lowercase(T::from(byte))
1050}
1051
1052#[cfg(not(any(unix, windows)))]
1053fn derive_ext_str(name: &str) -> Option<String> {
1054    // Skip a leading dot so dotfiles are not read as all-extension.
1055    let searchable = name.strip_prefix('.').unwrap_or(name);
1056    let dot = searchable.rfind('.')?;
1057    let (stem, last) = searchable.split_at(dot);
1058    if last.len() <= 1 {
1059        // A trailing dot with nothing after it is not an extension.
1060        return None;
1061    }
1062
1063    if let Some(inner_dot) = stem.rfind('.') {
1064        if stem[inner_dot..].eq_ignore_ascii_case(".tar") {
1065            return Some(format!(".tar{}", last.to_ascii_lowercase()));
1066        }
1067    }
1068
1069    Some(last.to_ascii_lowercase())
1070}
1071
1072#[cfg(unix)]
1073fn logical_ext_native(name: &OsStr) -> Option<String> {
1074    use std::os::unix::ffi::OsStrExt;
1075
1076    logical_ext_units(name.as_bytes(), b'.', |unit| unit.to_ascii_lowercase())
1077        .and_then(|units| String::from_utf8(units).ok())
1078}
1079
1080#[cfg(windows)]
1081fn logical_ext_native(name: &OsStr) -> Option<String> {
1082    use std::os::windows::ffi::OsStrExt;
1083
1084    let units: Vec<u16> = name.encode_wide().collect();
1085    logical_ext_units(&units, u16::from(b'.'), |unit| {
1086        // Lowercase the units that are single bytes and leave the rest alone. Asking
1087        // `try_from` whether it fits says that directly, where a comparison plus an
1088        // `expect` made the caller argue the bound was already checked.
1089        match u8::try_from(unit) {
1090            Ok(byte) => u16::from(byte.to_ascii_lowercase()),
1091            Err(_) => unit,
1092        }
1093    })
1094    .and_then(|extension| String::from_utf16(&extension).ok())
1095}
1096
1097#[cfg(not(any(unix, windows)))]
1098fn logical_ext_native(name: &OsStr) -> Option<String> {
1099    let units: Vec<char> = name.to_str()?.chars().collect();
1100    logical_ext_units(&units, '.', |unit| unit.to_ascii_lowercase())
1101        .map(|extension| extension.into_iter().collect())
1102}
1103
1104const MAX_LOGICAL_EXTENSION_COMPONENT: usize = 12;
1105
1106fn eligible_extension_component<T: Copy + Eq + From<u8>>(component: &[T]) -> bool {
1107    !component.is_empty()
1108        && component.len() <= MAX_LOGICAL_EXTENSION_COMPONENT
1109        && component.iter().all(|unit| {
1110            (b'0'..=b'9').chain(b'a'..=b'z').chain(b'A'..=b'Z').any(|byte| *unit == T::from(byte))
1111        })
1112}
1113
1114/// The components of a name's logical extension, without their dots: the inner one when
1115/// it is eligible, and the final one.
1116fn logical_ext_components<T: Copy + Eq + From<u8>>(
1117    name: &[T],
1118    dot: T,
1119) -> Option<(Option<&[T]>, &[T])> {
1120    let searchable = if name.first() == Some(&dot) { &name[1..] } else { name };
1121    let dot_index = searchable.iter().rposition(|unit| *unit == dot)?;
1122    let (stem, last) = searchable.split_at(dot_index);
1123    let last = &last[1..];
1124    if !eligible_extension_component(last) {
1125        return None;
1126    }
1127    let inner = stem
1128        .iter()
1129        .rposition(|unit| *unit == dot)
1130        .map(|inner_dot| &stem[inner_dot + 1..])
1131        .filter(|inner| eligible_extension_component(inner));
1132    Some((inner, last))
1133}
1134
1135fn logical_ext_units<T: Copy + Eq + From<u8>>(
1136    name: &[T],
1137    dot: T,
1138    lowercase: impl Fn(T) -> T,
1139) -> Option<Vec<T>> {
1140    let (inner, last) = logical_ext_components(name, dot)?;
1141    let mut extension = Vec::new();
1142    for component in inner.into_iter().chain([last]) {
1143        extension.push(dot);
1144        extension.extend(component.iter().copied().map(&lowercase));
1145    }
1146    Some(extension)
1147}
1148
1149/// The longest logical extension: two components of at most
1150/// [`MAX_LOGICAL_EXTENSION_COMPONENT`] units, each with its dot.
1151const MAX_LOGICAL_EXTENSION_BYTES: usize = 2 * (MAX_LOGICAL_EXTENSION_COMPONENT + 1);
1152
1153/// A logical or declared extension held inline: lowercase ASCII with its leading dot.
1154///
1155/// Declared extensions are matched for every classified file, and most files in a tree
1156/// match none. An eligible component is ASCII alphanumeric and bounded, so the key fits
1157/// here and matching allocates nothing: an unrecognized file pays only for the raw
1158/// extension its label names, and a recognized one only where a caller wants its
1159/// canonical extension as a `String`. Allocating the key made every unrecognized file on
1160/// an opened root cost one allocation more, which that route's allocation-slope guard
1161/// caught.
1162#[derive(Clone, Copy)]
1163struct InlineExtension {
1164    bytes: [u8; MAX_LOGICAL_EXTENSION_BYTES],
1165    len: usize,
1166}
1167
1168impl InlineExtension {
1169    const EMPTY: Self = Self { bytes: [0; MAX_LOGICAL_EXTENSION_BYTES], len: 0 };
1170
1171    /// The name's logical extension, spelled exactly as [`logical_ext`] spells it.
1172    fn logical(name: &OsStr) -> Option<Self> {
1173        #[cfg(unix)]
1174        let units = std::os::unix::ffi::OsStrExt::as_bytes(name);
1175        // A non-ASCII character is never a dot or an alphanumeric in any encoding, so a
1176        // Unicode name's UTF-8 bytes find the components its native units would. A
1177        // Windows name that is not Unicode is rare enough to take the allocating path.
1178        #[cfg(not(unix))]
1179        let Some(units) = name.to_str().map(str::as_bytes) else {
1180            return logical_ext(name).map(|extension| Self::EMPTY.with(extension.bytes()));
1181        };
1182        let (inner, last) = logical_ext_components(units, b'.')?;
1183        let mut extension = Self::EMPTY;
1184        for component in inner.into_iter().chain([last]) {
1185            extension = extension.with(*b".").with(component.iter().map(u8::to_ascii_lowercase));
1186        }
1187        Some(extension)
1188    }
1189
1190    /// `.component`, for a component already taken from a logical extension.
1191    fn dotted(component: &str) -> Self {
1192        Self::EMPTY.with(*b".").with(component.bytes())
1193    }
1194
1195    fn with(mut self, bytes: impl IntoIterator<Item = u8>) -> Self {
1196        for byte in bytes {
1197            self.bytes[self.len] = byte;
1198            self.len += 1;
1199        }
1200        self
1201    }
1202
1203    fn as_str(&self) -> &str {
1204        std::str::from_utf8(&self.bytes[..self.len]).expect("an eligible extension is ASCII")
1205    }
1206}
1207
1208#[cfg(test)]
1209mod tests {
1210    use super::type_rule_manifest::{MANIFEST_FAMILIES, ManifestRule, parse_manifest};
1211    use super::{
1212        ContentFamily, DetectionConfidence, DetectionSource, InlineExtension, NO_EXTENSION,
1213        TypeRegistry, classify_path, classify_path_with_prefix, classify_with, derive_ext,
1214        ext_bucket, family_from_name, logical_ext, type_rule_fingerprint,
1215    };
1216    use super::{GENERATED_RULES, human_language_name};
1217    use std::ffi::OsStr;
1218    use std::path::Path;
1219
1220    /// The manifest this build compiled, readable at test time as text.
1221    const DEFAULT_MANIFEST: &str = include_str!("../rules/file-types.toml");
1222
1223    fn default_manifest_rules() -> Vec<ManifestRule> {
1224        parse_manifest(DEFAULT_MANIFEST).expect("the repository's own manifest parses")
1225    }
1226
1227    #[test]
1228    fn every_validated_manifest_family_maps_to_an_engine_family() {
1229        for family in MANIFEST_FAMILIES {
1230            assert!(
1231                family_from_name(family).is_some(),
1232                "manifest validation admits {family:?}, but registry construction cannot map it"
1233            );
1234        }
1235    }
1236
1237    /// Assert both lookup tiers against their complete declarative source.
1238    fn registry_indexes_every_claim(registry: &TypeRegistry, rules: &[ManifestRule], label: &str) {
1239        fn filenames(rule: &ManifestRule) -> &Vec<String> {
1240            &rule.filenames
1241        }
1242        fn extensions(rule: &ManifestRule) -> &Vec<String> {
1243            &rule.extensions
1244        }
1245        /// One tier's key accessor, named so the array below stays readable.
1246        type KeysOf = fn(&ManifestRule) -> &Vec<String>;
1247        let tiers: [(KeysOf, _); 2] =
1248            [(filenames, &registry.by_filename), (extensions, &registry.by_extension)];
1249        for (keys_of, table) in tiers {
1250            let mut checked = 0;
1251            for expected in rules {
1252                for key in keys_of(expected) {
1253                    let indexed = &registry.rules[table[key.as_str()] as usize];
1254                    assert_eq!(
1255                        indexed.id, expected.id,
1256                        "{label}: key {key:?} resolves to {:?}, expected {:?}",
1257                        indexed.id, expected.id
1258                    );
1259                    checked += 1;
1260                }
1261            }
1262            assert!(checked > 0, "{label}: the rules produced no keys to check");
1263        }
1264    }
1265
1266    #[test]
1267    fn compiled_and_runtime_registries_index_every_manifest_claim() {
1268        let rules = default_manifest_rules();
1269        registry_indexes_every_claim(TypeRegistry::compiled(), &rules, "compiled");
1270        let parsed = TypeRegistry::from_manifest(DEFAULT_MANIFEST).expect("parses at run time");
1271        registry_indexes_every_claim(&parsed, &rules, "runtime");
1272    }
1273
1274    /// Parsing the default manifest at run time must reproduce the compiled registry.
1275    ///
1276    /// The migration assertion: the compiled table is a rendering of this text, so a
1277    /// runtime parse that disagreed with it anywhere would mean the two readers of one
1278    /// dialect had drifted -- which is exactly what sharing the parser is meant to
1279    /// prevent, and what no other test would notice.
1280    #[test]
1281    fn the_runtime_parsed_default_matches_the_compiled_one() {
1282        let compiled = TypeRegistry::compiled();
1283        let parsed = TypeRegistry::from_manifest(DEFAULT_MANIFEST).expect("parses at run time");
1284
1285        assert_eq!(parsed.fingerprint(), compiled.fingerprint(), "same text, same identity");
1286        assert_eq!(parsed.rule_count(), compiled.rule_count());
1287        assert_eq!(parsed.extension_count(), compiled.extension_count());
1288        assert_eq!(parsed.filename_count(), compiled.filename_count());
1289        assert_eq!(parsed.type_ids().collect::<Vec<_>>(), compiled.type_ids().collect::<Vec<_>>());
1290
1291        let mut probes: Vec<String> = Vec::new();
1292        for rule in default_manifest_rules() {
1293            probes.extend(rule.filenames.iter().cloned());
1294            probes.extend(rule.extensions.iter().map(|ext| format!("probe.{ext}")));
1295        }
1296        assert!(probes.len() > 100, "the manifest should offer a wide key set");
1297        for probe in probes {
1298            let path = Path::new(&probe);
1299            assert_eq!(
1300                classify_with(&parsed, path, None),
1301                classify_with(compiled, path, None),
1302                "{probe} classifies differently under the runtime-parsed default"
1303            );
1304        }
1305    }
1306
1307    /// Validation is tested for what it rejects: an accepted manifest proves less.
1308    #[test]
1309    fn a_registry_rejects_manifests_that_would_classify_ambiguously() {
1310        for (manifest, expected) in [
1311            ("", "at least one [[kind]] rule is required"),
1312            (
1313                "[[kind]]\nid = \"a\"\nfamily = \"code\"\n[[kind]]\nid = \"a\"\nfamily = \"code\"\n",
1314                "duplicate rule id",
1315            ),
1316            ("[[kind]]\nid = \"a\"\nfamily = \"pictures\"\n", "invalid family"),
1317            (
1318                "[[kind]]\nid = \"a\"\nfamily = \"code\"\nextensions = [\"q\"]\n[[kind]]\nid = \"b\"\nfamily = \"data\"\nextensions = [\"q\"]\n",
1319                "is assigned to both",
1320            ),
1321            ("[[kind]]\nid = \"A\"\nfamily = \"code\"\n", "invalid rule id"),
1322            (
1323                "[[kind]]\nid = \"a\"\nfamily = \"code\"\nextensions = [\".q\"]\n",
1324                "invalid extension",
1325            ),
1326            ("id = \"a\"\n", "field appears before [[kind]]"),
1327            ("[[kind]]\nid = a\n", "expected a quoted string"),
1328            ("[[kind]]\nid = \"a\"\nid = \"b\"\nfamily = \"code\"\n", "duplicate field \"id\""),
1329        ] {
1330            let error =
1331                TypeRegistry::from_manifest(manifest).expect_err("this manifest must be rejected");
1332            let message = error.to_string();
1333            assert!(message.contains(expected), "{message:?} should mention {expected:?}");
1334        }
1335    }
1336
1337    /// Supplied rules classify, and are a different registry by identity.
1338    #[test]
1339    fn supplied_rules_replace_the_compiled_taxonomy() {
1340        let registry = TypeRegistry::from_manifest(
1341            "[[kind]]\nid = \"notes\"\nfamily = \"prose\"\nextensions = [\"rs\"]\n",
1342        )
1343        .expect("a minimal manifest");
1344
1345        let ours = classify_with(&registry, Path::new("main.rs"), None);
1346        assert_eq!(ours.file_type.as_str(), "notes");
1347        assert_eq!(ours.family, ContentFamily::Prose);
1348
1349        // The compiled default is untouched by another registry existing.
1350        assert_eq!(classify_path(Path::new("main.rs")).file_type.as_str(), "rust");
1351
1352        // A type the supplied rules do not name falls to unknown rather than to the
1353        // default's answer: a registry is the whole taxonomy, not an overlay on one.
1354        assert_eq!(
1355            classify_with(&registry, Path::new("a.py"), None).source,
1356            DetectionSource::Unknown
1357        );
1358        assert_ne!(
1359            registry.fingerprint(),
1360            type_rule_fingerprint(),
1361            "different rules must invalidate a snapshot taken under the others"
1362        );
1363    }
1364
1365    #[test]
1366    fn file_rollup_v3_registry_supplies_display_taxonomy_and_content_classification() {
1367        let registry = TypeRegistry::from_manifest(
1368            r#"
1369schema_version = 3
1370registry_revision = 7
1371max_extension_components = 2
1372
1373[[group]]
1374id = "code"
1375label = "Code"
1376order = 10
1377
1378[[group]]
1379id = "other"
1380label = "Other"
1381order = 20
1382
1383[[family]]
1384id = "javascript"
1385label = "JavaScript"
1386group = "code"
1387order = 100
1388hue = 102.0
1389
1390[[kind]]
1391id = "javascript"
1392family = "javascript"
1393content_family = "code"
1394extensions = ["js", "js.map"]
1395filenames = []
1396shebangs = ["node"]
1397priority = 100
1398
1399[[kind]]
1400id = "make"
1401group = "other"
1402content_family = "code"
1403extensions = []
1404filenames = ["makefile"]
1405shebangs = []
1406priority = 100
1407"#,
1408        )
1409        .expect("File Rollup v3 registry parses");
1410
1411        assert_eq!(registry.registry_revision(), Some(7));
1412        assert_eq!(
1413            registry.groups().map(super::TypeGroup::id).collect::<Vec<_>>(),
1414            vec!["code", "other"]
1415        );
1416        let family = registry.family("javascript").expect("display family retained");
1417        assert_eq!(family.label(), "JavaScript");
1418        assert_eq!(family.group_id(), "code");
1419        assert_eq!(family.extensions(), &[".js", ".js.map"]);
1420
1421        let source_map = registry.classify_name(OsStr::new("bundle.js.map"));
1422        assert_eq!(source_map.kind_id(), Some("javascript"));
1423        assert_eq!(source_map.family_id(), Some("javascript"));
1424        assert_eq!(source_map.group_id(), Some("code"));
1425        assert_eq!(source_map.content_family(), ContentFamily::Code);
1426        assert_eq!(source_map.logical_extension(), Some(".js.map"));
1427        assert_eq!(source_map.canonical_extension(), Some(".js.map"));
1428
1429        let makefile = registry.classify_name(OsStr::new("Makefile"));
1430        assert_eq!(makefile.kind_id(), Some("make"));
1431        assert_eq!(makefile.family_id(), None);
1432        assert_eq!(makefile.group_id(), Some("other"));
1433        assert_eq!(makefile.logical_extension(), None);
1434        assert_eq!(makefile.canonical_extension(), None);
1435
1436        let unknown = registry.classify_name(OsStr::new("release.v2.widget"));
1437        assert_eq!(unknown.logical_extension(), Some(".v2.widget"));
1438        assert_eq!(unknown.canonical_extension(), None);
1439        // One canonical answer: the registry lookup agrees with the row for an undeclared
1440        // compound, a declared one, and a basename match.
1441        assert_eq!(registry.canonical_ext(OsStr::new("release.v2.widget")), None);
1442        assert_eq!(registry.canonical_ext(OsStr::new("bundle.js.map")).as_deref(), Some(".js.map"));
1443        assert_eq!(registry.canonical_ext(OsStr::new("Makefile")), None);
1444        assert_eq!(unknown.kind_id(), None);
1445        assert_eq!(unknown.family_id(), None);
1446        assert_eq!(unknown.group_id(), Some("other"));
1447        assert_eq!(unknown.content_family(), ContentFamily::Unknown);
1448    }
1449
1450    #[test]
1451    fn registry_identity_is_derived_from_semantics_not_formatting() {
1452        let compact = TypeRegistry::from_manifest(
1453            "[[kind]]\nid = \"notes\"\nfamily = \"prose\"\nextensions = [\"txt\"]\n",
1454        )
1455        .expect("compact manifest");
1456        let formatted = TypeRegistry::from_manifest(
1457            "# Equivalent rules with presentation-only differences.\r\n\r\n[[kind]]\r\n  id = \"notes\"\r\n  family = \"prose\"\r\n  extensions = [ \"txt\" ]\r\n",
1458        )
1459        .expect("formatted manifest");
1460        let changed = TypeRegistry::from_manifest(
1461            "[[kind]]\nid = \"notes\"\nfamily = \"prose\"\nextensions = [\"text\"]\n",
1462        )
1463        .expect("different manifest");
1464
1465        assert_eq!(compact.fingerprint(), formatted.fingerprint());
1466        assert_ne!(compact.fingerprint(), changed.fingerprint());
1467    }
1468
1469    /// A key moved between classification tiers changes what files are, so it changes
1470    /// identity. Without array boundaries in the hash the two registries below shared one
1471    /// fingerprint, and a snapshot recorded under either was served under the other.
1472    #[test]
1473    fn file_rollup_registry_identity_sees_a_key_move_between_tiers() {
1474        let registry = |extensions: &str, filenames: &str| {
1475            TypeRegistry::from_manifest(&format!(
1476                "schema_version = 3\nregistry_revision = 1\nmax_extension_components = 2\n\n\
1477                 [[group]]\nid = \"other\"\nlabel = \"Other\"\norder = 10\n\n\
1478                 [[kind]]\nid = \"notes\"\ngroup = \"other\"\ncontent_family = \"prose\"\n\
1479                 extensions = [{extensions}]\nfilenames = [{filenames}]\nshebangs = []\n\
1480                 priority = 100\n"
1481            ))
1482            .expect("File Rollup v3 registry parses")
1483        };
1484        let as_extension = registry("\"md\"", "");
1485        let as_filename = registry("", "\"md\"");
1486
1487        assert_eq!(as_extension.classify_name(OsStr::new("README.md")).kind_id(), Some("notes"));
1488        assert_eq!(as_filename.classify_name(OsStr::new("README.md")).kind_id(), None);
1489        assert_ne!(as_extension.fingerprint(), as_filename.fingerprint());
1490    }
1491
1492    /// One registry written as schema 3 and as schema 4 is one classification. Schema 4
1493    /// adds icons, and the same revision repaints a family; neither changes what a file
1494    /// is, so a snapshot recorded under either document is served under the other.
1495    #[test]
1496    fn a_schema_three_registry_and_its_schema_four_repaint_share_one_identity() {
1497        let registry = |schema: u32, group_icon: &str, hue: &str, family_icon: &str| {
1498            TypeRegistry::from_manifest(&format!(
1499                "schema_version = {schema}\nregistry_revision = 3\nmax_extension_components = 2\n\n\
1500                 [[group]]\nid = \"code\"\nlabel = \"Code\"\norder = 10\n{group_icon}\n\
1501                 [[group]]\nid = \"other\"\nlabel = \"Other\"\norder = 20\n{group_icon}\n\
1502                 [[family]]\nid = \"swift\"\nlabel = \"Swift\"\ngroup = \"code\"\norder = 190\n\
1503                 linguist = \"Swift\"\nlinguist_color = \"#f05138\"\nhue = {hue}\n{family_icon}\n\
1504                 [[kind]]\nid = \"swift\"\nfamily = \"swift\"\ncontent_family = \"code\"\n\
1505                 extensions = [\"swift\"]\nfilenames = []\nshebangs = []\npriority = 100\n"
1506            ))
1507            .unwrap_or_else(|error| panic!("schema {schema}: {error}"))
1508        };
1509        let three = registry(3, "", "31.62", "");
1510        let four = registry(
1511            4,
1512            "icon = \"alignLeft\"",
1513            "52.3",
1514            "deviation = \"\"\"Moved off svelte's hue.\"\"\"\nicon = \"fileText\"",
1515        );
1516
1517        assert_eq!(three.fingerprint(), four.fingerprint());
1518        assert_eq!(three.registry_revision(), four.registry_revision());
1519        let swift = OsStr::new("App.swift");
1520        assert_eq!(three.classify_name(swift).family_id(), Some("swift"));
1521        assert_eq!(four.classify_name(swift).family_id(), Some("swift"));
1522    }
1523
1524    /// A name that is not valid UTF-8 must classify as unknown, as it did when the tier
1525    /// compared raw `OsStr` bytes against an all-ASCII rules table.
1526    #[test]
1527    fn non_utf8_names_still_reach_the_unknown_tier() {
1528        #[cfg(unix)]
1529        {
1530            use std::os::unix::ffi::OsStrExt;
1531            let name = OsStr::from_bytes(b"weird\xff\xfename");
1532            let classification = classify_path(Path::new(name));
1533            assert_eq!(classification.source, DetectionSource::Unknown);
1534            assert_eq!(classification.family, ContentFamily::Unknown);
1535        }
1536    }
1537
1538    #[test]
1539    fn plain_extensions_lowercase() {
1540        assert_eq!(derive_ext(OsStr::new("main.RS")).as_deref(), Some(".rs"));
1541        assert_eq!(derive_ext(OsStr::new("Photo.JPEG")).as_deref(), Some(".jpeg"));
1542    }
1543
1544    /// The module documentation's extension-level table is read back and checked cell by
1545    /// cell, so a change to any level fails here instead of leaving the table wrong. The
1546    /// engine architecture document repeats the table and names this test as its source.
1547    #[test]
1548    fn module_documentation_extension_table_matches_the_functions() {
1549        let rules = TypeRegistry::compiled();
1550        let mut rows = 0;
1551        for line in include_str!("classify.rs").lines().take_while(|line| line.starts_with("//!")) {
1552            let row = line.trim_start_matches("//!").trim();
1553            if !row.starts_with("| `") {
1554                continue;
1555            }
1556            let cells: Vec<Option<&str>> = row
1557                .trim_matches('|')
1558                .split('|')
1559                .map(|cell| match cell.trim() {
1560                    "none" => None,
1561                    cell => Some(cell.trim_matches('`')),
1562                })
1563                .collect();
1564            let [Some(name), raw, bucket, logical, canonical] = cells.as_slice() else {
1565                panic!("an example row has a name and four levels: {row}");
1566            };
1567            let name = OsStr::new(name);
1568            assert_eq!(derive_ext(name).as_deref(), *raw, "raw {name:?}");
1569            assert_eq!(Some(ext_bucket(name).as_str()), *bucket, "bucket {name:?}");
1570            assert_eq!(logical_ext(name).as_deref(), *logical, "logical {name:?}");
1571            assert_eq!(rules.canonical_ext(name).as_deref(), *canonical, "canonical {name:?}");
1572            rows += 1;
1573        }
1574        assert_eq!(rows, 5, "every example row in the module documentation was read");
1575    }
1576
1577    /// Three extensions answer three questions, and these names are where they part.
1578    ///
1579    /// `logical_ext` is the File Rollup observation, `canonical_ext` the declared extension
1580    /// that matched, and `derive_ext` the raw extension the extension view and unknown type
1581    /// labels have always used. The eligibility rule once leaked into `derive_ext`, moving
1582    /// `file.c++` and `résumé.tëxt` into `(none)` while its documentation said nothing had
1583    /// changed; and a registry lookup once gave an undeclared extension a canonical form
1584    /// that the same name's row did not have.
1585    #[test]
1586    fn logical_canonical_and_raw_extensions_answer_different_questions() {
1587        let rules = TypeRegistry::compiled();
1588        for (name, logical, canonical, raw) in [
1589            ("archive.tar.gz", Some(".tar.gz"), Some(".tar.gz"), Some(".tar.gz")),
1590            ("release.v2.zip", Some(".v2.zip"), Some(".zip"), Some(".zip")),
1591            ("bundle.umd.min.js", Some(".min.js"), Some(".js"), Some(".js")),
1592            (".eslintrc.json", Some(".json"), Some(".json"), Some(".json")),
1593            (".gitignore", None, None, None),
1594            ("trailing.", None, None, None),
1595            ("notes.", None, None, None),
1596            // Undeclared compound: no canonical form; the raw extension is its final part.
1597            ("release.v2.widget", Some(".v2.widget"), None, Some(".widget")),
1598            // Ineligible for File Rollup, yet each is still a raw extension of its own.
1599            ("file.c++", None, None, Some(".c++")),
1600            ("notes.tar.gz~", None, None, Some(".tar.gz~")),
1601            ("a.b-c", None, None, Some(".b-c")),
1602            ("x.py_", None, None, Some(".py_")),
1603            ("x.abcdefghijklm", None, None, Some(".abcdefghijklm")),
1604            ("résumé.tëxt", None, None, Some(".tëxt")),
1605        ] {
1606            let name = OsStr::new(name);
1607            assert_eq!(logical_ext(name).as_deref(), logical, "logical {name:?}");
1608            assert_eq!(
1609                InlineExtension::logical(name).as_ref().map(InlineExtension::as_str),
1610                logical,
1611                "inline logical {name:?}"
1612            );
1613            assert_eq!(rules.canonical_ext(name).as_deref(), canonical, "canonical {name:?}");
1614            assert_eq!(
1615                rules.classify_name(name).canonical_extension(),
1616                canonical,
1617                "a row and a registry lookup agree about {name:?}"
1618            );
1619            assert_eq!(derive_ext(name).as_deref(), raw, "raw {name:?}");
1620            assert_eq!(ext_bucket(name), raw.unwrap_or(NO_EXTENSION), "bucket {name:?}");
1621        }
1622    }
1623
1624    /// The inline matching key spells what `logical_ext` does, at each of its bounds.
1625    #[test]
1626    fn inline_logical_extension_spells_what_logical_ext_does() {
1627        let twelve = "abcdefghijkl";
1628        let names = [
1629            "MAIN.RS".to_string(),
1630            "Archive.TAR.GZ".to_string(),
1631            // Both components at the length bound, so the key fills its buffer.
1632            format!("x.{twelve}.{}", twelve.to_uppercase()),
1633            format!("x.{twelve}m.{twelve}"),
1634            format!("x.{twelve}.{twelve}m"),
1635            ".hidden".to_string(),
1636            ".hidden.TOML".to_string(),
1637            "..".to_string(),
1638            ".".to_string(),
1639            String::new(),
1640            "a.".to_string(),
1641            "a..b".to_string(),
1642            "résumé.v2.tëxt".to_string(),
1643            "naïve.v2.Md".to_string(),
1644        ];
1645        let inline = |name: &OsStr| InlineExtension::logical(name).map(|e| e.as_str().to_owned());
1646        for name in &names {
1647            let name = OsStr::new(name);
1648            assert_eq!(inline(name), logical_ext(name), "{name:?}");
1649        }
1650
1651        #[cfg(unix)]
1652        {
1653            use std::os::unix::ffi::OsStrExt;
1654            let name = OsStr::from_bytes(b"caf\xe9.v2.TXT");
1655            assert_eq!(logical_ext(name).as_deref(), Some(".v2.txt"));
1656            assert_eq!(inline(name), logical_ext(name));
1657        }
1658        #[cfg(windows)]
1659        {
1660            use std::os::windows::ffi::OsStringExt;
1661            let units: Vec<u16> = [0xD800].into_iter().chain(".v2.TXT".encode_utf16()).collect();
1662            let name = std::ffi::OsString::from_wide(&units);
1663            assert_eq!(logical_ext(&name).as_deref(), Some(".v2.txt"));
1664            assert_eq!(inline(&name), logical_ext(&name));
1665        }
1666    }
1667
1668    #[test]
1669    fn tar_pairs_fold_into_one_extension() {
1670        assert_eq!(derive_ext(OsStr::new("archive.tar.gz")).as_deref(), Some(".tar.gz"));
1671        assert_eq!(derive_ext(OsStr::new("archive.tar.zst")).as_deref(), Some(".tar.zst"));
1672        assert_eq!(derive_ext(OsStr::new("archive.TAR.BZ2")).as_deref(), Some(".tar.bz2"));
1673        // Only .tar folds; an unrelated inner segment is not part of the extension.
1674        assert_eq!(derive_ext(OsStr::new("release.v2.zip")).as_deref(), Some(".zip"));
1675    }
1676
1677    #[test]
1678    fn names_without_a_usable_extension() {
1679        assert_eq!(derive_ext(OsStr::new("README")), None);
1680        assert_eq!(derive_ext(OsStr::new(".gitignore")), None);
1681        assert_eq!(derive_ext(OsStr::new(".bashrc")), None);
1682        assert_eq!(derive_ext(OsStr::new("trailing.")), None);
1683        assert_eq!(derive_ext(OsStr::new("")), None);
1684    }
1685
1686    #[test]
1687    fn dotfiles_with_a_real_extension_keep_it() {
1688        assert_eq!(derive_ext(OsStr::new(".eslintrc.json")).as_deref(), Some(".json"));
1689    }
1690
1691    #[test]
1692    fn compiled_rules_cover_exact_extension_compound_and_unknown_paths() {
1693        let makefile = classify_path(Path::new("Makefile"));
1694        assert_eq!(makefile.file_type.as_str(), "make");
1695        assert_eq!(makefile.source, DetectionSource::ExactFilename);
1696
1697        let rust = classify_path(Path::new("src/lib.RS"));
1698        assert_eq!(rust.file_type.as_str(), "rust");
1699        assert_eq!(rust.family, ContentFamily::Code);
1700        assert_eq!(rust.source, DetectionSource::Extension);
1701
1702        let archive = classify_path(Path::new("source.tar.ZST"));
1703        assert_eq!(archive.file_type.as_str(), "archive");
1704        assert_eq!(archive.source, DetectionSource::CompoundExtension);
1705
1706        let unknown = classify_path(Path::new("sample.widget"));
1707        assert_eq!(unknown.file_type.as_str(), "unknown:.widget");
1708        assert_eq!(unknown.family, ContentFamily::Unknown);
1709        // An unrecognized type keeps its raw extension, even one File Rollup cannot name.
1710        assert_eq!(classify_path(Path::new("file.c++")).file_type.as_str(), "unknown:.c++");
1711        assert_ne!(type_rule_fingerprint(), 0);
1712    }
1713
1714    #[test]
1715    fn every_code_rule_has_a_canonical_human_language_name() {
1716        for rule in GENERATED_RULES.iter().filter(|rule| rule.family == ContentFamily::Code) {
1717            assert_ne!(
1718                human_language_name(rule.id),
1719                rule.id,
1720                "code rule {} needs a human-facing language name",
1721                rule.id
1722            );
1723        }
1724
1725        assert_eq!(human_language_name("css"), "CSS");
1726        assert_eq!(human_language_name("cpp"), "C++");
1727        assert_eq!(human_language_name("csharp"), "C#");
1728        assert_eq!(human_language_name("javascript"), "JavaScript");
1729        assert_eq!(human_language_name("powershell"), "PowerShell");
1730        assert_eq!(human_language_name("protobuf"), "Protocol Buffers");
1731        assert_eq!(human_language_name("unknown"), "unknown");
1732    }
1733
1734    #[test]
1735    fn bounded_prefix_resolves_shebangs_and_unknown_binary_files() {
1736        let python = classify_path_with_prefix(
1737            Path::new("script"),
1738            Some(b"#!/usr/bin/env -S python3 -I\nprint('ok')\n"),
1739        );
1740        assert_eq!(python.file_type.as_str(), "python");
1741        assert_eq!(python.source, DetectionSource::Shebang);
1742        assert_eq!(python.confidence, DetectionConfidence::High);
1743
1744        let binary = classify_path_with_prefix(Path::new("payload.unknown"), Some(b"abc\0def"));
1745        assert_eq!(binary.file_type.as_str(), "unknown:.unknown");
1746        assert_eq!(binary.family, ContentFamily::Binary);
1747        assert_eq!(binary.source, DetectionSource::ContentProbe);
1748
1749        let spoofed = classify_path_with_prefix(
1750            Path::new("payload"),
1751            Some(b"#!/usr/bin/env python3\ntext\0binary"),
1752        );
1753        assert_eq!(spoofed.family, ContentFamily::Binary);
1754        assert_eq!(spoofed.source, DetectionSource::ContentProbe);
1755    }
1756
1757    #[test]
1758    fn bounded_deep_detection_is_explainable() {
1759        let c = classify_path_with_prefix(Path::new("include/value.h"), Some(b"int value;\n"));
1760        assert_eq!(c.file_type.as_str(), "c");
1761        assert_eq!(c.source, DetectionSource::Extension);
1762
1763        let cpp = classify_path_with_prefix(
1764            Path::new("include/value.h"),
1765            Some(b"namespace demo { constexpr int value = 1; }\n"),
1766        );
1767        assert_eq!(cpp.file_type.as_str(), "cpp");
1768        assert_eq!(cpp.source, DetectionSource::AmbiguousContent);
1769        assert_eq!(cpp.confidence, DetectionConfidence::High);
1770
1771        let modeline = classify_path_with_prefix(
1772            Path::new("script.unknown"),
1773            Some(b"# vim: set filetype=rust:\nfn main() {}\n"),
1774        );
1775        assert_eq!(modeline.file_type.as_str(), "rust");
1776        assert_eq!(modeline.source, DetectionSource::Modeline);
1777
1778        let xml = classify_path_with_prefix(
1779            Path::new("document.unknown"),
1780            Some(b"<?xml version=\"1.0\"?><root/>"),
1781        );
1782        assert_eq!(xml.file_type.as_str(), "xml");
1783        assert_eq!(xml.source, DetectionSource::FormatSignature);
1784
1785        let manual = classify_path_with_prefix(
1786            Path::new("fdu.1"),
1787            Some(b".TH FDU 1\n.SH NAME\nfdu - disk usage\n"),
1788        );
1789        assert_eq!(manual.file_type.as_str(), "manpage");
1790
1791        let pdf = classify_path_with_prefix(Path::new("download"), Some(b"%PDF-1.7\n"));
1792        assert_eq!(pdf.file_type.as_str(), "pdf");
1793        assert_eq!(pdf.family, ContentFamily::Binary);
1794    }
1795
1796    #[cfg(unix)]
1797    #[test]
1798    fn ascii_extension_survives_non_unicode_stem() {
1799        use std::ffi::OsString;
1800        use std::os::unix::ffi::OsStringExt;
1801
1802        let name = OsString::from_vec(vec![b'n', 0xff, b'.', b'R', b'S']);
1803        assert_eq!(derive_ext(&name).as_deref(), Some(".rs"));
1804    }
1805
1806    #[cfg(windows)]
1807    #[test]
1808    fn ascii_extension_survives_unpaired_wide_stem() {
1809        use std::ffi::OsString;
1810        use std::os::windows::ffi::OsStringExt;
1811
1812        let name = OsString::from_wide(&[
1813            u16::from(b'n'),
1814            0xd800,
1815            u16::from(b'.'),
1816            u16::from(b'R'),
1817            u16::from(b'S'),
1818        ]);
1819        assert_eq!(derive_ext(&name).as_deref(), Some(".rs"));
1820    }
1821}