1use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12pub struct EntityTypeCount {
13 pub entity_type: String,
14 pub count: u64,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
19pub struct StepMetadata {
20 pub file_schema: Option<String>,
22 pub entity_count: Option<u64>,
24 pub header_line_count: Option<u64>,
26 pub data_line_count: Option<u64>,
28 pub top_entity_types: Vec<EntityTypeCount>,
30}
31
32const TOP_N: usize = 20;
34
35pub fn extract(content: &[u8]) -> Option<StepMetadata> {
40 let text = std::str::from_utf8(content).ok()?;
41 Some(extract_str(text))
42}
43
44pub fn extract_str(text: &str) -> StepMetadata {
46 let file_schema = find_file_schema(text);
47 let (header_line_count, data_line_count) = section_line_counts(text);
48 let data = data_section(text).unwrap_or("");
49 let (entity_count, top_entity_types) = scan_entities(data);
50
51 StepMetadata {
52 file_schema,
53 entity_count: Some(entity_count),
54 header_line_count,
55 data_line_count,
56 top_entity_types,
57 }
58}
59
60fn find_keyword_line(text: &str, keyword: &str) -> Option<usize> {
62 let kw = keyword.to_ascii_uppercase();
63 text.lines()
64 .position(|line| line.trim().to_ascii_uppercase().starts_with(&kw))
65}
66
67fn section_line_counts(text: &str) -> (Option<u64>, Option<u64>) {
70 let lines: Vec<&str> = text.lines().collect();
71 let count_section = |start_kw: &str| -> Option<u64> {
72 let start = find_keyword_line(text, start_kw)?;
73 let mut count = 0u64;
74 for line in lines.iter().skip(start + 1) {
75 if line.trim().to_ascii_uppercase().starts_with("ENDSEC") {
76 break;
77 }
78 count += 1;
79 }
80 Some(count)
81 };
82 (count_section("HEADER"), count_section("DATA"))
83}
84
85fn data_section(text: &str) -> Option<&str> {
87 let upper = text.to_ascii_uppercase();
88 let data_pos = upper.find("DATA;")?;
89 let after = data_pos + "DATA;".len();
90 let end_rel = upper[after..].find("ENDSEC").unwrap_or(upper.len() - after);
91 Some(&text[after..after + end_rel])
92}
93
94fn find_file_schema(text: &str) -> Option<String> {
96 let upper = text.to_ascii_uppercase();
97 let key = "FILE_SCHEMA";
98 let pos = upper.find(key)?;
99 let rest = &text[pos + key.len()..];
101 let start = rest.find('\'')?;
102 let after = &rest[start + 1..];
103 let end = after.find('\'')?;
104 let schema = after[..end].trim();
105 if schema.is_empty() {
106 None
107 } else {
108 Some(schema.to_string())
109 }
110}
111
112fn scan_entities(data: &str) -> (u64, Vec<EntityTypeCount>) {
118 use std::collections::HashMap;
119
120 let bytes = data.as_bytes();
121 let mut counts: HashMap<&str, u64> = HashMap::new();
122 let mut total: u64 = 0;
123 let mut i = 0usize;
124 let n = bytes.len();
125
126 while i < n {
127 if bytes[i] != b'#' {
128 i += 1;
129 continue;
130 }
131 let mut j = i + 1;
133 while j < n && bytes[j].is_ascii_digit() {
134 j += 1;
135 }
136 if j == i + 1 {
137 i += 1;
139 continue;
140 }
141 let mut k = j;
143 while k < n && bytes[k].is_ascii_whitespace() {
144 k += 1;
145 }
146 if k >= n || bytes[k] != b'=' {
147 i = j;
148 continue;
149 }
150 k += 1;
151 while k < n && bytes[k].is_ascii_whitespace() {
153 k += 1;
154 }
155 if k < n && bytes[k] == b'(' {
158 k += 1;
159 while k < n && bytes[k].is_ascii_whitespace() {
160 k += 1;
161 }
162 }
163 let type_start = k;
165 while k < n && (bytes[k].is_ascii_alphanumeric() || bytes[k] == b'_') {
166 k += 1;
167 }
168 if k > type_start {
169 let ty = &data[type_start..k];
170 let mut m = k;
172 while m < n && bytes[m].is_ascii_whitespace() {
173 m += 1;
174 }
175 if m < n && bytes[m] == b'(' {
176 total += 1;
177 *counts.entry(ty).or_insert(0) += 1;
178 }
179 }
180 i = k.max(j);
181 }
182
183 let mut top: Vec<EntityTypeCount> = counts
184 .into_iter()
185 .map(|(entity_type, count)| EntityTypeCount {
186 entity_type: entity_type.to_string(),
187 count,
188 })
189 .collect();
190 top.sort_by(|a, b| {
192 b.count
193 .cmp(&a.count)
194 .then_with(|| a.entity_type.cmp(&b.entity_type))
195 });
196 top.truncate(TOP_N);
197
198 (total, top)
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204
205 const SAMPLE: &str = "\
206ISO-10303-21;
207HEADER;
208FILE_DESCRIPTION(('Example'),'2;1');
209FILE_NAME('cube.step','2026-06-12T00:00:00',('cadvm'),('cadvm'),'','','');
210FILE_SCHEMA(('AUTOMOTIVE_DESIGN'));
211ENDSEC;
212DATA;
213#1 = CARTESIAN_POINT('', (0.0, 0.0, 0.0));
214#2 = DIRECTION('', (0.0, 0.0, 1.0));
215#3 = CARTESIAN_POINT('', (1.0, 0.0, 0.0));
216ENDSEC;
217END-ISO-10303-21;
218";
219
220 #[test]
221 fn extracts_schema_and_counts() {
222 let md = extract_str(SAMPLE);
223 assert_eq!(md.file_schema.as_deref(), Some("AUTOMOTIVE_DESIGN"));
224 assert_eq!(md.entity_count, Some(3));
225 let top = &md.top_entity_types;
226 assert_eq!(top[0].entity_type, "CARTESIAN_POINT");
227 assert_eq!(top[0].count, 2);
228 assert_eq!(md.data_line_count, Some(3));
229 }
230
231 #[test]
232 fn ignores_references_inside_entities() {
233 let data = "#10 = ADVANCED_FACE('',(#11,#12),#13,.T.);\n#11 = PLANE('',#14);\n";
234 let (total, top) = scan_entities(data);
235 assert_eq!(total, 2);
236 assert!(top.iter().any(|t| t.entity_type == "ADVANCED_FACE"));
237 assert!(top.iter().all(|t| t.entity_type != "PLANE" || t.count == 1));
238 }
239}