ifc_lite_export/step_log/
write.rs

1// SPDX-License-Identifier: MPL-2.0
2//! The export itself: plan every change the log implies, write the header
3//! with the count those changes add up to, then stream the source records
4//! through and append what the log generated.
5//!
6//! Output order is the TypeScript `StepExporter`'s: source records in
7//! express-id order (minus the ones withheld or moved), then the generated
8//! property sets, the generated quantity sets, and last the type objects whose
9//! `HasPropertySets` was repointed.
10
11use 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/// What a mutation-log export reports beyond [`StepStats`].
30#[derive(Debug, Clone, Default, PartialEq)]
31#[non_exhaustive]
32pub struct LogExportStats {
33    /// Records written, and the conversion counters, as the plain writer
34    /// reports them. `attribute_edits_refused` counts named edits a REAL slot
35    /// refused.
36    pub written: usize,
37    /// Records in the source.
38    pub total: usize,
39    /// Records the log added (`StepExportResult.stats.newEntityCount`).
40    pub new_entities: usize,
41    /// Source records the log changed, counted per entity
42    /// (`modifiedEntityCount`).
43    pub modified_entities: usize,
44    /// Non-fatal refusals and withheld records, in the TypeScript exporter's
45    /// wording where it has one.
46    pub warnings: Vec<String>,
47    /// The conversion and refusal counters of the plain writer.
48    pub step: StepCounters,
49}
50
51/// [`StepStats`]' counters, carried over for a caller that reads them.
52#[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
84/// Export `content` with the edits in `log` applied, as a string.
85///
86/// Byte-identical to the TypeScript `StepExporter` given the same source and
87/// the same log (`importMutations` into a view wired like the viewer's),
88/// except the GlobalIds of records this export generates. Errs, writing
89/// nothing, when the log carries a mutation kind this writer does not apply
90/// yet, or when `opts` asks for something the log path does not combine with
91/// (`included`, or the per-edit vectors of the plain writer).
92pub 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
103/// [`export_step_with_log`], writing as it goes. The source is read, never
104/// copied: memory beyond the record index grows with the EDITS (the lines they
105/// change, the records they generate), not with the file.
106pub 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
118/// Every entity id the log names, the set the base-set maps are built for.
119fn touched(log: &MutationLog) -> HashSet<u32> {
120    log.mutations.iter().map(|m| m.entity_id).collect()
121}
122
123/// One record out, through the schema converter when the export converts.
124#[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    // The source lines the log rewrites, decided before the header because
176    // the header counts them: every record with an attribute, retype or
177    // positional edit, run through the same filter the stream applies so a
178    // withheld line nominates nothing, as in the TypeScript source pass.
179    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        // Copy-on-write narrowing before the omitted-reference filter, as the
191        // source pass orders them (#5794). A narrowed-to-nobody line is
192        // withheld silently; `settle` rules that out for the effective record.
193        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}