1#![deny(
32 clippy::unwrap_used,
33 clippy::expect_used,
34 clippy::indexing_slicing,
35 clippy::panic,
36 clippy::unreachable
37)]
38
39use std::collections::{HashMap, HashSet};
40use std::io::{Read, Seek};
41use std::path::Path;
42
43use crate::cadformat::{self, FormatKind};
44
45pub const MAX_SCAN: u64 = 32 * 1024 * 1024;
47const MAX_REFERENCES: usize = 500;
49const MAX_NAME: usize = 160;
51
52pub fn scans(format_id: &str) -> bool {
56 match cadformat::by_id(format_id) {
57 Some(f) => {
58 matches!(
59 f.kind,
60 FormatKind::Assembly | FormatKind::Drawing | FormatKind::Model
61 ) || matches!(
62 f.id,
63 "step" | "jt" | "plmxml" | "kicad-project" | "kicad-schematic"
64 )
65 }
66 None => false,
67 }
68}
69
70fn is_referencable(name: &str) -> bool {
77 cadformat::detect(name, &[]).is_some_and(|f| {
78 !f.id.starts_with("fluent-")
79 && !matches!(
80 f.kind,
81 FormatKind::Document
82 | FormatKind::Other
83 | FormatKind::Simulation
84 | FormatKind::Result
85 | FormatKind::Manufacturing
86 )
87 })
88}
89
90fn extensions() -> Vec<&'static str> {
92 let mut exts: Vec<&'static str> = cadformat::FORMATS
93 .iter()
94 .flat_map(|f| f.extensions.iter().copied())
95 .filter(|e| is_referencable(&format!("x.{e}")))
96 .collect();
97 exts.sort_by_key(|e| std::cmp::Reverse(e.len()));
98 exts.dedup();
99 exts
100}
101
102fn is_name_boundary(c: char) -> bool {
104 c.is_control()
105 || matches!(
106 c,
107 '\\' | '/'
108 | ':'
109 | '"'
110 | '\''
111 | '<'
112 | '>'
113 | '|'
114 | '*'
115 | '?'
116 | '='
117 | ','
118 | ';'
119 | '['
120 | ']'
121 | '{'
122 | '}'
123 )
124}
125
126fn names_in(run: &str, exts: &[&str], out: &mut Vec<String>) {
128 let chars: Vec<char> = run.chars().collect();
129 let lower: Vec<char> = chars.iter().flat_map(|c| c.to_lowercase()).collect();
130 if lower.len() != chars.len() {
131 return; }
133 let mut i = 0;
134 while let Some(&ch) = chars.get(i) {
135 if ch != '.' {
136 i += 1;
137 continue;
138 }
139 let matched = exts.iter().find(|ext| {
141 let end = i + 1 + ext.chars().count();
142 lower
143 .get(i + 1..end)
144 .is_some_and(|l| l.iter().copied().eq(ext.chars()))
145 && chars
146 .get(end)
147 .is_none_or(|c| !(c.is_alphanumeric() || *c == '_'))
148 });
149 let Some(ext) = matched else {
150 i += 1;
151 continue;
152 };
153 let end = i + 1 + ext.chars().count();
154 let mut start = i;
156 while let Some(&c) = start.checked_sub(1).and_then(|s| chars.get(s)) {
157 let double_space = c == ' ' && start >= 2 && chars.get(start - 2) == Some(&' ');
158 if is_name_boundary(c) || double_space {
159 break;
160 }
161 start -= 1;
162 }
163 let span: String = chars.get(start..end).unwrap_or_default().iter().collect();
164 let name = span
165 .trim()
166 .trim_start_matches(|c: char| !(c.is_alphanumeric() || c == '_'));
167 let stem = name.chars().count().saturating_sub(ext.chars().count() + 1);
170 let binary = name.contains(char::REPLACEMENT_CHARACTER)
175 || (span.contains(char::REPLACEMENT_CHARACTER)
176 && !name.chars().take(stem).any(char::is_alphabetic));
177 if stem > 0
178 && !binary
179 && name.len() <= MAX_NAME
180 && name.chars().take(stem).any(char::is_alphanumeric)
181 {
182 out.push(name.to_string());
183 }
184 i = end;
185 }
186}
187
188fn byte_runs(content: &[u8], skip: Option<&std::ops::Range<usize>>, mut f: impl FnMut(&str)) {
190 let mut start = None;
191 for (i, &b) in content.iter().enumerate() {
192 let printable =
193 ((0x20..0x7f).contains(&b) || b >= 0x80) && !skip.is_some_and(|s| s.contains(&i));
194 match (printable, start) {
195 (true, None) => start = Some(i),
196 (false, Some(s)) => {
197 if i - s >= 5 {
198 f(&String::from_utf8_lossy(
199 content.get(s..i).unwrap_or_default(),
200 ));
201 }
202 start = None;
203 }
204 _ => {}
205 }
206 }
207 if let Some(s) = start {
208 if content.len() - s >= 5 {
209 f(&String::from_utf8_lossy(
210 content.get(s..).unwrap_or_default(),
211 ));
212 }
213 }
214}
215
216fn utf16_runs(content: &[u8], skip: Option<&std::ops::Range<usize>>, mut f: impl FnMut(&str)) {
219 for align in 0..2 {
220 let mut run = String::new();
221 let mut i = align;
222 while let Some(&[lo, hi]) = content.get(i..i + 2) {
223 let unit = u16::from_le_bytes([lo, hi]);
224 let printable = printable_unit(unit)
225 && !skip.is_some_and(|s| s.contains(&i) || s.contains(&(i + 1)));
226 if printable {
227 run.push(char::from_u32(u32::from(unit)).unwrap_or('?'));
228 } else {
229 if run.chars().count() >= 5 {
230 f(&run);
231 }
232 run.clear();
233 }
234 i += 2;
235 }
236 if run.chars().count() >= 5 {
237 f(&run);
238 }
239 }
240}
241
242const PREFIXED_MIN: usize = 5;
245const PREFIXED_MAX: usize = 1024;
246
247fn printable_unit(unit: u16) -> bool {
250 (0x20..0x7f).contains(&unit) || (0xa0..0x180).contains(&unit)
251}
252
253fn prefixed_utf16_strings(
261 content: &[u8],
262 skip: Option<&std::ops::Range<usize>>,
263 mut f: impl FnMut(&str),
264) {
265 let mut i = 0;
266 while let Some(head) = content.get(i..i + 4) {
267 let count = <[u8; 4]>::try_from(head).map_or(0, u32::from_le_bytes) as usize;
268 if (PREFIXED_MIN..=PREFIXED_MAX).contains(&count) {
269 let end = i + 4 + 2 * count;
270 let skipped = skip.is_some_and(|s| s.start < end && i < s.end);
271 if let (false, Some(body)) = (skipped, content.get(i + 4..end)) {
272 let units: Vec<u16> = body
273 .chunks_exact(2)
274 .map(|p| <[u8; 2]>::try_from(p).map_or(0, u16::from_le_bytes))
275 .collect();
276 if units.iter().all(|&u| printable_unit(u)) {
277 let text: String = units
278 .iter()
279 .map(|&u| char::from_u32(u32::from(u)).unwrap_or('?'))
280 .collect();
281 f(&text);
282 i = end;
283 continue;
284 }
285 }
286 }
287 i += 1;
288 }
289}
290
291fn find_bytes(haystack: &[u8], from: usize, needle: &[u8]) -> Option<usize> {
293 haystack
294 .get(from..)?
295 .windows(needle.len())
296 .position(|window| window == needle)
297 .map(|i| from + i)
298}
299
300fn step_file_name_entity(content: &[u8]) -> Option<std::ops::Range<usize>> {
304 let start = content
305 .iter()
306 .position(|b| !b.is_ascii_whitespace())
307 .unwrap_or(content.len());
308 if !content.get(start..)?.starts_with(b"ISO-10303-21;") {
309 return None;
310 }
311 let header = find_bytes(content, start, b"HEADER;")?;
312 let header_end = find_bytes(content, header + b"HEADER;".len(), b"ENDSEC;")?;
313 let name = find_bytes(content, header + b"HEADER;".len(), b"FILE_NAME")?;
314 if name >= header_end {
315 return None;
316 }
317 let open = name + content.get(name..)?.iter().position(|&b| b == b'(')?;
318 let mut depth = 0usize;
320 let mut in_string = false;
321 let mut i = open;
322 while let Some(&b) = content.get(i) {
323 match b {
324 b'\'' if in_string && content.get(i + 1) == Some(&b'\'') => i += 1,
325 b'\'' => in_string = !in_string,
326 b'(' if !in_string => depth += 1,
327 b')' if !in_string => {
328 depth -= 1;
329 if depth == 0 {
330 return Some(name..i + 1);
331 }
332 }
333 _ => {}
334 }
335 i += 1;
336 }
337 None
338}
339
340pub const EXPORT_NAME_SCAN: u64 = 64 * 1024;
342
343pub fn step_export_name(content: &[u8], own_name: &str) -> Option<String> {
350 let entity = step_file_name_entity(content)?;
351 let text = content.get(entity)?;
352 let open = text.iter().position(|&b| b == b'(')?;
353 let rest = text.get(open + 1..)?;
354 let quote = rest.iter().position(|b| !b.is_ascii_whitespace())?;
355 if rest.get(quote) != Some(&b'\'') {
356 return None;
357 }
358 let mut value = Vec::new();
360 let mut i = quote + 1;
361 loop {
362 match (rest.get(i), rest.get(i + 1)) {
363 (Some(b'\''), Some(b'\'')) => {
364 value.push(b'\'');
365 i += 2;
366 }
367 (Some(b'\''), _) => break,
368 (Some(&b), _) => {
369 value.push(b);
370 i += 1;
371 }
372 (None, _) => return None,
373 }
374 }
375 let value = String::from_utf8_lossy(&value);
376 let name = value.rsplit(['\\', '/']).next().unwrap_or_default().trim();
377 let name = cadformat::normalize_name(name);
378 let fits = !name.is_empty()
379 && name.len() <= MAX_NAME
380 && !name.contains(char::REPLACEMENT_CHARACTER)
381 && is_referencable(name)
382 && name.to_lowercase() != cadformat::normalize_name(own_name).to_lowercase();
383 fits.then(|| name.to_string())
384}
385
386pub async fn scan_export_name(path: &Path, own_name: &str) -> Option<String> {
389 let path = path.to_path_buf();
390 let own = own_name.to_string();
391 tokio::task::spawn_blocking(move || -> std::io::Result<Option<String>> {
392 let mut head = Vec::new();
393 std::fs::File::open(&path)?
394 .take(EXPORT_NAME_SCAN)
395 .read_to_end(&mut head)?;
396 Ok(step_export_name(&head, &own))
397 })
398 .await
399 .ok()
400 .and_then(Result::ok)
401 .flatten()
402}
403
404pub fn find(content: &[u8], own_name: &str) -> Vec<String> {
407 if let Some(streams) = crate::nxpart::reference_streams_in(content) {
410 return from_nx_streams(&streams, own_name);
411 }
412 if let Ok(Some(data)) = crate::swpart::reference_data(std::io::Cursor::new(content), MAX_SCAN) {
414 return from_sw_sections(data, own_name);
415 }
416 finish(raw_names(content), own_name)
417}
418
419fn from_sw_sections(data: crate::swpart::ReferenceData, own_name: &str) -> Vec<String> {
423 let names = match (data.components, data.content_manager) {
424 (Some(paths), _) => paths
425 .iter()
426 .map(|p| p.rsplit(['\\', '/']).next().unwrap_or(p).trim().to_string())
427 .collect(),
428 (None, Some(section)) => raw_names(§ion),
429 (None, None) => Vec::new(),
430 };
431 finish(names, own_name)
432}
433
434fn raw_names(content: &[u8]) -> Vec<String> {
436 let exts = extensions();
437 let skip = step_file_name_entity(content);
439 let mut found = Vec::new();
440 byte_runs(content, skip.as_ref(), |run| {
441 names_in(run, &exts, &mut found)
442 });
443 utf16_runs(content, skip.as_ref(), |run| {
444 names_in(run, &exts, &mut found)
445 });
446 let mut exact = Vec::new();
450 prefixed_utf16_strings(content, skip.as_ref(), |text| {
451 let last = text.rsplit(['\\', '/']).next().unwrap_or(text).trim();
452 if last.len() <= MAX_NAME && is_referencable(last) {
453 exact.push(last.to_string());
454 } else {
455 names_in(text, &exts, &mut found);
456 }
457 });
458 found.retain(|name| {
461 let name = name.to_lowercase();
462 !exact.iter().any(|e| {
463 let e = e.to_lowercase();
464 e.len() > name.len()
466 && e.ends_with(&name)
467 && e.get(..e.len() - name.len()).is_some_and(|head| {
468 head.ends_with(" ")
469 || head
470 .trim_end()
471 .chars()
472 .next_back()
473 .is_some_and(is_name_boundary)
474 })
475 })
476 });
477 found.extend(exact);
478 found
479}
480
481fn finish(found: Vec<String>, own_name: &str) -> Vec<String> {
483 let own = cadformat::normalize_name(own_name).to_lowercase();
484 let mut seen = HashSet::new();
485 found
486 .into_iter()
487 .map(|n| cadformat::normalize_name(&n).to_string())
488 .filter(|n| n.to_lowercase() != own && is_referencable(n))
489 .filter(|n| seen.insert(n.to_lowercase()))
490 .take(MAX_REFERENCES)
491 .collect()
492}
493
494fn from_nx_streams(streams: &[Vec<u8>], own_name: &str) -> Vec<String> {
498 let names = streams
499 .iter()
500 .flat_map(|s| raw_names(s).into_iter().skip(1))
501 .collect();
502 finish(names, own_name)
503}
504
505#[derive(Clone, Debug, PartialEq, Eq)]
508pub struct FileRef {
509 pub name: String,
510 pub instances: Option<u32>,
513}
514
515fn count_key(name: &str) -> String {
518 cadformat::normalize_name(name).to_lowercase()
519}
520
521pub fn instance_counts(path: &Path, format: &str) -> Option<HashMap<String, u32>> {
538 let mut counts: HashMap<String, u32> = HashMap::new();
539 match format {
540 "nx-part" => {
541 let comps = match crate::nxasm::kind_of(path).ok()? {
542 crate::nxasm::Kind::Assembly(c) | crate::nxasm::Kind::Part(c) => c,
543 crate::nxasm::Kind::Other => return None,
544 };
545 for c in comps {
546 let key = count_key(&format!("{}.prt", c.name));
547 let n = counts.entry(key).or_default();
548 *n = n.saturating_add(1);
549 }
550 }
551 "solidworks-assembly" => {
552 let file = std::io::BufReader::new(std::fs::File::open(path).ok()?);
553 for (p, n) in crate::swpart::file_component_counts(file, MAX_SCAN).ok()?? {
554 let key = count_key(p.rsplit(['\\', '/']).next().unwrap_or(&p).trim());
555 let total = counts.entry(key).or_default();
556 *total = total.saturating_add(n);
557 }
558 }
559 "step" => {
560 let mut content = Vec::new();
561 std::fs::File::open(path)
562 .ok()?
563 .take(MAX_SCAN)
564 .read_to_end(&mut content)
565 .ok()?;
566 for (name, n) in crate::stepbom::external_instance_counts(&content)? {
567 let total = counts.entry(count_key(&name)).or_default();
568 *total = total.saturating_add(n);
569 }
570 }
571 "inventor-assembly" => {
572 let file = std::io::BufReader::new(std::fs::File::open(path).ok()?);
573 let found = crate::invasm::file_occurrence_counts(file, MAX_SCAN).ok()??;
574 if found.is_empty() {
575 return None; }
577 for (name, n) in found {
578 let total = counts.entry(count_key(&name)).or_default();
579 *total = total.saturating_add(n);
580 }
581 }
582 _ => return None,
583 }
584 Some(counts)
585}
586
587pub fn with_counts(names: Vec<String>, counts: Option<&HashMap<String, u32>>) -> Vec<FileRef> {
590 names
591 .into_iter()
592 .map(|name| {
593 let instances = counts.and_then(|c| c.get(&count_key(&name)).copied());
594 FileRef { name, instances }
595 })
596 .collect()
597}
598
599pub async fn scan_file(path: &Path, own_name: &str, format: &str) -> Vec<FileRef> {
605 let path = path.to_path_buf();
606 let own = own_name.to_string();
607 let format = format.to_string();
608 tokio::task::spawn_blocking(move || -> std::io::Result<Vec<FileRef>> {
609 let names = names_in_file(&path, &own)?;
610 let counts = if names.is_empty() {
611 None
612 } else {
613 instance_counts(&path, &format)
614 };
615 Ok(with_counts(names, counts.as_ref()))
616 })
617 .await
618 .ok()
619 .and_then(Result::ok)
620 .unwrap_or_default()
621}
622
623fn names_in_file(path: &Path, own: &str) -> std::io::Result<Vec<String>> {
624 let mut file = std::io::BufReader::new(std::fs::File::open(path)?);
625 if let Some(streams) = crate::nxpart::reference_streams(&mut file, MAX_SCAN)? {
626 return Ok(from_nx_streams(&streams, own));
627 }
628 if let Some(data) = crate::swpart::reference_data(&mut file, MAX_SCAN)? {
629 return Ok(from_sw_sections(data, own));
630 }
631 let mut content = Vec::new();
632 file.seek(std::io::SeekFrom::Start(0))?;
633 file.take(MAX_SCAN).read_to_end(&mut content)?;
634 Ok(find(&content, own))
635}
636
637#[cfg(test)]
638mod tests {
639 use super::*;
640
641 fn utf16(s: &str) -> Vec<u8> {
642 s.encode_utf16().flat_map(u16::to_le_bytes).collect()
643 }
644
645 #[test]
646 fn fluent_meshes_are_not_referencable() {
647 for name in ["pipe.msh.gz", "pipe.msh.h5", "pipe.msh", "pipe.cas.h5"] {
648 assert!(!is_referencable(name), "{name}");
649 }
650 assert!(is_referencable("pipe.stl"));
651 assert!(!extensions().contains(&"msh.gz"));
652 }
653
654 #[test]
655 fn which_formats_are_scanned() {
656 for id in [
657 "solidworks-assembly",
658 "solidworks-drawing",
659 "inventor-assembly",
660 "catia-product",
661 "creo-assembly",
662 "solidedge-assembly",
663 "nx-part",
664 "fusion-distributed",
665 "dwg",
666 "step",
667 "jt",
668 "kicad-project",
669 ] {
670 assert!(scans(id), "{id}");
671 }
672 for id in [
673 "solidworks-part",
674 "stl",
675 "pdf",
676 "parasolid-text",
677 "",
678 "nope",
679 ] {
680 assert!(!scans(id), "{id}");
681 }
682 }
683
684 #[test]
685 fn windows_paths_in_utf16() {
686 let mut content = vec![0x01, 0x00, 0x7f, 0x13];
688 content.extend(utf16("C:\\Users\\erika\\Rover\\Motor mount.SLDPRT"));
689 content.extend([0x00, 0x00, 0x05, 0x00]);
690 content.extend(utf16("D:\\Lib\\ISO 4762 M6 x 20.ipt"));
691 content.extend([0xff, 0xfe, 0x00, 0x00]);
692 content.extend(utf16("Halterung_für_Düse.SLDPRT"));
693 content.extend([0x00, 0x00]);
694 content.extend(utf16("Rover.SLDASM")); let refs = find(&content, "rover.sldasm");
696 assert_eq!(
697 refs,
698 vec![
699 "Motor mount.SLDPRT",
700 "ISO 4762 M6 x 20.ipt",
701 "Halterung_für_Düse.SLDPRT"
702 ]
703 );
704 }
705
706 fn prefixed(s: &str) -> Vec<u8> {
709 let units = utf16(s);
710 let mut out = u32::try_from(units.len() / 2)
711 .unwrap_or(0)
712 .to_le_bytes()
713 .to_vec();
714 out.extend(units);
715 out
716 }
717
718 #[test]
719 fn inventor_length_prefixed_references() {
720 let mut content = vec![0x00, 0x13, 0x88, 0x01];
724 content.extend(prefixed(
725 "C:\\Projects\\Rover\\Workspace\\Drive\\Gearbox\\Planet\\\
726 Design Accelerator\\Planet_SpurGear.iam",
727 ));
728 content.extend([0x43, 0x00, 0x00, 0x00]);
729 content.extend(prefixed("Workspace"));
730 content.extend([0x02, 0x00, 0x00, 0x00, 0xff, 0xfe]);
731 content.extend(prefixed("Drive\\Gearbox\\RingGear.iam"));
732 content.extend([0x43, 0x00, 0x00, 0x00]);
733 content.extend(prefixed("C:\\Rover\\Bumper\\Spring L=20_D=8.ipt"));
735 content.extend([0x43, 0x00, 0x00, 0x00]);
736 content.extend(prefixed(
737 "C:\\Rover\\Standard parts\\Bolt M4 x 12, stainless.ipt",
738 ));
739 content.extend([0x00, 0x00]);
740 let refs = find(&content, "Gearbox.iam");
741 assert_eq!(
742 refs,
743 vec![
744 "Planet_SpurGear.iam",
745 "RingGear.iam",
746 "Spring L=20_D=8.ipt",
747 "Bolt M4 x 12, stainless.ipt"
748 ]
749 );
750 let mut both = prefixed("C:\\Rover\\Big Bracket.ipt");
752 both.extend([0x00, 0x00, 0x00, 0x00]);
753 both.extend(utf16("C:\\Rover\\Bracket.ipt"));
754 both.extend([0x00, 0x00]);
755 assert_eq!(
756 find(&both, "rover.iam"),
757 vec!["Big Bracket.ipt", "Bracket.ipt"]
758 );
759 let mut plain = utf16("C:\\Rover\\Motor mount.iam");
761 plain.extend([0x43, 0x00, 0x00, 0x00]);
762 assert!(find(&plain, "rover.iam").is_empty());
763 }
764
765 #[test]
766 fn prefixed_strings_need_exact_printable_length() {
767 fn collect(content: &[u8]) -> Vec<String> {
768 let mut out = Vec::new();
769 prefixed_utf16_strings(content, None, |t| out.push(t.to_string()));
770 out
771 }
772 let mut short = 40u32.to_le_bytes().to_vec();
774 short.extend(utf16("bracket.ipt"));
775 assert!(collect(&short).is_empty());
776 let mut broken = 11u32.to_le_bytes().to_vec();
778 broken.extend(utf16("brack"));
779 broken.extend([0x01, 0x00]);
780 broken.extend(utf16("t.ipt"));
781 assert!(collect(&broken).is_empty());
782 let mut tiny = prefixed("a.ip");
784 tiny.extend([0x00, 0x00]);
785 assert!(collect(&tiny).is_empty());
786 let mut exact = prefixed("bracket.ipt");
788 exact.extend(utf16("CX"));
789 assert_eq!(collect(&exact), vec!["bracket.ipt"]);
790 let mut skipped = Vec::new();
792 let range = 0..exact.len();
793 prefixed_utf16_strings(&exact, Some(&range), |t| skipped.push(t.to_string()));
794 assert!(skipped.is_empty());
795 }
796
797 #[test]
798 fn odd_alignment_and_ascii() {
799 let mut content = vec![0x00];
800 content.extend(utf16("bracket.CATPart"));
801 content.extend([0x00, 0x00, 0x09]);
802 content.extend(b"#UGC:2 ASSEMBLY\x00\x03BRACKET.PRT\x00\x02FRAME_SIDE.PRT.3\x00");
803 let refs = find(&content, "stage.asm");
804 assert_eq!(
805 refs,
806 vec!["BRACKET.PRT", "FRAME_SIDE.PRT", "bracket.CATPart"]
807 );
808 }
809
810 #[test]
811 fn text_formats() {
812 let step = b"ISO-10303-21;\nDATA;\n#5=DOCUMENT_FILE('nozzle.stp','',$,#6,'','');\n\
813 #7=APPLIED_EXTERNAL_IDENTIFICATION_ASSIGNMENT('injector plate.STEP',#8,(#9));\n\
814 #10=PRODUCT('NOZ','Nozzle 1.5 mm','',(#2));\nENDSEC;";
815 assert_eq!(
816 find(step, "engine.step"),
817 vec!["nozzle.stp", "injector plate.STEP"]
818 );
819 let kicad = b"(kicad_sch (sheet (property \"Sheetfile\" \"power.kicad_sch\") \
820 (property \"Sheetfile\" \"sensors/imu.kicad_sch\")))";
821 assert_eq!(
822 find(kicad, "main.kicad_sch"),
823 vec!["power.kicad_sch", "imu.kicad_sch"]
824 );
825 }
826
827 #[test]
828 fn step_header_name_is_not_a_reference() {
829 let step = b"ISO-10303-21;\nHEADER;\n\
830 FILE_DESCRIPTION((''),'2;1');\n\
831 FILE_NAME('export name.stp','2021-01-01',(''),(''),'','','');\n\
832 FILE_SCHEMA(('AUTOMOTIVE_DESIGN'));\nENDSEC;\nDATA;\n\
833 #5=DOCUMENT_FILE('export name.stp','',$,#6,'','');\n\
834 #7=DOCUMENT_FILE('nozzle.stp','',$,#6,'','');\nENDSEC;\nEND-ISO-10303-21;\n";
835 assert_eq!(
836 find(step, "stored.stp"),
837 vec!["export name.stp", "nozzle.stp"]
838 );
839 }
840
841 #[test]
842 fn no_false_friends() {
843 let content = b"Version 2.5 released\x00see manual.pdf\x00Part.prtdot\x00\
845 stl.stlfile\x00.SLDPRT\x00file.x_tx\x00 - .stp\x00";
846 assert!(
847 find(content, "a.sldasm").is_empty(),
848 "{:?}",
849 find(content, "a.sldasm")
850 );
851 assert!(find(b"", "a.sldasm").is_empty());
852 let content =
854 b"\x00bolt.sldprt\x00BOLT.SLDPRT\x00Bolt.sldprt\x00frame.prt.2\x00frame.prt.3\x00";
855 assert_eq!(find(content, "x.sldasm"), vec!["bolt.sldprt", "frame.prt"]);
856 }
857
858 #[test]
859 fn many_references_are_capped() {
860 let mut content = Vec::new();
861 for i in 0..800 {
862 content.extend(format!("\x00part-{i}.ipt").as_bytes());
863 }
864 assert_eq!(find(&content, "big.iam").len(), MAX_REFERENCES);
865 }
866
867 #[test]
868 fn paths_are_reduced_to_the_file_name() {
869 let content = b"\x00/home/erika/rover/wheel.sldprt\x00\
871 \\\\server\\share\\cad\\hub.ipt\x00file:///C:/lib/axle.step\x00";
872 assert_eq!(
873 find(content, "rover.sldasm"),
874 vec!["wheel.sldprt", "hub.ipt", "axle.step"]
875 );
876 }
877
878 #[test]
879 fn the_file_itself_is_never_a_reference() {
880 let content = b"\x00C:\\work\\FRAME.ASM.4\x00frame.asm\x00bolt.prt.2\x00";
882 assert_eq!(find(content, "frame.asm.7"), vec!["bolt.prt"]);
883 }
884
885 #[test]
886 fn non_cad_files_are_not_references() {
887 let content = b"\x00manual.pdf\x00job.inp\x00result.frd\x00mesh.vtu\x00\
890 nc.mfg\x00sheet.slddrt\x00project.ipj\x00real part.par\x00";
891 assert_eq!(find(content, "a.asm"), vec!["real part.par"]);
892 }
893
894 #[test]
895 fn names_end_at_separators_and_double_spaces() {
896 let content =
899 b"text Motor mount.ipt\x00name=\"Shaft 20.ipt\";ref='nut.ipt'\x00[plate.ipt]";
900 assert_eq!(
901 find(content, "x.iam"),
902 vec!["Motor mount.ipt", "Shaft 20.ipt", "nut.ipt", "plate.ipt"]
903 );
904 }
905
906 #[test]
907 fn the_longest_extension_wins() {
908 let content = b"\x00power.kicad_sch\x00Top.CATProduct\x00shaft.stpx\x00";
911 assert_eq!(
912 find(content, "main.kicad_pro"),
913 vec!["power.kicad_sch", "Top.CATProduct"]
914 );
915 }
916
917 #[test]
918 fn names_without_a_stem_or_too_long_are_dropped() {
919 let long = format!("\x00{}.ipt\x00", "x".repeat(MAX_NAME));
920 assert!(
921 find(long.as_bytes(), "a.iam").is_empty(),
922 "longer than MAX_NAME"
923 );
924 let fits = format!("\x00{}.ipt\x00", "x".repeat(MAX_NAME - 4));
925 assert_eq!(find(fits.as_bytes(), "a.iam").len(), 1);
926 assert!(find(b"\x00___.ipt\x00--.stp\x00", "a.iam").is_empty());
928 }
929
930 #[test]
931 fn short_runs_are_ignored() {
932 assert!(find(b"\x00a.st\x00", "x.asm").is_empty());
934 assert_eq!(find(b"\x00ab.stp\x00", "x.asm"), vec!["ab.stp"]);
935 }
936
937 #[test]
938 fn step_header_name_is_only_skipped_inside_the_header() {
939 let step = b"ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\nENDSEC;\nDATA;\n\
941 #1=FILE_NAME('wheel.stp');\nENDSEC;\n";
942 assert_eq!(find(step, "rover.stp"), vec!["wheel.stp"]);
943 let step = b"ISO-10303-21;\nHEADER;\n\
945 FILE_NAME('it''s (v2).stp','2026',('a(b)'),(''),'','','');\nENDSEC;\nDATA;\n\
946 #5=DOCUMENT_FILE('rim.stp','',$,#6,'','');\nENDSEC;\n";
947 assert_eq!(find(step, "rover.stp"), vec!["rim.stp"]);
948 let not_step = b"FILE_NAME('export.stp','2026');";
950 assert_eq!(find(not_step, "x.stp"), vec!["export.stp"]);
951 }
952
953 #[test]
954 fn solidworks_sections_reduce_component_paths() {
955 let data = crate::swpart::ReferenceData {
956 components: Some(vec![
957 "C:\\p\\Bolt.SLDPRT".into(),
958 "D:/lib/Nut.SLDPRT".into(),
959 "C:\\p\\bolt.sldprt".into(),
960 "C:\\p\\Top.SLDASM".into(),
961 ]),
962 content_manager: Some(b"\x00ignored.SLDPRT\x00".to_vec()),
963 };
964 assert_eq!(
965 from_sw_sections(data, "top.sldasm"),
966 vec!["Bolt.SLDPRT", "Nut.SLDPRT"],
967 "the component tree wins over the document manager"
968 );
969 let drawing = crate::swpart::ReferenceData {
970 components: None,
971 content_manager: Some(b"\x00C:\\p\\Bracket.SLDPRT\x00".to_vec()),
972 };
973 assert_eq!(
974 from_sw_sections(drawing, "b.slddrw"),
975 vec!["Bracket.SLDPRT"]
976 );
977 let empty = crate::swpart::ReferenceData {
978 components: None,
979 content_manager: None,
980 };
981 assert!(from_sw_sections(empty, "b.slddrw").is_empty());
982 }
983
984 #[test]
985 fn nx_streams_skip_the_name_the_part_was_saved_with() {
986 let streams = vec![
988 b"\x00old_name.prt\x00wheel.prt\x00hub.prt\x00".to_vec(),
989 b"\x00old_name.prt\x00HUB.prt\x00axle.prt\x00".to_vec(),
990 ];
991 assert_eq!(
992 from_nx_streams(&streams, "new_name.prt"),
993 vec!["wheel.prt", "hub.prt", "axle.prt"]
994 );
995 }
996
997 #[tokio::test]
998 async fn scan_file_reads_stored_files() {
999 let path = std::env::temp_dir().join(format!("teamcraft-cadrefs-{}", uuid::Uuid::new_v4()));
1000 std::fs::write(&path, b"\x00bracket.SLDPRT\x00frame.SLDASM\x00").unwrap();
1001 assert_eq!(
1003 scan_file(&path, "frame.sldasm", "solidworks-assembly").await,
1004 vec![FileRef {
1005 name: "bracket.SLDPRT".into(),
1006 instances: None
1007 }]
1008 );
1009 std::fs::remove_file(&path).ok();
1010 assert!(
1011 scan_file(&path, "frame.sldasm", "solidworks-assembly")
1012 .await
1013 .is_empty(),
1014 "missing file"
1015 );
1016 }
1017
1018 #[test]
1019 fn counts_are_matched_by_the_normalised_name_in_any_case() {
1020 let counts: HashMap<String, u32> = [
1021 ("bolt.sldprt".to_string(), 4),
1022 ("glue.sldprt".to_string(), 0),
1023 ("gear.prt".to_string(), 2),
1024 ]
1025 .into();
1026 let names = vec![
1027 "Bolt.SLDPRT".to_string(),
1028 "glue.sldprt".to_string(),
1029 "gear.prt.3".to_string(),
1031 "nut.sldprt".to_string(),
1032 ];
1033 let refs = with_counts(names.clone(), Some(&counts));
1034 let got: Vec<_> = refs
1035 .iter()
1036 .map(|r| (r.name.as_str(), r.instances))
1037 .collect();
1038 assert_eq!(
1039 got,
1040 [
1041 ("Bolt.SLDPRT", Some(4)),
1042 ("glue.sldprt", Some(0)),
1043 ("gear.prt.3", Some(2)),
1044 ("nut.sldprt", None)
1045 ]
1046 );
1047 assert!(with_counts(names, None)
1048 .iter()
1049 .all(|r| r.instances.is_none()));
1050 }
1051
1052 #[test]
1053 fn inventor_assemblies_count_their_occurrences() {
1054 let path = std::env::temp_dir().join(format!("teamcraft-iam-{}", uuid::Uuid::new_v4()));
1055 std::fs::write(
1056 &path,
1057 crate::invasm::test_assembly(&[
1058 ("C:\\Rover\\Wheel\\Wheel.iam", false, 4),
1059 ("C:\\CC\\ISO 4762\\ISO 4762 - M6 x 20.ipt", true, 16),
1060 ("C:\\Rover\\Frame.ipt", false, 1),
1061 ]),
1062 )
1063 .unwrap();
1064 let counts = instance_counts(&path, "inventor-assembly").unwrap();
1065 assert_eq!(counts.get("wheel.iam"), Some(&4));
1066 assert_eq!(counts.get("iso 4762 - m6 x 20.ipt"), Some(&16));
1067 assert_eq!(counts.get("frame.ipt"), Some(&1));
1068 let refs = with_counts(
1069 vec!["Wheel.iam".into(), "Frame.ipt".into(), "Bracket.ipt".into()],
1070 Some(&counts),
1071 );
1072 assert_eq!(
1073 refs.iter().map(|r| r.instances).collect::<Vec<_>>(),
1074 vec![Some(4), Some(1), None]
1075 );
1076 assert_eq!(instance_counts(&path, "inventor-part"), None);
1079 std::fs::write(&path, crate::invasm::test_assembly(&[])).unwrap();
1080 assert_eq!(instance_counts(&path, "inventor-assembly"), None);
1081 std::fs::write(
1084 &path,
1085 crate::invasm::test_assembly_with_records(
1086 &[
1087 ("C:\\Rover\\Wheel.iam", false, 2),
1088 ("C:\\Rover\\Frame.ipt", false, 1),
1089 ],
1090 &[("Wheel", false), ("Frame", true), ("Wheel", true)],
1091 ),
1092 )
1093 .unwrap();
1094 let counts = instance_counts(&path, "inventor-assembly").unwrap();
1095 assert_eq!(counts.get("wheel.iam"), Some(&1));
1096 assert_eq!(counts.get("frame.ipt"), Some(&0));
1097 std::fs::remove_file(&path).ok();
1098 }
1099
1100 #[test]
1101 fn only_assemblies_with_component_data_have_counts() {
1102 let path = std::env::temp_dir().join(format!("teamcraft-counts-{}", uuid::Uuid::new_v4()));
1103 std::fs::write(&path, b"\x00bracket.SLDPRT\x00").unwrap();
1104 for format in [
1105 "solidworks-assembly",
1106 "nx-part",
1107 "inventor-assembly",
1108 "step",
1109 ] {
1110 assert_eq!(instance_counts(&path, format), None, "{format}");
1111 }
1112 std::fs::remove_file(&path).ok();
1113 }
1114
1115 #[test]
1120 fn a_non_ascii_letter_in_the_extension_does_not_panic() {
1121 let content = "board a.\u{212A}icad_pcb and other text".as_bytes();
1122 let names = find(content, "x.kicad_pro");
1123 assert!(names.iter().all(|n| n.starts_with('a')), "{names:?}");
1124 let utf16: Vec<u8> = "a.\u{212A}icad_pcb "
1126 .encode_utf16()
1127 .flat_map(u16::to_le_bytes)
1128 .collect();
1129 find(&utf16, "x.kicad_pro");
1130 }
1131
1132 fn step(header_name: &str) -> Vec<u8> {
1133 format!(
1134 "ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION((''),'2;1');\n\
1135 FILE_NAME({header_name},'2026-11-06',(''),(''),'','','');\nENDSEC;\nDATA;\nENDSEC;\n"
1136 )
1137 .into_bytes()
1138 }
1139
1140 #[test]
1141 fn the_step_export_name_is_a_second_name_of_the_file() {
1142 assert_eq!(
1143 step_export_name(&step("'bracket.stp'"), "bracket_v2.stp").as_deref(),
1144 Some("bracket.stp")
1145 );
1146 assert_eq!(
1148 step_export_name(&step(" 'C:\\Export\\Rover\\Wheel hub.STEP' "), "x.stp").as_deref(),
1149 Some("Wheel hub.STEP")
1150 );
1151 assert_eq!(
1152 step_export_name(&step("'/srv/out/it''s (v2).stp'"), "x.stp").as_deref(),
1153 Some("it's (v2).stp")
1154 );
1155 assert_eq!(
1157 step_export_name(&step("'Bracket.SLDPRT'"), "bracket.stp").as_deref(),
1158 Some("Bracket.SLDPRT")
1159 );
1160 for header in [
1163 "'BRACKET_V2.stp'",
1164 "''",
1165 "' '",
1166 "'Part1'",
1167 "'C:\\Export\\'",
1168 "'notes.pdf'",
1169 "$",
1170 "'unterminated.stp",
1171 ] {
1172 assert_eq!(
1173 step_export_name(&step(header), "bracket_v2.stp"),
1174 None,
1175 "{header}"
1176 );
1177 }
1178 let latin1 = b"ISO-10303-21;\nHEADER;\nFILE_NAME('Halterung_f\xfcr.stp','x');\nENDSEC;\n";
1179 assert_eq!(step_export_name(latin1, "x.stp"), None);
1180 let no_step = b"FILE_NAME('bracket.stp','2026');";
1181 assert_eq!(step_export_name(no_step, "x.stp"), None);
1182 let in_data =
1183 b"ISO-10303-21;\nHEADER;\nFILE_SCHEMA(('X'));\nENDSEC;\nDATA;\n#1=FILE_NAME('b.stp');\n";
1184 assert_eq!(step_export_name(in_data, "x.stp"), None);
1185 assert!(find(&step("'bracket.stp'"), "bracket_v2.stp").is_empty());
1187 }
1188
1189 #[test]
1190 fn binary_noise_is_no_file_name() {
1191 let mut content = vec![0x01, 0x9f, b'4', b'.', b'J', b'T', b'\n'];
1194 content.extend(b"\x02ab\x9fcd.stp\x00");
1196 assert!(
1197 find(&content, "x.jt").is_empty(),
1198 "{:?}",
1199 find(&content, "x.jt")
1200 );
1201 let mut content = vec![0x01, 0x8e];
1203 content.extend(b"Bracket.SLDPRT\x00\x004711.stp\x00");
1204 assert_eq!(find(&content, "x.sldasm"), ["Bracket.SLDPRT", "4711.stp"]);
1205 }
1206
1207 #[test]
1210 fn byte_runs_need_five_printable_bytes() {
1211 let runs = |content: &[u8], skip: Option<&std::ops::Range<usize>>| {
1212 let mut out = Vec::new();
1213 byte_runs(content, skip, |r| out.push(r.to_string()));
1214 out
1215 };
1216 let content = b"\0\0\0\0\0\0\0\0abcd\0ABCDE\0\0xyz";
1217 assert_eq!(runs(content, None), ["ABCDE"]);
1218 assert_eq!(
1219 runs(b"\0\0\0\0\0\0\0\0UVWXY", None),
1220 ["UVWXY"],
1221 "a run at the end"
1222 );
1223 assert_eq!(runs(b"\0\0\0\0\0\0\0\0UVWX", None), Vec::<String>::new());
1224 assert_eq!(runs(b"ABCDEFGHIJ", Some(&(5..6))), ["ABCDE"]);
1226 }
1227
1228 #[test]
1232 fn the_file_name_entity_is_found_inside_the_header_only() {
1233 let entity = b"FILE_NAME('it''s a) b.stp','',(''),(''),'','','')";
1234 let file = |before_header: &str| {
1235 let mut c = format!("ISO-10303-21;{before_header}HEADER;\n").into_bytes();
1236 let at = c.len();
1237 c.extend_from_slice(entity);
1238 c.extend_from_slice(b";\nENDSEC;\nDATA;\nENDSEC;\nEND-ISO-10303-21;\n");
1239 (c, at)
1240 };
1241 let comment = format!("\n/* {} */\n", "x".repeat(400));
1242 let (content, at) = file(&comment);
1243 assert_eq!(step_file_name_entity(&content), Some(at..at + entity.len()));
1244 let (content, at) = file("\nENDSEC;");
1245 assert_eq!(step_file_name_entity(&content), Some(at..at + entity.len()));
1246 let late = b"ISO-10303-21;\nHEADER;\nENDSEC;\nDATA;\n#1=FILE_NAME('x');\nENDSEC;\n";
1248 assert_eq!(step_file_name_entity(late), None);
1249 }
1250
1251 #[tokio::test]
1254 async fn the_export_name_of_a_stored_file() {
1255 let dir = std::env::temp_dir().join(format!("teamcraft-step-{}", uuid::Uuid::new_v4()));
1256 std::fs::create_dir_all(&dir).unwrap();
1257 let stored = dir.join("object");
1258 std::fs::write(&stored, step("'Bracket.STEP'")).unwrap();
1259 assert_eq!(
1260 scan_export_name(&stored, "bracket_v2.stp").await.as_deref(),
1261 Some("Bracket.STEP")
1262 );
1263 assert_eq!(
1264 scan_export_name(&stored, "bracket.step").await,
1265 None,
1266 "own name"
1267 );
1268 assert_eq!(scan_export_name(&dir.join("missing"), "x.stp").await, None);
1269 let mut late = format!("ISO-10303-21;\n/* {} */\n", "x".repeat(70 * 1024)).into_bytes();
1271 late.extend(
1272 step("'Bracket.STEP'")
1273 .into_iter()
1274 .skip("ISO-10303-21;\n".len()),
1275 );
1276 let far = dir.join("far");
1277 std::fs::write(&far, &late).unwrap();
1278 assert_eq!(
1279 step_export_name(&late, "x.stp").as_deref(),
1280 Some("Bracket.STEP")
1281 );
1282 assert_eq!(scan_export_name(&far, "x.stp").await, None);
1283 std::fs::remove_dir_all(&dir).ok();
1284 }
1285
1286 #[test]
1288 fn utf16_runs_end_at_non_printable_units() {
1289 let utf16 = |s: &str| {
1290 s.encode_utf16()
1291 .flat_map(u16::to_le_bytes)
1292 .collect::<Vec<u8>>()
1293 };
1294 let runs = |content: &[u8], skip: Option<&std::ops::Range<usize>>| {
1295 let mut out = Vec::new();
1296 utf16_runs(content, skip, |r| out.push(r.to_string()));
1297 out
1298 };
1299 let mut content = utf16("ABCDE");
1300 content.extend_from_slice(&[0, 0]);
1301 content.extend(utf16("Größe"));
1302 let found = runs(&content, None);
1303 assert!(found.contains(&"ABCDE".to_string()), "{found:?}");
1304 assert!(found.contains(&"Größe".to_string()), "{found:?}");
1305 assert!(found.iter().all(|r| !r.contains('\0')), "{found:?}");
1306 let found = runs(&utf16("ABCDEFGH"), Some(&(8..9)));
1308 assert!(found.iter().all(|r| r.chars().count() < 8), "{found:?}");
1309 assert_eq!(runs(&utf16("ABCDEFGHIJKL"), Some(&(9..10))), ["FGHIJKL"]);
1312 }
1313
1314 #[test]
1317 fn scan_limits() {
1318 assert_eq!(MAX_SCAN, 32 * 1024 * 1024);
1319 assert_eq!(EXPORT_NAME_SCAN, 64 * 1024);
1320 }
1321}