Skip to main content

teamcraft/
cadformat.rs

1//! File formats of the common CAD programs.
2//!
3//! TeamCraft stores files of any type. On top of that it knows the native
4//! formats of the common CAD, BIM and 3D programs – SolidWorks, Siemens NX /
5//! Solid Edge, Autodesk Inventor / Fusion / AutoCAD / Revit, PTC Creo,
6//! CATIA V5, Rhino, Blender, Maya, … – the neutral exchange formats (STEP,
7//! IGES, Parasolid, JT, ACIS, IFC, STL, glTF, …) and point clouds (E57, LAS):
8//!
9//! * every file is classified when it is uploaded; the id is stored in
10//!   `files.format` (`''` = unknown) and the file list shows what the file is,
11//! * downloads carry the registered content type where one exists,
12//! * the item search filters by CAD program and finds "SolidWorks", "NX", …,
13//! * Creo iteration suffixes are dropped (`bracket.prt.7` → `bracket.prt`), so
14//!   every save in Creo becomes a new *version* of the same file,
15//! * extensions used by two programs are told apart by the file content:
16//!   `.prt` is Creo (text header `#UGC:`) or NX, `.asm` Creo or Solid Edge
17//!   (OLE compound document), `.sch` KiCad 5 (`EESchema`) or Eagle, `.sc` a
18//!   SolidCraft design (ZIP container or JSON) or a script, `.pdf` with an
19//!   embedded U3D/PRC model is a 3D PDF.
20//! * simulation models and results (FEA/CFD) are recognised the same way.
21//!   `.sim`, `.fem` and `.afm` are NX when they start with the `SPLMSSTR`
22//!   container; a `.sim` that names STAR-CCM+ near its start is STAR-CCM+,
23//!   any other `.sim` stays NX; a text `.fem` is an OptiStruct deck. Generic
24//!   extensions only count with matching content: `.dat` only with Fluent's
25//!   `(0 …)` sections or Nastran bulk-data markers, `.msh` with Fluent
26//!   sections or Gmsh's `$MeshFormat`, `.java` only naming STAR-CCM+, `.out`
27//!   only as a Fluent report file, `.cas.h5`/`.dat.h5`/`.msh.h5` only with
28//!   the HDF5 signature and `.cas.gz`/… with the gzip magic, `.e` only as
29//!   netCDF/HDF5 (Exodus II), `.db` not for SQLite/OLE/text files, `.rst` not for text (reStructuredText), `.msg`
30//!   and `.sta` only as text (not Outlook mails), `.odb` not as ZIP
31//!   (OpenDocument database), `.bdf` not for bitmap fonts. OpenFOAM cases
32//!   are recognised by `controlDict` (or a `.foam` marker), LS-DYNA results
33//!   by `d3plot`, `d3plot01`, …
34//!
35//! Native formats are proprietary; TeamCraft identifies and versions them,
36//! scans the ones with readable references (see `cadrefs`) and previews the
37//! open formats in 3D (see `preview`). Onshape keeps its documents in the
38//! cloud – store their STEP/Parasolid exports.
39//!
40//! Extensions are matched case-insensitively (`.CATPart`, `.SLDPRT`).
41#![deny(
42    clippy::unwrap_used,
43    clippy::expect_used,
44    clippy::indexing_slicing,
45    clippy::panic,
46    clippy::unreachable
47)]
48
49use serde::Serialize;
50
51/// What a file of the format contains.
52#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
53#[serde(rename_all = "snake_case")]
54pub enum FormatKind {
55    /// Native part (single component).
56    Part,
57    /// Native assembly / product structure; references other files.
58    Assembly,
59    /// 2D drawing (native or DWG/DXF).
60    Drawing,
61    /// Native file that holds parts, assemblies or drawings (NX, Fusion, …).
62    Model,
63    /// Neutral B-rep exchange format (STEP, IGES, Parasolid, …).
64    Exchange,
65    /// Triangle mesh (STL, OBJ, 3MF, …), e.g. for 3D printing.
66    Mesh,
67    /// Lightweight visualisation format (JT, CGR, glTF, …).
68    Visualization,
69    /// Electronics design (schematics, boards, Gerber).
70    Electronics,
71    /// Simulation and analysis models, solver input decks.
72    Simulation,
73    /// Simulation results (fields, solver output, logs).
74    Result,
75    /// Manufacturing data (CAM, G-code).
76    Manufacturing,
77    /// Documents and images.
78    Document,
79    /// Templates, libraries, project files.
80    Other,
81}
82
83impl FormatKind {
84    pub const ALL: [FormatKind; 13] = [
85        FormatKind::Part,
86        FormatKind::Assembly,
87        FormatKind::Drawing,
88        FormatKind::Model,
89        FormatKind::Exchange,
90        FormatKind::Mesh,
91        FormatKind::Visualization,
92        FormatKind::Electronics,
93        FormatKind::Simulation,
94        FormatKind::Result,
95        FormatKind::Manufacturing,
96        FormatKind::Document,
97        FormatKind::Other,
98    ];
99
100    pub fn as_str(self) -> &'static str {
101        match self {
102            FormatKind::Part => "part",
103            FormatKind::Assembly => "assembly",
104            FormatKind::Drawing => "drawing",
105            FormatKind::Model => "model",
106            FormatKind::Exchange => "exchange",
107            FormatKind::Mesh => "mesh",
108            FormatKind::Visualization => "visualization",
109            FormatKind::Electronics => "electronics",
110            FormatKind::Simulation => "simulation",
111            FormatKind::Result => "result",
112            FormatKind::Manufacturing => "manufacturing",
113            FormatKind::Document => "document",
114            FormatKind::Other => "other",
115        }
116    }
117
118    pub fn label(self) -> &'static str {
119        match self {
120            FormatKind::Part => "Part",
121            FormatKind::Assembly => "Assembly",
122            FormatKind::Drawing => "Drawing",
123            FormatKind::Model => "CAD model",
124            FormatKind::Exchange => "Exchange format",
125            FormatKind::Mesh => "Mesh",
126            FormatKind::Visualization => "Visualisation",
127            FormatKind::Electronics => "Electronics",
128            FormatKind::Simulation => "Simulation",
129            FormatKind::Result => "Simulation result",
130            FormatKind::Manufacturing => "Manufacturing",
131            FormatKind::Document => "Document",
132            FormatKind::Other => "Other",
133        }
134    }
135}
136
137/// A CAD program (or a group of neutral formats).
138#[derive(Clone, Copy, Debug, Serialize)]
139pub struct CadSystem {
140    pub id: &'static str,
141    pub name: &'static str,
142    /// Short name used in labels and the search ("NX", "Creo", …).
143    pub short: &'static str,
144    pub vendor: &'static str,
145    /// Further names the search understands ("Unigraphics", "Pro/E", …).
146    #[serde(skip)]
147    pub aliases: &'static [&'static str],
148}
149
150/// A file format.
151#[derive(Clone, Copy, Debug, Serialize)]
152pub struct CadFormat {
153    /// Stable id, stored in `files.format`.
154    pub id: &'static str,
155    pub label: &'static str,
156    /// Id of the [`CadSystem`].
157    pub system: &'static str,
158    pub kind: FormatKind,
159    /// Lower-case extensions without the dot.
160    pub extensions: &'static [&'static str],
161    /// Registered content type; `""` keeps the type sent by the client
162    /// (documents) and native CAD formats use `application/octet-stream`.
163    #[serde(skip)]
164    pub mime: &'static str,
165}
166
167const OCTET: &str = "application/octet-stream";
168
169macro_rules! system {
170    ($id:literal, $name:literal, $short:literal, $vendor:literal $(, $alias:literal)*) => {
171        CadSystem { id: $id, name: $name, short: $short, vendor: $vendor, aliases: &[$($alias),*] }
172    };
173}
174
175macro_rules! cad_format {
176    ($id:literal, $label:literal, $system:literal, $kind:ident, [$($ext:literal),+], $mime:expr) => {
177        CadFormat {
178            id: $id,
179            label: $label,
180            system: $system,
181            kind: FormatKind::$kind,
182            extensions: &[$($ext),+],
183            mime: $mime,
184        }
185    };
186}
187
188/// The CAD programs, in the order of the help page and the search filter.
189#[rustfmt::skip]
190pub const SYSTEMS: &[CadSystem] = &[
191    system!("solidworks", "SolidWorks", "SolidWorks", "Dassault Systèmes", "sw"),
192    system!("nx", "Siemens NX", "NX", "Siemens", "unigraphics", "ug", "nx cad"),
193    system!("teamcenter", "Siemens Teamcenter (JT, PLM XML)", "Teamcenter", "Siemens", "tc"),
194    system!("solid-edge", "Siemens Solid Edge", "Solid Edge", "Siemens", "solidedge"),
195    system!("inventor", "Autodesk Inventor", "Inventor", "Autodesk"),
196    system!("fusion", "Autodesk Fusion", "Fusion", "Autodesk", "fusion 360", "fusion360"),
197    system!("autocad", "AutoCAD (DWG, DXF)", "AutoCAD", "Autodesk"),
198    system!("creo", "PTC Creo (Pro/ENGINEER)", "Creo", "PTC", "pro/engineer", "proe", "pro/e", "creo parametric"),
199    system!("catia", "CATIA V5", "CATIA", "Dassault Systèmes", "catia v5", "3dexperience"),
200    system!("spaceclaim", "Ansys SpaceClaim / Discovery", "SpaceClaim", "Ansys", "discovery"),
201    system!("freecad", "FreeCAD", "FreeCAD", "open source"),
202    system!("tangency", "SolidCraft and FieldCraft", "SolidCraft", "Tangency Labs (open source)", "fieldcraft", "tangency"),
203    system!("rhino", "Rhino", "Rhino", "Robert McNeel & Associates", "rhinoceros"),
204    system!("alias", "Autodesk Alias", "Alias", "Autodesk", "alias autostudio"),
205    system!("openscad", "OpenSCAD", "OpenSCAD", "open source"),
206    system!("revit", "Autodesk Revit", "Revit", "Autodesk"),
207    system!("navisworks", "Autodesk Navisworks", "Navisworks", "Autodesk"),
208    system!("microstation", "Bentley MicroStation", "MicroStation", "Bentley Systems", "dgn"),
209    system!("archicad", "Graphisoft Archicad", "Archicad", "Graphisoft"),
210    system!("sketchup", "SketchUp", "SketchUp", "Trimble"),
211    system!("bim", "Open BIM (IFC)", "IFC", "buildingSMART", "open bim", "industry foundation classes"),
212    system!("blender", "Blender", "Blender", "Blender Foundation"),
213    system!("maya", "Autodesk Maya", "Maya", "Autodesk"),
214    system!("3dsmax", "Autodesk 3ds Max", "3ds Max", "Autodesk", "3dsmax", "3d studio max"),
215    system!("cinema4d", "Maxon Cinema 4D", "Cinema 4D", "Maxon", "c4d"),
216    system!("modo", "Foundry Modo", "Modo", "Foundry"),
217    system!("houdini", "SideFX Houdini", "Houdini", "SideFX"),
218    system!("exchange", "Neutral exchange formats", "Neutral", "ISO / vendor independent", "neutral"),
219    system!("mesh", "Mesh and 3D printing", "Mesh", "vendor independent", "3d printing"),
220    system!("visualization", "Visualisation formats", "Visualisation", "vendor independent", "visualization"),
221    system!("pointcloud", "Point clouds and scans", "Point cloud", "vendor independent", "scan", "lidar", "point clouds"),
222    system!("kicad", "KiCad", "KiCad", "open source"),
223    system!("altium", "Altium Designer", "Altium", "Altium"),
224    system!("eagle", "Eagle", "Eagle", "Autodesk"),
225    system!("pcb-fab", "PCB manufacturing (Gerber, drill)", "PCB fab", "vendor independent", "gerber"),
226    system!("rocketry", "Rocketry (OpenRocket, RockSim, RASAero)", "Rocketry", "various", "openrocket", "rocksim", "rasaero"),
227    system!("cae", "Simulation (ANSYS, Abaqus, COMSOL, Nastran, CalculiX, LS-DYNA)", "Simulation", "various", "ansys", "abaqus", "comsol", "nastran", "calculix", "ls-dyna", "lsdyna", "fea", "optistruct", "simcenter"),
228    system!("cfd", "CFD (OpenFOAM, Fluent, STAR-CCM+)", "CFD", "various", "openfoam", "fluent", "star-ccm+", "starccm"),
229    system!("cae-open", "Open simulation data (VTK, CGNS, Exodus II, Gmsh)", "VTK/CGNS", "vendor independent", "vtk", "paraview", "cgns", "exodus", "gmsh"),
230    system!("cam", "CNC and 3D printer programs (G-code)", "G-code", "vendor independent", "gcode", "cnc"),
231    system!("documents", "Documents and images", "Document", "various"),
232];
233
234/// Every known format. Extensions are unique except the ones in
235/// [`AMBIGUOUS`], which [`detect`] resolves from the file content.
236#[rustfmt::skip]
237pub const FORMATS: &[CadFormat] = &[
238    // SolidWorks
239    cad_format!("solidworks-part", "SolidWorks part", "solidworks", Part, ["sldprt"], OCTET),
240    cad_format!("solidworks-assembly", "SolidWorks assembly", "solidworks", Assembly, ["sldasm"], OCTET),
241    cad_format!("solidworks-drawing", "SolidWorks drawing", "solidworks", Drawing, ["slddrw"], OCTET),
242    cad_format!("solidworks-template", "SolidWorks template / sheet format", "solidworks", Other, ["prtdot", "asmdot", "drwdot", "slddrt"], OCTET),
243    cad_format!("solidworks-library", "SolidWorks library feature / block", "solidworks", Other, ["sldlfp", "sldblk"], OCTET),
244    // Siemens NX
245    cad_format!("nx-part", "NX part", "nx", Model, ["prt"], OCTET),
246    cad_format!("nx-simulation", "NX simulation / FE model", "nx", Simulation, ["sim", "fem", "afm"], OCTET),
247    // Siemens Teamcenter
248    cad_format!("jt", "JT visualisation (ISO 14306)", "teamcenter", Visualization, ["jt"], "model/jt"),
249    cad_format!("plmxml", "Teamcenter PLM XML", "teamcenter", Exchange, ["plmxml"], "application/xml"),
250    // Siemens Solid Edge
251    cad_format!("solidedge-part", "Solid Edge part", "solid-edge", Part, ["par"], OCTET),
252    cad_format!("solidedge-sheetmetal", "Solid Edge sheet metal part", "solid-edge", Part, ["psm"], OCTET),
253    cad_format!("solidedge-assembly", "Solid Edge assembly", "solid-edge", Assembly, ["asm"], OCTET),
254    cad_format!("solidedge-weldment", "Solid Edge weldment", "solid-edge", Assembly, ["pwd"], OCTET),
255    cad_format!("solidedge-drawing", "Solid Edge drawing", "solid-edge", Drawing, ["dft"], OCTET),
256    // Autodesk Inventor
257    cad_format!("inventor-part", "Inventor part", "inventor", Part, ["ipt"], OCTET),
258    cad_format!("inventor-assembly", "Inventor assembly", "inventor", Assembly, ["iam"], OCTET),
259    cad_format!("inventor-drawing", "Inventor drawing", "inventor", Drawing, ["idw"], OCTET),
260    cad_format!("inventor-presentation", "Inventor presentation", "inventor", Other, ["ipn"], OCTET),
261    cad_format!("inventor-project", "Inventor project file", "inventor", Other, ["ipj"], OCTET),
262    // Autodesk Fusion
263    cad_format!("fusion-design", "Fusion design archive", "fusion", Model, ["f3d"], OCTET),
264    cad_format!("fusion-distributed", "Fusion distributed design (with references)", "fusion", Model, ["f3z"], OCTET),
265    cad_format!("fusion-drawing", "Fusion drawing archive", "fusion", Drawing, ["f2d"], OCTET),
266    // AutoCAD
267    cad_format!("dwg", "DWG drawing (AutoCAD, Inventor)", "autocad", Drawing, ["dwg"], "image/vnd.dwg"),
268    cad_format!("dxf", "DXF drawing exchange", "autocad", Drawing, ["dxf"], "image/vnd.dxf"),
269    cad_format!("autocad-template", "AutoCAD drawing template", "autocad", Other, ["dwt"], OCTET),
270    // PTC Creo
271    cad_format!("creo-part", "Creo part", "creo", Part, ["prt"], OCTET),
272    cad_format!("creo-assembly", "Creo assembly", "creo", Assembly, ["asm"], OCTET),
273    cad_format!("creo-drawing", "Creo drawing", "creo", Drawing, ["drw"], OCTET),
274    cad_format!("creo-format", "Creo drawing format", "creo", Other, ["frm"], OCTET),
275    cad_format!("creo-layout", "Creo layout", "creo", Other, ["lay"], OCTET),
276    cad_format!("creo-sketch", "Creo sketch", "creo", Other, ["sec"], OCTET),
277    cad_format!("creo-manufacturing", "Creo manufacturing model", "creo", Manufacturing, ["mfg"], OCTET),
278    cad_format!("creo-neutral", "Creo neutral file", "creo", Exchange, ["neu"], OCTET),
279    cad_format!("creo-view", "Creo View visualisation", "creo", Visualization, ["pvz", "pvs"], OCTET),
280    // CATIA V5 / 3DEXPERIENCE
281    cad_format!("catia-part", "CATIA part", "catia", Part, ["catpart"], OCTET),
282    cad_format!("catia-product", "CATIA product (assembly)", "catia", Assembly, ["catproduct"], OCTET),
283    cad_format!("catia-drawing", "CATIA drawing", "catia", Drawing, ["catdrawing"], OCTET),
284    cad_format!("catia-cgr", "CATIA CGR visualisation", "catia", Visualization, ["cgr"], OCTET),
285    cad_format!("catia-analysis", "CATIA analysis", "catia", Simulation, ["catanalysis"], OCTET),
286    cad_format!("catia-process", "CATIA process", "catia", Manufacturing, ["catprocess"], OCTET),
287    cad_format!("catia-material", "CATIA material library", "catia", Other, ["catmaterial"], OCTET),
288    cad_format!("3dxml", "3D XML (3DEXPERIENCE)", "catia", Visualization, ["3dxml"], OCTET),
289    // Ansys SpaceClaim / Discovery
290    cad_format!("spaceclaim", "SpaceClaim document", "spaceclaim", Model, ["scdoc"], OCTET),
291    // Further CAD programs
292    cad_format!("freecad", "FreeCAD document", "freecad", Model, ["fcstd"], OCTET),
293    cad_format!("solidcraft-design", "SolidCraft design", "tangency", Model, ["sc", "solidcraft"], OCTET),
294    cad_format!("fieldcraft-model", "FieldCraft analysis model", "tangency", Simulation, ["fieldcraft"], OCTET),
295    cad_format!("rhino", "Rhino 3D model", "rhino", Model, ["3dm"], OCTET),
296    cad_format!("alias-wire", "Alias wire file", "alias", Model, ["wire"], OCTET),
297    cad_format!("openscad", "OpenSCAD script", "openscad", Model, ["scad"], "text/plain"),
298    // BIM / architecture / infrastructure
299    cad_format!("revit-project", "Revit project", "revit", Model, ["rvt"], OCTET),
300    cad_format!("revit-family", "Revit family (component)", "revit", Part, ["rfa"], OCTET),
301    cad_format!("revit-template", "Revit template", "revit", Other, ["rte", "rft"], OCTET),
302    cad_format!("navisworks-document", "Navisworks document", "navisworks", Visualization, ["nwd"], OCTET),
303    cad_format!("navisworks-cache", "Navisworks cache", "navisworks", Visualization, ["nwc"], OCTET),
304    cad_format!("navisworks-file-set", "Navisworks file set (with references)", "navisworks", Other, ["nwf"], OCTET),
305    cad_format!("dgn", "MicroStation design (DGN)", "microstation", Model, ["dgn"], OCTET),
306    cad_format!("archicad-project", "Archicad project", "archicad", Model, ["pln"], OCTET),
307    cad_format!("archicad-archive", "Archicad archive", "archicad", Model, ["pla"], OCTET),
308    cad_format!("sketchup", "SketchUp model", "sketchup", Model, ["skp"], OCTET),
309    cad_format!("ifc", "IFC building model (ISO 16739)", "bim", Exchange, ["ifc"], OCTET),
310    cad_format!("ifc-xml", "IFC building model (XML)", "bim", Exchange, ["ifcxml"], "application/xml"),
311    cad_format!("ifc-zip", "IFC building model (zipped)", "bim", Exchange, ["ifczip"], OCTET),
312    // 3D / DCC / animation
313    cad_format!("blender", "Blender scene", "blender", Model, ["blend"], OCTET),
314    cad_format!("maya-ascii", "Maya scene (ASCII)", "maya", Model, ["ma"], OCTET),
315    cad_format!("maya-binary", "Maya scene (binary)", "maya", Model, ["mb"], OCTET),
316    cad_format!("3dsmax-scene", "3ds Max scene", "3dsmax", Model, ["max"], OCTET),
317    cad_format!("3ds", "3DS mesh (legacy 3D Studio)", "3dsmax", Mesh, ["3ds"], "application/x-3ds"),
318    cad_format!("cinema4d", "Cinema 4D scene", "cinema4d", Model, ["c4d"], OCTET),
319    cad_format!("modo", "Modo scene", "modo", Model, ["lxo"], OCTET),
320    cad_format!("houdini", "Houdini scene", "houdini", Model, ["hip", "hipnc", "hiplc"], OCTET),
321    // Neutral exchange formats
322    cad_format!("step", "STEP (ISO 10303)", "exchange", Exchange, ["step", "stp", "p21"], "model/step"),
323    cad_format!("iges", "IGES", "exchange", Exchange, ["iges", "igs"], "model/iges"),
324    cad_format!("parasolid-text", "Parasolid (text)", "exchange", Exchange, ["x_t", "xmt_txt"], "model/vnd.parasolid.transmit.text"),
325    cad_format!("parasolid-binary", "Parasolid (binary)", "exchange", Exchange, ["x_b", "xmt_bin"], "model/vnd.parasolid.transmit.binary"),
326    cad_format!("acis-text", "ACIS (text)", "exchange", Exchange, ["sat"], OCTET),
327    cad_format!("acis-binary", "ACIS (binary)", "exchange", Exchange, ["sab"], OCTET),
328    cad_format!("brep", "OpenCascade BREP", "exchange", Exchange, ["brep", "brp"], OCTET),
329    cad_format!("vda-fs", "VDA-FS", "exchange", Exchange, ["vda"], OCTET),
330    // Meshes and 3D printing
331    cad_format!("stl", "STL mesh", "mesh", Mesh, ["stl"], "model/stl"),
332    cad_format!("obj", "Wavefront OBJ mesh", "mesh", Mesh, ["obj"], "model/obj"),
333    cad_format!("3mf", "3MF (3D printing)", "mesh", Mesh, ["3mf"], "model/3mf"),
334    cad_format!("ply", "PLY mesh / point cloud", "mesh", Mesh, ["ply"], OCTET),
335    cad_format!("off", "OFF mesh", "mesh", Mesh, ["off"], OCTET),
336    cad_format!("amf", "AMF (3D printing)", "mesh", Mesh, ["amf"], OCTET),
337    // Visualisation
338    cad_format!("gltf", "glTF", "visualization", Visualization, ["gltf"], "model/gltf+json"),
339    cad_format!("glb", "glTF binary", "visualization", Visualization, ["glb"], "model/gltf-binary"),
340    cad_format!("fbx", "FBX", "visualization", Visualization, ["fbx"], OCTET),
341    cad_format!("collada", "COLLADA", "visualization", Visualization, ["dae"], "model/vnd.collada+xml"),
342    cad_format!("usd", "USD scene (Universal Scene Description)", "visualization", Visualization, ["usd", "usda", "usdc"], OCTET),
343    cad_format!("usdz", "USDZ (AR)", "visualization", Visualization, ["usdz"], "model/vnd.usdz+zip"),
344    cad_format!("alembic", "Alembic cache", "visualization", Visualization, ["abc"], OCTET),
345    cad_format!("vrml", "VRML", "visualization", Visualization, ["wrl", "vrml"], "model/vrml"),
346    cad_format!("x3d", "X3D", "visualization", Visualization, ["x3d"], "model/x3d+xml"),
347    cad_format!("pdf-3d", "3D PDF (U3D / PRC)", "visualization", Visualization, ["pdf"], "application/pdf"),
348    // Point clouds and scans
349    cad_format!("e57", "E57 point cloud (ASTM E2807)", "pointcloud", Visualization, ["e57"], OCTET),
350    cad_format!("las", "LAS point cloud (LiDAR)", "pointcloud", Visualization, ["las"], OCTET),
351    cad_format!("laz", "LAZ point cloud (compressed LAS)", "pointcloud", Visualization, ["laz"], OCTET),
352    cad_format!("pts", "PTS point cloud", "pointcloud", Visualization, ["pts"], "text/plain"),
353    cad_format!("ptx", "PTX point cloud (scanner stations)", "pointcloud", Visualization, ["ptx"], "text/plain"),
354    cad_format!("xyz", "XYZ point cloud", "pointcloud", Visualization, ["xyz"], "text/plain"),
355    cad_format!("pcd", "PCD point cloud (PCL)", "pointcloud", Visualization, ["pcd"], OCTET),
356    // Electronics
357    cad_format!("kicad-pcb", "KiCad board", "kicad", Electronics, ["kicad_pcb"], OCTET),
358    cad_format!("kicad-schematic", "KiCad schematic", "kicad", Electronics, ["kicad_sch"], OCTET),
359    cad_format!("kicad-project", "KiCad project", "kicad", Electronics, ["kicad_pro"], OCTET),
360    cad_format!("kicad-library", "KiCad symbol / footprint library", "kicad", Electronics, ["kicad_sym", "kicad_mod"], OCTET),
361    cad_format!("altium-pcb", "Altium board", "altium", Electronics, ["pcbdoc"], OCTET),
362    cad_format!("altium-schematic", "Altium schematic", "altium", Electronics, ["schdoc"], OCTET),
363    cad_format!("altium-project", "Altium project", "altium", Electronics, ["prjpcb"], OCTET),
364    cad_format!("altium-library", "Altium library", "altium", Electronics, ["pcblib", "schlib", "intlib"], OCTET),
365    cad_format!("eagle-board", "Eagle board", "eagle", Electronics, ["brd"], OCTET),
366    cad_format!("eagle-schematic", "Eagle schematic", "eagle", Electronics, ["sch"], OCTET),
367    cad_format!("eagle-library", "Eagle library", "eagle", Electronics, ["lbr"], OCTET),
368    cad_format!("gerber", "Gerber (PCB manufacturing)", "pcb-fab", Electronics, ["gbr", "gerber", "ger"], OCTET),
369    cad_format!("excellon", "Excellon drill file", "pcb-fab", Electronics, ["drl", "xln"], OCTET),
370    // Rocketry
371    cad_format!("openrocket", "OpenRocket design", "rocketry", Simulation, ["ork"], OCTET),
372    cad_format!("rocksim", "RockSim design", "rocketry", Simulation, ["rkt"], OCTET),
373    cad_format!("rasaero", "RASAero II project", "rocketry", Simulation, ["cdx1"], OCTET),
374    cad_format!("motor-eng", "Motor thrust curve (RASP .eng)", "rocketry", Simulation, ["eng"], "text/plain"),
375    cad_format!("motor-rse", "Motor thrust curve (RockSim .rse)", "rocketry", Simulation, ["rse"], "application/xml"),
376    // Simulation
377    cad_format!("ansys-workbench", "ANSYS Workbench project", "cae", Simulation, ["wbpj"], OCTET),
378    cad_format!("abaqus-cae", "Abaqus model database", "cae", Simulation, ["cae"], OCTET),
379    cad_format!("abaqus-input", "Abaqus input file", "cae", Simulation, ["inp"], "text/plain"),
380    cad_format!("comsol", "COMSOL model", "cae", Simulation, ["mph"], OCTET),
381    cad_format!("abaqus-odb", "Abaqus output database (results)", "cae", Result, ["odb"], OCTET),
382    cad_format!("abaqus-status", "Abaqus status file", "cae", Result, ["sta"], "text/plain"),
383    cad_format!("abaqus-message", "Abaqus message file", "cae", Result, ["msg"], "text/plain"),
384    cad_format!("ansys-result", "ANSYS result file", "cae", Result, ["rst"], OCTET),
385    cad_format!("ansys-thermal-result", "ANSYS thermal result file", "cae", Result, ["rth"], OCTET),
386    cad_format!("ansys-database", "ANSYS Mechanical APDL database", "cae", Simulation, ["db"], OCTET),
387    cad_format!("nastran-bulk", "Nastran input deck (bulk data)", "cae", Simulation, ["bdf", "nas", "dat"], "text/plain"),
388    cad_format!("nastran-op2", "Nastran binary results (OP2)", "cae", Result, ["op2"], OCTET),
389    cad_format!("nastran-f06", "Nastran print output (F06)", "cae", Result, ["f06"], "text/plain"),
390    cad_format!("nastran-f04", "Nastran execution summary (F04)", "cae", Result, ["f04"], "text/plain"),
391    cad_format!("nastran-punch", "Nastran punch output (PCH)", "cae", Result, ["pch"], "text/plain"),
392    cad_format!("optistruct-deck", "OptiStruct / HyperMesh input deck", "cae", Simulation, ["fem"], "text/plain"),
393    cad_format!("calculix-frd", "CalculiX results (FRD)", "cae", Result, ["frd"], "text/plain"),
394    cad_format!("lsdyna-keyword", "LS-DYNA keyword input deck", "cae", Simulation, ["k"], "text/plain"),
395    cad_format!("lsdyna-d3plot", "LS-DYNA results (d3plot)", "cae", Result, ["d3plot"], OCTET),
396    // CFD
397    cad_format!("openfoam-case", "OpenFOAM case (controlDict, .foam marker)", "cfd", Simulation, ["foam"], "text/plain"),
398    cad_format!("fluent-case", "Fluent case file", "cfd", Simulation, ["cas"], OCTET),
399    cad_format!("fluent-data", "Fluent data file (results)", "cfd", Result, ["dat"], OCTET),
400    cad_format!("fluent-case-h5", "Fluent case file (HDF5)", "cfd", Simulation, ["cas.h5"], OCTET),
401    cad_format!("fluent-data-h5", "Fluent data file (HDF5, results)", "cfd", Result, ["dat.h5"], OCTET),
402    cad_format!("fluent-mesh", "Fluent mesh file", "cfd", Mesh, ["msh"], OCTET),
403    cad_format!("fluent-mesh-h5", "Fluent mesh file (HDF5)", "cfd", Mesh, ["msh.h5"], OCTET),
404    cad_format!("fluent-case-gz", "Fluent case file (gzip)", "cfd", Simulation, ["cas.gz"], "application/gzip"),
405    cad_format!("fluent-data-gz", "Fluent data file (gzip, results)", "cfd", Result, ["dat.gz"], "application/gzip"),
406    cad_format!("fluent-mesh-gz", "Fluent mesh file (gzip)", "cfd", Mesh, ["msh.gz"], "application/gzip"),
407    cad_format!("fluent-project", "Fluent project", "cfd", Simulation, ["flprj"], OCTET),
408    cad_format!("fluent-cdat", "Fluent CFD-Post data (results)", "cfd", Result, ["cdat"], OCTET),
409    cad_format!("fluent-journal", "Fluent journal (run script)", "cfd", Simulation, ["jou"], "text/plain"),
410    cad_format!("fluent-transcript", "Fluent transcript (solver log)", "cfd", Result, ["trn"], "text/plain"),
411    cad_format!("fluent-report", "Fluent report file (monitor values)", "cfd", Result, ["out"], "text/plain"),
412    cad_format!("starccm-sim", "STAR-CCM+ simulation", "cfd", Simulation, ["sim"], OCTET),
413    cad_format!("starccm-history", "STAR-CCM+ solution history (results)", "cfd", Result, ["simh"], OCTET),
414    cad_format!("starccm-macro", "STAR-CCM+ macro (Java)", "cfd", Simulation, ["java"], "text/plain"),
415    // Open simulation data formats
416    cad_format!("vtk-legacy", "VTK legacy data", "cae-open", Result, ["vtk"], OCTET),
417    cad_format!("vtk-unstructured", "VTK unstructured grid (XML)", "cae-open", Result, ["vtu"], "application/xml"),
418    cad_format!("vtk-polydata", "VTK polygonal data (XML)", "cae-open", Result, ["vtp"], "application/xml"),
419    cad_format!("vtk-structured", "VTK structured grid (XML)", "cae-open", Result, ["vts"], "application/xml"),
420    cad_format!("vtk-rectilinear", "VTK rectilinear grid (XML)", "cae-open", Result, ["vtr"], "application/xml"),
421    cad_format!("vtk-collection", "ParaView data collection (time steps)", "cae-open", Result, ["pvd"], "application/xml"),
422    cad_format!("vtk-multiblock", "VTK multiblock data set (XML)", "cae-open", Result, ["vtm"], "application/xml"),
423    cad_format!("cgns", "CGNS (CFD General Notation System)", "cae-open", Result, ["cgns"], OCTET),
424    cad_format!("exodus", "Exodus II mesh and results", "cae-open", Result, ["exo", "e"], OCTET),
425    cad_format!("gmsh", "Gmsh mesh", "cae-open", Mesh, ["msh"], OCTET),
426    // CNC / 3D printer programs
427    cad_format!("gcode", "G-code program", "cam", Manufacturing, ["gcode", "gco", "ngc", "nc", "bgcode"], "text/plain"),
428    // Documents and images
429    cad_format!("pdf", "PDF document", "documents", Document, ["pdf"], "application/pdf"),
430    cad_format!("word", "Text document", "documents", Document, ["docx", "doc", "odt", "rtf"], ""),
431    cad_format!("spreadsheet", "Spreadsheet", "documents", Document, ["xlsx", "xls", "ods"], ""),
432    cad_format!("csv", "CSV table", "documents", Document, ["csv"], "text/csv"),
433    cad_format!("presentation", "Presentation", "documents", Document, ["pptx", "ppt", "odp"], ""),
434    cad_format!("text", "Text file", "documents", Document, ["txt", "md"], "text/plain"),
435    cad_format!("png", "PNG image", "documents", Document, ["png"], "image/png"),
436    cad_format!("jpeg", "JPEG image", "documents", Document, ["jpg", "jpeg"], "image/jpeg"),
437    cad_format!("zip", "ZIP archive", "documents", Other, ["zip"], "application/zip"),
438    cad_format!("7z", "7-Zip archive", "documents", Other, ["7z"], "application/x-7z-compressed"),
439];
440
441/// Extensions shared by two formats; [`detect`] (and for 3D PDFs
442/// [`detect_stored`]) decides by content.
443pub const AMBIGUOUS: [&str; 9] = [
444    "prt", "asm", "pdf", "sim", "fem", "dat", "msh", "java", "out",
445];
446
447/// Creo saves every iteration as `<name>.<ext>.<n>`.
448const CREO_ITERATED: [&str; 7] = ["prt", "asm", "drw", "frm", "lay", "sec", "mfg"];
449
450/// Bytes of the file content [`detect`] looks at.
451pub const HEAD_LEN: usize = 1024;
452
453const OLE_MAGIC: [u8; 8] = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
454
455pub fn by_id(id: &str) -> Option<&'static CadFormat> {
456    FORMATS.iter().find(|f| f.id == id)
457}
458
459pub fn system(id: &str) -> Option<&'static CadSystem> {
460    SYSTEMS.iter().find(|s| s.id == id)
461}
462
463fn first_with_extension(ext: &str) -> Option<&'static CadFormat> {
464    FORMATS.iter().find(|f| f.extensions.contains(&ext))
465}
466
467/// `bracket.prt.12` → `("bracket.prt", 12)`: the Creo iteration suffix.
468pub fn creo_iteration(name: &str) -> Option<(&str, u32)> {
469    let (base, number) = name.rsplit_once('.')?;
470    if number.is_empty() || number.len() > 6 || !number.bytes().all(|b| b.is_ascii_digit()) {
471        return None;
472    }
473    let (stem, ext) = base.rsplit_once('.')?;
474    if stem.is_empty() || !CREO_ITERATED.contains(&ext.to_ascii_lowercase().as_str()) {
475        return None;
476    }
477    Some((base, number.parse().ok()?))
478}
479
480/// The name a file is stored under: Creo iteration suffixes are dropped.
481pub fn normalize_name(name: &str) -> &str {
482    creo_iteration(name).map_or(name, |(base, _)| base)
483}
484
485/// Lower-case extension of a (normalised) file name; `None` for names
486/// without one or hidden files such as `.stl`.
487pub fn extension(name: &str) -> Option<String> {
488    let (stem, ext) = normalize_name(name).rsplit_once('.')?;
489    (!stem.is_empty() && !ext.is_empty()).then(|| ext.to_ascii_lowercase())
490}
491
492fn is_creo(head: &[u8]) -> bool {
493    head.starts_with(b"#UGC:")
494}
495
496fn is_ole(head: &[u8]) -> bool {
497    head.starts_with(&OLE_MAGIC)
498}
499
500/// Mostly printable text without NUL bytes (assembler sources, ASCII files).
501fn looks_textual(head: &[u8]) -> bool {
502    !head.is_empty()
503        && !head.contains(&0)
504        && head
505            .iter()
506            .filter(|b| b.is_ascii_graphic() || b.is_ascii_whitespace() || **b >= 0x80)
507            .count()
508            * 100
509            >= head.len() * 95
510}
511
512/// Classifies a file by its name and the first [`HEAD_LEN`] bytes of its
513/// content (pass `&[]` if the content is not at hand).
514pub fn detect(name: &str, head: &[u8]) -> Option<&'static CadFormat> {
515    if let Some(format) = detect_by_name(name) {
516        return Some(format);
517    }
518    let iterated = creo_iteration(name).is_some();
519    // `head` is empty when only the name is known: then the name decides.
520    let known = !head.is_empty();
521    if let Some(compound) = compound_extension(name) {
522        return detect_compound(compound, head, known);
523    }
524    let ext = extension(name)?;
525    match ext.as_str() {
526        // NX writes its simulation and FE model files in the same SPLMSSTR
527        // container as its parts (a positive test). STAR-CCM+ names itself
528        // near the start of the file; an unrecognised .sim stays NX (older
529        // NX releases have no container signature).
530        "sim" if is_nx_container(head) => by_id("nx-simulation"),
531        "sim" if contains_ascii_ci(head, b"star-ccm") || contains_ascii_ci(head, b"starccm") => {
532            by_id("starccm-sim")
533        }
534        "sim" => by_id("nx-simulation"),
535        // .fem: NX FE model (container) or an OptiStruct/HyperMesh text deck.
536        "fem" if !known || is_nx_container(head) => by_id("nx-simulation"),
537        "fem" if looks_textual(head) => by_id("optistruct-deck"),
538        "fem" => None,
539        // .afm: NX assembly FEM; Adobe font metrics (text) use it too.
540        "afm" if known && !is_nx_container(head) => None,
541        // Abaqus writes .msg and .sta as text; Outlook messages are OLE files.
542        "msg" | "sta" if known && !looks_textual(head) => None,
543        // Text logs and scripts of Nastran and Fluent.
544        "f04" | "pch" | "jou" | "trn" if known && !looks_textual(head) => None,
545        // OP2 is binary (Fortran records); a text .op2 is something else.
546        "op2" if known && looks_textual(head) => None,
547        // ANSYS databases are binary; SQLite files and Windows thumbnail
548        // caches use .db too.
549        "db" if name.eq_ignore_ascii_case("thumbs.db")
550            || head.starts_with(b"SQLite format 3")
551            || is_ole(head)
552            || looks_textual(head) =>
553        {
554            None
555        }
556        // ANSYS results are binary; text .rst files are reStructuredText.
557        "rst" if looks_textual(head) => None,
558        // Bitmap fonts (BDF) start with STARTFONT, Nastran decks do not.
559        "bdf" if head.starts_with(b"STARTFONT") => None,
560        // OpenDocument databases (LibreOffice Base) are ZIP files.
561        "odb" if head.starts_with(b"PK\x03\x04") => None,
562        // .dat is everywhere: only Fluent's "(0 …)" section syntax or the
563        // markers of a Nastran deck (Simcenter 3D writes its decks as .dat)
564        // count.
565        "dat" if is_fluent_sections(head) => by_id("fluent-data"),
566        "dat" if looks_textual(head) && is_nastran_deck(head) => by_id("nastran-bulk"),
567        "dat" => None,
568        // .msh: Fluent sections or Gmsh's "$MeshFormat"; anything else
569        // (and a name alone) stays unclassified.
570        "msh" if is_fluent_mesh(head) => by_id("fluent-mesh"),
571        "msh" if trim_start(head).starts_with(b"$MeshFormat") => by_id("gmsh"),
572        "msh" => None,
573        // Fluent report files: a quoted title line and a header of quoted
574        // column names; by name only "report-file-*.out".
575        "out" if is_fluent_report(head) => by_id("fluent-report"),
576        "out" if !known && is_report_file_name(name) => by_id("fluent-report"),
577        "out" => None,
578        // Only Java sources that name STAR-CCM+ are macros.
579        "java" if is_starccm_macro(head) => by_id("starccm-macro"),
580        "java" => None,
581        // Exodus II is netCDF or HDF5; other .e files are not.
582        "e" if known && !is_netcdf_or_hdf5(head) => None,
583        // LS-DYNA keyword decks are text.
584        "k" if known && !looks_textual(head) => None,
585        // NX and Creo both use .prt; only Creo writes the "#UGC:" text header
586        // and iteration suffixes.
587        "prt" if iterated || is_creo(head) => by_id("creo-part"),
588        "prt" => by_id("nx-part"),
589        // Creo (header) or Solid Edge (OLE compound document); plain text is
590        // an assembler source, not a CAD file.
591        "asm" if iterated || is_creo(head) => by_id("creo-assembly"),
592        "asm" if is_ole(head) => by_id("solidedge-assembly"),
593        "asm" if looks_textual(head) => None,
594        // KiCad 5 schematics start with "EESchema", Eagle's are XML.
595        "sch" if head.starts_with(b"EESchema") => by_id("kicad-schematic"),
596        // Compiled object files share the extension with OBJ meshes.
597        "obj" if !head.is_empty() && head.contains(&0) => None,
598        // 3D PDFs are recognised by detect_stored, which reads the whole file.
599        "pdf" => by_id("pdf"),
600        // SolidCraft: a ZIP container or a JSON document; other .sc files
601        // (scripts) are not CAD files.
602        "sc" if head.is_empty() || head.starts_with(b"PK\x03\x04") || is_solidcraft_json(head) => {
603            by_id("solidcraft-design")
604        }
605        "sc" => None,
606        other => first_with_extension(other),
607    }
608}
609
610/// Formats recognised by the whole file name rather than its extension:
611/// the OpenFOAM `controlDict` (marks a case folder), LS-DYNA `d3plot`,
612/// `d3plot01`, … (two-part extensions such as `.cas.h5` are
613/// [`compound_extension`]s).
614fn detect_by_name(name: &str) -> Option<&'static CadFormat> {
615    let base = name.rsplit(['/', '\\']).next().unwrap_or(name);
616    let lower = base.to_ascii_lowercase();
617    if base == "controlDict" {
618        return by_id("openfoam-case");
619    }
620    if let Some(rest) = lower.strip_prefix("d3plot") {
621        if rest.bytes().all(|b| b.is_ascii_digit()) {
622            return by_id("lsdyna-d3plot");
623        }
624    }
625    None
626}
627
628/// Two-part extensions of Fluent: HDF5 (Common Fluids Format) and gzip
629/// compressed legacy files.
630const COMPOUND: [&str; 6] = ["cas.h5", "dat.h5", "msh.h5", "cas.gz", "dat.gz", "msh.gz"];
631
632/// The two-part extension of a file name (`pipe.cas.h5` → `cas.h5`), lower
633/// case; `None` without a stem.
634fn compound_extension(name: &str) -> Option<&'static str> {
635    let base = name.rsplit(['/', '\\']).next().unwrap_or(name);
636    let lower = base.to_ascii_lowercase();
637    COMPOUND.into_iter().find(|ext| {
638        lower.len() > ext.len() + 1
639            && lower.ends_with(ext)
640            && lower.as_bytes().get(lower.len() - ext.len() - 1) == Some(&b'.')
641    })
642}
643
644/// A two-part extension: the name decides when only the name is known,
645/// otherwise the HDF5 signature or the gzip magic must be there too.
646fn detect_compound(ext: &str, head: &[u8], known: bool) -> Option<&'static CadFormat> {
647    let id = match ext {
648        "cas.h5" => "fluent-case-h5",
649        "dat.h5" => "fluent-data-h5",
650        "msh.h5" => "fluent-mesh-h5",
651        "cas.gz" => "fluent-case-gz",
652        "dat.gz" => "fluent-data-gz",
653        "msh.gz" => "fluent-mesh-gz",
654        _ => return None,
655    };
656    let content_ok = if ext.ends_with(".h5") {
657        is_hdf5(head)
658    } else {
659        head.starts_with(&GZIP_MAGIC)
660    };
661    if known && !content_ok {
662        return None;
663    }
664    by_id(id)
665}
666
667const HDF5_SIGNATURE: &[u8; 8] = b"\x89HDF\r\n\x1a\n";
668const GZIP_MAGIC: [u8; 2] = [0x1f, 0x8b];
669
670/// The HDF5 signature at offset 0 or 512 (HDF5 allows a user block in
671/// front of the superblock; later offsets 1024, 2048, … lie beyond
672/// [`HEAD_LEN`]).
673fn is_hdf5(head: &[u8]) -> bool {
674    head.starts_with(HDF5_SIGNATURE) || head.get(512..520) == Some(HDF5_SIGNATURE.as_slice())
675}
676
677/// The `SPLMSSTR` container NX writes its parts, FE models and simulations
678/// in (NX 1980 and later).
679fn is_nx_container(head: &[u8]) -> bool {
680    head.starts_with(crate::nxpart::MAGIC)
681}
682
683/// `head` without leading white space.
684fn trim_start(head: &[u8]) -> &[u8] {
685    let start = head
686        .iter()
687        .position(|b| !b.is_ascii_whitespace())
688        .unwrap_or(head.len());
689    head.get(start..).unwrap_or_default()
690}
691
692/// Lines of a text head (the last, possibly cut line included).
693fn head_lines(head: &[u8]) -> impl Iterator<Item = &[u8]> {
694    head.split(|b| *b == b'\n')
695        .map(|l| l.strip_suffix(b"\r").unwrap_or(l))
696}
697
698/// A Nastran bulk-data deck: a line starting with `SOL `, `CEND`,
699/// `BEGIN BULK` or `INIT MASTER`, or a `$` comment that names Nastran or
700/// Simcenter (the translator comment of Simcenter 3D).
701fn is_nastran_deck(head: &[u8]) -> bool {
702    head_lines(head).any(|line| {
703        let line = trim_start(line);
704        // the keyword as a whole word: "CEND", not "CENDRA"
705        let starts = |p: &[u8]| {
706            line.get(..p.len())
707                .is_some_and(|s| s.eq_ignore_ascii_case(p))
708                && (p.ends_with(b" ")
709                    || !line
710                        .get(p.len())
711                        .is_some_and(|b| b.is_ascii_alphanumeric() || *b == b'_'))
712        };
713        starts(b"SOL ")
714            || starts(b"CEND")
715            || starts(b"BEGIN BULK")
716            || starts(b"INIT MASTER")
717            || (line.starts_with(b"$")
718                && (contains_ascii_ci(line, b"nastran") || contains_ascii_ci(line, b"simcenter")))
719    })
720}
721
722/// Fluent mesh files: the sections of case files plus the dimension `(2 …)`
723/// and node `(10 …)` sections a mesh may start with.
724fn is_fluent_mesh(head: &[u8]) -> bool {
725    let rest = trim_start(head);
726    is_fluent_sections(head) || rest.starts_with(b"(2 ") || rest.starts_with(b"(10 ")
727}
728
729/// The tokens of a line made only of double-quoted strings separated by
730/// white space, optionally in parentheses (`"Iteration" "report-def-0"` or
731/// `("Iteration" "report-def-0")`); `None` for any other line.
732fn quoted_tokens(line: &[u8]) -> Option<usize> {
733    let mut rest = trim_start(line);
734    if let Some(inner) = rest.strip_prefix(b"(") {
735        let inner = inner.trim_ascii_end();
736        rest = inner.strip_suffix(b")")?;
737    }
738    let mut count = 0;
739    loop {
740        rest = trim_start(rest);
741        if rest.is_empty() {
742            return (count > 0).then_some(count);
743        }
744        let after = rest.strip_prefix(b"\"")?;
745        let end = after.iter().position(|b| *b == b'"')?;
746        rest = after.get(end + 1..)?;
747        count += 1;
748    }
749}
750
751/// A Fluent report file (`report-file-0.out`): the first line a quoted
752/// title, the second a header of at least two quoted column names.
753fn is_fluent_report(head: &[u8]) -> bool {
754    let mut lines = head_lines(head).filter(|l| !trim_start(l).is_empty());
755    matches!(lines.next().map(quoted_tokens), Some(Some(1)))
756        && matches!(lines.next().map(quoted_tokens), Some(Some(n)) if n >= 2)
757}
758
759/// `report-file-*.out`, the name Fluent gives its report files.
760fn is_report_file_name(name: &str) -> bool {
761    let base = name.rsplit(['/', '\\']).next().unwrap_or(name);
762    base.to_ascii_lowercase().starts_with("report-file")
763}
764
765/// A Java source that names STAR-CCM+ (the header comment of a recorded
766/// macro) or imports its API.
767fn is_starccm_macro(head: &[u8]) -> bool {
768    looks_textual(head)
769        && (contains_ascii_ci(head, b"star-ccm+") || contains_ascii_ci(head, b"import star.common"))
770}
771
772/// Case-insensitive search for an ASCII needle.
773fn contains_ascii_ci(head: &[u8], needle: &[u8]) -> bool {
774    head.windows(needle.len())
775        .any(|w| w.eq_ignore_ascii_case(needle))
776}
777
778/// Fluent case and data files consist of `(<index> …)` sections, starting
779/// with a comment section `(0 "…")` or the header `(1 "…")`.
780fn is_fluent_sections(head: &[u8]) -> bool {
781    let start = head
782        .iter()
783        .position(|b| !b.is_ascii_whitespace())
784        .unwrap_or(head.len());
785    let rest = head.get(start..).unwrap_or_default();
786    rest.starts_with(b"(0 ") || rest.starts_with(b"(1 ") || rest.starts_with(b"(4 ")
787}
788
789/// netCDF classic/64-bit (`CDF\x01`, `CDF\x02`, `CDF\x05`) or HDF5 signature.
790fn is_netcdf_or_hdf5(head: &[u8]) -> bool {
791    head.starts_with(b"CDF\x01")
792        || head.starts_with(b"CDF\x02")
793        || head.starts_with(b"CDF\x05")
794        || head.starts_with(b"\x89HDF\r\n\x1a\n")
795}
796
797/// JSON design of SolidCraft (`"format": "solidcraft/N"`).
798fn is_solidcraft_json(head: &[u8]) -> bool {
799    let text = String::from_utf8_lossy(head);
800    text.trim_start().starts_with('{')
801        && (text.contains("\"solidcraft/") || text.contains("\"solvecraft/"))
802}
803
804/// Whether a PDF embeds a 3D model: the 3D stream dictionaries (`/Subtype
805/// /U3D` or `/PRC`) are never compressed, so a byte search finds them.
806async fn pdf_has_3d(path: &std::path::Path) -> bool {
807    pdf_has_3d_within(path, PDF_3D_SCAN_LIMIT).await
808}
809
810/// How much of a PDF [`pdf_has_3d`] reads: 3D PDFs are rarely larger, bigger
811/// documents are treated as 2D.
812const PDF_3D_SCAN_LIMIT: u64 = 512 * 1024 * 1024;
813
814/// [`pdf_has_3d`] reading at most `limit` bytes (rounded up to a whole read).
815async fn pdf_has_3d_within(path: &std::path::Path, limit: u64) -> bool {
816    use tokio::io::AsyncReadExt;
817    let Ok(mut file) = tokio::fs::File::open(path).await else {
818        return false;
819    };
820    let needles: [&[u8]; 2] = [b"/U3D", b"/PRC"];
821    let mut buf = vec![0u8; 256 * 1024];
822    let mut carry: Vec<u8> = Vec::new();
823    let mut scanned = 0u64;
824    while scanned < limit {
825        let n = match file.read(&mut buf).await {
826            Ok(0) | Err(_) => return false,
827            Ok(n) => n,
828        };
829        scanned += n as u64;
830        carry.extend_from_slice(buf.get(..n).unwrap_or_default());
831        if needles
832            .iter()
833            .any(|needle| carry.windows(needle.len()).any(|w| w == *needle))
834        {
835            return true;
836        }
837        let keep = carry.len().saturating_sub(8);
838        carry.drain(..keep);
839    }
840    false
841}
842
843/// Content type to store for an upload: the registered type of the format,
844/// otherwise the (cleaned) type the client sent.
845pub fn content_type(format: Option<&CadFormat>, client: &str) -> String {
846    match format {
847        Some(f) if !f.mime.is_empty() => f.mime.to_string(),
848        _ => client.to_string(),
849    }
850}
851
852/// Classifies a stored file: reads the first [`HEAD_LEN`] bytes of `path`
853/// (a missing or unreadable file is classified by its name only).
854pub async fn detect_stored(path: &std::path::Path, name: &str) -> Option<&'static CadFormat> {
855    use tokio::io::AsyncReadExt;
856    let mut head = Vec::with_capacity(HEAD_LEN);
857    if let Ok(file) = tokio::fs::File::open(path).await {
858        let _ = file.take(HEAD_LEN as u64).read_to_end(&mut head).await;
859    }
860    let format = detect(name, &head);
861    if format.is_some_and(|f| f.id == "pdf") && pdf_has_3d(path).await {
862        return by_id("pdf-3d");
863    }
864    format
865}
866
867/// What the UI and the API show about a stored format id.
868#[derive(Clone, Copy, Debug, Serialize)]
869pub struct FormatInfo {
870    pub id: &'static str,
871    pub label: &'static str,
872    pub system: &'static str,
873    pub system_name: &'static str,
874    pub system_short: &'static str,
875    pub kind: FormatKind,
876    pub kind_label: &'static str,
877    /// Format of the browser 3D preview (`stl`, `step`, `iges`, `brep`).
878    pub preview: Option<&'static str>,
879    /// Files of this format carry a preview image (see `thumbnail`).
880    pub thumbnail: bool,
881    /// Native format family (the authoring program's system id, see
882    /// [`family`]); `None` for neutral formats.
883    pub family: Option<&'static str>,
884}
885
886impl CadFormat {
887    pub fn info(&'static self) -> FormatInfo {
888        let system = system(self.system);
889        FormatInfo {
890            id: self.id,
891            label: self.label,
892            system: self.system,
893            system_name: system.map_or("", |s| s.name),
894            system_short: system.map_or("", |s| s.short),
895            kind: self.kind,
896            kind_label: self.kind.label(),
897            preview: crate::preview::PreviewFormat::of_format(self.id).map(|p| p.as_str()),
898            thumbnail: crate::thumbnail::supports(self.id),
899            family: family(self.id).map(|s| s.id),
900        }
901    }
902}
903
904/// Info for a stored format id (`''` or unknown ids give `None`).
905pub fn info(id: &str) -> Option<FormatInfo> {
906    by_id(id).map(CadFormat::info)
907}
908
909/// Format ids of a CAD program, for the search filter.
910pub fn ids_of_system(system_id: &str) -> Vec<String> {
911    FORMATS
912        .iter()
913        .filter(|f| f.system == system_id)
914        .map(|f| f.id.to_string())
915        .collect()
916}
917
918/// Letters and digits only, lower case: "Solid Edge" → "solidedge".
919fn squash(s: &str) -> String {
920    s.chars()
921        .filter(|c| c.is_alphanumeric())
922        .flat_map(char::to_lowercase)
923        .collect()
924}
925
926/// The label without a trailing explanation: "CATIA product (assembly)" →
927/// "CATIA product".
928fn label_core(label: &str) -> &str {
929    label.split(" (").next().unwrap_or(label)
930}
931
932/// Format ids a free-text search names exactly: a CAD program
933/// ("SolidWorks", "NX", "Pro/E"), a format ("CATIA product", "Parasolid") or
934/// a format id ("jt", "step"). Case, spaces and punctuation do not matter.
935pub fn ids_named_by(query: &str) -> Vec<String> {
936    let q = squash(query);
937    if q.len() < 2 {
938        return Vec::new();
939    }
940    let systems: Vec<&str> = SYSTEMS
941        .iter()
942        .filter(|s| {
943            squash(s.id) == q
944                || squash(s.name) == q
945                || squash(s.short) == q
946                || s.aliases.iter().any(|a| squash(a) == q)
947        })
948        .map(|s| s.id)
949        .collect();
950    FORMATS
951        .iter()
952        .filter(|f| {
953            systems.contains(&f.system)
954                || squash(f.id) == q
955                || squash(f.label) == q
956                || squash(label_core(f.label)) == q
957        })
958        .map(|f| f.id.to_string())
959        .collect()
960}
961
962// ---------------------------------------------------------------------------
963// Format families (preferred CAD program)
964// ---------------------------------------------------------------------------
965
966/// Groups of [`SYSTEMS`] that are not one authoring program: neutral and
967/// exchange formats, meshes, visualisation, point clouds, open BIM, PCB
968/// fabrication data, multi-vendor simulation and CFD, G-code, documents.
969/// Their files belong to no family and are never collapsed. JT and PLM XML
970/// (`teamcenter`) are read by every major CAD program, so they count as
971/// neutral too.
972const NEUTRAL_SYSTEMS: [&str; 13] = [
973    "teamcenter",
974    "exchange",
975    "mesh",
976    "visualization",
977    "pointcloud",
978    "bim",
979    "pcb-fab",
980    "rocketry",
981    "cae",
982    "cfd",
983    "cae-open",
984    "cam",
985    "documents",
986];
987
988/// Formats of a program's system that are exchange formats in practice:
989/// DXF (written and read by every 2D/3D CAD program) and the legacy 3DS mesh.
990const NEUTRAL_FORMATS: [&str; 2] = ["dxf", "3ds"];
991
992/// The native format family of a stored format id: the [`CadSystem`] of the
993/// authoring program (`nx` for NX parts, assemblies, drawings and simulation
994/// files, `solidworks`, `inventor`, …). `None` for neutral and exchange
995/// formats (STEP, JT, STL, PDF, images, …) and unknown ids – those are never
996/// collapsed by a preferred CAD program.
997pub fn family(format_id: &str) -> Option<&'static CadSystem> {
998    let format = by_id(format_id)?;
999    if NEUTRAL_FORMATS.contains(&format.id) || NEUTRAL_SYSTEMS.contains(&format.system) {
1000        return None;
1001    }
1002    system(format.system)
1003}
1004
1005/// The families a user can prefer, in the order of [`SYSTEMS`]: every
1006/// program that owns at least one native format.
1007pub fn families() -> Vec<&'static CadSystem> {
1008    SYSTEMS
1009        .iter()
1010        .filter(|s| {
1011            FORMATS
1012                .iter()
1013                .any(|f| family(f.id).is_some_and(|x| x.id == s.id))
1014        })
1015        .collect()
1016}
1017
1018/// Whether `id` names a family a user can prefer.
1019pub fn is_family(id: &str) -> bool {
1020    families().iter().any(|s| s.id == id)
1021}
1022
1023/// How a file list is shown for a preferred CAD program.
1024#[derive(Clone, Debug, Default, PartialEq, Eq)]
1025pub struct FamilyGrouping {
1026    /// One flag per file, in the order given: `true` = a native file of
1027    /// another family, shown in the collapsed group.
1028    pub collapsed: Vec<bool>,
1029    /// Short names of the collapsed families, in the order of first
1030    /// appearance ("SolidWorks", "Inventor").
1031    pub other_families: Vec<&'static str>,
1032}
1033
1034impl FamilyGrouping {
1035    /// Number of collapsed files.
1036    pub fn collapsed_count(&self) -> usize {
1037        self.collapsed.iter().filter(|c| **c).count()
1038    }
1039}
1040
1041/// Splits the files of a revision (their stored format ids) for a preferred
1042/// family (`""` or an unknown id = show all). Only when at least one file
1043/// belongs to the preferred family are the native files of other families
1044/// collapsed; neutral files and files of the preferred family stay visible.
1045/// Without a file of the preferred family nothing is collapsed. Display only:
1046/// nothing is removed from the data.
1047pub fn group_by_family(preferred: &str, formats: &[&str]) -> FamilyGrouping {
1048    let none = || FamilyGrouping {
1049        collapsed: vec![false; formats.len()],
1050        other_families: Vec::new(),
1051    };
1052    if preferred.is_empty() || !is_family(preferred) {
1053        return none();
1054    }
1055    let families: Vec<Option<&'static CadSystem>> = formats.iter().map(|f| family(f)).collect();
1056    if !families.iter().flatten().any(|s| s.id == preferred) {
1057        return none();
1058    }
1059    let mut grouping = FamilyGrouping::default();
1060    for fam in families {
1061        let other = fam.filter(|s| s.id != preferred);
1062        if let Some(s) = other {
1063            if !grouping.other_families.contains(&s.short) {
1064                grouping.other_families.push(s.short);
1065            }
1066        }
1067        grouping.collapsed.push(other.is_some());
1068    }
1069    grouping
1070}
1071
1072/// Systems with their formats, for the help page and `GET /api/v1/formats`.
1073pub fn catalogue() -> Vec<serde_json::Value> {
1074    SYSTEMS
1075        .iter()
1076        .map(|s| {
1077            let formats: Vec<_> = FORMATS
1078                .iter()
1079                .filter(|f| f.system == s.id)
1080                .map(|f| {
1081                    let info = f.info();
1082                    serde_json::json!({
1083                        "id": f.id,
1084                        "label": f.label,
1085                        "kind": f.kind,
1086                        "kind_label": f.kind.label(),
1087                        "extensions": f.extensions,
1088                        "preview": info.preview,
1089                        "searchable_text": f.extensions.first().is_some_and(|e| crate::textindex::indexes(&format!("x.{e}"))),
1090                        "ambiguous": f.extensions.iter().any(|e| AMBIGUOUS.contains(e)),
1091                        "creo_iterations": f.extensions.iter().any(|e| CREO_ITERATED.contains(e)) && f.system == "creo",
1092                    })
1093                })
1094                .collect();
1095            serde_json::json!({
1096                "id": s.id,
1097                "name": s.name,
1098                "short": s.short,
1099                "vendor": s.vendor,
1100                "formats": formats,
1101            })
1102        })
1103        .collect()
1104}
1105
1106#[cfg(test)]
1107mod tests {
1108    use super::*;
1109    use std::collections::{HashMap, HashSet};
1110
1111    fn id(name: &str, head: &[u8]) -> Option<&'static str> {
1112        detect(name, head).map(|f| f.id)
1113    }
1114
1115    #[test]
1116    fn native_formats_of_the_common_cad_programs() {
1117        let cases = [
1118            ("Bracket.SLDPRT", "solidworks-part"),
1119            ("Rover.sldasm", "solidworks-assembly"),
1120            ("Rover.SLDDRW", "solidworks-drawing"),
1121            ("valve.ipt", "inventor-part"),
1122            ("valve.IAM", "inventor-assembly"),
1123            ("valve.idw", "inventor-drawing"),
1124            ("engine.f3d", "fusion-design"),
1125            ("engine.f3z", "fusion-distributed"),
1126            ("Nozzle.CATPart", "catia-part"),
1127            ("Stage.CATProduct", "catia-product"),
1128            ("Stage.CATDrawing", "catia-drawing"),
1129            ("plate.par", "solidedge-part"),
1130            ("plate.psm", "solidedge-sheetmetal"),
1131            ("plate.dft", "solidedge-drawing"),
1132            ("frame.drw", "creo-drawing"),
1133            ("layout.dwg", "dwg"),
1134            ("cut.DXF", "dxf"),
1135            ("model.jt", "jt"),
1136            ("structure.plmxml", "plmxml"),
1137            ("rover.FCStd", "freecad"),
1138            ("hull.scdoc", "spaceclaim"),
1139            ("tower.rvt", "revit-project"),
1140            ("door.rfa", "revit-family"),
1141            ("site.dgn", "dgn"),
1142            ("coordination.nwd", "navisworks-document"),
1143            ("coordination.nwc", "navisworks-cache"),
1144            ("coordination.nwf", "navisworks-file-set"),
1145            ("house.pln", "archicad-project"),
1146            ("house.pla", "archicad-archive"),
1147            ("house.skp", "sketchup"),
1148            ("body.3dm", "rhino"),
1149            ("body.wire", "alias-wire"),
1150            ("scene.blend", "blender"),
1151            ("scene.ma", "maya-ascii"),
1152            ("scene.mb", "maya-binary"),
1153            ("scene.max", "3dsmax-scene"),
1154            ("scene.c4d", "cinema4d"),
1155            ("scene.lxo", "modo"),
1156            ("scene.hipnc", "houdini"),
1157            ("bracket.fieldcraft", "fieldcraft-model"),
1158            ("bracket.solidcraft", "solidcraft-design"),
1159        ];
1160        for (name, expected) in cases {
1161            assert_eq!(id(name, &[]), Some(expected), "{name}");
1162        }
1163    }
1164
1165    #[test]
1166    fn neutral_formats() {
1167        let cases = [
1168            ("a.step", "step"),
1169            ("a.STP", "step"),
1170            ("a.p21", "step"),
1171            ("a.igs", "iges"),
1172            ("a.IGES", "iges"),
1173            ("a.x_t", "parasolid-text"),
1174            ("a.X_B", "parasolid-binary"),
1175            ("a.xmt_txt", "parasolid-text"),
1176            ("a.sat", "acis-text"),
1177            ("a.sab", "acis-binary"),
1178            ("a.stl", "stl"),
1179            ("a.3mf", "3mf"),
1180            ("a.brep", "brep"),
1181            ("board.kicad_pcb", "kicad-pcb"),
1182            ("flight.ork", "openrocket"),
1183            ("M2000.eng", "motor-eng"),
1184            ("building.ifc", "ifc"),
1185            ("building.IFCZIP", "ifc-zip"),
1186            ("mesh.off", "off"),
1187            ("legacy.3ds", "3ds"),
1188            ("scene.dae", "collada"),
1189            ("scene.usdz", "usdz"),
1190            ("scene.usda", "usd"),
1191            ("anim.abc", "alembic"),
1192            ("scan.e57", "e57"),
1193            ("scan.las", "las"),
1194            ("scan.laz", "laz"),
1195            ("scan.pts", "pts"),
1196            ("scan.xyz", "xyz"),
1197            ("report.pdf", "pdf"),
1198        ];
1199        for (name, expected) in cases {
1200            assert_eq!(id(name, &[]), Some(expected), "{name}");
1201        }
1202        assert_eq!(id("README", &[]), None);
1203        assert_eq!(id(".stl", &[]), None, "hidden file without a name");
1204        assert_eq!(id("archive.unknownext", &[]), None);
1205    }
1206
1207    #[test]
1208    fn ambiguous_extensions_are_resolved_by_content() {
1209        // .prt: NX unless the Creo header or an iteration suffix says Creo.
1210        assert_eq!(id("housing.prt", b"\x00\x01binary"), Some("nx-part"));
1211        assert_eq!(id("housing.prt", &[]), Some("nx-part"));
1212        assert_eq!(
1213            id("housing.prt", b"#UGC:2 PART 1182 500 10 1 1 15 2400"),
1214            Some("creo-part")
1215        );
1216        assert_eq!(id("housing.prt.3", b"\x00binary"), Some("creo-part"));
1217        // .asm: Creo, Solid Edge (OLE) or an assembler source (unknown).
1218        assert_eq!(id("rover.asm", b"#UGC:2 ASSEMBLY 1"), Some("creo-assembly"));
1219        assert_eq!(id("rover.asm.12", &[]), Some("creo-assembly"));
1220        let mut ole = OLE_MAGIC.to_vec();
1221        ole.extend_from_slice(&[0; 16]);
1222        assert_eq!(id("rover.asm", &ole), Some("solidedge-assembly"));
1223        // the OLE signature decides even when the rest of the head is printable
1224        // (found by cargo mutants: without NULs it would look like a source)
1225        let mut printable_ole = OLE_MAGIC.to_vec();
1226        printable_ole.extend_from_slice(&[b'a'; 60]);
1227        assert_eq!(id("rover.asm", &printable_ole), Some("solidedge-assembly"));
1228        assert_eq!(id("startup.asm", b"  .section .text\n  mov r0, #1\n"), None);
1229        // binary, neither Creo nor OLE: still a CAD assembly by its extension
1230        assert!(id("rover.asm", b"\x00\x01\x02binary").is_some());
1231        // .sch: KiCad 5 or Eagle.
1232        assert_eq!(
1233            id("power.sch", b"EESchema Schematic File Version 4"),
1234            Some("kicad-schematic")
1235        );
1236        assert_eq!(
1237            id("power.sch", b"<?xml version=\"1.0\"?><!DOCTYPE eagle"),
1238            Some("eagle-schematic")
1239        );
1240        // .obj: mesh (text) or compiled object file (binary).
1241        assert_eq!(id("hull.obj", b"v 0 0 0\nv 1 0 0\n"), Some("obj"));
1242        assert_eq!(id("main.obj", b"\x4c\x01\x00\x00"), None);
1243        // .sc: SolidCraft container (ZIP) or JSON design – or a script.
1244        assert_eq!(
1245            id("bracket.sc", b"PK\x03\x04rest"),
1246            Some("solidcraft-design")
1247        );
1248        assert_eq!(
1249            id("bracket.sc", b"{\n  \"format\": \"solidcraft/24\",\n"),
1250            Some("solidcraft-design")
1251        );
1252        assert_eq!(
1253            id("synth.sc", b"SynthDef(\\sine, { Out.ar(0, SinOsc.ar) })"),
1254            None
1255        );
1256        // .pdf by name is a document; 3D content is found in the stored file
1257        assert_eq!(id("model.pdf", b"%PDF-1.7"), Some("pdf"));
1258    }
1259
1260    #[tokio::test]
1261    async fn three_d_pdfs_are_found_by_content() {
1262        let dir = std::env::temp_dir().join(format!("teamcraft-pdf-{}", uuid::Uuid::new_v4()));
1263        std::fs::create_dir_all(&dir).unwrap();
1264        let flat = dir.join("flat.pdf");
1265        std::fs::write(&flat, b"%PDF-1.7\n1 0 obj << /Type /Page >> endobj\n%%EOF").unwrap();
1266        let mut model = b"%PDF-1.7\n".to_vec();
1267        model.extend(std::iter::repeat_n(b'x', 300 * 1024)); // across a read boundary
1268        model.extend_from_slice(b"\n7 0 obj << /Type /3D /Subtype /U3D /Length 9 >>\nstream");
1269        let three_d = dir.join("model.pdf");
1270        std::fs::write(&three_d, &model).unwrap();
1271        assert_eq!(
1272            detect_stored(&flat, "flat.pdf").await.map(|f| f.id),
1273            Some("pdf")
1274        );
1275        assert_eq!(
1276            detect_stored(&three_d, "model.pdf").await.map(|f| f.id),
1277            Some("pdf-3d")
1278        );
1279        std::fs::remove_dir_all(&dir).ok();
1280    }
1281
1282    /// Found by cargo mutants: the scan limit was not pinned down. Tested
1283    /// with a 1 MiB limit (four reads of 256 KiB) to stay fast.
1284    #[tokio::test]
1285    async fn three_d_markers_are_searched_up_to_the_limit_only() {
1286        use std::io::{Seek, SeekFrom, Write};
1287        let dir = std::env::temp_dir().join(format!("teamcraft-pdf-{}", uuid::Uuid::new_v4()));
1288        std::fs::create_dir_all(&dir).unwrap();
1289        let at = |offset: u64, name: &str| {
1290            let path = dir.join(name);
1291            let mut f = std::fs::File::create(&path).unwrap();
1292            f.write_all(b"%PDF-1.7\n").unwrap();
1293            f.seek(SeekFrom::Start(offset)).unwrap();
1294            f.write_all(b"/Subtype /U3D").unwrap();
1295            path
1296        };
1297        const MIB: u64 = 1024 * 1024;
1298        assert_eq!(PDF_3D_SCAN_LIMIT, 512 * MIB);
1299        let inside = at(MIB - 64, "inside.pdf");
1300        let past = at(MIB, "past-limit.pdf");
1301        assert!(pdf_has_3d_within(&inside, MIB).await, "within the limit");
1302        assert!(
1303            !pdf_has_3d_within(&past, MIB).await,
1304            "right after the limit: treated as a 2D document"
1305        );
1306        assert!(
1307            pdf_has_3d_within(&past, 2 * MIB).await,
1308            "found with a larger limit"
1309        );
1310        // the real limit: past the first megabytes is still searched
1311        assert!(pdf_has_3d(&at(2 * MIB, "two-mib.pdf")).await);
1312        std::fs::remove_dir_all(&dir).ok();
1313    }
1314
1315    #[test]
1316    fn creo_iterations() {
1317        assert_eq!(creo_iteration("bracket.prt.7"), Some(("bracket.prt", 7)));
1318        assert_eq!(creo_iteration("Frame.ASM.12"), Some(("Frame.ASM", 12)));
1319        assert_eq!(creo_iteration("sheet.drw.1"), Some(("sheet.drw", 1)));
1320        assert_eq!(creo_iteration("bracket.prt"), None);
1321        assert_eq!(creo_iteration("report.pdf.2"), None, "only Creo files");
1322        assert_eq!(creo_iteration("v1.2"), None);
1323        assert_eq!(creo_iteration(".prt.1"), None);
1324        assert_eq!(creo_iteration("bracket.prt.x"), None);
1325        assert_eq!(normalize_name("bracket.prt.7"), "bracket.prt");
1326        assert_eq!(normalize_name("bracket.stp"), "bracket.stp");
1327        assert_eq!(extension("Bracket.PRT.3").as_deref(), Some("prt"));
1328    }
1329
1330    #[test]
1331    fn simulation_models_and_results() {
1332        let by_name = [
1333            ("mesh.vtk", "vtk-legacy"),
1334            ("field.VTU", "vtk-unstructured"),
1335            ("surface.vtp", "vtk-polydata"),
1336            ("grid.vts", "vtk-structured"),
1337            ("grid.vtr", "vtk-rectilinear"),
1338            ("run.pvd", "vtk-collection"),
1339            ("blocks.vtm", "vtk-multiblock"),
1340            ("job.frd", "calculix-frd"),
1341            ("model.op2", "nastran-op2"),
1342            ("model.f06", "nastran-f06"),
1343            ("model.bdf", "nastran-bulk"),
1344            ("model.nas", "nastran-bulk"),
1345            ("file.rst", "ansys-result"),
1346            ("file.rth", "ansys-thermal-result"),
1347            ("file.db", "ansys-database"),
1348            ("Job-1.odb", "abaqus-odb"),
1349            ("Job-1.sta", "abaqus-status"),
1350            ("Job-1.msg", "abaqus-message"),
1351            ("flow.cgns", "cgns"),
1352            ("mesh.exo", "exodus"),
1353            ("mesh.e", "exodus"),
1354            ("pipe.cas", "fluent-case"),
1355            ("pipe.cas.h5", "fluent-case-h5"),
1356            ("pipe-1-00100.DAT.H5", "fluent-data-h5"),
1357            ("bracket.sim", "nx-simulation"),
1358            ("bracket_fem1.fem", "nx-simulation"),
1359            ("crash.k", "lsdyna-keyword"),
1360            ("crash.d3plot", "lsdyna-d3plot"),
1361            ("d3plot", "lsdyna-d3plot"),
1362            ("d3plot07", "lsdyna-d3plot"),
1363            ("controlDict", "openfoam-case"),
1364            ("system/controlDict", "openfoam-case"),
1365            ("pitzDaily.foam", "openfoam-case"),
1366        ];
1367        for (name, expected) in by_name {
1368            assert_eq!(id(name, &[]), Some(expected), "{name}");
1369        }
1370        for (expected, kind) in [
1371            ("vtk-unstructured", FormatKind::Result),
1372            ("calculix-frd", FormatKind::Result),
1373            ("nastran-bulk", FormatKind::Simulation),
1374            ("fluent-data", FormatKind::Result),
1375            ("openfoam-case", FormatKind::Simulation),
1376        ] {
1377            assert_eq!(by_id(expected).unwrap().kind, kind, "{expected}");
1378        }
1379        // decided by content
1380        assert_eq!(
1381            id("pipe.sim", b"\x00\x01STAR-CCM+ 2402 simulation"),
1382            Some("starccm-sim")
1383        );
1384        assert_eq!(id("pipe.sim", b"\x00\x01\x02NX"), Some("nx-simulation"));
1385        assert_eq!(id("pipe.dat", &[]), None, ".dat alone is not enough");
1386        assert_eq!(
1387            id("pipe.dat", b"(0 \"fluent data\")\n"),
1388            Some("fluent-data")
1389        );
1390        assert_eq!(
1391            id("pipe.dat", b"  (1 \"ANSYS Fluent 2024\")"),
1392            Some("fluent-data")
1393        );
1394        assert_eq!(id("values.dat", b"1 2 3\n4 5 6\n"), None);
1395        assert_eq!(id("mesh.e", b"CDF\x02\x00\x00\x00\x01"), Some("exodus"));
1396        assert_eq!(id("mesh.e", b"\x89HDF\r\n\x1a\n\x00"), Some("exodus"));
1397        assert_eq!(id("notes.e", b"plain text"), None);
1398        assert_eq!(id("app.db", b"SQLite format 3\x00"), None);
1399        assert_eq!(id("Thumbs.db", &[]), None);
1400        assert_eq!(id("cache.db", &OLE_MAGIC), None);
1401        assert_eq!(
1402            id("file.db", b"\x00\x00\x00\x64ANSYS"),
1403            Some("ansys-database")
1404        );
1405        assert_eq!(
1406            id("README.rst", b"Title\n=====\n\nText"),
1407            None,
1408            "reStructuredText"
1409        );
1410        assert_eq!(
1411            id("file.rst", b"\x00\x00\x00\x64\x00\x00"),
1412            Some("ansys-result")
1413        );
1414        assert_eq!(id("mail.msg", &OLE_MAGIC), None, "Outlook message");
1415        assert_eq!(
1416            id("Job-1.msg", b" Abaqus 2024\n STEP 1\n"),
1417            Some("abaqus-message")
1418        );
1419        assert_eq!(id("font.bdf", b"STARTFONT 2.1\n"), None);
1420        assert_eq!(id("model.bdf", b"SOL 101\nCEND\n"), Some("nastran-bulk"));
1421        assert_eq!(id("base.odb", b"PK\x03\x04"), None, "OpenDocument database");
1422        assert_eq!(id("crash.k", b"*KEYWORD\n*NODE\n"), Some("lsdyna-keyword"));
1423        assert_eq!(id("crash.k", b"\x00\x01\x02\x03"), None);
1424        assert_eq!(id("d3plotter.txt", &[]), Some("text"), "not d3plot");
1425        assert_eq!(id(".cas.h5", &[]), None, "no stem");
1426        // the search knows the solvers
1427        assert!(!ids_named_by("simulation").is_empty());
1428        assert!(ids_named_by("OpenFOAM").contains(&"openfoam-case".to_string()));
1429        assert!(ids_named_by("vtk").contains(&"vtk-unstructured".to_string()));
1430        assert!(ids_named_by("Nastran").contains(&"nastran-op2".to_string()));
1431    }
1432
1433    #[test]
1434    fn registry_is_consistent() {
1435        let mut ids = HashSet::new();
1436        let mut owners: HashMap<&str, Vec<&str>> = HashMap::new();
1437        for f in FORMATS {
1438            assert!(ids.insert(f.id), "duplicate format id {}", f.id);
1439            assert!(system(f.system).is_some(), "{}: unknown system", f.id);
1440            assert!(!f.extensions.is_empty(), "{}", f.id);
1441            for ext in f.extensions {
1442                assert_eq!(*ext, ext.to_ascii_lowercase(), "{}: lower case", f.id);
1443                assert!(!ext.starts_with('.'), "{}: without dot", f.id);
1444                owners.entry(ext).or_default().push(f.id);
1445            }
1446            assert!(
1447                f.mime.is_empty() || (f.mime.contains('/') && !f.mime.contains(' ')),
1448                "{}: content type",
1449                f.id
1450            );
1451            assert!(by_id(f.id).is_some_and(|g| g.label == f.label));
1452        }
1453        for (ext, formats) in owners {
1454            if AMBIGUOUS.contains(&ext) {
1455                // two owners, or one that needs the content (.java, .out)
1456                assert!((1..=2).contains(&formats.len()), "{ext}: {formats:?}");
1457            } else {
1458                assert_eq!(formats.len(), 1, "extension {ext} belongs to {formats:?}");
1459            }
1460        }
1461        for s in SYSTEMS {
1462            assert!(
1463                FORMATS.iter().any(|f| f.system == s.id),
1464                "system {} has no formats",
1465                s.id
1466            );
1467        }
1468    }
1469
1470    #[test]
1471    fn the_programs_the_users_asked_for_are_covered() {
1472        for system_id in [
1473            "solidworks",
1474            "nx",
1475            "teamcenter",
1476            "inventor",
1477            "fusion",
1478            "creo",
1479            "catia",
1480            "solid-edge",
1481            "autocad",
1482            "exchange",
1483        ] {
1484            assert!(system(system_id).is_some(), "{system_id}");
1485        }
1486        for ext in [
1487            "sldprt",
1488            "sldasm",
1489            "slddrw",
1490            "prt",
1491            "ipt",
1492            "iam",
1493            "idw",
1494            "dwg",
1495            "f3d",
1496            "f3z",
1497            "asm",
1498            "drw",
1499            "catpart",
1500            "catproduct",
1501            "catdrawing",
1502            "par",
1503            "dft",
1504            "dxf",
1505            "stp",
1506            "step",
1507            "igs",
1508            "iges",
1509            "x_t",
1510            "x_b",
1511            "jt",
1512            "sat",
1513            "sab",
1514            "stl",
1515            "psm",
1516            "scdoc",
1517            "rvt",
1518            "rfa",
1519            "skp",
1520            "dgn",
1521            "ifc",
1522            "nwd",
1523            "nwc",
1524            "nwf",
1525            "pln",
1526            "pla",
1527            "3dm",
1528            "wire",
1529            "blend",
1530            "ma",
1531            "mb",
1532            "max",
1533            "3ds",
1534            "c4d",
1535            "lxo",
1536            "hip",
1537            "hipnc",
1538            "pdf",
1539            "obj",
1540            "fbx",
1541            "gltf",
1542            "glb",
1543            "3mf",
1544            "ply",
1545            "dae",
1546            "usd",
1547            "usdz",
1548            "amf",
1549            "wrl",
1550            "x3d",
1551            "e57",
1552            "pts",
1553            "xyz",
1554            "las",
1555            "laz",
1556            "abc",
1557        ] {
1558            assert!(
1559                detect(&format!("file.{ext}"), &[]).is_some()
1560                    || detect(&format!("file.{ext}"), b"#UGC:2").is_some(),
1561                ".{ext}"
1562            );
1563        }
1564    }
1565
1566    #[test]
1567    fn content_types() {
1568        assert_eq!(content_type(by_id("step"), OCTET), "model/step");
1569        assert_eq!(
1570            content_type(by_id("solidworks-part"), "application/x-whatever"),
1571            OCTET
1572        );
1573        assert_eq!(
1574            content_type(by_id("word"), "application/msword"),
1575            "application/msword",
1576            "documents keep the client's type"
1577        );
1578        assert_eq!(content_type(None, "text/plain"), "text/plain");
1579    }
1580
1581    #[test]
1582    fn search_names() {
1583        let sw = ids_named_by("SolidWorks");
1584        assert!(sw.contains(&"solidworks-part".to_string()) && sw.len() == 5);
1585        assert!(ids_named_by("nx").contains(&"nx-part".to_string()));
1586        assert!(!ids_named_by("nx").contains(&"creo-part".to_string()));
1587        assert!(ids_named_by("Pro/E").contains(&"creo-part".to_string()));
1588        assert!(ids_named_by("solid edge").contains(&"solidedge-drawing".to_string()));
1589        assert!(ids_named_by("Fusion 360").contains(&"fusion-design".to_string()));
1590        assert_eq!(ids_named_by("CATIA product"), vec!["catia-product"]);
1591        assert_eq!(ids_named_by("jt"), vec!["jt"]);
1592        assert!(ids_named_by("step").contains(&"step".to_string()));
1593        assert!(ids_named_by("bracket").is_empty());
1594        assert!(ids_named_by("x").is_empty());
1595        // by the full program name only: neither id, short name nor alias
1596        // matches "Autodesk Inventor" (found by cargo mutants)
1597        assert!(ids_named_by("Autodesk Inventor").contains(&"inventor-part".to_string()));
1598        assert_eq!(ids_of_system("inventor").len(), 5);
1599    }
1600
1601    #[test]
1602    fn extensions_are_case_insensitive() {
1603        for name in ["Bracket.SLDPRT", "bracket.sldprt", "BRACKET.SldPrt"] {
1604            assert_eq!(id(name, &[]), Some("solidworks-part"), "{name}");
1605        }
1606        assert_eq!(id("Top.CATProduct", &[]), Some("catia-product"));
1607        assert_eq!(id("TOP.CATPRODUCT", &[]), Some("catia-product"));
1608        assert_eq!(extension("A.STEP").as_deref(), Some("step"));
1609        assert_eq!(extension("noext"), None);
1610        assert_eq!(extension("trailing."), None);
1611        assert_eq!(extension(".hidden"), None);
1612    }
1613
1614    #[test]
1615    fn name_only_detection_trusts_the_extension() {
1616        // Without content (`&[]`) the extension decides even for the
1617        // extensions that need content otherwise.
1618        assert_eq!(id("Job-1.msg", &[]), Some("abaqus-message"));
1619        assert_eq!(id("Job-1.sta", &[]), Some("abaqus-status"));
1620        assert_eq!(id("crash.k", &[]), Some("lsdyna-keyword"));
1621        assert_eq!(id("mesh.e", &[]), Some("exodus"));
1622        assert_eq!(id("hull.obj", &[]), Some("obj"));
1623        assert_eq!(id("design.sc", &[]), Some("solidcraft-design"));
1624        // ... but .dat never counts without Fluent sections.
1625        assert_eq!(id("pipe.dat", &[]), None);
1626    }
1627
1628    #[test]
1629    fn whole_file_names_and_paths() {
1630        assert_eq!(id("case\\system\\controlDict", &[]), Some("openfoam-case"));
1631        assert_eq!(
1632            id("controldict", &[]),
1633            None,
1634            "OpenFOAM spells it controlDict"
1635        );
1636        assert_eq!(id("run/D3PLOT12", &[]), Some("lsdyna-d3plot"));
1637        assert_eq!(id("d3plot_a", &[]), None);
1638        assert_eq!(id("d3plot01.bak", &[]), None);
1639        // The STAR-CCM+ marker is matched case-insensitively.
1640        assert_eq!(id("x.sim", b"..starccm+.."), Some("starccm-sim"));
1641        // Fluent sections after leading white space; (4 …) too.
1642        assert_eq!(id("x.dat", b"\n\t(4 (\"rp\"))"), Some("fluent-data"));
1643        assert_eq!(id("x.dat", b"(2 3)"), None);
1644        assert_eq!(id("x.e", b"CDF\x03"), None, "unknown netCDF version");
1645    }
1646
1647    /// The research page `docs/src/research/solver-files.md`: synthetic
1648    /// heads for every rule (no real files of these programs were at hand).
1649    #[test]
1650    fn simcenter_3d_files() {
1651        let mut nx = crate::nxpart::MAGIC.to_vec();
1652        nx.extend_from_slice(&[0, 0, 0, 1, 0xFF, 0x00]);
1653        // the SPLMSSTR container is a positive NX test for .sim/.fem/.afm
1654        for name in ["bracket_sim1.sim", "bracket_fem1.fem", "frame.afm"] {
1655            assert_eq!(id(name, &nx), Some("nx-simulation"), "{name}");
1656            assert_eq!(id(name, &[]), Some("nx-simulation"), "{name} by name");
1657        }
1658        // even when the container happens to contain the STAR-CCM+ name
1659        let mut nx_star = nx.clone();
1660        nx_star.extend_from_slice(b"STAR-CCM+ import");
1661        assert_eq!(id("a.sim", &nx_star), Some("nx-simulation"));
1662        // a .sim with neither marker keeps the NX fallback
1663        assert_eq!(
1664            id("old.sim", b"\x00\x00\x00\x07binary"),
1665            Some("nx-simulation")
1666        );
1667        // .fem: OptiStruct/HyperMesh text deck, other binary files unknown
1668        assert_eq!(
1669            id(
1670                "bracket.fem",
1671                b"$$ HyperMesh OptiStruct deck\nBEGIN BULK\nGRID,1,,0.,0.,0.\n"
1672            ),
1673            Some("optistruct-deck")
1674        );
1675        assert_eq!(id("bracket.fem", b"\x00\x01\x02\x03\x04"), None);
1676        // .afm: Adobe font metrics are text
1677        assert_eq!(
1678            id(
1679                "Helvetica.afm",
1680                b"StartFontMetrics 4.1\nFontName Helvetica\n"
1681            ),
1682            None
1683        );
1684        // Nastran decks written by Simcenter 3D as .dat
1685        for deck in [
1686            &b"$ Simcenter Nastran input deck\nSOL 101\nCEND\n"[..],
1687            b"SOL 101\nCEND\nBEGIN BULK\n",
1688            b"  sol 103\n",
1689            b"INIT MASTER(S)\nID bracket\n",
1690            b"TITLE = x\r\nBEGIN BULK\r\nGRID,1\r\n",
1691            b"$ NASTRAN deck\nGRID,1,,0.,0.,0.\n",
1692        ] {
1693            assert_eq!(
1694                id("bracket_sim1-solution_1.dat", deck),
1695                Some("nastran-bulk")
1696            );
1697        }
1698        assert_eq!(id("values.dat", b"SOLAR 1 2 3\nCENDRA\n"), None);
1699        assert_eq!(id("values.dat", b"$ just a comment\n1 2 3\n"), None);
1700        assert_eq!(id("blob.dat", b"\x00SOL 101\nCEND\n"), None, "binary");
1701        assert_eq!(
1702            id("pipe.dat", b"(0 \"fluent\")\nSOL 101\n"),
1703            Some("fluent-data")
1704        );
1705        // the rest of the Nastran set
1706        assert_eq!(
1707            id("run.f04", b"  NASTRAN EXECUTIVE SUMMARY\n"),
1708            Some("nastran-f04")
1709        );
1710        assert_eq!(id("run.f04", &[]), Some("nastran-f04"));
1711        assert_eq!(id("run.f04", b"\x00\x01\x02\x03"), None);
1712        assert_eq!(id("run.pch", b"$TITLE   = x\n"), Some("nastran-punch"));
1713        assert_eq!(id("run.pch", b"\x00\x01\x02\x03"), None);
1714        assert_eq!(
1715            id(
1716                "run.op2",
1717                b"\x00\x00\x00\x1cNASTRAN FORT TAPE ID CODE - \x00"
1718            ),
1719            Some("nastran-op2")
1720        );
1721        assert_eq!(id("run.op2", &[]), Some("nastran-op2"));
1722        assert_eq!(id("notes.op2", b"plain text notes\n"), None);
1723    }
1724
1725    #[test]
1726    fn fluent_files() {
1727        let mut hdf5 = HDF5_SIGNATURE.to_vec();
1728        hdf5.extend_from_slice(&[0; 16]);
1729        let mut user_block = vec![0u8; 512];
1730        user_block.extend_from_slice(HDF5_SIGNATURE);
1731        let gz = [0x1f, 0x8b, 0x08, 0x00, 0, 0, 0, 0];
1732        for (name, expected) in [
1733            ("pipe.cas.h5", "fluent-case-h5"),
1734            ("pipe-1-00100.dat.h5", "fluent-data-h5"),
1735            ("pipe.MSH.H5", "fluent-mesh-h5"),
1736        ] {
1737            assert_eq!(id(name, &hdf5), Some(expected), "{name}");
1738            assert_eq!(id(name, &user_block), Some(expected), "{name}, user block");
1739            assert_eq!(id(name, &[]), Some(expected), "{name} by name");
1740            assert_eq!(
1741                id(name, b"not hdf5 at all"),
1742                None,
1743                "{name} without signature"
1744            );
1745            assert_eq!(id(name, &gz), None, "{name} gzip");
1746        }
1747        for (name, expected) in [
1748            ("pipe.cas.gz", "fluent-case-gz"),
1749            ("pipe.dat.gz", "fluent-data-gz"),
1750            ("pipe.msh.gz", "fluent-mesh-gz"),
1751        ] {
1752            assert_eq!(id(name, &gz), Some(expected), "{name}");
1753            assert_eq!(id(name, &[]), Some(expected), "{name} by name");
1754            assert_eq!(id(name, &hdf5), None, "{name} without gzip magic");
1755        }
1756        assert_eq!(id(".msh.h5", &[]), None, "no stem");
1757        assert_eq!(id("pipe.xcas.h5", &[]), None, "not .cas.h5");
1758        assert_eq!(id("archive.gz", &gz), None);
1759        // .msh: Fluent sections or Gmsh
1760        assert_eq!(
1761            id("pipe.msh", b"(0 \"Grid:\")\n(2 3)\n"),
1762            Some("fluent-mesh")
1763        );
1764        assert_eq!(
1765            id("pipe.msh", b"(2 3)\n(10 (0 1 2c8 0))\n"),
1766            Some("fluent-mesh")
1767        );
1768        assert_eq!(id("pipe.msh", b"\n(10 (0 1 2c8 0))\n"), Some("fluent-mesh"));
1769        assert_eq!(
1770            id("pipe.msh", b"$MeshFormat\n4.1 0 8\n$EndMeshFormat\n"),
1771            Some("gmsh")
1772        );
1773        assert_eq!(id("pipe.msh", b"random words\n"), None);
1774        assert_eq!(id("pipe.msh", &[]), None, "the name alone is ambiguous");
1775        // project, CFD-Post data, journal, transcript
1776        assert_eq!(id("pipe.flprj", &[]), Some("fluent-project"));
1777        assert_eq!(id("pipe.cdat", &[]), Some("fluent-cdat"));
1778        assert_eq!(
1779            id(
1780                "run.jou",
1781                b"/file/read-case \"pipe.cas.h5\"\n/solve/iterate 500\n"
1782            ),
1783            Some("fluent-journal")
1784        );
1785        assert_eq!(id("run.jou", b"\x00\x01\x02\x03"), None);
1786        assert_eq!(
1787            id("run.trn", b"Welcome to ANSYS Fluent 2024 R2\n"),
1788            Some("fluent-transcript")
1789        );
1790        assert_eq!(id("run.trn", b"\x00\x01\x02\x03"), None);
1791        // report files
1792        let report =
1793            b"\"report-def-0-rfile\"\n\"Iteration\" \"report-def-0\" \"flow-time\"\n1 0.5 0.001\n";
1794        assert_eq!(id("report-file-0.out", report), Some("fluent-report"));
1795        assert_eq!(
1796            id("monitor.out", report),
1797            Some("fluent-report"),
1798            "by content"
1799        );
1800        let repeated = b"\"title\"\r\n(\"Time Step\" \"drag\")\r\n1 2\r\n";
1801        assert_eq!(id("x.out", repeated), Some("fluent-report"));
1802        assert_eq!(id("report-file-0.out", &[]), Some("fluent-report"));
1803        assert_eq!(id("a.out", &[]), None);
1804        assert_eq!(id("a.out", b"\x7fELF\x02\x01\x01"), None, "an executable");
1805        assert_eq!(id("a.out", b"\"title\"\n1 2 3\n"), None, "no header");
1806        assert_eq!(id("a.out", b"\"a\" \"b\"\n\"c\" \"d\"\n"), None, "no title");
1807        assert_eq!(id("a.out", b"\"unclosed\n\"a\" \"b\n"), None);
1808    }
1809
1810    #[test]
1811    fn star_ccm_files() {
1812        assert_eq!(id("pipe.simh", &[]), Some("starccm-history"));
1813        let recorded = b"// Simcenter STAR-CCM+ macro: run.java\n// Written by Simcenter STAR-CCM+ 18.06.006\npackage macro;\n";
1814        assert_eq!(id("run.java", recorded), Some("starccm-macro"));
1815        assert_eq!(
1816            id("Export.java", b"package macro;\n\nimport star.common.*;\n"),
1817            Some("starccm-macro")
1818        );
1819        assert_eq!(
1820            id(
1821                "Main.java",
1822                b"package com.example;\n\npublic class Main {}\n"
1823            ),
1824            None,
1825            "ordinary Java"
1826        );
1827        assert_eq!(id("Main.java", &[]), None, "the name alone is not enough");
1828        assert_eq!(
1829            id("pipe.sim", b"\x00\x01Simcenter STAR-CCM+ 2402"),
1830            Some("starccm-sim")
1831        );
1832    }
1833
1834    #[test]
1835    fn solidcraft_json_needs_its_format_marker() {
1836        assert_eq!(
1837            id("a.sc", b"  {\"format\": \"solvecraft/2\"}"),
1838            Some("solidcraft-design")
1839        );
1840        assert_eq!(id("a.sc", b"{\"format\": \"other/1\"}"), None);
1841        assert_eq!(id("a.sc", b"[\"solidcraft/1\"]"), None, "not an object");
1842    }
1843
1844    #[test]
1845    fn creo_iteration_limits() {
1846        assert_eq!(creo_iteration("a.prt.123456"), Some(("a.prt", 123_456)));
1847        assert_eq!(creo_iteration("a.prt.1234567"), None, "at most six digits");
1848        assert_eq!(creo_iteration("a.prt."), None);
1849        assert_eq!(creo_iteration("a.mfg.3"), Some(("a.mfg", 3)));
1850        assert_eq!(creo_iteration("a.MFG.3"), Some(("a.MFG", 3)));
1851        // Iterated names keep their Creo format, even for .prt/.asm.
1852        assert_eq!(id("plate.prt.12", b"\x00binary"), Some("creo-part"));
1853        assert_eq!(id("lay.lay.2", &[]), Some("creo-layout"));
1854    }
1855
1856    #[test]
1857    fn kinds_and_labels() {
1858        let names: HashSet<&str> = FormatKind::ALL.iter().map(|k| k.as_str()).collect();
1859        assert_eq!(names.len(), FormatKind::ALL.len(), "unique ids");
1860        let labels: HashSet<&str> = FormatKind::ALL.iter().map(|k| k.label()).collect();
1861        assert_eq!(labels.len(), FormatKind::ALL.len(), "unique labels");
1862        for f in FORMATS {
1863            assert!(FormatKind::ALL.contains(&f.kind));
1864        }
1865        // serde uses the same ids as as_str
1866        for k in FormatKind::ALL {
1867            assert_eq!(
1868                serde_json::to_value(k).unwrap(),
1869                serde_json::Value::String(k.as_str().into())
1870            );
1871        }
1872    }
1873
1874    #[test]
1875    fn content_type_falls_back_to_the_client() {
1876        // Office documents have no registered type: the client's is kept.
1877        for id in ["word", "spreadsheet", "presentation"] {
1878            assert_eq!(content_type(by_id(id), "x/y"), "x/y", "{id}");
1879        }
1880        assert_eq!(content_type(by_id("nastran-bulk"), OCTET), "text/plain");
1881    }
1882
1883    #[test]
1884    fn search_ignores_case_spaces_and_punctuation() {
1885        assert_eq!(ids_named_by("SOLID WORKS"), ids_named_by("solidworks"));
1886        assert_eq!(ids_named_by("Solid-Edge"), ids_named_by("solid edge"));
1887        assert!(ids_named_by("  ").is_empty());
1888        let mut ids = ids_of_system("solidworks");
1889        ids.sort();
1890        let mut named = ids_named_by("SolidWorks");
1891        named.sort();
1892        assert_eq!(ids, named);
1893        assert!(ids_of_system("nope").is_empty());
1894    }
1895
1896    #[test]
1897    fn infos() {
1898        let product = info("catia-product").unwrap();
1899        assert_eq!(product.system_short, "CATIA");
1900        assert_eq!(product.kind, FormatKind::Assembly);
1901        // put together from its parts (services::assemblies)
1902        assert_eq!(product.preview, Some("catia-product"));
1903        assert_eq!(info("catia-drawing").unwrap().preview, None);
1904        assert_eq!(ids_named_by("parasolid").len(), 2);
1905        assert_eq!(ids_named_by("NX part"), vec!["nx-part"]);
1906        assert_eq!(info("step").unwrap().preview, Some("step"));
1907        assert_eq!(info("3mf").unwrap().preview, Some("3mf"));
1908        assert_eq!(info("las").unwrap().preview, Some("las"));
1909        assert_eq!(
1910            info("brep").unwrap().preview,
1911            None,
1912            "no pure-Rust BREP reader"
1913        );
1914        assert_eq!(info("iges").unwrap().preview, None);
1915        assert_eq!(ids_named_by("revit").len(), 3);
1916        assert!(ids_named_by("point clouds").contains(&"e57".to_string()));
1917        assert_eq!(
1918            ids_named_by("FieldCraft"),
1919            vec!["solidcraft-design", "fieldcraft-model"]
1920        );
1921        assert!(info("").is_none() && info("nope").is_none());
1922        let catalogue = catalogue();
1923        assert_eq!(catalogue.len(), SYSTEMS.len());
1924        assert!(catalogue
1925            .iter()
1926            .all(|s| !s["formats"].as_array().unwrap().is_empty()));
1927        // each system lists exactly its own formats (found by cargo mutants)
1928        let listed: usize = catalogue
1929            .iter()
1930            .map(|s| s["formats"].as_array().unwrap().len())
1931            .sum();
1932        assert_eq!(listed, FORMATS.len(), "every format once");
1933        for s in &catalogue {
1934            for f in s["formats"].as_array().unwrap() {
1935                let id = f["id"].as_str().unwrap();
1936                assert_eq!(by_id(id).unwrap().system, s["id"], "{id}");
1937            }
1938        }
1939    }
1940
1941    /// Found by cargo mutants: a line without tokens is not a token line.
1942    #[test]
1943    fn quoted_tokens_counts_quoted_strings_only() {
1944        assert_eq!(quoted_tokens(br#""Iteration" "report-def-0""#), Some(2));
1945        assert_eq!(quoted_tokens(br#"  ("Flow Time")  "#), Some(1));
1946        assert_eq!(quoted_tokens(b"()"), None, "nothing inside the parentheses");
1947        assert_eq!(quoted_tokens(b"   "), None);
1948        assert_eq!(quoted_tokens(br#""open"#), None, "unterminated");
1949        assert_eq!(quoted_tokens(br#""a" b"#), None, "unquoted token");
1950        assert_eq!(quoted_tokens(br#"("a""#), None, "unclosed parenthesis");
1951    }
1952
1953    /// Found by cargo mutants: the 95 % threshold was not pinned down.
1954    #[test]
1955    fn text_needs_95_percent_printable_bytes_and_no_nul() {
1956        let with_controls = |n: usize| {
1957            let mut head = vec![b'a'; 20 - n];
1958            head.extend(std::iter::repeat_n(0x01, n));
1959            head
1960        };
1961        assert!(looks_textual(b"GRID    1       0.0     1.0\n"));
1962        assert!(
1963            looks_textual("Ümlaut-Kommentar\n".as_bytes()),
1964            "non-ASCII counts as text"
1965        );
1966        assert!(looks_textual(&with_controls(1)), "19 of 20 = 95 %");
1967        assert!(!looks_textual(&with_controls(2)), "18 of 20 = 90 %");
1968        assert!(!looks_textual(&[0x01; 20]));
1969        assert!(!looks_textual(b"text\0with a NUL"));
1970        assert!(!looks_textual(b""));
1971    }
1972
1973    /// Every registered format, pinned to its family (`None` = neutral).
1974    #[rustfmt::skip]
1975    const EXPECTED_FAMILIES: &[(&str, &[&str])] = &[
1976        ("solidworks", &["solidworks-part", "solidworks-assembly", "solidworks-drawing", "solidworks-template", "solidworks-library"]),
1977        ("nx", &["nx-part", "nx-simulation"]),
1978        ("solid-edge", &["solidedge-part", "solidedge-sheetmetal", "solidedge-assembly", "solidedge-weldment", "solidedge-drawing"]),
1979        ("inventor", &["inventor-part", "inventor-assembly", "inventor-drawing", "inventor-presentation", "inventor-project"]),
1980        ("fusion", &["fusion-design", "fusion-distributed", "fusion-drawing"]),
1981        ("autocad", &["dwg", "autocad-template"]),
1982        ("creo", &["creo-part", "creo-assembly", "creo-drawing", "creo-format", "creo-layout", "creo-sketch", "creo-manufacturing", "creo-neutral", "creo-view"]),
1983        ("catia", &["catia-part", "catia-product", "catia-drawing", "catia-cgr", "catia-analysis", "catia-process", "catia-material", "3dxml"]),
1984        ("spaceclaim", &["spaceclaim"]),
1985        ("freecad", &["freecad"]),
1986        ("tangency", &["solidcraft-design", "fieldcraft-model"]),
1987        ("rhino", &["rhino"]),
1988        ("alias", &["alias-wire"]),
1989        ("openscad", &["openscad"]),
1990        ("revit", &["revit-project", "revit-family", "revit-template"]),
1991        ("navisworks", &["navisworks-document", "navisworks-cache", "navisworks-file-set"]),
1992        ("microstation", &["dgn"]),
1993        ("archicad", &["archicad-project", "archicad-archive"]),
1994        ("sketchup", &["sketchup"]),
1995        ("blender", &["blender"]),
1996        ("maya", &["maya-ascii", "maya-binary"]),
1997        ("3dsmax", &["3dsmax-scene"]),
1998        ("cinema4d", &["cinema4d"]),
1999        ("modo", &["modo"]),
2000        ("houdini", &["houdini"]),
2001        ("kicad", &["kicad-pcb", "kicad-schematic", "kicad-project", "kicad-library"]),
2002        ("altium", &["altium-pcb", "altium-schematic", "altium-project", "altium-library"]),
2003        ("eagle", &["eagle-board", "eagle-schematic", "eagle-library"]),
2004    ];
2005
2006    fn expected_family(format_id: &str) -> Option<&'static str> {
2007        EXPECTED_FAMILIES
2008            .iter()
2009            .find(|(_, ids)| ids.contains(&format_id))
2010            .map(|(family, _)| *family)
2011    }
2012
2013    #[test]
2014    fn every_registered_format_has_the_expected_family() {
2015        for f in FORMATS {
2016            assert_eq!(
2017                family(f.id).map(|s| s.id),
2018                expected_family(f.id),
2019                "family of {}",
2020                f.id
2021            );
2022        }
2023        // the table names registered formats only, each once
2024        let mut seen = HashSet::new();
2025        for (fam, ids) in EXPECTED_FAMILIES {
2026            assert!(system(fam).is_some(), "{fam} is a registered system");
2027            for id in *ids {
2028                assert!(by_id(id).is_some(), "{id} is registered");
2029                assert!(seen.insert(*id), "{id} listed twice");
2030            }
2031        }
2032    }
2033
2034    #[test]
2035    fn neutral_formats_belong_to_no_family() {
2036        for id in [
2037            "step",
2038            "iges",
2039            "parasolid-text",
2040            "jt",
2041            "plmxml",
2042            "stl",
2043            "3mf",
2044            "gltf",
2045            "pdf",
2046            "pdf-3d",
2047            "png",
2048            "jpeg",
2049            "text",
2050            "word",
2051            "zip",
2052            "dxf",
2053            "3ds",
2054            "ifc",
2055            "e57",
2056            "gerber",
2057            "abaqus-input",
2058            "fluent-case",
2059            "vtk-legacy",
2060            "gcode",
2061            "openrocket",
2062        ] {
2063            assert!(by_id(id).is_some(), "{id} is registered");
2064            assert_eq!(family(id).map(|s| s.id), None, "{id} is neutral");
2065        }
2066        assert!(family("").is_none(), "unknown format");
2067        assert!(family("no-such-format").is_none());
2068    }
2069
2070    #[test]
2071    fn families_are_the_programs_with_native_formats() {
2072        let ids: Vec<&str> = families().iter().map(|s| s.id).collect();
2073        let expected: Vec<&str> = SYSTEMS
2074            .iter()
2075            .map(|s| s.id)
2076            .filter(|id| EXPECTED_FAMILIES.iter().any(|(f, _)| f == id))
2077            .collect();
2078        assert_eq!(ids, expected, "in the order of SYSTEMS");
2079        assert!(is_family("nx") && is_family("solidworks") && is_family("kicad"));
2080        assert!(!is_family("exchange") && !is_family("teamcenter") && !is_family(""));
2081        assert_eq!(info("nx-part").and_then(|i| i.family), Some("nx"));
2082        assert_eq!(info("step").and_then(|i| i.family), None);
2083    }
2084
2085    #[test]
2086    fn grouping_collapses_other_families_when_the_preferred_one_is_present() {
2087        let files = [
2088            "nx-part",
2089            "solidworks-part",
2090            "step",
2091            "inventor-assembly",
2092            "nx-simulation",
2093            "solidworks-drawing",
2094            "pdf",
2095            "",
2096        ];
2097        let g = group_by_family("nx", &files);
2098        assert_eq!(
2099            g.collapsed,
2100            [false, true, false, true, false, true, false, false]
2101        );
2102        assert_eq!(g.other_families, ["SolidWorks", "Inventor"]);
2103        assert_eq!(g.collapsed_count(), 3);
2104    }
2105
2106    #[test]
2107    fn grouping_shows_everything_without_a_preferred_file() {
2108        let files = ["solidworks-part", "inventor-part", "step"];
2109        let g = group_by_family("nx", &files);
2110        assert_eq!(g.collapsed, [false, false, false]);
2111        assert!(g.other_families.is_empty());
2112        assert_eq!(g.collapsed_count(), 0);
2113    }
2114
2115    #[test]
2116    fn grouping_never_collapses_neutral_files() {
2117        let files = ["step", "stl", "pdf", "jt", "png"];
2118        for preferred in ["nx", "solidworks", ""] {
2119            let g = group_by_family(preferred, &files);
2120            assert_eq!(g.collapsed, [false; 5], "{preferred}");
2121            assert!(g.other_families.is_empty());
2122        }
2123    }
2124
2125    #[test]
2126    fn grouping_without_or_with_an_unknown_preference_shows_all() {
2127        let files = ["nx-part", "solidworks-part"];
2128        for preferred in ["", "exchange", "teamcenter", "nonsense"] {
2129            let g = group_by_family(preferred, &files);
2130            assert_eq!(g.collapsed, [false, false], "{preferred}");
2131        }
2132        assert_eq!(group_by_family("nx", &[]), FamilyGrouping::default());
2133    }
2134
2135    #[test]
2136    fn grouping_with_only_the_preferred_family_collapses_nothing() {
2137        let g = group_by_family(
2138            "solidworks",
2139            &["solidworks-part", "solidworks-drawing", "step"],
2140        );
2141        assert_eq!(g.collapsed, [false, false, false]);
2142        assert!(g.other_families.is_empty());
2143    }
2144}