ifc_lite_export/step_log/
write.rs1use std::collections::HashSet;
12use std::io::{self, Write};
13
14use crate::schema_detect::detect_schema;
15use crate::step_api::{StepOptions, StepStats};
16
17use super::attrs::schema_family;
18use super::base::BaseSets;
19use super::collect::{collect, retain_shared_atoms};
20use super::generate::generate;
21use super::created::write_created;
22use super::ledger::Kind;
23use super::refilter::{filter_source_line, Filtered};
24use super::replay::replay;
25use super::pass::Pass;
26use super::source::Source;
27use super::wire::MutationLog;
28
29#[derive(Debug, Clone, Default, PartialEq)]
31#[non_exhaustive]
32pub struct LogExportStats {
33 pub written: usize,
37 pub total: usize,
39 pub new_entities: usize,
41 pub modified_entities: usize,
44 pub warnings: Vec<String>,
47 pub step: StepCounters,
49}
50
51#[derive(Debug, Clone, Default, PartialEq)]
53#[non_exhaustive]
54pub struct StepCounters {
55 pub attribute_edits_refused: usize,
56 pub owner_history_unfilled: usize,
57 pub required_slots_unfilled: usize,
58 pub ifc4_required_slots_unfilled: usize,
59 pub enum_values_lost: usize,
60 pub enum_values_refused: usize,
61}
62
63impl From<&LogExportStats> for StepStats {
64 fn from(s: &LogExportStats) -> Self {
65 StepStats {
66 total: s.total,
67 written: s.written,
68 copies_refused: 0,
69 refused_refs: 0,
70 attribute_edits_refused: s.step.attribute_edits_refused,
71 owner_history_unfilled: s.step.owner_history_unfilled,
72 required_slots_unfilled: s.step.required_slots_unfilled,
73 ifc4_required_slots_unfilled: s.step.ifc4_required_slots_unfilled,
74 enum_values_lost: s.step.enum_values_lost,
75 enum_values_refused: s.step.enum_values_refused,
76 }
77 }
78}
79
80fn invalid(msg: impl Into<String>) -> io::Error {
81 io::Error::new(io::ErrorKind::InvalidInput, msg.into())
82}
83
84pub fn export_step_with_log(
93 content: &[u8],
94 opts: &StepOptions,
95 log: &MutationLog,
96) -> io::Result<(String, LogExportStats)> {
97 let mut buf = Vec::with_capacity(content.len());
98 let stats = emit(content, opts, log, &mut buf)?;
99 let out = String::from_utf8(buf).expect("the writer emits UTF-8");
100 Ok((out, stats))
101}
102
103pub fn export_step_with_log_to_writer<W: Write>(
107 content: &[u8],
108 opts: &StepOptions,
109 log: &MutationLog,
110 w: &mut W,
111) -> io::Result<LogExportStats> {
112 let mut buffered = io::BufWriter::with_capacity(1 << 20, w);
113 let stats = emit(content, opts, log, &mut buffered)?;
114 buffered.flush()?;
115 Ok(stats)
116}
117
118fn touched(log: &MutationLog) -> HashSet<u32> {
120 log.mutations.iter().map(|m| m.entity_id).collect()
121}
122
123#[allow(clippy::too_many_arguments)]
125fn write_line<W: Write>(
126 out: &mut W,
127 text: &str,
128 id: u32,
129 converting: bool,
130 from: &str,
131 to: &str,
132 slot_fill: &mut crate::schema_ifc2x3_slots::Ifc2x3SlotFill,
133 checks: &mut crate::schema_enum::ConversionChecks,
134) -> io::Result<()> {
135 if converting {
136 let converted = crate::schema_convert::convert_step_line(text, from, to, id, slot_fill, Some(checks))?;
137 out.write_all(converted.as_bytes())?;
138 } else {
139 out.write_all(text.as_bytes())?;
140 }
141 out.write_all(b"\n")
142}
143
144fn emit<W: Write>(content: &[u8], opts: &StepOptions, log: &MutationLog, out: &mut W) -> io::Result<LogExportStats> {
145 if opts.included.is_some() {
146 return Err(invalid("export_step_with_log: `included` is not supported with a mutation log"));
147 }
148 if !opts.attribute_mutations.is_empty() || !opts.property_mutations.is_empty() || !opts.copy_on_write.is_empty() {
149 return Err(invalid(
150 "export_step_with_log: pass edits in the log, not in StepOptions' attribute/property/copy-on-write vectors",
151 ));
152 }
153 let src = Source::index(content);
154 let family = schema_family(content);
155 let source_label = detect_schema(content);
156 let target = opts.schema.clone().unwrap_or_else(|| source_label.clone());
157 let converting = opts.schema.is_some() && crate::schema_convert::needs_conversion(&source_label, &target);
158
159 let target_is_ifc2x3 = target.trim().to_ascii_uppercase().starts_with("IFC2X3");
160 if target_is_ifc2x3 && log.georef_mutations.as_ref().is_some_and(super::georef::requests_anything) {
161 return Err(invalid(format!("export_step_with_log: {}", super::georef::IFC2X3_REFUSAL)));
162 }
163 let wanted = touched(log);
164 let mut base = BaseSets::new(&src, &wanted);
165 let overlay = replay(&log.mutations, &log.new_entities, &mut base)
166 .map_err(|e| invalid(format!("export_step_with_log: {}", e.0)))?;
167 let mut pass = Pass::new(&src, family, overlay);
168 let unwritable = |e: super::values::Unwritable| invalid(format!("export_step_with_log: {}", e.0));
169 let collected = collect(&mut pass, &mut base);
170 if let Some(georef) = &log.georef_mutations {
171 super::georef::apply(&mut pass, georef);
172 }
173 retain_shared_atoms(&mut pass);
174
175 let mut edited: Vec<u32> = pass.modified_attributes.iter().map(|(id, _)| *id).collect();
180 edited.extend(pass.overlay.positional.keys().copied());
181 edited.extend(pass.overlay.retype_order.iter().copied());
182 edited.sort_unstable();
183 edited.dedup();
184 for id in edited {
185 if pass.skip.contains(&id) || pass.rewritten.contains(&id) || pass.is_deleted(id) || pass.is_overlay_created(id) {
186 continue;
187 }
188 let Some(line) = src.line(id).map(|l| l.into_owned()) else { continue };
189 let (text, delivery) = pass.mutate_line(id, &line).map_err(unwritable)?;
190 let Some(text) = super::cow::detach(&pass, id, text) else {
194 pass.skip.insert(id);
195 continue;
196 };
197 let upper = pass.type_of(id).unwrap_or_default();
198 let filtered = {
199 let excluded = |r: u32| pass.is_omitted(r);
200 filter_source_line(&text, id, &upper, pass.schema, &excluded, !pass.overlay.tombstones.is_empty())
201 };
202 let text = match filtered {
203 Filtered::Keep => Some(text),
204 Filtered::Rewrite(t) => Some(t),
205 Filtered::Withhold(_) => None,
206 };
207 if text.is_some() {
208 if delivery.retyped {
209 pass.ledger.nominate(id, Kind::Retype);
210 }
211 if delivery.positional {
212 pass.ledger.nominate(id, Kind::Positional);
213 }
214 pass.nominees.nominate_delivered(&mut pass.ledger, id, &delivery);
215 }
216 pass.mutated_lines.insert(id, (text, delivery));
217 }
218 generate(&mut pass, collected).map_err(unwritable)?;
219 write_created(&mut pass).map_err(unwritable)?;
220
221 let modifications = pass.new_entity_count + pass.ledger.modified_count();
222 let source_header = crate::source_header::parse_source_header(content);
223 let declared = source_header.as_ref().and_then(|h| h.schema_identifiers.first()).filter(|s| !s.is_empty());
224 let header_schema = match declared {
225 Some(token) if !converting => token.as_str(),
226 _ => crate::file_schema::file_schema_identifier(&target),
227 };
228 crate::step_header::write_header_counted(out, opts, source_header.as_ref(), header_schema, modifications)?;
229
230 let mut slot_fill = crate::schema_ifc2x3_slots::Ifc2x3SlotFill::new(pass.fallback_owner_history());
231 let mut checks = crate::schema_enum::ConversionChecks::new();
232 let mut written = 0usize;
233 for &id in &src.order {
234 if pass.skip.contains(&id) || pass.rewritten.contains(&id) || pass.is_deleted(id) {
235 continue;
236 }
237 let owned;
238 let text: &str = match pass.mutated_lines.get(&id) {
239 Some((Some(text), _)) => text,
240 Some((None, _)) => {
241 let upper = pass.type_of(id).unwrap_or_default();
242 pass.warnings.push(format!("Relationship #{id} ({upper}) was withheld from the export: it names at least one entity that has no line in this export, in a slot with no spelling for an omitted reference (a single-valued attribute, or a set whose every member is omitted). Anything else that relationship associated is no longer associated in the output."));
243 continue;
244 }
245 None => {
246 let Some(raw) = src.line(id) else { continue };
247 let raw = match super::cow::detach(&pass, id, raw.into_owned()) {
248 Some(line) => line,
249 None => continue,
250 };
251 let upper = pass.type_of(id).unwrap_or_default();
252 let filtered = {
253 let excluded = |r: u32| pass.is_omitted(r);
254 filter_source_line(&raw, id, &upper, pass.schema, &excluded, !pass.overlay.tombstones.is_empty())
255 };
256 owned = match filtered {
257 Filtered::Keep => raw,
258 Filtered::Rewrite(t) => t,
259 Filtered::Withhold(w) => {
260 pass.warnings.push(w);
261 continue;
262 }
263 };
264 &owned
265 }
266 };
267 write_line(out, text, id, converting, &source_label, &target, &mut slot_fill, &mut checks)?;
268 written += 1;
269 }
270 let appended = pass.rewritten_lines.iter().map(|(_, l)| l).chain(pass.georef_lines.iter());
271 for line in pass.generated.iter().chain(appended) {
272 out.write_all(line.as_bytes())?;
273 out.write_all(b"\n")?;
274 written += 1;
275 }
276 for (id, line) in &pass.created_lines {
277 write_line(out, line, *id, converting, &source_label, &target, &mut slot_fill, &mut checks)?;
278 written += 1;
279 }
280 out.write_all(b"ENDSEC;\nEND-ISO-10303-21;\n")?;
281
282 let mut warnings = std::mem::take(&mut pass.warnings);
283 warnings.extend(slot_fill.warnings());
284 warnings.extend(checks.warnings());
285 let refused = warnings.iter().filter(|w| w.contains("is not a number and the slot is REAL-typed")).count();
286 Ok(LogExportStats {
287 written,
288 total: src.order.len(),
289 new_entities: pass.new_entity_count,
290 modified_entities: pass.ledger.modified_count(),
291 warnings,
292 step: StepCounters {
293 attribute_edits_refused: refused,
294 owner_history_unfilled: slot_fill.owner_history_unfilled(),
295 required_slots_unfilled: slot_fill.required_slots_unfilled(),
296 ifc4_required_slots_unfilled: checks.ifc4_slots.required_slots_unfilled(),
297 enum_values_lost: checks.enums.lost(),
298 enum_values_refused: checks.enums.refused(),
299 },
300 })
301}