Skip to main content

teamcraft/
cadrefs.rs

1//! References inside CAD files: an assembly names its component files, a
2//! drawing the model it documents, a STEP file its external parts, a KiCad
3//! project its sheets.
4//!
5//! The names are found without the CAD programs' own libraries: TeamCraft
6//! scans the file for text – ASCII/UTF-8 and UTF-16 (Windows programs such
7//! as SolidWorks, Inventor, Solid Edge and CATIA store paths that way) – that
8//! ends with the extension of a CAD file (`Bracket.SLDPRT`,
9//! `C:\Rover\Motor mount.ipt`, `'nozzle.stp'`, `"power.kicad_sch"`). Paths
10//! are reduced to the file name. UTF-16 strings with a length prefix (as
11//! Inventor stores its references) are also read by their length, so a
12//! binary field right after the name cannot hide it. The result is a list of referenced file
13//! names; `services::references` matches them against the files in the
14//! vault.
15//!
16//! Two containers are read by their structure instead: NX parts
17//! (`nxpart`: only the reference streams) and SOLIDWORKS 2015+ files
18//! (`swpart`: the component tree of an assembly, otherwise the document
19//! manager's section – both inflated from their compressed sections). The
20//! rest of such a file (history, old names) is never scanned.
21//!
22//! For those two – and for STEP assemblies with external references – the
23//! scan also tells how often an assembly places each component
24//! ([`instance_counts`], stored as `file_references.instances`); every
25//! other format gives names only.
26//!
27//! For every other file this is a heuristic: a name that a CAD program keeps
28//! in its file after the component was removed is still found, and
29//! compressed sections of unknown containers are not read. The revision page
30//! therefore shows the names as "found in the file".
31#![deny(
32    clippy::unwrap_used,
33    clippy::expect_used,
34    clippy::indexing_slicing,
35    clippy::panic,
36    clippy::unreachable
37)]
38
39use std::collections::{HashMap, HashSet};
40use std::io::{Read, Seek};
41use std::path::Path;
42
43use crate::cadformat::{self, FormatKind};
44
45/// Bytes scanned per file.
46pub const MAX_SCAN: u64 = 32 * 1024 * 1024;
47/// References kept per file.
48const MAX_REFERENCES: usize = 500;
49/// Longest file name accepted.
50const MAX_NAME: usize = 160;
51
52/// Whether files of this format are scanned for references: assemblies,
53/// drawings, multi-purpose models (NX, Fusion, FreeCAD, …), STEP/JT (external
54/// references), PLM XML and KiCad projects/schematics.
55pub fn scans(format_id: &str) -> bool {
56    match cadformat::by_id(format_id) {
57        Some(f) => {
58            matches!(
59                f.kind,
60                FormatKind::Assembly | FormatKind::Drawing | FormatKind::Model
61            ) || matches!(
62                f.id,
63                "step" | "jt" | "plmxml" | "kicad-project" | "kicad-schematic"
64            )
65        }
66        None => false,
67    }
68}
69
70/// Whether a file name ends with the extension of a file an assembly or a
71/// drawing can reference (parts, assemblies, drawings, models, exchange,
72/// mesh, visualisation and electronics formats – not simulation models or
73/// results, documents, templates
74/// or archives). Fluent meshes (`.msh.h5`, `.msh.gz`) are solver input like
75/// the plain `.msh`, which the name alone does not classify: not collected.
76fn is_referencable(name: &str) -> bool {
77    cadformat::detect(name, &[]).is_some_and(|f| {
78        !f.id.starts_with("fluent-")
79            && !matches!(
80                f.kind,
81                FormatKind::Document
82                    | FormatKind::Other
83                    | FormatKind::Simulation
84                    | FormatKind::Result
85                    | FormatKind::Manufacturing
86            )
87    })
88}
89
90/// Lower-case extensions of all referencable formats, longest first.
91fn extensions() -> Vec<&'static str> {
92    let mut exts: Vec<&'static str> = cadformat::FORMATS
93        .iter()
94        .flat_map(|f| f.extensions.iter().copied())
95        .filter(|e| is_referencable(&format!("x.{e}")))
96        .collect();
97    exts.sort_by_key(|e| std::cmp::Reverse(e.len()));
98    exts.dedup();
99    exts
100}
101
102/// Characters that end a file name when walking back from its extension.
103fn is_name_boundary(c: char) -> bool {
104    c.is_control()
105        || matches!(
106            c,
107            '\\' | '/'
108                | ':'
109                | '"'
110                | '\''
111                | '<'
112                | '>'
113                | '|'
114                | '*'
115                | '?'
116                | '='
117                | ','
118                | ';'
119                | '['
120                | ']'
121                | '{'
122                | '}'
123        )
124}
125
126/// File names with a referencable extension inside one text run.
127fn names_in(run: &str, exts: &[&str], out: &mut Vec<String>) {
128    let chars: Vec<char> = run.chars().collect();
129    let lower: Vec<char> = chars.iter().flat_map(|c| c.to_lowercase()).collect();
130    if lower.len() != chars.len() {
131        return; // exotic case mappings: not a file name we can handle
132    }
133    let mut i = 0;
134    while let Some(&ch) = chars.get(i) {
135        if ch != '.' {
136            i += 1;
137            continue;
138        }
139        // The longest extension that matches here and ends the name.
140        let matched = exts.iter().find(|ext| {
141            let end = i + 1 + ext.chars().count();
142            lower
143                .get(i + 1..end)
144                .is_some_and(|l| l.iter().copied().eq(ext.chars()))
145                && chars
146                    .get(end)
147                    .is_none_or(|c| !(c.is_alphanumeric() || *c == '_'))
148        });
149        let Some(ext) = matched else {
150            i += 1;
151            continue;
152        };
153        let end = i + 1 + ext.chars().count();
154        // Walk back to the start of the name (a single space may be part of it).
155        let mut start = i;
156        while let Some(&c) = start.checked_sub(1).and_then(|s| chars.get(s)) {
157            let double_space = c == ' ' && start >= 2 && chars.get(start - 2) == Some(&' ');
158            if is_name_boundary(c) || double_space {
159                break;
160            }
161            start -= 1;
162        }
163        let span: String = chars.get(start..end).unwrap_or_default().iter().collect();
164        let name = span
165            .trim()
166            .trim_start_matches(|c: char| !(c.is_alphanumeric() || c == '_'));
167        // The stem in characters: the extension matched in lower case may
168        // have more bytes than `ext` (the Kelvin sign is three bytes).
169        let stem = name.chars().count().saturating_sub(ext.chars().count() + 1);
170        // Binary data that happens to end in `.jt` or the like: bytes that
171        // are no text (U+FFFD after the lossy decoding) inside the name, or
172        // right before a name without a letter (`�4.JT`). A real name after
173        // a length byte (`�Bracket.SLDPRT`) stays.
174        let binary = name.contains(char::REPLACEMENT_CHARACTER)
175            || (span.contains(char::REPLACEMENT_CHARACTER)
176                && !name.chars().take(stem).any(char::is_alphabetic));
177        if stem > 0
178            && !binary
179            && name.len() <= MAX_NAME
180            && name.chars().take(stem).any(char::is_alphanumeric)
181        {
182            out.push(name.to_string());
183        }
184        i = end;
185    }
186}
187
188/// Runs of printable text in single-byte encodings (ASCII, UTF-8, Latin-1).
189fn byte_runs(content: &[u8], skip: Option<&std::ops::Range<usize>>, mut f: impl FnMut(&str)) {
190    let mut start = None;
191    for (i, &b) in content.iter().enumerate() {
192        let printable =
193            ((0x20..0x7f).contains(&b) || b >= 0x80) && !skip.is_some_and(|s| s.contains(&i));
194        match (printable, start) {
195            (true, None) => start = Some(i),
196            (false, Some(s)) => {
197                if i - s >= 5 {
198                    f(&String::from_utf8_lossy(
199                        content.get(s..i).unwrap_or_default(),
200                    ));
201                }
202                start = None;
203            }
204            _ => {}
205        }
206    }
207    if let Some(s) = start {
208        if content.len() - s >= 5 {
209            f(&String::from_utf8_lossy(
210                content.get(s..).unwrap_or_default(),
211            ));
212        }
213    }
214}
215
216/// Runs of printable UTF-16LE text (Latin-1 and Latin Extended-A), at both
217/// byte alignments.
218fn utf16_runs(content: &[u8], skip: Option<&std::ops::Range<usize>>, mut f: impl FnMut(&str)) {
219    for align in 0..2 {
220        let mut run = String::new();
221        let mut i = align;
222        while let Some(&[lo, hi]) = content.get(i..i + 2) {
223            let unit = u16::from_le_bytes([lo, hi]);
224            let printable = printable_unit(unit)
225                && !skip.is_some_and(|s| s.contains(&i) || s.contains(&(i + 1)));
226            if printable {
227                run.push(char::from_u32(u32::from(unit)).unwrap_or('?'));
228            } else {
229                if run.chars().count() >= 5 {
230                    f(&run);
231                }
232                run.clear();
233            }
234            i += 2;
235        }
236        if run.chars().count() >= 5 {
237            f(&run);
238        }
239    }
240}
241
242/// Shortest and longest length-prefixed UTF-16 string read by
243/// [`prefixed_utf16_strings`], in code units.
244const PREFIXED_MIN: usize = 5;
245const PREFIXED_MAX: usize = 1024;
246
247/// Whether a UTF-16 code unit is text for the scans (Latin-1 and Latin
248/// Extended-A, as in [`utf16_runs`]).
249fn printable_unit(unit: u16) -> bool {
250    (0x20..0x7f).contains(&unit) || (0xa0..0x180).contains(&unit)
251}
252
253/// Length-prefixed UTF-16LE strings: a `u32` LE count of code units directly
254/// followed by exactly that many printable units. Inventor stores the paths
255/// of its references this way (`57 00 00 00` + 87 units of
256/// `C:\…\Planet_SpurGear.iam`), and the field after the string may
257/// itself look like text – `43 00` is a `C` – which a plain
258/// [`utf16_runs`] scan glues onto the extension (`….iamC`), so the name was
259/// lost. The count gives the exact end of the string.
260fn prefixed_utf16_strings(
261    content: &[u8],
262    skip: Option<&std::ops::Range<usize>>,
263    mut f: impl FnMut(&str),
264) {
265    let mut i = 0;
266    while let Some(head) = content.get(i..i + 4) {
267        let count = <[u8; 4]>::try_from(head).map_or(0, u32::from_le_bytes) as usize;
268        if (PREFIXED_MIN..=PREFIXED_MAX).contains(&count) {
269            let end = i + 4 + 2 * count;
270            let skipped = skip.is_some_and(|s| s.start < end && i < s.end);
271            if let (false, Some(body)) = (skipped, content.get(i + 4..end)) {
272                let units: Vec<u16> = body
273                    .chunks_exact(2)
274                    .map(|p| <[u8; 2]>::try_from(p).map_or(0, u16::from_le_bytes))
275                    .collect();
276                if units.iter().all(|&u| printable_unit(u)) {
277                    let text: String = units
278                        .iter()
279                        .map(|&u| char::from_u32(u32::from(u)).unwrap_or('?'))
280                        .collect();
281                    f(&text);
282                    i = end;
283                    continue;
284                }
285            }
286        }
287        i += 1;
288    }
289}
290
291/// Finds `needle` in `haystack` at or after `from`.
292fn find_bytes(haystack: &[u8], from: usize, needle: &[u8]) -> Option<usize> {
293    haystack
294        .get(from..)?
295        .windows(needle.len())
296        .position(|window| window == needle)
297        .map(|i| from + i)
298}
299
300/// Byte range of the `FILE_NAME(...)` entity in the HEADER of an ISO
301/// 10303-21 (STEP) file, if there is one. It carries the name the exporting
302/// program chose, not a reference to another file.
303fn step_file_name_entity(content: &[u8]) -> Option<std::ops::Range<usize>> {
304    let start = content
305        .iter()
306        .position(|b| !b.is_ascii_whitespace())
307        .unwrap_or(content.len());
308    if !content.get(start..)?.starts_with(b"ISO-10303-21;") {
309        return None;
310    }
311    let header = find_bytes(content, start, b"HEADER;")?;
312    let header_end = find_bytes(content, header + b"HEADER;".len(), b"ENDSEC;")?;
313    let name = find_bytes(content, header + b"HEADER;".len(), b"FILE_NAME")?;
314    if name >= header_end {
315        return None;
316    }
317    let open = name + content.get(name..)?.iter().position(|&b| b == b'(')?;
318    // Quoted strings may contain parentheses; `''` is an escaped quote.
319    let mut depth = 0usize;
320    let mut in_string = false;
321    let mut i = open;
322    while let Some(&b) = content.get(i) {
323        match b {
324            b'\'' if in_string && content.get(i + 1) == Some(&b'\'') => i += 1,
325            b'\'' => in_string = !in_string,
326            b'(' if !in_string => depth += 1,
327            b')' if !in_string => {
328                depth -= 1;
329                if depth == 0 {
330                    return Some(name..i + 1);
331                }
332            }
333            _ => {}
334        }
335        i += 1;
336    }
337    None
338}
339
340/// Bytes of a STEP file read for its export name: the HEADER comes first.
341pub const EXPORT_NAME_SCAN: u64 = 64 * 1024;
342
343/// The name a STEP file was exported under – the first string of the
344/// `FILE_NAME(...)` entity in its HEADER, reduced to the file name – when it
345/// is the name of a referencable file and differs from `own_name`. Other
346/// files may reference the file under that name (an assembly exported
347/// together with its parts, a part renamed after the export); it is a
348/// second name of the file itself and never a reference to another file.
349pub fn step_export_name(content: &[u8], own_name: &str) -> Option<String> {
350    let entity = step_file_name_entity(content)?;
351    let text = content.get(entity)?;
352    let open = text.iter().position(|&b| b == b'(')?;
353    let rest = text.get(open + 1..)?;
354    let quote = rest.iter().position(|b| !b.is_ascii_whitespace())?;
355    if rest.get(quote) != Some(&b'\'') {
356        return None;
357    }
358    // the string up to the closing quote; `''` is an escaped quote
359    let mut value = Vec::new();
360    let mut i = quote + 1;
361    loop {
362        match (rest.get(i), rest.get(i + 1)) {
363            (Some(b'\''), Some(b'\'')) => {
364                value.push(b'\'');
365                i += 2;
366            }
367            (Some(b'\''), _) => break,
368            (Some(&b), _) => {
369                value.push(b);
370                i += 1;
371            }
372            (None, _) => return None,
373        }
374    }
375    let value = String::from_utf8_lossy(&value);
376    let name = value.rsplit(['\\', '/']).next().unwrap_or_default().trim();
377    let name = cadformat::normalize_name(name);
378    let fits = !name.is_empty()
379        && name.len() <= MAX_NAME
380        && !name.contains(char::REPLACEMENT_CHARACTER)
381        && is_referencable(name)
382        && name.to_lowercase() != cadformat::normalize_name(own_name).to_lowercase();
383    fits.then(|| name.to_string())
384}
385
386/// [`step_export_name`] of a stored file (its first [`EXPORT_NAME_SCAN`]
387/// bytes); `None` if it cannot be read.
388pub async fn scan_export_name(path: &Path, own_name: &str) -> Option<String> {
389    let path = path.to_path_buf();
390    let own = own_name.to_string();
391    tokio::task::spawn_blocking(move || -> std::io::Result<Option<String>> {
392        let mut head = Vec::new();
393        std::fs::File::open(&path)?
394            .take(EXPORT_NAME_SCAN)
395            .read_to_end(&mut head)?;
396        Ok(step_export_name(&head, &own))
397    })
398    .await
399    .ok()
400    .and_then(Result::ok)
401    .flatten()
402}
403
404/// The CAD file names referenced in `content`, without `own_name`; each name
405/// once (case-insensitive), in the order found.
406pub fn find(content: &[u8], own_name: &str) -> Vec<String> {
407    // NX parts: only the reference streams, never the history in the rest
408    // of the file ("Part renamed from …").
409    if let Some(streams) = crate::nxpart::reference_streams_in(content) {
410        return from_nx_streams(&streams, own_name);
411    }
412    // SOLIDWORKS 2015+: only the inflated reference sections.
413    if let Ok(Some(data)) = crate::swpart::reference_data(std::io::Cursor::new(content), MAX_SCAN) {
414        return from_sw_sections(data, own_name);
415    }
416    finish(raw_names(content), own_name)
417}
418
419/// SOLIDWORKS 2015+ files: the component files of an assembly's component
420/// tree; without one (drawings) the names in the document manager's
421/// section; nothing if neither inflates.
422fn from_sw_sections(data: crate::swpart::ReferenceData, own_name: &str) -> Vec<String> {
423    let names = match (data.components, data.content_manager) {
424        (Some(paths), _) => paths
425            .iter()
426            .map(|p| p.rsplit(['\\', '/']).next().unwrap_or(p).trim().to_string())
427            .collect(),
428        (None, Some(section)) => raw_names(&section),
429        (None, None) => Vec::new(),
430    };
431    finish(names, own_name)
432}
433
434/// All names with a referencable extension, in the order found.
435fn raw_names(content: &[u8]) -> Vec<String> {
436    let exts = extensions();
437    // The export name in the STEP header is not a reference to another file.
438    let skip = step_file_name_entity(content);
439    let mut found = Vec::new();
440    byte_runs(content, skip.as_ref(), |run| {
441        names_in(run, &exts, &mut found)
442    });
443    utf16_runs(content, skip.as_ref(), |run| {
444        names_in(run, &exts, &mut found)
445    });
446    // Length-prefixed strings have exact ends: a path is reduced to its last
447    // part as a whole, so `=`, `,` or `(` inside the name do not cut it
448    // (`Spring L=20_D=8.ipt`, `Bolt M4 x 12, stainless.ipt`).
449    let mut exact = Vec::new();
450    prefixed_utf16_strings(content, skip.as_ref(), |text| {
451        let last = text.rsplit(['\\', '/']).next().unwrap_or(text).trim();
452        if last.len() <= MAX_NAME && is_referencable(last) {
453            exact.push(last.to_string());
454        } else {
455            names_in(text, &exts, &mut found);
456        }
457    });
458    // The other scans cut such names at those characters: drop a name that
459    // is only the tail of an exact one after a boundary (`8.ipt`).
460    found.retain(|name| {
461        let name = name.to_lowercase();
462        !exact.iter().any(|e| {
463            let e = e.to_lowercase();
464            // the scans trim spaces after the boundary (`…M4 x 12, stainless.ipt`)
465            e.len() > name.len()
466                && e.ends_with(&name)
467                && e.get(..e.len() - name.len()).is_some_and(|head| {
468                    head.ends_with("  ")
469                        || head
470                            .trim_end()
471                            .chars()
472                            .next_back()
473                            .is_some_and(is_name_boundary)
474                })
475        })
476    });
477    found.extend(exact);
478    found
479}
480
481/// Normalised, without the file itself, each name once.
482fn finish(found: Vec<String>, own_name: &str) -> Vec<String> {
483    let own = cadformat::normalize_name(own_name).to_lowercase();
484    let mut seen = HashSet::new();
485    found
486        .into_iter()
487        .map(|n| cadformat::normalize_name(&n).to_string())
488        .filter(|n| n.to_lowercase() != own && is_referencable(n))
489        .filter(|n| seen.insert(n.to_lowercase()))
490        .take(MAX_REFERENCES)
491        .collect()
492}
493
494/// Each reference stream of an NX part starts with the part itself – under
495/// the name it was saved with, which differs from the file name after a
496/// rename – followed by its components.
497fn from_nx_streams(streams: &[Vec<u8>], own_name: &str) -> Vec<String> {
498    let names = streams
499        .iter()
500        .flat_map(|s| raw_names(s).into_iter().skip(1))
501        .collect();
502    finish(names, own_name)
503}
504
505/// A file name referenced by a CAD file, with how often the file places it
506/// where its format tells (see [`instance_counts`]).
507#[derive(Clone, Debug, PartialEq, Eq)]
508pub struct FileRef {
509    pub name: String,
510    /// Instances counted in the file; `Some(0)`: only instances excluded
511    /// from the BOM; `None`: not known.
512    pub instances: Option<u32>,
513}
514
515/// The lookup key of a referenced file name: normalised as [`finish`] does,
516/// lower case.
517fn count_key(name: &str) -> String {
518    cadformat::normalize_name(name).to_lowercase()
519}
520
521/// How often an assembly places each component file – keyed by the lower
522/// case, normalised file name – where its format tells:
523///
524/// * NX (`nx-part`): the placed components (`NAME.prt`), hidden layers left
525///   out as in the preview (`nxasm::kind_of`);
526/// * SOLIDWORKS 2015+ assemblies: the instances of the component tree
527///   without `swExcludeFromBOM` (`swpart::file_component_counts`; a file
528///   with only excluded instances counts 0);
529/// * STEP assemblies with external references: the assembly occurrences of
530///   each external file (`stepbom::external_instance_counts`);
531/// * Inventor assemblies: the occurrences of each referenced file from the
532///   file descriptor table without the suppressed ones
533///   (`invasm::file_occurrence_counts`; only suppressed ones count 0).
534///
535/// `None` for every other format and for files without component data.
536/// Blocking (reads the file).
537pub fn instance_counts(path: &Path, format: &str) -> Option<HashMap<String, u32>> {
538    let mut counts: HashMap<String, u32> = HashMap::new();
539    match format {
540        "nx-part" => {
541            let comps = match crate::nxasm::kind_of(path).ok()? {
542                crate::nxasm::Kind::Assembly(c) | crate::nxasm::Kind::Part(c) => c,
543                crate::nxasm::Kind::Other => return None,
544            };
545            for c in comps {
546                let key = count_key(&format!("{}.prt", c.name));
547                let n = counts.entry(key).or_default();
548                *n = n.saturating_add(1);
549            }
550        }
551        "solidworks-assembly" => {
552            let file = std::io::BufReader::new(std::fs::File::open(path).ok()?);
553            for (p, n) in crate::swpart::file_component_counts(file, MAX_SCAN).ok()?? {
554                let key = count_key(p.rsplit(['\\', '/']).next().unwrap_or(&p).trim());
555                let total = counts.entry(key).or_default();
556                *total = total.saturating_add(n);
557            }
558        }
559        "step" => {
560            let mut content = Vec::new();
561            std::fs::File::open(path)
562                .ok()?
563                .take(MAX_SCAN)
564                .read_to_end(&mut content)
565                .ok()?;
566            for (name, n) in crate::stepbom::external_instance_counts(&content)? {
567                let total = counts.entry(count_key(&name)).or_default();
568                *total = total.saturating_add(n);
569            }
570        }
571        "inventor-assembly" => {
572            let file = std::io::BufReader::new(std::fs::File::open(path).ok()?);
573            let found = crate::invasm::file_occurrence_counts(file, MAX_SCAN).ok()??;
574            if found.is_empty() {
575                return None; // no descriptor table: the counts are not known
576            }
577            for (name, n) in found {
578                let total = counts.entry(count_key(&name)).or_default();
579                *total = total.saturating_add(n);
580            }
581        }
582        _ => return None,
583    }
584    Some(counts)
585}
586
587/// The names with their counts: a name the counts do not list is `None`
588/// (not known), as is every name when the file tells no counts.
589pub fn with_counts(names: Vec<String>, counts: Option<&HashMap<String, u32>>) -> Vec<FileRef> {
590    names
591        .into_iter()
592        .map(|name| {
593            let instances = counts.and_then(|c| c.get(&count_key(&name)).copied());
594            FileRef { name, instances }
595        })
596        .collect()
597}
598
599/// Reads a stored file and finds its references: NX parts through their
600/// stream directory and SOLIDWORKS 2015+ files through their sections (with
601/// seeks, so large files are read completely), everything else from its
602/// first [`MAX_SCAN`] bytes. The instance counts come from
603/// [`instance_counts`] for the file's `format`.
604pub async fn scan_file(path: &Path, own_name: &str, format: &str) -> Vec<FileRef> {
605    let path = path.to_path_buf();
606    let own = own_name.to_string();
607    let format = format.to_string();
608    tokio::task::spawn_blocking(move || -> std::io::Result<Vec<FileRef>> {
609        let names = names_in_file(&path, &own)?;
610        let counts = if names.is_empty() {
611            None
612        } else {
613            instance_counts(&path, &format)
614        };
615        Ok(with_counts(names, counts.as_ref()))
616    })
617    .await
618    .ok()
619    .and_then(Result::ok)
620    .unwrap_or_default()
621}
622
623fn names_in_file(path: &Path, own: &str) -> std::io::Result<Vec<String>> {
624    let mut file = std::io::BufReader::new(std::fs::File::open(path)?);
625    if let Some(streams) = crate::nxpart::reference_streams(&mut file, MAX_SCAN)? {
626        return Ok(from_nx_streams(&streams, own));
627    }
628    if let Some(data) = crate::swpart::reference_data(&mut file, MAX_SCAN)? {
629        return Ok(from_sw_sections(data, own));
630    }
631    let mut content = Vec::new();
632    file.seek(std::io::SeekFrom::Start(0))?;
633    file.take(MAX_SCAN).read_to_end(&mut content)?;
634    Ok(find(&content, own))
635}
636
637#[cfg(test)]
638mod tests {
639    use super::*;
640
641    fn utf16(s: &str) -> Vec<u8> {
642        s.encode_utf16().flat_map(u16::to_le_bytes).collect()
643    }
644
645    #[test]
646    fn fluent_meshes_are_not_referencable() {
647        for name in ["pipe.msh.gz", "pipe.msh.h5", "pipe.msh", "pipe.cas.h5"] {
648            assert!(!is_referencable(name), "{name}");
649        }
650        assert!(is_referencable("pipe.stl"));
651        assert!(!extensions().contains(&"msh.gz"));
652    }
653
654    #[test]
655    fn which_formats_are_scanned() {
656        for id in [
657            "solidworks-assembly",
658            "solidworks-drawing",
659            "inventor-assembly",
660            "catia-product",
661            "creo-assembly",
662            "solidedge-assembly",
663            "nx-part",
664            "fusion-distributed",
665            "dwg",
666            "step",
667            "jt",
668            "kicad-project",
669        ] {
670            assert!(scans(id), "{id}");
671        }
672        for id in [
673            "solidworks-part",
674            "stl",
675            "pdf",
676            "parasolid-text",
677            "",
678            "nope",
679        ] {
680            assert!(!scans(id), "{id}");
681        }
682    }
683
684    #[test]
685    fn windows_paths_in_utf16() {
686        // SolidWorks / Inventor style: UTF-16LE paths between binary data.
687        let mut content = vec![0x01, 0x00, 0x7f, 0x13];
688        content.extend(utf16("C:\\Users\\erika\\Rover\\Motor mount.SLDPRT"));
689        content.extend([0x00, 0x00, 0x05, 0x00]);
690        content.extend(utf16("D:\\Lib\\ISO 4762 M6 x 20.ipt"));
691        content.extend([0xff, 0xfe, 0x00, 0x00]);
692        content.extend(utf16("Halterung_für_Düse.SLDPRT"));
693        content.extend([0x00, 0x00]);
694        content.extend(utf16("Rover.SLDASM")); // the file itself
695        let refs = find(&content, "rover.sldasm");
696        assert_eq!(
697            refs,
698            vec![
699                "Motor mount.SLDPRT",
700                "ISO 4762 M6 x 20.ipt",
701                "Halterung_für_Düse.SLDPRT"
702            ]
703        );
704    }
705
706    /// A length-prefixed UTF-16 string as Inventor writes it: `u32` count of
707    /// code units, then the units.
708    fn prefixed(s: &str) -> Vec<u8> {
709        let units = utf16(s);
710        let mut out = u32::try_from(units.len() / 2)
711            .unwrap_or(0)
712            .to_le_bytes()
713            .to_vec();
714        out.extend(units);
715        out
716    }
717
718    #[test]
719    fn inventor_length_prefixed_references() {
720        // Bytes as in a real Inventor 2027 assembly: the path, then the next
721        // field `43 00 00 00` (a "C" to a plain UTF-16 scan) and the
722        // length-prefixed workspace name.
723        let mut content = vec![0x00, 0x13, 0x88, 0x01];
724        content.extend(prefixed(
725            "C:\\Projects\\Rover\\Workspace\\Drive\\Gearbox\\Planet\\\
726             Design Accelerator\\Planet_SpurGear.iam",
727        ));
728        content.extend([0x43, 0x00, 0x00, 0x00]);
729        content.extend(prefixed("Workspace"));
730        content.extend([0x02, 0x00, 0x00, 0x00, 0xff, 0xfe]);
731        content.extend(prefixed("Drive\\Gearbox\\RingGear.iam"));
732        content.extend([0x43, 0x00, 0x00, 0x00]);
733        // names with `=` and `,` stay whole – only the cut tail was found before
734        content.extend(prefixed("C:\\Rover\\Bumper\\Spring L=20_D=8.ipt"));
735        content.extend([0x43, 0x00, 0x00, 0x00]);
736        content.extend(prefixed(
737            "C:\\Rover\\Standard parts\\Bolt M4 x 12, stainless.ipt",
738        ));
739        content.extend([0x00, 0x00]);
740        let refs = find(&content, "Gearbox.iam");
741        assert_eq!(
742            refs,
743            vec![
744                "Planet_SpurGear.iam",
745                "RingGear.iam",
746                "Spring L=20_D=8.ipt",
747                "Bolt M4 x 12, stainless.ipt"
748            ]
749        );
750        // a different file whose name only ends the same way is kept
751        let mut both = prefixed("C:\\Rover\\Big Bracket.ipt");
752        both.extend([0x00, 0x00, 0x00, 0x00]);
753        both.extend(utf16("C:\\Rover\\Bracket.ipt"));
754        both.extend([0x00, 0x00]);
755        assert_eq!(
756            find(&both, "rover.iam"),
757            vec!["Big Bracket.ipt", "Bracket.ipt"]
758        );
759        // without the length prefix the glued "C" hides the name
760        let mut plain = utf16("C:\\Rover\\Motor mount.iam");
761        plain.extend([0x43, 0x00, 0x00, 0x00]);
762        assert!(find(&plain, "rover.iam").is_empty());
763    }
764
765    #[test]
766    fn prefixed_strings_need_exact_printable_length() {
767        fn collect(content: &[u8]) -> Vec<String> {
768            let mut out = Vec::new();
769            prefixed_utf16_strings(content, None, |t| out.push(t.to_string()));
770            out
771        }
772        // count larger than the data: nothing
773        let mut short = 40u32.to_le_bytes().to_vec();
774        short.extend(utf16("bracket.ipt"));
775        assert!(collect(&short).is_empty());
776        // a non-printable unit inside the counted range: nothing
777        let mut broken = 11u32.to_le_bytes().to_vec();
778        broken.extend(utf16("brack"));
779        broken.extend([0x01, 0x00]);
780        broken.extend(utf16("t.ipt"));
781        assert!(collect(&broken).is_empty());
782        // below the minimum length: nothing
783        let mut tiny = prefixed("a.ip");
784        tiny.extend([0x00, 0x00]);
785        assert!(collect(&tiny).is_empty());
786        // the exact string, even with text right after it
787        let mut exact = prefixed("bracket.ipt");
788        exact.extend(utf16("CX"));
789        assert_eq!(collect(&exact), vec!["bracket.ipt"]);
790        // inside a skipped range (STEP FILE_NAME): nothing
791        let mut skipped = Vec::new();
792        let range = 0..exact.len();
793        prefixed_utf16_strings(&exact, Some(&range), |t| skipped.push(t.to_string()));
794        assert!(skipped.is_empty());
795    }
796
797    #[test]
798    fn odd_alignment_and_ascii() {
799        let mut content = vec![0x00];
800        content.extend(utf16("bracket.CATPart"));
801        content.extend([0x00, 0x00, 0x09]);
802        content.extend(b"#UGC:2 ASSEMBLY\x00\x03BRACKET.PRT\x00\x02FRAME_SIDE.PRT.3\x00");
803        let refs = find(&content, "stage.asm");
804        assert_eq!(
805            refs,
806            vec!["BRACKET.PRT", "FRAME_SIDE.PRT", "bracket.CATPart"]
807        );
808    }
809
810    #[test]
811    fn text_formats() {
812        let step = b"ISO-10303-21;\nDATA;\n#5=DOCUMENT_FILE('nozzle.stp','',$,#6,'','');\n\
813            #7=APPLIED_EXTERNAL_IDENTIFICATION_ASSIGNMENT('injector plate.STEP',#8,(#9));\n\
814            #10=PRODUCT('NOZ','Nozzle 1.5 mm','',(#2));\nENDSEC;";
815        assert_eq!(
816            find(step, "engine.step"),
817            vec!["nozzle.stp", "injector plate.STEP"]
818        );
819        let kicad = b"(kicad_sch (sheet (property \"Sheetfile\" \"power.kicad_sch\") \
820            (property \"Sheetfile\" \"sensors/imu.kicad_sch\")))";
821        assert_eq!(
822            find(kicad, "main.kicad_sch"),
823            vec!["power.kicad_sch", "imu.kicad_sch"]
824        );
825    }
826
827    #[test]
828    fn step_header_name_is_not_a_reference() {
829        let step = b"ISO-10303-21;\nHEADER;\n\
830            FILE_DESCRIPTION((''),'2;1');\n\
831            FILE_NAME('export name.stp','2021-01-01',(''),(''),'','','');\n\
832            FILE_SCHEMA(('AUTOMOTIVE_DESIGN'));\nENDSEC;\nDATA;\n\
833            #5=DOCUMENT_FILE('export name.stp','',$,#6,'','');\n\
834            #7=DOCUMENT_FILE('nozzle.stp','',$,#6,'','');\nENDSEC;\nEND-ISO-10303-21;\n";
835        assert_eq!(
836            find(step, "stored.stp"),
837            vec!["export name.stp", "nozzle.stp"]
838        );
839    }
840
841    #[test]
842    fn no_false_friends() {
843        // Version numbers, documents, templates, extensions inside words.
844        let content = b"Version 2.5 released\x00see manual.pdf\x00Part.prtdot\x00\
845            stl.stlfile\x00.SLDPRT\x00file.x_tx\x00  -  .stp\x00";
846        assert!(
847            find(content, "a.sldasm").is_empty(),
848            "{:?}",
849            find(content, "a.sldasm")
850        );
851        assert!(find(b"", "a.sldasm").is_empty());
852        // Each name once, regardless of case; Creo iterations collapse.
853        let content =
854            b"\x00bolt.sldprt\x00BOLT.SLDPRT\x00Bolt.sldprt\x00frame.prt.2\x00frame.prt.3\x00";
855        assert_eq!(find(content, "x.sldasm"), vec!["bolt.sldprt", "frame.prt"]);
856    }
857
858    #[test]
859    fn many_references_are_capped() {
860        let mut content = Vec::new();
861        for i in 0..800 {
862            content.extend(format!("\x00part-{i}.ipt").as_bytes());
863        }
864        assert_eq!(find(&content, "big.iam").len(), MAX_REFERENCES);
865    }
866
867    #[test]
868    fn paths_are_reduced_to_the_file_name() {
869        // POSIX, Windows, UNC and URL-like paths: only the last component.
870        let content = b"\x00/home/erika/rover/wheel.sldprt\x00\
871            \\\\server\\share\\cad\\hub.ipt\x00file:///C:/lib/axle.step\x00";
872        assert_eq!(
873            find(content, "rover.sldasm"),
874            vec!["wheel.sldprt", "hub.ipt", "axle.step"]
875        );
876    }
877
878    #[test]
879    fn the_file_itself_is_never_a_reference() {
880        // Own name in another case, with a path and as a Creo iteration.
881        let content = b"\x00C:\\work\\FRAME.ASM.4\x00frame.asm\x00bolt.prt.2\x00";
882        assert_eq!(find(content, "frame.asm.7"), vec!["bolt.prt"]);
883    }
884
885    #[test]
886    fn non_cad_files_are_not_references() {
887        // Documents, simulation decks and results, manufacturing data,
888        // templates: never component files of an assembly.
889        let content = b"\x00manual.pdf\x00job.inp\x00result.frd\x00mesh.vtu\x00\
890            nc.mfg\x00sheet.slddrt\x00project.ipj\x00real part.par\x00";
891        assert_eq!(find(content, "a.asm"), vec!["real part.par"]);
892    }
893
894    #[test]
895    fn names_end_at_separators_and_double_spaces() {
896        // A single space is part of a name, two spaces end it; quotes,
897        // `=` and `;` are separators.
898        let content =
899            b"text  Motor mount.ipt\x00name=\"Shaft 20.ipt\";ref='nut.ipt'\x00[plate.ipt]";
900        assert_eq!(
901            find(content, "x.iam"),
902            vec!["Motor mount.ipt", "Shaft 20.ipt", "nut.ipt", "plate.ipt"]
903        );
904    }
905
906    #[test]
907    fn the_longest_extension_wins() {
908        // `.kicad_sch` is not cut to some shorter extension, `.catproduct`
909        // not to `.cat…`; a name followed by letters is no match at all.
910        let content = b"\x00power.kicad_sch\x00Top.CATProduct\x00shaft.stpx\x00";
911        assert_eq!(
912            find(content, "main.kicad_pro"),
913            vec!["power.kicad_sch", "Top.CATProduct"]
914        );
915    }
916
917    #[test]
918    fn names_without_a_stem_or_too_long_are_dropped() {
919        let long = format!("\x00{}.ipt\x00", "x".repeat(MAX_NAME));
920        assert!(
921            find(long.as_bytes(), "a.iam").is_empty(),
922            "longer than MAX_NAME"
923        );
924        let fits = format!("\x00{}.ipt\x00", "x".repeat(MAX_NAME - 4));
925        assert_eq!(find(fits.as_bytes(), "a.iam").len(), 1);
926        // only punctuation before the extension
927        assert!(find(b"\x00___.ipt\x00--.stp\x00", "a.iam").is_empty());
928    }
929
930    #[test]
931    fn short_runs_are_ignored() {
932        // Printable runs shorter than five characters are binary noise.
933        assert!(find(b"\x00a.st\x00", "x.asm").is_empty());
934        assert_eq!(find(b"\x00ab.stp\x00", "x.asm"), vec!["ab.stp"]);
935    }
936
937    #[test]
938    fn step_header_name_is_only_skipped_inside_the_header() {
939        // A FILE_NAME-like string in the DATA section is not the header entity.
940        let step = b"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nENDSEC;\nDATA;\n\
941            #1=FILE_NAME('wheel.stp');\nENDSEC;\n";
942        assert_eq!(find(step, "rover.stp"), vec!["wheel.stp"]);
943        // Parentheses and escaped quotes inside the header name are handled.
944        let step = b"ISO-10303-21;\nHEADER;\n\
945            FILE_NAME('it''s (v2).stp','2026',('a(b)'),(''),'','','');\nENDSEC;\nDATA;\n\
946            #5=DOCUMENT_FILE('rim.stp','',$,#6,'','');\nENDSEC;\n";
947        assert_eq!(find(step, "rover.stp"), vec!["rim.stp"]);
948        // Without the ISO header the same text is an ordinary string.
949        let not_step = b"FILE_NAME('export.stp','2026');";
950        assert_eq!(find(not_step, "x.stp"), vec!["export.stp"]);
951    }
952
953    #[test]
954    fn solidworks_sections_reduce_component_paths() {
955        let data = crate::swpart::ReferenceData {
956            components: Some(vec![
957                "C:\\p\\Bolt.SLDPRT".into(),
958                "D:/lib/Nut.SLDPRT".into(),
959                "C:\\p\\bolt.sldprt".into(),
960                "C:\\p\\Top.SLDASM".into(),
961            ]),
962            content_manager: Some(b"\x00ignored.SLDPRT\x00".to_vec()),
963        };
964        assert_eq!(
965            from_sw_sections(data, "top.sldasm"),
966            vec!["Bolt.SLDPRT", "Nut.SLDPRT"],
967            "the component tree wins over the document manager"
968        );
969        let drawing = crate::swpart::ReferenceData {
970            components: None,
971            content_manager: Some(b"\x00C:\\p\\Bracket.SLDPRT\x00".to_vec()),
972        };
973        assert_eq!(
974            from_sw_sections(drawing, "b.slddrw"),
975            vec!["Bracket.SLDPRT"]
976        );
977        let empty = crate::swpart::ReferenceData {
978            components: None,
979            content_manager: None,
980        };
981        assert!(from_sw_sections(empty, "b.slddrw").is_empty());
982    }
983
984    #[test]
985    fn nx_streams_skip_the_name_the_part_was_saved_with() {
986        // Each stream starts with the part itself (here under an old name).
987        let streams = vec![
988            b"\x00old_name.prt\x00wheel.prt\x00hub.prt\x00".to_vec(),
989            b"\x00old_name.prt\x00HUB.prt\x00axle.prt\x00".to_vec(),
990        ];
991        assert_eq!(
992            from_nx_streams(&streams, "new_name.prt"),
993            vec!["wheel.prt", "hub.prt", "axle.prt"]
994        );
995    }
996
997    #[tokio::test]
998    async fn scan_file_reads_stored_files() {
999        let path = std::env::temp_dir().join(format!("teamcraft-cadrefs-{}", uuid::Uuid::new_v4()));
1000        std::fs::write(&path, b"\x00bracket.SLDPRT\x00frame.SLDASM\x00").unwrap();
1001        // not a SOLIDWORKS 2015+ container: names only, no counts
1002        assert_eq!(
1003            scan_file(&path, "frame.sldasm", "solidworks-assembly").await,
1004            vec![FileRef {
1005                name: "bracket.SLDPRT".into(),
1006                instances: None
1007            }]
1008        );
1009        std::fs::remove_file(&path).ok();
1010        assert!(
1011            scan_file(&path, "frame.sldasm", "solidworks-assembly")
1012                .await
1013                .is_empty(),
1014            "missing file"
1015        );
1016    }
1017
1018    #[test]
1019    fn counts_are_matched_by_the_normalised_name_in_any_case() {
1020        let counts: HashMap<String, u32> = [
1021            ("bolt.sldprt".to_string(), 4),
1022            ("glue.sldprt".to_string(), 0),
1023            ("gear.prt".to_string(), 2),
1024        ]
1025        .into();
1026        let names = vec![
1027            "Bolt.SLDPRT".to_string(),
1028            "glue.sldprt".to_string(),
1029            // a Creo iteration suffix is not part of the name
1030            "gear.prt.3".to_string(),
1031            "nut.sldprt".to_string(),
1032        ];
1033        let refs = with_counts(names.clone(), Some(&counts));
1034        let got: Vec<_> = refs
1035            .iter()
1036            .map(|r| (r.name.as_str(), r.instances))
1037            .collect();
1038        assert_eq!(
1039            got,
1040            [
1041                ("Bolt.SLDPRT", Some(4)),
1042                ("glue.sldprt", Some(0)),
1043                ("gear.prt.3", Some(2)),
1044                ("nut.sldprt", None)
1045            ]
1046        );
1047        assert!(with_counts(names, None)
1048            .iter()
1049            .all(|r| r.instances.is_none()));
1050    }
1051
1052    #[test]
1053    fn inventor_assemblies_count_their_occurrences() {
1054        let path = std::env::temp_dir().join(format!("teamcraft-iam-{}", uuid::Uuid::new_v4()));
1055        std::fs::write(
1056            &path,
1057            crate::invasm::test_assembly(&[
1058                ("C:\\Rover\\Wheel\\Wheel.iam", false, 4),
1059                ("C:\\CC\\ISO 4762\\ISO 4762 - M6 x 20.ipt", true, 16),
1060                ("C:\\Rover\\Frame.ipt", false, 1),
1061            ]),
1062        )
1063        .unwrap();
1064        let counts = instance_counts(&path, "inventor-assembly").unwrap();
1065        assert_eq!(counts.get("wheel.iam"), Some(&4));
1066        assert_eq!(counts.get("iso 4762 - m6 x 20.ipt"), Some(&16));
1067        assert_eq!(counts.get("frame.ipt"), Some(&1));
1068        let refs = with_counts(
1069            vec!["Wheel.iam".into(), "Frame.ipt".into(), "Bracket.ipt".into()],
1070            Some(&counts),
1071        );
1072        assert_eq!(
1073            refs.iter().map(|r| r.instances).collect::<Vec<_>>(),
1074            vec![Some(4), Some(1), None]
1075        );
1076        // a part file (no assembly occurrences) and a compound file without
1077        // a descriptor table have no counts
1078        assert_eq!(instance_counts(&path, "inventor-part"), None);
1079        std::fs::write(&path, crate::invasm::test_assembly(&[])).unwrap();
1080        assert_eq!(instance_counts(&path, "inventor-assembly"), None);
1081        // suppressed occurrences are not counted; only suppressed ones: 0
1082        // (still referenced, left out of the BOM like excluded SOLIDWORKS parts)
1083        std::fs::write(
1084            &path,
1085            crate::invasm::test_assembly_with_records(
1086                &[
1087                    ("C:\\Rover\\Wheel.iam", false, 2),
1088                    ("C:\\Rover\\Frame.ipt", false, 1),
1089                ],
1090                &[("Wheel", false), ("Frame", true), ("Wheel", true)],
1091            ),
1092        )
1093        .unwrap();
1094        let counts = instance_counts(&path, "inventor-assembly").unwrap();
1095        assert_eq!(counts.get("wheel.iam"), Some(&1));
1096        assert_eq!(counts.get("frame.ipt"), Some(&0));
1097        std::fs::remove_file(&path).ok();
1098    }
1099
1100    #[test]
1101    fn only_assemblies_with_component_data_have_counts() {
1102        let path = std::env::temp_dir().join(format!("teamcraft-counts-{}", uuid::Uuid::new_v4()));
1103        std::fs::write(&path, b"\x00bracket.SLDPRT\x00").unwrap();
1104        for format in [
1105            "solidworks-assembly",
1106            "nx-part",
1107            "inventor-assembly",
1108            "step",
1109        ] {
1110            assert_eq!(instance_counts(&path, format), None, "{format}");
1111        }
1112        std::fs::remove_file(&path).ok();
1113    }
1114
1115    /// Regression: the extension is compared in lower case, and the Kelvin
1116    /// sign `K` (U+212A, three bytes in UTF-8) lower-cases to an ASCII `k`.
1117    /// The name's stem was then cut at a byte position inside that sign – a
1118    /// panic on a file that holds such text.
1119    #[test]
1120    fn a_non_ascii_letter_in_the_extension_does_not_panic() {
1121        let content = "board a.\u{212A}icad_pcb and other text".as_bytes();
1122        let names = find(content, "x.kicad_pro");
1123        assert!(names.iter().all(|n| n.starts_with('a')), "{names:?}");
1124        // the same text as UTF-16 is not even read as a name (Latin only)
1125        let utf16: Vec<u8> = "a.\u{212A}icad_pcb "
1126            .encode_utf16()
1127            .flat_map(u16::to_le_bytes)
1128            .collect();
1129        find(&utf16, "x.kicad_pro");
1130    }
1131
1132    fn step(header_name: &str) -> Vec<u8> {
1133        format!(
1134            "ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\n\
1135             FILE_NAME({header_name},'2026-11-06',(''),(''),'','','');\nENDSEC;\nDATA;\nENDSEC;\n"
1136        )
1137        .into_bytes()
1138    }
1139
1140    #[test]
1141    fn the_step_export_name_is_a_second_name_of_the_file() {
1142        assert_eq!(
1143            step_export_name(&step("'bracket.stp'"), "bracket_v2.stp").as_deref(),
1144            Some("bracket.stp")
1145        );
1146        // a path is reduced to the file name, spaces around it are dropped
1147        assert_eq!(
1148            step_export_name(&step("  'C:\\Export\\Rover\\Wheel hub.STEP' "), "x.stp").as_deref(),
1149            Some("Wheel hub.STEP")
1150        );
1151        assert_eq!(
1152            step_export_name(&step("'/srv/out/it''s (v2).stp'"), "x.stp").as_deref(),
1153            Some("it's (v2).stp")
1154        );
1155        // a native name the exporting program kept is a name as well
1156        assert_eq!(
1157            step_export_name(&step("'Bracket.SLDPRT'"), "bracket.stp").as_deref(),
1158            Some("Bracket.SLDPRT")
1159        );
1160        // not a second name: the same name, no name, no file name, a
1161        // document, a name that is not text, a broken or missing entity
1162        for header in [
1163            "'BRACKET_V2.stp'",
1164            "''",
1165            "'   '",
1166            "'Part1'",
1167            "'C:\\Export\\'",
1168            "'notes.pdf'",
1169            "$",
1170            "'unterminated.stp",
1171        ] {
1172            assert_eq!(
1173                step_export_name(&step(header), "bracket_v2.stp"),
1174                None,
1175                "{header}"
1176            );
1177        }
1178        let latin1 = b"ISO-10303-21;\nHEADER;\nFILE_NAME('Halterung_f\xfcr.stp','x');\nENDSEC;\n";
1179        assert_eq!(step_export_name(latin1, "x.stp"), None);
1180        let no_step = b"FILE_NAME('bracket.stp','2026');";
1181        assert_eq!(step_export_name(no_step, "x.stp"), None);
1182        let in_data =
1183            b"ISO-10303-21;\nHEADER;\nFILE_SCHEMA(('X'));\nENDSEC;\nDATA;\n#1=FILE_NAME('b.stp');\n";
1184        assert_eq!(step_export_name(in_data, "x.stp"), None);
1185        // never an outgoing reference of the file itself
1186        assert!(find(&step("'bracket.stp'"), "bracket_v2.stp").is_empty());
1187    }
1188
1189    #[test]
1190    fn binary_noise_is_no_file_name() {
1191        // `.JT` after bytes that are no text (seen in a JT file's
1192        // compressed segments): no name
1193        let mut content = vec![0x01, 0x9f, b'4', b'.', b'J', b'T', b'\n'];
1194        // inside a name as well
1195        content.extend(b"\x02ab\x9fcd.stp\x00");
1196        assert!(
1197            find(&content, "x.jt").is_empty(),
1198            "{:?}",
1199            find(&content, "x.jt")
1200        );
1201        // a real name after a length byte stays, also a numeric one in text
1202        let mut content = vec![0x01, 0x8e];
1203        content.extend(b"Bracket.SLDPRT\x00\x004711.stp\x00");
1204        assert_eq!(find(&content, "x.sldasm"), ["Bracket.SLDPRT", "4711.stp"]);
1205    }
1206
1207    /// Found by cargo mutants: runs shorter than five bytes are dropped, also
1208    /// late in the file and at its end.
1209    #[test]
1210    fn byte_runs_need_five_printable_bytes() {
1211        let runs = |content: &[u8], skip: Option<&std::ops::Range<usize>>| {
1212            let mut out = Vec::new();
1213            byte_runs(content, skip, |r| out.push(r.to_string()));
1214            out
1215        };
1216        let content = b"\0\0\0\0\0\0\0\0abcd\0ABCDE\0\0xyz";
1217        assert_eq!(runs(content, None), ["ABCDE"]);
1218        assert_eq!(
1219            runs(b"\0\0\0\0\0\0\0\0UVWXY", None),
1220            ["UVWXY"],
1221            "a run at the end"
1222        );
1223        assert_eq!(runs(b"\0\0\0\0\0\0\0\0UVWX", None), Vec::<String>::new());
1224        // a skipped range splits a run
1225        assert_eq!(runs(b"ABCDEFGHIJ", Some(&(5..6))), ["ABCDE"]);
1226    }
1227
1228    /// Found by cargo mutants: the end of the HEADER section is searched
1229    /// after `HEADER;` – also when the header starts far into the file and
1230    /// right after an `ENDSEC;`.
1231    #[test]
1232    fn the_file_name_entity_is_found_inside_the_header_only() {
1233        let entity = b"FILE_NAME('it''s a) b.stp','',(''),(''),'','','')";
1234        let file = |before_header: &str| {
1235            let mut c = format!("ISO-10303-21;{before_header}HEADER;\n").into_bytes();
1236            let at = c.len();
1237            c.extend_from_slice(entity);
1238            c.extend_from_slice(b";\nENDSEC;\nDATA;\nENDSEC;\nEND-ISO-10303-21;\n");
1239            (c, at)
1240        };
1241        let comment = format!("\n/* {} */\n", "x".repeat(400));
1242        let (content, at) = file(&comment);
1243        assert_eq!(step_file_name_entity(&content), Some(at..at + entity.len()));
1244        let (content, at) = file("\nENDSEC;");
1245        assert_eq!(step_file_name_entity(&content), Some(at..at + entity.len()));
1246        // FILE_NAME after the end of the header is not the entity
1247        let late = b"ISO-10303-21;\nHEADER;\nENDSEC;\nDATA;\n#1=FILE_NAME('x');\nENDSEC;\n";
1248        assert_eq!(step_file_name_entity(late), None);
1249    }
1250
1251    /// Found by cargo mutants: the stored-file variant was only covered by
1252    /// the integration tests.
1253    #[tokio::test]
1254    async fn the_export_name_of_a_stored_file() {
1255        let dir = std::env::temp_dir().join(format!("teamcraft-step-{}", uuid::Uuid::new_v4()));
1256        std::fs::create_dir_all(&dir).unwrap();
1257        let stored = dir.join("object");
1258        std::fs::write(&stored, step("'Bracket.STEP'")).unwrap();
1259        assert_eq!(
1260            scan_export_name(&stored, "bracket_v2.stp").await.as_deref(),
1261            Some("Bracket.STEP")
1262        );
1263        assert_eq!(
1264            scan_export_name(&stored, "bracket.step").await,
1265            None,
1266            "own name"
1267        );
1268        assert_eq!(scan_export_name(&dir.join("missing"), "x.stp").await, None);
1269        // only the first EXPORT_NAME_SCAN bytes are read
1270        let mut late = format!("ISO-10303-21;\n/* {} */\n", "x".repeat(70 * 1024)).into_bytes();
1271        late.extend(
1272            step("'Bracket.STEP'")
1273                .into_iter()
1274                .skip("ISO-10303-21;\n".len()),
1275        );
1276        let far = dir.join("far");
1277        std::fs::write(&far, &late).unwrap();
1278        assert_eq!(
1279            step_export_name(&late, "x.stp").as_deref(),
1280            Some("Bracket.STEP")
1281        );
1282        assert_eq!(scan_export_name(&far, "x.stp").await, None);
1283        std::fs::remove_dir_all(&dir).ok();
1284    }
1285
1286    /// Found by cargo mutants: a non-printable unit ends a UTF-16 run.
1287    #[test]
1288    fn utf16_runs_end_at_non_printable_units() {
1289        let utf16 = |s: &str| {
1290            s.encode_utf16()
1291                .flat_map(u16::to_le_bytes)
1292                .collect::<Vec<u8>>()
1293        };
1294        let runs = |content: &[u8], skip: Option<&std::ops::Range<usize>>| {
1295            let mut out = Vec::new();
1296            utf16_runs(content, skip, |r| out.push(r.to_string()));
1297            out
1298        };
1299        let mut content = utf16("ABCDE");
1300        content.extend_from_slice(&[0, 0]);
1301        content.extend(utf16("Größe"));
1302        let found = runs(&content, None);
1303        assert!(found.contains(&"ABCDE".to_string()), "{found:?}");
1304        assert!(found.contains(&"Größe".to_string()), "{found:?}");
1305        assert!(found.iter().all(|r| !r.contains('\0')), "{found:?}");
1306        // a skipped byte splits the run: both halves are too short
1307        let found = runs(&utf16("ABCDEFGH"), Some(&(8..9)));
1308        assert!(found.iter().all(|r| r.chars().count() < 8), "{found:?}");
1309        // skipping only the high byte of 'E' (bytes 8–9) drops exactly that
1310        // unit: "ABCD" is too short, "FGHIJKL" stays
1311        assert_eq!(runs(&utf16("ABCDEFGHIJKL"), Some(&(9..10))), ["FGHIJKL"]);
1312    }
1313
1314    /// The documented limits (found by cargo mutants: a behavioural test
1315    /// would have to scan more than 32 MiB).
1316    #[test]
1317    fn scan_limits() {
1318        assert_eq!(MAX_SCAN, 32 * 1024 * 1024);
1319        assert_eq!(EXPORT_NAME_SCAN, 64 * 1024);
1320    }
1321}