Skip to main content

teamcraft/
bomtable.rs

1//! Bills of materials exported by CAD programs: CSV/TSV and spreadsheets
2//! (Excel `.xlsx`/`.xlsm`/`.xlsb`/`.xls`, OpenDocument `.ods`).
3//!
4//! Typical sources are the BOM editor of Autodesk Inventor ("Export Bill of
5//! Materials", structured, all levels), SolidWorks BOM tables ("Save as" CSV
6//! or Excel), parts lists of Solid Edge, Fusion, Creo and NX, SAP-style BOM
7//! explosions and hand-made lists. The reader
8//!
9//! * finds the header row among the first 30 rows (English and German column
10//!   names, e.g. `Item`, `Part Number`, `Description`, `QTY` or `Pos.`,
11//!   `Teilenummer`, `Benennung`, `Menge`),
12//! * takes the structure from a level column (`Level`, `Ebene`, also `.1`,
13//!   `..2`) or from indented position numbers (`1`, `1.1`, `1.1.2`); without
14//!   either, every row is a direct child. A single first row on level 0 is
15//!   the assembly itself,
16//! * skips repeated header rows and components whose BOM structure is
17//!   `Reference` (Inventor), together with everything below them,
18//! * understands German number formats (`2,5`), units in values (`125 g`,
19//!   `4 Stk`, `2.5 m`, `0.28 lbmass`) and Windows-1252 encoded CSV files.
20//!
21//! The column names are a heuristic built from the documented export layouts;
22//! they were not tested with files of every CAD program. Columns can always
23//! be renamed in the spreadsheet before the import.
24//!
25//! Libraries: `csv` (MIT/Unlicense), `calamine` (MIT), `encoding_rs`
26//! (MIT/Apache-2.0).
27#![deny(
28    clippy::unwrap_used,
29    clippy::expect_used,
30    clippy::indexing_slicing,
31    clippy::panic,
32    clippy::unreachable
33)]
34
35use std::{collections::HashMap, io::Cursor};
36
37use serde::Serialize;
38
39/// At most this many rows are read.
40pub const MAX_ROWS: usize = 20_000;
41
42/// One component row of the table.
43#[derive(Clone, Debug, PartialEq, Serialize)]
44pub struct BomRow {
45    /// Row in the file (1-based, for messages).
46    pub line: usize,
47    /// 1 = direct child of the imported assembly.
48    pub level: usize,
49    pub find_number: Option<i32>,
50    /// Part number as written in the file (may be empty).
51    pub number: String,
52    pub name: String,
53    pub description: String,
54    pub quantity: f64,
55    /// Normalised unit (`each`, `set`, `m`, …), empty if not given.
56    pub unit: String,
57    pub material: String,
58    pub mass_kg: Option<f64>,
59    /// BOM structure / procurement as written (`Purchased`, `Normal`, `Kaufteil` …),
60    /// see `domain::Classification::from_bom_structure`.
61    pub structure: String,
62    /// Values of the columns no field is read from, in the order of
63    /// [`BomTable::extra_columns`] – custom attributes can be taken from them.
64    pub extra: Vec<String>,
65}
66
67/// A column of the file that is used, and for what.
68#[derive(Clone, Debug, PartialEq, Serialize)]
69pub struct Column {
70    pub header: String,
71    pub field: &'static str,
72    pub label: &'static str,
73}
74
75/// The rows of a table and how its columns were understood.
76#[derive(Clone, Debug, Serialize)]
77pub struct BomTable {
78    /// The assembly itself, if the table lists it (one first row on level 0).
79    pub root: Option<BomRow>,
80    pub rows: Vec<BomRow>,
81    pub columns: Vec<Column>,
82    /// Headers of the other non-empty columns (vendor, order number, cost
83    /// center …); their values are in [`BomRow::extra`].
84    pub extra_columns: Vec<String>,
85    pub warnings: Vec<String>,
86}
87
88/// Column header synonyms per field, compared in lower case with letters and
89/// digits only (`ITEM NO.` → `itemno`).
90#[rustfmt::skip]
91const FIELDS: &[(&str, &[&str])] = &[
92    ("level", &["level", "ebene", "stufe", "bomlevel", "lvl", "strukturstufe", "strukturebene"]),
93    ("position", &["item", "itemno", "itemnumber", "pos", "position", "posnr", "positionsnummer",
94        "findno", "findnumber", "index", "pcno", "nr", "no", "lfdnr", "balloon", "callout"]),
95    ("number", &["partnumber", "partno", "pn", "number", "nummer", "teilenummer", "sachnummer",
96        "artikelnummer", "itemid", "documentnumber", "dokumentnummer", "drawingnumber",
97        "zeichnungsnummer", "partnum", "materialnumber", "materialnummer", "dbpartno",
98        "identnummer", "identnr"]),
99    ("name", &["name", "title", "titel", "benennung", "bezeichnung", "partname", "componentname",
100        "component", "bauteil", "dbpartname", "descriptivepartname", "nomenclature"]),
101    ("description", &["description", "beschreibung", "desc", "comment", "kommentar", "bemerkung",
102        "bemerkungen"]),
103    ("quantity", &["qty", "quantity", "menge", "anzahl", "stk", "stck", "stück", "stueck",
104        "count", "itemqty", "qtyperassembly"]),
105    ("unit", &["unit", "units", "einheit", "uom", "baseunit", "mengeneinheit", "me"]),
106    ("material", &["material", "werkstoff", "materialname"]),
107    ("mass", &["mass", "masse", "weight", "gewicht", "massg", "masskg", "masslb", "massekg",
108        "masseg", "weightkg", "weightg", "weightlb", "gewichtkg", "gewichtg"]),
109    ("structure", &["bomstructure", "stücklistenstruktur", "stuecklistenstruktur", "structure",
110        "struktur", "procurement", "procurementtype", "beschaffung", "beschaffungsart", "makebuy",
111        "makeorbuy", "classification", "klassifizierung"]),
112    ("file", &["filename", "filenamenoextension", "dateiname", "file", "datei"]),
113];
114
115/// English label of a field (preview pages).
116pub fn field_label(field: &str) -> &'static str {
117    match field {
118        "level" => "Level",
119        "position" => "Find number / position",
120        "number" => "Part number",
121        "name" => "Name",
122        "description" => "Description",
123        "quantity" => "Quantity",
124        "unit" => "Unit",
125        "material" => "Material",
126        "mass" => "Mass",
127        "structure" => "BOM structure / classification",
128        "file" => "File name",
129        _ => "?",
130    }
131}
132
133fn squash(s: &str) -> String {
134    s.chars()
135        .filter(|c| c.is_alphanumeric())
136        .flat_map(char::to_lowercase)
137        .collect()
138}
139
140fn field_of(header: &str) -> Option<&'static str> {
141    let h = squash(header);
142    FIELDS
143        .iter()
144        .find(|(_, names)| names.contains(&h.as_str()))
145        .map(|(f, _)| *f)
146}
147
148/// Text of a CSV file: UTF-8 (with or without BOM), otherwise Windows-1252
149/// as written by Excel on German Windows.
150fn decode_text(bytes: &[u8]) -> String {
151    let bytes = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes);
152    match std::str::from_utf8(bytes) {
153        Ok(s) => s.to_string(),
154        Err(_) => encoding_rs::WINDOWS_1252.decode(bytes).0.into_owned(),
155    }
156}
157
158/// `;` (German Excel), tab or `,` – whichever one line of the first 30 has
159/// most of (a title line above the header has none).
160fn sniff_delimiter(text: &str) -> u8 {
161    let most = |d: u8| {
162        text.lines()
163            .take(30)
164            .map(|l| l.matches(d as char).count())
165            .max()
166            .unwrap_or(0)
167    };
168    // max_by_key keeps the last of equal candidates: `,` wins ties.
169    b";\t,"
170        .iter()
171        .copied()
172        .max_by_key(|d| most(*d))
173        .unwrap_or(b',')
174}
175
176fn is_spreadsheet(filename: &str) -> bool {
177    let lower = filename.to_ascii_lowercase();
178    [".xlsx", ".xlsm", ".xlsb", ".xls", ".ods"]
179        .iter()
180        .any(|e| lower.ends_with(e))
181}
182
183/// Whether TeamCraft reads BOMs from files with this name.
184pub fn supported(filename: &str) -> bool {
185    let lower = filename.to_ascii_lowercase();
186    is_spreadsheet(&lower) || [".csv", ".tsv", ".txt"].iter().any(|e| lower.ends_with(e))
187}
188
189fn cell_text(cell: &calamine::Data) -> String {
190    use calamine::Data;
191    match cell {
192        Data::Empty => String::new(),
193        Data::String(s) => s.clone(),
194        Data::Int(i) => i.to_string(),
195        Data::Float(f) if f.fract() == 0.0 && f.abs() < 1e15 => format!("{}", *f as i64),
196        Data::Float(f) => f.to_string(),
197        Data::Bool(b) => b.to_string(),
198        other => other.to_string(),
199    }
200}
201
202/// The cells of the first worksheet or of the CSV file.
203pub(crate) fn read_grid(filename: &str, bytes: &[u8]) -> Result<Vec<Vec<String>>, String> {
204    if is_spreadsheet(filename) {
205        use calamine::Reader;
206        let mut workbook = calamine::open_workbook_auto_from_rs(Cursor::new(bytes.to_vec()))
207            .map_err(|e| format!("{filename} is not a readable spreadsheet: {e}"))?;
208        let sheet = workbook
209            .sheet_names()
210            .first()
211            .cloned()
212            .ok_or_else(|| format!("{filename} has no worksheet."))?;
213        let range = workbook
214            .worksheet_range(&sheet)
215            .map_err(|e| format!("Worksheet '{sheet}' cannot be read: {e}"))?;
216        return Ok(range
217            .rows()
218            .map(|r| r.iter().map(cell_text).collect())
219            .collect());
220    }
221    let text = decode_text(bytes);
222    let mut reader = csv::ReaderBuilder::new()
223        .delimiter(sniff_delimiter(&text))
224        .has_headers(false)
225        .flexible(true)
226        .from_reader(text.as_bytes());
227    reader
228        .records()
229        .map(|r| {
230            r.map(|rec| rec.iter().map(str::to_string).collect())
231                .map_err(|e| format!("{filename} is not a valid CSV file: {e}"))
232        })
233        .collect()
234}
235
236/// `4`, `4,5`, `1.234,5`, `2.5 m`, `4 Stk` → (number, unit text after it).
237fn split_number(raw: &str) -> Option<(f64, &str)> {
238    let s = raw.trim();
239    let end = s
240        .char_indices()
241        .find(|(_, c)| !(c.is_ascii_digit() || matches!(c, '.' | ',' | '-' | '+')))
242        .map_or(s.len(), |(i, _)| i);
243    let (num, unit) = (s.get(..end)?, s.get(end..)?);
244    if !num.bytes().any(|b| b.is_ascii_digit()) {
245        return None;
246    }
247    let normalized = match (num.rfind(','), num.rfind('.')) {
248        // 1.234,5 (German) or 1,234.5 (English): the last separator is the decimal one
249        (Some(c), Some(d)) if c > d => num.replace('.', "").replace(',', "."),
250        (Some(_), Some(_)) => num.replace(',', ""),
251        (Some(_), None) if num.matches(',').count() == 1 => num.replace(',', "."),
252        (Some(_), None) => num.replace(',', ""),
253        _ => num.to_string(),
254    };
255    let value = normalized.parse::<f64>().ok().filter(|v| v.is_finite())?;
256    Some((value, unit.trim()))
257}
258
259/// The number at the start of a cell (`4,5 m` → 4.5).
260pub fn parse_number(raw: &str) -> Option<f64> {
261    split_number(raw).map(|(v, _)| v)
262}
263
264/// Units as TeamCraft writes them: `Stk`, `Stück`, `pcs`, `EA` → `each`,
265/// `Satz` → `set`; anything else in lower case (at most 16 characters).
266pub fn normalize_unit(raw: &str) -> String {
267    let u = raw.trim();
268    match squash(u).as_str() {
269        "" => String::new(),
270        "each" | "ea" | "stk" | "stck" | "stück" | "stueck" | "st" | "pcs" | "pc" | "piece"
271        | "pieces" | "x" | "count" => "each".into(),
272        "satz" | "set" | "sets" => "set".into(),
273        "m²" | "qm" | "sqm" => "m2".into(),
274        "liter" | "litre" => "l".into(),
275        _ => u.to_lowercase().chars().take(16).collect(),
276    }
277}
278
279/// Mass in kg from `0.125`, `0.125 kg`, `125 g` or `0.28 lbmass` (Inventor);
280/// without a unit in the value, the header decides (`Mass [g]`). `None` for
281/// unknown units.
282fn parse_mass(raw: &str, header: &str) -> Option<f64> {
283    let (value, unit) = split_number(raw)?;
284    let header = squash(header);
285    let unit = if !unit.is_empty() {
286        unit
287    } else if header.ends_with("kg") {
288        "kg"
289    } else if header.ends_with("lb") {
290        "lb"
291    } else if header.ends_with('g') {
292        "g"
293    } else {
294        ""
295    };
296    let factor = match squash(unit).as_str() {
297        "" | "kg" => 1.0,
298        "g" => 0.001,
299        "lb" | "lbs" | "lbm" | "lbmass" => 0.453_592_37,
300        _ => return None,
301    };
302    Some(value * factor).filter(|m| *m >= 0.0)
303}
304
305/// `1`, `1.2`, `1.2.3` → (level, find number of the last segment).
306fn parse_position(raw: &str) -> Option<(usize, Option<i32>)> {
307    let s = raw.trim().trim_end_matches('.');
308    if s.is_empty() || !s.chars().all(|c| c.is_ascii_digit() || c == '.') {
309        return None;
310    }
311    let parts: Vec<&str> = s.split('.').collect();
312    if parts.iter().any(|p| p.is_empty()) {
313        return None;
314    }
315    Some((parts.len(), parts.last().and_then(|p| p.parse().ok())))
316}
317
318/// Level of a row with the dotted position `pos` (`1.3.1`): one below the
319/// nearest earlier row whose position is a prefix of it. If the direct parent
320/// (`1.3`) is missing, the row goes below the nearest existing ancestor (`1`)
321/// – or to the top level – with a warning, instead of silently below
322/// whatever row came last. `positions[i]` belongs to `rows[i]`.
323fn dotted_level(
324    pos: &str,
325    positions: &[String],
326    rows: &[BomRow],
327    line: usize,
328    warnings: &mut Vec<String>,
329) -> usize {
330    let Some((parent, _)) = pos.rsplit_once('.') else {
331        return 1;
332    };
333    let ancestor = positions
334        .iter()
335        .enumerate()
336        .rev()
337        .find(|(_, p)| !p.is_empty() && pos.starts_with(&format!("{p}.")));
338    match ancestor {
339        Some((i, p)) => {
340            // positions and rows grow together, one entry per row
341            let Some(row) = rows.get(i) else { return 1 };
342            let after = rows.get(i + 1..).unwrap_or_default();
343            if p != parent {
344                warnings.push(format!(
345                    "Row {line}: position {pos} has no row {parent} – placed below {p}."
346                ));
347            } else if after.iter().any(|r| r.level <= row.level) {
348                warnings.push(format!(
349                    "Row {line}: position {pos} comes after rows that are not below {p} – placed \
350                     below the nearest row above."
351                ));
352            }
353            row.level + 1
354        }
355        None => {
356            warnings.push(format!(
357                "Row {line}: position {pos} has no row {parent} – placed on the top level."
358            ));
359            1
360        }
361    }
362}
363
364/// `Part.ipt` → `Part` (file names used as component names).
365fn file_stem(name: &str) -> &str {
366    match name.rsplit_once('.') {
367        Some((stem, ext))
368            if !stem.is_empty()
369                && (1..=8).contains(&ext.len())
370                && ext.chars().all(|c| c.is_ascii_alphanumeric()) =>
371        {
372            stem
373        }
374        _ => name,
375    }
376}
377
378/// Reads a BOM table.
379pub fn read(filename: &str, bytes: &[u8]) -> Result<BomTable, String> {
380    parse(read_grid(filename, bytes)?)
381}
382
383fn parse(grid: Vec<Vec<String>>) -> Result<BomTable, String> {
384    // The header is the first row (of the first 30) with two known columns,
385    // one of them a number, a name, a description or a file name.
386    let (header_index, mapping) = grid
387        .iter()
388        .take(30)
389        .enumerate()
390        .find_map(|(i, row)| {
391            let fields: Vec<Option<&'static str>> = row.iter().map(|h| field_of(h)).collect();
392            let known = fields.iter().flatten().count();
393            let identifying = fields
394                .iter()
395                .flatten()
396                .any(|f| matches!(*f, "number" | "name" | "description" | "file"));
397            (known >= 2 && identifying).then_some((i, fields))
398        })
399        .ok_or_else(|| {
400            "No header row found: the table needs columns such as Part Number / Teilenummer, \
401             Description / Benennung and QTY / Menge."
402                .to_string()
403        })?;
404    let header: &[String] = grid
405        .get(header_index)
406        .map(Vec::as_slice)
407        .unwrap_or_default();
408    // a header cell, trimmed ("" past the end of the row)
409    let head = |c: usize| header.get(c).map_or("", |s| s.trim());
410    let mut warnings = Vec::new();
411    let mut columns = Vec::new();
412    let mut index: HashMap<&'static str, usize> = HashMap::new();
413    for (col, field) in mapping.iter().enumerate() {
414        let Some(f) = *field else { continue };
415        if let Some(first) = index.get(f) {
416            warnings.push(format!(
417                "Column '{}' is ignored: '{}' already provides the {}.",
418                head(col),
419                head(*first),
420                field_label(f).to_lowercase()
421            ));
422            continue;
423        }
424        index.insert(f, col);
425        columns.push(Column {
426            header: head(col).to_string(),
427            field: f,
428            label: field_label(f),
429        });
430    }
431    // Columns no field is read from (custom attributes may use them).
432    let extra_index: Vec<usize> = mapping
433        .iter()
434        .enumerate()
435        .filter(|(col, field)| field.is_none() && !head(*col).is_empty())
436        .map(|(col, _)| col)
437        .collect();
438    let extra_columns: Vec<String> = extra_index.iter().map(|c| head(*c).to_string()).collect();
439    let has = |f: &str| index.contains_key(f);
440    let cell = |row: &[String], field: &str| -> String {
441        index
442            .get(field)
443            .and_then(|c| row.get(*c))
444            .map(|s| s.trim().to_string())
445            .unwrap_or_default()
446    };
447    let body = grid.get(header_index + 1..).unwrap_or_default();
448    if body.len() > MAX_ROWS {
449        return Err(format!("The table has more than {MAX_ROWS} rows."));
450    }
451    // Structure: level column, else dotted positions, else flat.
452    let dotted = !has("level")
453        && has("position")
454        && body
455            .iter()
456            .any(|r| parse_position(&cell(r, "position")).is_some_and(|(l, _)| l > 1));
457    let raw_head = |c: usize| header.get(c).map_or("", String::as_str);
458    let mass_header = index.get("mass").map_or("", |c| raw_head(*c));
459    let is_header_again = |row: &[String]| {
460        index
461            .values()
462            .all(|c| row.get(*c).map(|v| squash(v)) == Some(squash(raw_head(*c))))
463    };
464
465    let mut rows: Vec<BomRow> = Vec::new();
466    // Level of a skipped reference component: rows below it are skipped too.
467    let mut skip_below: Option<usize> = None;
468    // Dotted positions: the position of every row in `rows`, and of skipped
469    // reference components (their rows below are skipped by prefix).
470    let mut positions: Vec<String> = Vec::new();
471    let mut skipped_positions: Vec<String> = Vec::new();
472    for (offset, row) in body.iter().enumerate() {
473        let line = header_index + offset + 2;
474        if is_header_again(row) {
475            continue; // repeated on every page
476        }
477        let number = cell(row, "number");
478        let mut name = cell(row, "name");
479        let mut description = cell(row, "description");
480        let file = cell(row, "file");
481        if number.is_empty() && name.is_empty() && description.is_empty() && file.is_empty() {
482            continue; // empty or separator row
483        }
484        let position = cell(row, "position");
485        let (level, find_number) = if has("level") {
486            let raw = cell(row, "level");
487            let level = raw.trim_start_matches('.').parse::<usize>().ok();
488            if level.is_none() {
489                warnings.push(format!(
490                    "Row {line}: level '{raw}' is not a number – used 1."
491                ));
492            }
493            (
494                level.unwrap_or(1),
495                parse_position(&position).and_then(|(_, f)| f),
496            )
497        } else if dotted {
498            match parse_position(&position) {
499                None => {
500                    warnings.push(format!(
501                        "Row {line}: position '{position}' is not like 1.2.3 – used level 1."
502                    ));
503                    (1, None)
504                }
505                Some((_, find_number)) => {
506                    let pos = position.trim().trim_end_matches('.');
507                    if skipped_positions
508                        .iter()
509                        .any(|s| pos.starts_with(&format!("{s}.")))
510                    {
511                        continue; // below a skipped reference component
512                    }
513                    (
514                        dotted_level(pos, &positions, &rows, line, &mut warnings),
515                        find_number,
516                    )
517                }
518            }
519        } else {
520            (1, position.parse::<i32>().ok())
521        };
522        if let Some(l) = skip_below {
523            if level > l {
524                continue;
525            }
526            skip_below = None;
527        }
528        if matches!(
529            squash(&cell(row, "structure")).as_str(),
530            "reference" | "referenz"
531        ) {
532            let shown = if number.is_empty() { &name } else { &number };
533            warnings.push(format!(
534                "Row {line}: {shown} is a reference component and is not imported."
535            ));
536            skip_below = Some(level);
537            if dotted {
538                skipped_positions.push(position.trim().trim_end_matches('.').to_string());
539            }
540            continue;
541        }
542        if name.is_empty() {
543            name = std::mem::take(&mut description);
544        }
545        if name.is_empty() {
546            name = file_stem(&file).to_string();
547        }
548        if name.is_empty() {
549            name = number.clone();
550        }
551        let quantity_raw = cell(row, "quantity");
552        let mut unit = normalize_unit(&cell(row, "unit"));
553        let quantity = match split_number(&quantity_raw) {
554            _ if quantity_raw.is_empty() => 1.0,
555            Some((q, u)) if q > 0.0 && q < 1e9 => {
556                if unit.is_empty() {
557                    unit = normalize_unit(u);
558                }
559                q
560            }
561            _ => {
562                warnings.push(format!(
563                    "Row {line}: quantity '{quantity_raw}' is not a positive number – used 1."
564                ));
565                1.0
566            }
567        };
568        let mass_raw = cell(row, "mass");
569        let mass_kg = if mass_raw.is_empty() {
570            None
571        } else {
572            let mass = parse_mass(&mass_raw, mass_header);
573            if mass.is_none() {
574                warnings.push(format!(
575                    "Row {line}: mass '{mass_raw}' is not understood – left empty."
576                ));
577            }
578            mass
579        };
580        rows.push(BomRow {
581            line,
582            level,
583            find_number: find_number.filter(|f| (1..=999_999).contains(f)),
584            number,
585            name,
586            description,
587            quantity,
588            unit,
589            material: cell(row, "material"),
590            mass_kg,
591            structure: cell(row, "structure"),
592            extra: extra_index
593                .iter()
594                .map(|c| {
595                    row.get(*c)
596                        .map(|s| s.trim().to_string())
597                        .unwrap_or_default()
598                })
599                .collect(),
600        });
601        positions.push(position.trim().trim_end_matches('.').to_string());
602    }
603
604    // One first row on level 0 below which everything else lies: the assembly itself.
605    let root = match rows.as_slice() {
606        [first, rest @ ..] => {
607            first.level == 0 && !rest.is_empty() && rest.iter().all(|r| r.level > 0)
608        }
609        [] => false,
610    }
611    .then(|| rows.remove(0));
612    if rows.is_empty() {
613        return Err("The table has a header but no component rows.".into());
614    }
615    // Levels as depth below the assembly: 0- or 1-based numbering and gaps
616    // (1 → 3) are all fine – a row is a child of the nearest row above with a
617    // smaller level.
618    let mut ancestors: Vec<usize> = Vec::new();
619    let mut gap_reported = false;
620    for r in &mut rows {
621        while ancestors.last().is_some_and(|l| *l >= r.level) {
622            ancestors.pop();
623        }
624        if !gap_reported && ancestors.last().is_some_and(|l| r.level > l + 1) {
625            warnings.push(format!(
626                "Row {}: the level jumps from {} to {} – the row is placed directly below the row above.",
627                r.line,
628                ancestors.last().copied().unwrap_or_default(),
629                r.level
630            ));
631            gap_reported = true;
632        }
633        ancestors.push(r.level);
634        r.level = ancestors.len();
635    }
636    Ok(BomTable {
637        root,
638        rows,
639        columns,
640        extra_columns,
641        warnings,
642    })
643}
644
645#[cfg(test)]
646mod tests {
647    use super::*;
648
649    fn levels(t: &BomTable) -> Vec<usize> {
650        t.rows.iter().map(|r| r.level).collect()
651    }
652
653    #[test]
654    fn inventor_structured_export() {
655        // Inventor: BOM editor → Structured (all levels) → Export Bill of Materials (CSV)
656        let csv = "Item,Part Number,BOM Structure,Unit QTY,QTY,Description,Material,Mass\n\
657                   1,ASM-PUMP,Normal,,1,Pump assembly,Generic,1.25 kg\n\
658                   1.1,PUMP-HOUSING,Normal,,1,Housing,AlMg3,0.8 kg\n\
659                   1.2,ISO 4762 M6 x 20,Purchased,,6,Socket head cap screw,Steel,4 g\n\
660                   2,SKETCH-REF,Reference,,1,Layout,,\n\
661                   2.1,REF-CHILD,Normal,,1,Below the reference,,\n\
662                   3,BRACKET-01,Normal,,2,Bracket,1.4301,0.28 lbmass\n";
663        let t = read("bom.csv", csv.as_bytes()).unwrap();
664        assert!(t.root.is_none());
665        assert_eq!(t.rows.len(), 4, "the reference and its child are skipped");
666        assert_eq!(levels(&t), vec![1, 2, 2, 1]);
667        assert_eq!(
668            (t.rows[1].find_number, t.rows[1].number.as_str()),
669            (Some(1), "PUMP-HOUSING")
670        );
671        assert_eq!(t.rows[2].quantity, 6.0);
672        assert_eq!(t.rows[2].mass_kg, Some(0.004));
673        assert!((t.rows[3].mass_kg.unwrap() - 0.127).abs() < 0.001);
674        assert_eq!(
675            t.rows[0].name, "Pump assembly",
676            "description becomes the name"
677        );
678        assert!(t
679            .columns
680            .iter()
681            .any(|c| c.header == "QTY" && c.field == "quantity"));
682        assert!(!t.columns.iter().any(|c| c.header == "Unit QTY"));
683        assert!(t.warnings.iter().any(|w| w.contains("SKETCH-REF")));
684        assert_eq!(
685            t.rows[2].structure, "Purchased",
686            "kept for the classification"
687        );
688        assert_eq!(t.extra_columns, ["Unit QTY"], "unread columns are kept");
689        assert_eq!(t.rows[0].extra, [""]);
690    }
691
692    #[test]
693    fn extra_columns_for_custom_attributes() {
694        let csv = "Pos;Teilenummer;Benennung;Menge;Beschaffungsart;Lieferant;;Bestellnummer\n\
695                   1;GUIDE-1;Führung;2;Kaufteil;Bosch Rexroth;x;R1605\n\
696                   2;PLATE-1;Platte;1;Eigenfertigung;;;\n";
697        let t = read("bom.csv", csv.as_bytes()).unwrap();
698        assert!(t
699            .columns
700            .iter()
701            .any(|c| c.header == "Beschaffungsart" && c.field == "structure"));
702        assert_eq!(
703            t.extra_columns,
704            ["Lieferant", "Bestellnummer"],
705            "columns without a header are left out"
706        );
707        assert_eq!(t.rows[0].extra, ["Bosch Rexroth", "R1605"]);
708        assert_eq!(t.rows[0].structure, "Kaufteil");
709        assert_eq!(t.rows[1].extra, ["", ""]);
710    }
711
712    #[test]
713    fn german_semicolon_csv_in_windows_1252() {
714        let text = "Pos.;Teilenummer;Benennung;Menge;Werkstoff;Masse [g]\r\n\
715                    1;NX-001;Düsenhalter;2;1.4404;125,5\r\n\
716                    2;;Schraube M6;8 Stk;A2-70;\r\n\
717                    Pos.;Teilenummer;Benennung;Menge;Werkstoff;Masse [g]\r\n\
718                    3;NX-002;Rohr;2,5 m;;\r\n";
719        let (bytes, _, _) = encoding_rs::WINDOWS_1252.encode(text);
720        let t = read("stueckliste.csv", &bytes).unwrap();
721        assert_eq!(t.rows.len(), 3, "the repeated header is skipped");
722        assert_eq!(t.rows[0].name, "Düsenhalter");
723        assert_eq!(t.rows[0].mass_kg, Some(0.1255));
724        assert_eq!(t.rows[0].find_number, Some(1));
725        assert_eq!((t.rows[1].quantity, t.rows[1].unit.as_str()), (8.0, "each"));
726        assert_eq!(t.rows[1].number, "", "rows without number are kept");
727        assert_eq!((t.rows[2].quantity, t.rows[2].unit.as_str()), (2.5, "m"));
728        assert_eq!(levels(&t), vec![1, 1, 1], "flat list");
729    }
730
731    #[test]
732    fn level_column_root_row_and_preamble() {
733        let tsv = "Bill of Materials for ROVER\n\nLevel\tNumber\tName\tQuantity\n\
734                   0\tROVER\tRover\t1\n1\tWHEEL\tWheel\t6\n2\tHUB\tHub\t1\n1\tFRAME\tFrame\t1\n";
735        let t = read("rover.tsv", tsv.as_bytes()).unwrap();
736        assert_eq!(t.root.as_ref().map(|r| r.number.as_str()), Some("ROVER"));
737        assert_eq!(levels(&t), vec![1, 2, 1]);
738        // SAP-style levels with dots and a gap
739        let csv = "Level,Component,Description,Quantity\n.1,A,,1\n...3,B,,1\n...3,C,,1\n..2,D,,1\n";
740        let t = read("sap.csv", csv.as_bytes()).unwrap();
741        assert!(t.root.is_none());
742        assert_eq!(levels(&t), vec![1, 2, 2, 2], "B and C are siblings below A");
743        assert_eq!(t.rows[0].name, "A", "the component name");
744        assert_eq!(t.warnings.len(), 1, "{:?}", t.warnings);
745    }
746
747    #[test]
748    fn spreadsheets() {
749        let mut book = rust_xlsxwriter::Workbook::new();
750        let sheet = book.add_worksheet();
751        for (c, h) in ["ITEM NO.", "PART NUMBER", "DESCRIPTION", "QTY."]
752            .iter()
753            .enumerate()
754        {
755            sheet.write_string(0, c as u16, *h).unwrap();
756        }
757        sheet.write_number(1, 0, 1.0).unwrap();
758        sheet.write_string(1, 1, "SW-0815").unwrap();
759        sheet.write_string(1, 2, "Motor mount").unwrap();
760        sheet.write_number(1, 3, 2.0).unwrap();
761        sheet.write_string(2, 0, "1.1").unwrap();
762        sheet.write_string(2, 1, "SW-0816").unwrap();
763        sheet.write_string(2, 2, "Bolt").unwrap();
764        sheet.write_number(2, 3, 4.0).unwrap();
765        let bytes = book.save_to_buffer().unwrap();
766        let t = read("SolidWorks BOM.xlsx", &bytes).unwrap();
767        assert_eq!(t.rows.len(), 2);
768        assert_eq!(t.rows[0].number, "SW-0815");
769        assert_eq!(
770            t.rows[0].find_number,
771            Some(1),
772            "1.0 from Excel is position 1"
773        );
774        assert_eq!((t.rows[1].level, t.rows[1].quantity), (2, 4.0));
775        assert!(read("broken.xlsx", b"not a zip").is_err());
776    }
777
778    #[test]
779    fn numbers_units_and_errors() {
780        assert_eq!(parse_number("2,5"), Some(2.5));
781        assert_eq!(parse_number("1.234,5"), Some(1234.5));
782        assert_eq!(parse_number("1,234.5"), Some(1234.5));
783        assert_eq!(parse_number("4 Each"), Some(4.0));
784        assert_eq!(parse_number("abc"), None);
785        assert_eq!(normalize_unit("Stück"), "each");
786        assert_eq!(normalize_unit("Satz"), "set");
787        assert_eq!(normalize_unit("MM"), "mm");
788        assert_eq!(parse_mass("125", "Masse [g]"), Some(0.125));
789        assert_eq!(parse_mass("2 Zentner", "Mass"), None);
790        assert_eq!(parse_position("1.2.3"), Some((3, Some(3))));
791        assert_eq!(parse_position("4."), Some((1, Some(4))));
792        assert_eq!(parse_position("1..2"), None);
793        assert_eq!(file_stem("Bracket.ipt"), "Bracket");
794        assert_eq!(file_stem("M6x20"), "M6x20");
795        assert!(
796            read("x.csv", b"just,some,text\n1,2,3\n").is_err(),
797            "no header"
798        );
799        assert!(
800            read("x.csv", b"Part Number,Description\n").is_err(),
801            "no rows"
802        );
803        assert!(supported("BOM.XLSX") && supported("bom.csv") && !supported("bom.pdf"));
804        let t = read("x.csv", b"Part Number,Description,QTY\nA,One,-2\n").unwrap();
805        assert_eq!(t.rows[0].quantity, 1.0);
806        assert_eq!(t.warnings.len(), 1);
807    }
808
809    #[test]
810    fn header_synonyms_in_english_and_german() {
811        for (header, field) in [
812            ("ITEM NO.", "position"),
813            ("Pos.", "position"),
814            ("Find No", "position"),
815            ("Part Number", "number"),
816            ("Teilenummer", "number"),
817            ("Sachnummer", "number"),
818            ("Benennung", "name"),
819            ("Bezeichnung", "name"),
820            ("Beschreibung", "description"),
821            ("QTY.", "quantity"),
822            ("Menge", "quantity"),
823            ("Stück", "quantity"),
824            ("Einheit", "unit"),
825            ("UoM", "unit"),
826            ("Werkstoff", "material"),
827            ("Mass [kg]", "mass"),
828            ("Gewicht", "mass"),
829            ("BOM Structure", "structure"),
830            ("Stücklistenstruktur", "structure"),
831            ("File Name", "file"),
832            ("Ebene", "level"),
833            ("Strukturstufe", "level"),
834        ] {
835            assert_eq!(field_of(header), Some(field), "{header}");
836        }
837        assert_eq!(field_of("Colour"), None);
838        assert_eq!(field_of(""), None);
839        assert_eq!(field_label("number"), "Part number");
840        assert_eq!(field_label("unknown"), "?");
841    }
842
843    #[test]
844    fn utf8_byte_order_mark_and_delimiters() {
845        // Excel's "CSV UTF-8" writes a BOM before the header.
846        let mut bytes = b"\xEF\xBB\xBF".to_vec();
847        bytes.extend_from_slice("Teilenummer;Benennung;Menge\nA-1;Düse;2\n".as_bytes());
848        let t = read("bom.csv", &bytes).unwrap();
849        assert_eq!(t.columns[0].header, "Teilenummer", "no BOM in the header");
850        assert_eq!((t.rows[0].name.as_str(), t.rows[0].quantity), ("Düse", 2.0));
851        assert_eq!(sniff_delimiter("a;b;c\n1;2;3"), b';');
852        assert_eq!(sniff_delimiter("a\tb\tc"), b'\t');
853        assert_eq!(sniff_delimiter("a,b;c"), b',', "ties go to the comma");
854        assert_eq!(sniff_delimiter(""), b',');
855        // Commas inside a German semicolon file are decimal separators.
856        let t = read("bom.txt", b"Part Number;Name;QTY\nA;Tube;2,5\n").unwrap();
857        assert_eq!(t.rows[0].quantity, 2.5);
858    }
859
860    #[test]
861    fn header_row_needs_an_identifying_column() {
862        // Level and quantity alone do not make a header.
863        assert!(read("x.csv", b"Level,QTY\n1,2\n").is_err());
864        // The header may follow up to 29 title rows ...
865        let mut csv = "Title\n".repeat(29);
866        csv.push_str("Part Number,QTY\nA,1\n");
867        assert_eq!(read("x.csv", csv.as_bytes()).unwrap().rows.len(), 1);
868        // ... but not more.
869        let mut csv = "Title\n".repeat(30);
870        csv.push_str("Part Number,QTY\nA,1\n");
871        let err = read("x.csv", csv.as_bytes()).unwrap_err();
872        assert!(err.contains("No header row"), "{err}");
873    }
874
875    #[test]
876    fn duplicate_columns_keep_the_first() {
877        let t = read("x.csv", b"Part Number,PN,Name\nA,B,Bolt\n").unwrap();
878        assert_eq!(t.rows[0].number, "A");
879        assert_eq!(t.columns.len(), 2);
880        assert_eq!(t.warnings.len(), 1);
881        assert!(t.warnings[0].contains("'PN'") && t.warnings[0].contains("'Part Number'"));
882    }
883
884    #[test]
885    fn names_fall_back_to_description_file_and_number() {
886        let csv = "Part Number,Name,Description,File Name\n\
887                   A,,Bracket left,\n\
888                   B,,,Motor mount.ipt\n\
889                   C,,,\n\
890                   ,,,\n\
891                   D,Shaft,Turned shaft,shaft.ipt\n";
892        let t = read("x.csv", csv.as_bytes()).unwrap();
893        let names: Vec<_> = t.rows.iter().map(|r| r.name.as_str()).collect();
894        assert_eq!(names, vec!["Bracket left", "Motor mount", "C", "Shaft"]);
895        assert_eq!(t.rows[0].description, "", "moved to the name");
896        assert_eq!(t.rows[3].description, "Turned shaft");
897        // The empty row is skipped, line numbers still match the file.
898        assert_eq!(
899            t.rows.iter().map(|r| r.line).collect::<Vec<_>>(),
900            vec![2, 3, 4, 6]
901        );
902    }
903
904    #[test]
905    fn quantities_and_units() {
906        let csv =
907            "Part Number,QTY,Unit\nA,,\nB,4 pcs,\nC,2.5 m,Satz\nD,abc,\nE,0,\nF,1000000000,\n";
908        let t = read("x.csv", csv.as_bytes()).unwrap();
909        let q: Vec<(f64, &str)> = t
910            .rows
911            .iter()
912            .map(|r| (r.quantity, r.unit.as_str()))
913            .collect();
914        assert_eq!(
915            q,
916            vec![
917                (1.0, ""),
918                (4.0, "each"),
919                (2.5, "set"),
920                (1.0, ""),
921                (1.0, ""),
922                (1.0, ""),
923            ],
924            "empty = 1; the unit column wins over the unit in the value"
925        );
926        assert_eq!(t.warnings.len(), 3, "abc, 0 and 10^9: {:?}", t.warnings);
927    }
928
929    #[test]
930    fn units_are_normalised() {
931        for (raw, unit) in [
932            ("EA", "each"),
933            ("Stk.", "each"),
934            ("pcs", "each"),
935            ("sets", "set"),
936            ("m²", "m2"),
937            ("qm", "m2"),
938            ("Liter", "l"),
939            ("  ", ""),
940            ("KG", "kg"),
941        ] {
942            assert_eq!(normalize_unit(raw), unit, "{raw}");
943        }
944        assert_eq!(normalize_unit(&"x".repeat(40)).chars().count(), 16);
945    }
946
947    #[test]
948    fn masses_in_any_unit() {
949        assert_eq!(parse_mass("0.125", "Mass"), Some(0.125), "kg by default");
950        assert_eq!(
951            parse_mass("125 g", "Mass [kg]"),
952            Some(0.125),
953            "the value's unit wins"
954        );
955        assert_eq!(parse_mass("2", "Weight (lb)"), Some(2.0 * 0.453_592_37));
956        assert_eq!(parse_mass("1,5", "Gewicht [kg]"), Some(1.5));
957        assert_eq!(parse_mass("-1", "Mass"), None, "negative");
958        assert_eq!(parse_mass("", "Mass"), None);
959        let t = read("x.csv", b"Part Number,Mass\nA,heavy\nB,\n").unwrap();
960        assert_eq!((t.rows[0].mass_kg, t.rows[1].mass_kg), (None, None));
961        assert_eq!(t.warnings.len(), 1, "only the value that is not understood");
962    }
963
964    #[test]
965    fn number_formats() {
966        assert_eq!(parse_number("1.234.567"), None, "two dots are no number");
967        assert_eq!(parse_number("1,234,567"), Some(1_234_567.0), "thousands");
968        assert_eq!(parse_number(" 7 "), Some(7.0));
969        assert_eq!(parse_number("-3"), Some(-3.0));
970        assert_eq!(parse_number("."), None);
971        assert_eq!(parse_number(""), None);
972    }
973
974    #[test]
975    fn dotted_positions_and_find_numbers() {
976        let csv = "Item,Part Number\n1,A\n1.1,B\n1.1.1,C\n1.2,D\nx,E\n2,F\n";
977        let t = read("x.csv", csv.as_bytes()).unwrap();
978        assert_eq!(levels(&t), vec![1, 2, 3, 2, 1, 1]);
979        assert_eq!(
980            t.rows.iter().map(|r| r.find_number).collect::<Vec<_>>(),
981            vec![Some(1), Some(1), Some(1), Some(2), None, Some(2)]
982        );
983        assert!(t.warnings.iter().any(|w| w.contains("'x'")));
984        // Without any dotted position the positions are find numbers of a flat list.
985        let t = read("x.csv", b"Pos,Part Number\n10,A\n0,B\n1000000,C\n").unwrap();
986        assert_eq!(levels(&t), vec![1, 1, 1]);
987        assert_eq!(
988            t.rows.iter().map(|r| r.find_number).collect::<Vec<_>>(),
989            vec![Some(10), None, None],
990            "find numbers outside 1..=999999 are dropped"
991        );
992    }
993
994    #[test]
995    fn level_column_edge_cases() {
996        // A non-numeric level is reported and used as 1.
997        let t = read("x.csv", b"Level,Part Number\n1,A\nzwei,B\n").unwrap();
998        assert_eq!(levels(&t), vec![1, 1]);
999        assert!(t.warnings[0].contains("'zwei'"));
1000        // Two rows on level 0: no assembly row, both are direct children.
1001        let t = read("x.csv", b"Level,Part Number\n0,A\n0,B\n1,C\n").unwrap();
1002        assert!(t.root.is_none());
1003        assert_eq!(levels(&t), vec![1, 1, 2]);
1004        // A single level-0 row alone is a component, not the assembly.
1005        let t = read("x.csv", b"Level,Part Number\n0,A\n").unwrap();
1006        assert!(t.root.is_none());
1007        assert_eq!(t.rows.len(), 1);
1008        // Level gaps are closed (the row goes directly below the row above)
1009        // and reported only once.
1010        let t = read("x.csv", b"Level,Part Number\n1,A\n3,B\n1,C\n4,D\n").unwrap();
1011        assert_eq!(levels(&t), vec![1, 2, 1, 2]);
1012        assert_eq!(t.warnings.len(), 1, "{:?}", t.warnings);
1013    }
1014
1015    #[test]
1016    fn german_reference_components_are_skipped_with_their_children() {
1017        let csv = "Pos.;Teilenummer;Stücklistenstruktur\n\
1018                   1;A;Normal\n2;REF;Referenz\n2.1;REF-KIND;Normal\n2.1.1;REF-ENKEL;Normal\n\
1019                   3;B;Normal\n3.1;C;Normal\n";
1020        let t = read("x.csv", csv.as_bytes()).unwrap();
1021        let numbers: Vec<_> = t.rows.iter().map(|r| r.number.as_str()).collect();
1022        assert_eq!(numbers, vec!["A", "B", "C"]);
1023        assert_eq!(levels(&t), vec![1, 1, 2]);
1024    }
1025
1026    #[test]
1027    fn too_many_rows_are_refused() {
1028        let mut csv = String::from("Part Number\n");
1029        let mut csv2 = String::from("Part Number,Name\n");
1030        for i in 0..=MAX_ROWS {
1031            csv.push_str(&format!("P{i}\n"));
1032            csv2.push_str(&format!("P{i},N\n"));
1033        }
1034        assert!(
1035            read("x.csv", csv.as_bytes()).is_err(),
1036            "one column is no header"
1037        );
1038        let err = read("x.csv", csv2.as_bytes()).unwrap_err();
1039        assert!(err.contains("more than"), "{err}");
1040    }
1041
1042    #[test]
1043    fn supported_file_names() {
1044        for name in [
1045            "a.csv", "a.TSV", "a.txt", "a.xlsx", "a.xlsm", "a.xlsb", "a.XLS", "a.ods",
1046        ] {
1047            assert!(supported(name), "{name}");
1048        }
1049        for name in ["a.pdf", "a.step", "a.csv.zip", "csv"] {
1050            assert!(!supported(name), "{name}");
1051        }
1052    }
1053
1054    // ----- found by cargo mutants: rules the tests above did not pin down -----
1055
1056    #[test]
1057    fn every_field_has_its_own_label() {
1058        let labels = [
1059            ("level", "Level"),
1060            ("position", "Find number / position"),
1061            ("number", "Part number"),
1062            ("name", "Name"),
1063            ("description", "Description"),
1064            ("quantity", "Quantity"),
1065            ("unit", "Unit"),
1066            ("material", "Material"),
1067            ("mass", "Mass"),
1068            ("structure", "BOM structure / classification"),
1069            ("file", "File name"),
1070        ];
1071        for (field, label) in labels {
1072            assert_eq!(field_label(field), label, "{field}");
1073        }
1074        assert_eq!(field_label("colour"), "?");
1075    }
1076
1077    #[test]
1078    fn spreadsheet_numbers_become_cell_text() {
1079        use calamine::Data;
1080        assert_eq!(cell_text(&Data::Float(4.0)), "4");
1081        assert_eq!(cell_text(&Data::Float(4.5)), "4.5");
1082        assert_eq!(
1083            cell_text(&Data::Float(-0.0)),
1084            "0",
1085            "no '-0' for an empty sum"
1086        );
1087        // beyond i64: printed as the float, not saturated to i64::MAX
1088        assert_eq!(cell_text(&Data::Float(1e20)), "100000000000000000000");
1089        assert_eq!(cell_text(&Data::Int(7)), "7");
1090        assert_eq!(cell_text(&Data::Empty), "");
1091    }
1092
1093    #[test]
1094    fn units_without_letters_are_empty() {
1095        for raw in ["", "  ", "-", "/", "–"] {
1096            assert_eq!(normalize_unit(raw), "", "{raw:?}");
1097        }
1098        assert_eq!(normalize_unit(" Stk "), "each");
1099        assert_eq!(normalize_unit("MM"), "mm");
1100    }
1101
1102    #[test]
1103    fn only_short_alphanumeric_extensions_are_cut_off() {
1104        assert_eq!(file_stem("Part.ipt"), "Part");
1105        assert_eq!(file_stem("Bracket.SLDPRT"), "Bracket");
1106        assert_eq!(file_stem(".ipt"), ".ipt", "nothing before the dot");
1107        assert_eq!(file_stem("Part.a-b"), "Part.a-b", "not an extension");
1108        assert_eq!(
1109            file_stem("Part.verylongext"),
1110            "Part.verylongext",
1111            "too long"
1112        );
1113        assert_eq!(file_stem("Part."), "Part.", "empty extension");
1114        assert_eq!(file_stem("Part"), "Part");
1115    }
1116
1117    #[test]
1118    fn a_clean_dotted_structure_has_no_warnings() {
1119        let csv = "Pos,Part Number,Description,QTY\n\
1120                   1,ASM-1,Top,1\n\
1121                   1.1,PRT-1,Child,2\n\
1122                   1.2,PRT-2,Child,1\n\
1123                   2,PRT-3,Second top,1\n";
1124        let t = read("bom.csv", csv.as_bytes()).unwrap();
1125        assert_eq!(levels(&t), vec![1, 2, 2, 1]);
1126        assert!(t.warnings.is_empty(), "{:?}", t.warnings);
1127    }
1128
1129    #[test]
1130    fn a_dotted_row_after_its_siblings_parent_is_flagged() {
1131        // 1.1 comes after 2, which is not below 1
1132        let csv = "Pos,Part Number,Description,QTY\n\
1133                   1,ASM-1,Top,1\n\
1134                   2,PRT-3,Second top,1\n\
1135                   1.1,PRT-1,Child,2\n";
1136        let t = read("bom.csv", csv.as_bytes()).unwrap();
1137        assert_eq!(levels(&t), vec![1, 1, 2]);
1138        assert_eq!(t.warnings.len(), 1, "{:?}", t.warnings);
1139        assert!(t.warnings[0].contains("comes after rows that are not below 1"));
1140    }
1141
1142    #[test]
1143    fn flat_tables_are_not_read_as_dotted() {
1144        // no level column and positions without dots: flat, no warnings
1145        let csv = "Pos,Part Number,Description,QTY\n\
1146                   1,PRT-1,One,1\n\
1147                   A,PRT-2,Lettered position,1\n\
1148                   2,PRT-3,Two,1\n";
1149        let t = read("bom.csv", csv.as_bytes()).unwrap();
1150        assert_eq!(levels(&t), vec![1, 1, 1]);
1151        assert_eq!(t.rows[1].find_number, None);
1152        assert!(t.warnings.is_empty(), "{:?}", t.warnings);
1153        // no position column at all
1154        let csv = "Part Number,Description,QTY\nPRT-1,One,1\nPRT-2,Two,1\n";
1155        let t = read("bom.csv", csv.as_bytes()).unwrap();
1156        assert_eq!(levels(&t), vec![1, 1]);
1157        assert!(t.warnings.is_empty(), "{:?}", t.warnings);
1158    }
1159
1160    #[test]
1161    fn rows_below_a_reference_component_are_skipped_by_level() {
1162        let csv = "Level,Part Number,BOM Structure,QTY,Description\n\
1163                   1,ASM-1,Normal,1,Top\n\
1164                   2,SKETCH,Reference,1,Layout\n\
1165                   3,REF-CHILD,Normal,1,Below the reference\n\
1166                   2,PRT-1,Normal,2,Sibling of the reference\n\
1167                   1,PRT-2,Normal,1,Second top\n";
1168        let t = read("bom.csv", csv.as_bytes()).unwrap();
1169        let numbers: Vec<&str> = t.rows.iter().map(|r| r.number.as_str()).collect();
1170        assert_eq!(numbers, ["ASM-1", "PRT-1", "PRT-2"]);
1171        assert_eq!(levels(&t), vec![1, 2, 1]);
1172    }
1173
1174    #[test]
1175    fn only_a_level_gap_is_reported() {
1176        let table = |levels: &str| {
1177            let mut csv = String::from("Level,Part Number,Description,QTY\n");
1178            for (i, l) in levels.split(' ').enumerate() {
1179                csv.push_str(&format!("{l},P{i},Part,1\n"));
1180            }
1181            read("bom.csv", csv.as_bytes()).unwrap()
1182        };
1183        assert!(table("1 2 3 2 1").warnings.is_empty(), "steps of one");
1184        let t = table("1 3 4 1 3");
1185        assert_eq!(t.warnings.len(), 1, "reported once: {:?}", t.warnings);
1186        assert!(
1187            t.warnings[0].contains("jumps from 1 to 3"),
1188            "{:?}",
1189            t.warnings
1190        );
1191    }
1192
1193    #[test]
1194    fn the_row_limit_is_inclusive() {
1195        let mut csv = String::from("Part Number,Description,QTY\n");
1196        for i in 0..MAX_ROWS {
1197            csv.push_str(&format!("P{i},Part,1\n"));
1198        }
1199        assert_eq!(
1200            read("bom.csv", csv.as_bytes()).unwrap().rows.len(),
1201            MAX_ROWS
1202        );
1203        csv.push_str("ONE-MORE,Part,1\n");
1204        let err = read("bom.csv", csv.as_bytes()).unwrap_err();
1205        assert!(err.contains("more than"), "{err}");
1206    }
1207}