1#![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
39pub const MAX_ROWS: usize = 20_000;
41
42#[derive(Clone, Debug, PartialEq, Serialize)]
44pub struct BomRow {
45 pub line: usize,
47 pub level: usize,
49 pub find_number: Option<i32>,
50 pub number: String,
52 pub name: String,
53 pub description: String,
54 pub quantity: f64,
55 pub unit: String,
57 pub material: String,
58 pub mass_kg: Option<f64>,
59 pub structure: String,
62 pub extra: Vec<String>,
65}
66
67#[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#[derive(Clone, Debug, Serialize)]
77pub struct BomTable {
78 pub root: Option<BomRow>,
80 pub rows: Vec<BomRow>,
81 pub columns: Vec<Column>,
82 pub extra_columns: Vec<String>,
85 pub warnings: Vec<String>,
86}
87
88#[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
115pub 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
148fn 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
158fn 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 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
183pub 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
202pub(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
236fn 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 (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
259pub fn parse_number(raw: &str) -> Option<f64> {
261 split_number(raw).map(|(v, _)| v)
262}
263
264pub 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
279fn 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
305fn 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
318fn 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 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
364fn 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
378pub 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 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 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 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 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 let mut skip_below: Option<usize> = None;
468 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; }
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; }
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; }
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 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 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 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 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 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 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 assert!(read("x.csv", b"Level,QTY\n1,2\n").is_err());
864 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 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 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 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 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 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 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 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 #[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 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 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 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 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}