step_io/emit/mod.rs
1//! The Part 21 envelope — the last step of writing.
2//!
3//! The `DATA` section — the entities themselves — comes from the generated
4//! writer ([`generated::write`](crate::generated::write)); this module wraps
5//! it in the Part 21 envelope, the fixed `ISO-10303-21;` …
6//! `END-ISO-10303-21;` frame around a `HEADER` section. [`FileHeader`]
7//! models the header's `FILE_DESCRIPTION` and `FILE_NAME` records (file
8//! name, authors, timestamp, …); the third record, `FILE_SCHEMA`, comes
9//! from the output target.
10
11mod projection;
12pub use projection::LossReport;
13
14use crate::generated::model::StepModel;
15use crate::generated::profile::{ApdInfo, SchemaProfile, SchemaTarget};
16use crate::generated::write::Writer;
17
18/// Universal output marker — step-io's all-AP union is not a real Application
19/// Protocol, so the `FILE_SCHEMA` header and APD declare a non-standard format. This
20/// keeps the output internally consistent and prevents it masquerading as a real AP.
21/// `UNIVERSAL_APD` mirrors a real target's `SchemaProfile::apd`; `description` is the
22/// `application_context` text (the `legal`/`downgrade` sets have no Universal analog —
23/// Universal skips projection).
24const UNIVERSAL_FILE_SCHEMA: &[&str] = &["STEPIO_UNIVERSAL"];
25static UNIVERSAL_APD: ApdInfo = ApdInfo {
26 status: "not a standard",
27 name: "stepio_universal",
28 year: 0,
29 description: "step-io universal union (non-standard, all-AP superset)",
30};
31
32/// Emit the model as the **Universal** target — the full read model, no projection
33/// (the union schema is a superset, so nothing is illegal). The `FILE_SCHEMA` header
34/// and APD/`application_context` entities are set to the non-standard universal
35/// marker so the output is internally consistent (header ↔ APD agree).
36#[doc(hidden)]
37#[must_use]
38pub fn write_universal(model: &mut StepModel) -> String {
39 write_target(model, SchemaTarget::Universal).0
40}
41
42/// Emit the model to a specific output target. The output is always internally
43/// consistent: the `FILE_SCHEMA` header AND the APD/`application_context` entities are
44/// absolutely set to the target's values (no restore needed — a later write simply
45/// overwrites again). For real AP targets the model is projected onto the target's
46/// legal entity set: rename-safe subtypes are downgraded, the rest dropped
47/// (referential-closure cascade), stranded orphans pruned — all recorded in the
48/// returned [`LossReport`]. `Universal` skips projection (union superset).
49///
50/// The original input schema is preserved in `Report.schema` (read time) and is not
51/// affected by writes.
52///
53/// There is no ground-truth to compare a projected output against; correctness
54/// is "valid + fully accounted" — the output re-reads with zero drops, and
55/// `input == kept + report.dropped + report.downgraded`.
56/// Internal round-trip oracle — not a supported entry point; the supported
57/// write path is the authoring API ([`crate::StepBuilder`] / [`crate::Ap242Author`]).
58#[doc(hidden)]
59#[must_use]
60pub fn write_target(model: &mut StepModel, target: SchemaTarget) -> (String, LossReport) {
61 write_target_with_header(model, target, &FileHeader::default())
62}
63
64/// [`write_target`] with explicit Part 21 HEADER fields (the plain form
65/// emits the all-empty default header).
66#[doc(hidden)]
67#[must_use]
68pub fn write_target_with_header(
69 model: &mut StepModel,
70 target: SchemaTarget,
71 header: &FileHeader,
72) -> (String, LossReport) {
73 let profile = SchemaProfile::for_target(target);
74
75 // Absolutely set the header schema + APD/AC entities to the target's values.
76 stamp_header(model, profile.map_or(&UNIVERSAL_APD, |p| &p.apd));
77 let file_schema = profile.map_or(UNIVERSAL_FILE_SCHEMA, |p| p.file_schema);
78
79 // Project (real AP) or pass through (Universal), then wrap.
80 let (body, loss) = match profile {
81 Some(profile) => {
82 let w = Writer::new(&*model);
83 let plan = projection::plan(&w, profile);
84 (w.emit_all_with_plan(&plan.dropped, plan.rename), plan.loss)
85 }
86 None => (Writer::new(&*model).emit_all(), LossReport::default()),
87 };
88 (wrap_envelope(&body, file_schema, header), loss)
89}
90
91/// Absolutely set the model's APD entities (status/name/year) and
92/// `application_context` entities (application = `apd.description`) to `apd`, so the
93/// emitted file's APD agrees with the `FILE_SCHEMA` header.
94fn stamp_header(model: &mut StepModel, apd: &ApdInfo) {
95 for x in &mut model.application_protocol_definition_arena.items {
96 x.status = apd.status.to_string();
97 x.application_interpreted_model_schema_name = apd.name.to_string();
98 x.application_protocol_year = apd.year;
99 }
100 for ac in &mut model.application_context_arena.items {
101 ac.application = apd.description.to_string();
102 }
103}
104
105/// The Part 21 HEADER section fields (`FILE_DESCRIPTION` + `FILE_NAME`).
106/// The default (all empty) matches what step-io has always emitted; the
107/// builder fills it from user input plus its automatic timestamp and
108/// preprocessor stamp.
109#[derive(Debug, Clone, Default)]
110pub struct FileHeader {
111 /// `FILE_DESCRIPTION.description` (single entry).
112 pub description: String,
113 /// `FILE_NAME.name`.
114 pub file_name: String,
115 /// `FILE_NAME.time_stamp`.
116 pub time_stamp: String,
117 /// `FILE_NAME.author`; empty renders as the customary `('')`.
118 pub authors: Vec<String>,
119 /// `FILE_NAME.organization`; empty renders as the customary `('')`.
120 pub organizations: Vec<String>,
121 /// `FILE_NAME.preprocessor_version`.
122 pub preprocessor_version: String,
123 /// `FILE_NAME.originating_system`.
124 pub originating_system: String,
125 /// `FILE_NAME.authorisation`.
126 pub authorisation: String,
127}
128
129/// Part 21 string literal for the HEADER section — same convention as the
130/// generated DATA writer's `step_str` (inner `'` doubled, non-ASCII passes
131/// through raw).
132fn header_str(s: &str) -> String {
133 let mut out = String::with_capacity(s.len() + 2);
134 out.push('\'');
135 for ch in s.chars() {
136 if ch == '\'' {
137 out.push('\'');
138 }
139 out.push(ch);
140 }
141 out.push('\'');
142 out
143}
144
145/// A HEADER string list; an empty list renders as the customary `('')`.
146fn header_list(items: &[String]) -> String {
147 if items.is_empty() {
148 return "('')".to_owned();
149 }
150 let inner = items
151 .iter()
152 .map(|s| header_str(s))
153 .collect::<Vec<_>>()
154 .join(",");
155 format!("({inner})")
156}
157
158/// Wrap a DATA body in the Part 21 envelope with the given `FILE_SCHEMA`
159/// and HEADER fields.
160fn wrap_envelope(data_body: &str, file_schema: &[&str], header: &FileHeader) -> String {
161 let schema = file_schema
162 .iter()
163 .map(|s| format!("'{s}'"))
164 .collect::<Vec<_>>()
165 .join(",");
166 format!(
167 "ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION(({desc}),'2;1');\n\
168 FILE_NAME({name},{stamp},{authors},{orgs},{pre},{orig},{auth});\n\
169 FILE_SCHEMA(({schema}));\nENDSEC;\nDATA;\n{data_body}ENDSEC;\nEND-ISO-10303-21;\n",
170 desc = header_str(&header.description),
171 name = header_str(&header.file_name),
172 stamp = header_str(&header.time_stamp),
173 authors = header_list(&header.authors),
174 orgs = header_list(&header.organizations),
175 pre = header_str(&header.preprocessor_version),
176 orig = header_str(&header.originating_system),
177 auth = header_str(&header.authorisation),
178 )
179}