ifc_lite_export/
csv.rs

1// SPDX-License-Identifier: MPL-2.0
2//! CSV exporter — entities (with optional flattened property columns), one-row-per
3//! property, and one-row-per-quantity. Ports `packages/export/src/csv-exporter.ts`,
4//! including the spreadsheet formula-injection guard (CWE-1236) and RFC-4180 quoting.
5
6use std::collections::{HashMap, HashSet};
7
8use crate::csv_cell::{escape_csv_cell, CsvCellOptions};
9use crate::model::{build_export_model, fmt_num, EntityRow, ExportModel};
10
11/// Which CSV view to emit.
12#[derive(Clone, Copy, PartialEq, Eq)]
13pub enum CsvMode {
14    /// One row per IfcProduct, native columns (+ flattened `Pset_Prop` columns).
15    Entities,
16    /// One row per property value.
17    Properties,
18    /// One row per quantity value.
19    Quantities,
20    /// One row per spatial node (project → sites → buildings → storeys → elements).
21    SpatialHierarchy,
22}
23
24/// Options for CSV export.
25pub struct CsvOptions {
26    /// Column delimiter (default `,`).
27    pub delimiter: String,
28    /// Append flattened `PsetName_PropName` columns to the entities view.
29    pub include_properties: bool,
30}
31
32impl Default for CsvOptions {
33    fn default() -> Self {
34        Self { delimiter: ",".to_string(), include_properties: false }
35    }
36}
37
38/// RFC-4180 escape + spreadsheet formula-injection guard.
39///
40/// A thin alias over [`crate::csv_cell::escape_csv_cell`], which is THE escaper
41/// for this crate. This function used to carry its own copy of the guard, and
42/// that copy tested the formula trigger anchored at offset 0 — so a BOM, ZWSP,
43/// LRM, NBSP or U+2028 in front of `=` sailed straight past it. Keep it a
44/// delegation: `scripts/check-csv-escaper-copies.mjs` fails the build if the
45/// guard is re-inlined anywhere.
46fn escape(value: &str, delimiter: &str) -> String {
47    // Fields spelled out rather than `..Default::default()`: a new option on a
48    // security-relevant guard should force this call site to make a decision,
49    // not silently inherit one.
50    escape_csv_cell(
51        value,
52        &CsvCellOptions {
53            delimiter,
54            exempt_numbers: true,
55            quote_whitespace_padded: false,
56        },
57    )
58}
59
60fn join(values: &[String], delimiter: &str) -> String {
61    values.join(delimiter)
62}
63
64/// Export the requested CSV view from raw IFC bytes.
65pub fn export_csv(content: &[u8], mode: CsvMode, opts: &CsvOptions) -> String {
66    let model = build_export_model(content);
67    match mode {
68        CsvMode::Entities => entities_csv(&model, opts),
69        CsvMode::Properties => properties_csv(&model, opts),
70        CsvMode::Quantities => quantities_csv(&model, opts),
71        CsvMode::SpatialHierarchy => spatial_csv(content, &model, opts),
72    }
73}
74
75/// One row per spatial node, depth-first from the project root.
76fn spatial_csv(content: &[u8], model: &ExportModel, opts: &CsvOptions) -> String {
77    let d = &opts.delimiter;
78    let by_id: HashMap<u32, &EntityRow> = model.entities.iter().map(|e| (e.express_id, e)).collect();
79    let (children, project) = crate::ifc5::spatial_children(content);
80
81    // The project node isn't an IfcProduct, so decode its GlobalId + Name directly.
82    let (mut proj_gid, mut proj_name) = (String::new(), String::new());
83    if let Some(pid) = project {
84        if let Some(e) = crate::ifc5::decode_one(content, pid) {
85            proj_gid = e.get(0).and_then(|a| a.as_string()).unwrap_or("").to_string();
86            proj_name = e.get(2).and_then(|a| a.as_string()).unwrap_or("").to_string();
87        }
88    }
89
90    let info = |id: u32| -> (String, String, String) {
91        if Some(id) == project {
92            (proj_gid.clone(), proj_name.clone(), "IfcProject".to_string())
93        } else if let Some(e) = by_id.get(&id) {
94            (
95                e.global_id.clone().unwrap_or_default(),
96                e.name.clone().unwrap_or_default(),
97                e.ifc_type.clone(),
98            )
99        } else {
100            (String::new(), String::new(), String::new())
101        }
102    };
103
104    let headers = ["expressId", "globalId", "name", "type", "parentId", "level"];
105    let mut lines = vec![join(&headers.iter().map(|h| escape(h, d)).collect::<Vec<_>>(), d)];
106
107    let mut visited = HashSet::new();
108    let mut stack: Vec<(u32, Option<u32>, usize)> = Vec::new();
109    if let Some(pid) = project {
110        stack.push((pid, None, 0));
111    }
112    while let Some((id, parent, level)) = stack.pop() {
113        if !visited.insert(id) {
114            continue;
115        }
116        let (gid, name, ty) = info(id);
117        let row = vec![
118            escape(&id.to_string(), d),
119            escape(&gid, d),
120            escape(&name, d),
121            escape(&ty, d),
122            escape(&parent.map(|p| p.to_string()).unwrap_or_default(), d),
123            escape(&level.to_string(), d),
124        ];
125        lines.push(join(&row, d));
126        if let Some(kids) = children.get(&id) {
127            // Push reversed so siblings emit in source order.
128            for &k in kids.iter().rev() {
129                if !visited.contains(&k) {
130                    stack.push((k, Some(id), level + 1));
131                }
132            }
133        }
134    }
135    lines.join("\n")
136}
137
138fn entities_csv(model: &ExportModel, opts: &CsvOptions) -> String {
139    let d = &opts.delimiter;
140    let mut headers: Vec<String> = ["expressId", "globalId", "name", "type", "description", "objectType", "hasGeometry"]
141        .iter()
142        .map(|s| s.to_string())
143        .collect();
144
145    // Collect flattened property columns (first-seen order, deduped).
146    let mut flat_cols: Vec<(String, String)> = Vec::new();
147    if opts.include_properties {
148        let mut seen = std::collections::HashSet::new();
149        for e in &model.entities {
150            for ps in &e.property_sets {
151                for p in &ps.properties {
152                    let key = (ps.name.clone(), p.name.clone());
153                    if seen.insert(key.clone()) {
154                        flat_cols.push(key);
155                    }
156                }
157            }
158        }
159        for (pset, prop) in &flat_cols {
160            headers.push(format!("{pset}_{prop}"));
161        }
162    }
163
164    let mut lines = Vec::with_capacity(model.entities.len() + 1);
165    lines.push(join(&headers.iter().map(|h| escape(h, d)).collect::<Vec<_>>(), d));
166
167    for e in &model.entities {
168        let mut row = vec![
169            escape(&e.express_id.to_string(), d),
170            escape(e.global_id.as_deref().unwrap_or(""), d),
171            escape(e.name.as_deref().unwrap_or(""), d),
172            escape(&e.ifc_type, d),
173            escape(e.description.as_deref().unwrap_or(""), d),
174            escape(e.object_type.as_deref().unwrap_or(""), d),
175            escape(if e.has_geometry { "true" } else { "false" }, d),
176        ];
177        if opts.include_properties {
178            for (pset, prop) in &flat_cols {
179                let v = e.lookup(pset, prop).unwrap_or_default();
180                row.push(escape(&v, d));
181            }
182        }
183        lines.push(join(&row, d));
184    }
185    lines.join("\n")
186}
187
188fn properties_csv(model: &ExportModel, opts: &CsvOptions) -> String {
189    let d = &opts.delimiter;
190    let headers = ["entityId", "globalId", "entityName", "entityType", "psetName", "propName", "value", "type"];
191    let mut lines = vec![join(&headers.iter().map(|h| escape(h, d)).collect::<Vec<_>>(), d)];
192
193    for e in &model.entities {
194        for ps in &e.property_sets {
195            for p in &ps.properties {
196                let row = vec![
197                    escape(&e.express_id.to_string(), d),
198                    escape(e.global_id.as_deref().unwrap_or(""), d),
199                    escape(e.name.as_deref().unwrap_or(""), d),
200                    escape(&e.ifc_type, d),
201                    escape(&ps.name, d),
202                    escape(&p.name, d),
203                    escape(&p.value, d),
204                    escape(&p.value_type, d),
205                ];
206                lines.push(join(&row, d));
207            }
208        }
209    }
210    lines.join("\n")
211}
212
213fn quantities_csv(model: &ExportModel, opts: &CsvOptions) -> String {
214    let d = &opts.delimiter;
215    let headers = ["entityId", "globalId", "entityName", "entityType", "qsetName", "quantityName", "value", "type"];
216    let mut lines = vec![join(&headers.iter().map(|h| escape(h, d)).collect::<Vec<_>>(), d)];
217
218    for e in &model.entities {
219        for qs in &e.quantity_sets {
220            for q in &qs.quantities {
221                let row = vec![
222                    escape(&e.express_id.to_string(), d),
223                    escape(e.global_id.as_deref().unwrap_or(""), d),
224                    escape(e.name.as_deref().unwrap_or(""), d),
225                    escape(&e.ifc_type, d),
226                    escape(&qs.name, d),
227                    escape(&q.name, d),
228                    escape(&fmt_num(q.value), d),
229                    escape(q.kind, d),
230                ];
231                lines.push(join(&row, d));
232            }
233        }
234    }
235    lines.join("\n")
236}
237
238#[cfg(test)]
239#[path = "csv_tests.rs"]
240mod tests;