Skip to main content

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}