Skip to main content

powerio_dist/
convert.rs

1//! Distribution format dispatch.
2
3use crate::model::{DistSourceFormat, MulticonductorNetwork};
4
5/// Extra files an emitter generated beside the primary text payload.
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub(crate) struct TextSidecar {
8    /// Relative file path the primary text refers to.
9    pub(crate) path: String,
10    /// File content.
11    pub(crate) text: String,
12}
13
14/// One format serializer's internal output before it commits to a destination.
15#[derive(Debug, Clone)]
16pub(crate) struct TextEmission {
17    pub(crate) text: String,
18    pub(crate) sidecars: Vec<TextSidecar>,
19    pub(crate) diagnostics: Vec<crate::diagnostics::Diagnostic>,
20    pub(crate) fidelity: powerio_core::Fidelity,
21}
22
23impl TextEmission {
24    pub(crate) fn new(
25        text: String,
26        sidecars: Vec<TextSidecar>,
27        diagnostics: crate::diagnostics::Diagnostics,
28    ) -> Self {
29        Self {
30            text,
31            sidecars,
32            diagnostics: diagnostics.into_records(),
33            fidelity: powerio_core::Fidelity::Canonical,
34        }
35    }
36
37    /// An emission that dropped nothing, e.g. a same format echo.
38    pub(crate) fn faithful(text: String) -> Self {
39        let mut emission = Self::new(text, Vec::new(), crate::diagnostics::Diagnostics::new());
40        emission.fidelity = powerio_core::Fidelity::ExactSameFormat;
41        emission
42    }
43
44    /// Record one finding after the writer has run.
45    pub(crate) fn push(
46        &mut self,
47        info: &'static crate::diagnostics::DiagnosticInfo,
48        message: impl Into<String>,
49    ) {
50        self.diagnostics
51            .push(crate::diagnostics::Diagnostic::of(info, message));
52    }
53
54    #[cfg(test)]
55    pub(crate) fn render_diagnostics(&self) -> Vec<String> {
56        crate::diagnostics::render_diagnostics(&self.diagnostics)
57    }
58}
59
60/// A writable distribution format.
61#[derive(Clone, Copy, Debug, PartialEq, Eq)]
62#[non_exhaustive]
63pub enum DistTargetFormat {
64    Dss,
65    BmopfJson,
66    PmdJson,
67}
68
69/// Format specific policies for distribution network emission.
70#[derive(Clone, Debug, Default, PartialEq)]
71#[non_exhaustive]
72pub struct EmitOptions {
73    pub dss: crate::dss::DssEmitOptions,
74    pub bmopf: crate::bmopf::BmopfEmitOptions,
75}
76
77impl EmitOptions {
78    fn is_default_for(&self, format: DistTargetFormat) -> bool {
79        match format {
80            DistTargetFormat::Dss => self.dss == crate::dss::DssEmitOptions::default(),
81            DistTargetFormat::BmopfJson => self.bmopf == crate::bmopf::BmopfEmitOptions::default(),
82            DistTargetFormat::PmdJson => true,
83        }
84    }
85}
86
87/// Resolves common names and file extensions to a target format.
88pub fn parse_dist_target_format(name: &str) -> Option<DistTargetFormat> {
89    let key = canonical_key(name);
90    match key.as_str() {
91        "dss" | "opendss" => Some(DistTargetFormat::Dss),
92        "pmd" | "pmdjson" | "engineering" => Some(DistTargetFormat::PmdJson),
93        "bmopf" | "bmopfjson" => Some(DistTargetFormat::BmopfJson),
94        _ => None,
95    }
96}
97
98impl std::str::FromStr for DistTargetFormat {
99    type Err = crate::Error;
100
101    /// [`parse_dist_target_format`] as a `Result`, matching the transmission
102    /// hub's `TargetFormat: FromStr`.
103    fn from_str(s: &str) -> crate::Result<Self> {
104        parse_dist_target_format(s).ok_or_else(|| crate::Error::UnknownFormat(s.to_string()))
105    }
106}
107
108impl DistTargetFormat {
109    /// The canonical format name (`dss`, `pmd-json`, `bmopf-json`), accepted
110    /// back by [`parse_dist_target_format`].
111    pub fn name(self) -> &'static str {
112        match self {
113            DistTargetFormat::Dss => "dss",
114            DistTargetFormat::PmdJson => "pmd-json",
115            DistTargetFormat::BmopfJson => "bmopf-json",
116        }
117    }
118}
119
120fn canonical_key(name: &str) -> String {
121    name.to_ascii_lowercase()
122        .chars()
123        .filter(|c| *c != '-' && *c != '_')
124        .collect()
125}
126
127/// Element tables that identify a distribution document beside its `bus`
128/// table.
129///
130/// `load`, `shunt`, and `switch` are shared with PowerModels, so on their own
131/// they cannot tell the two apart. They stay in the list all the same, because
132/// [`NOT_BMOPF_KEYS`] is what refuses a PowerModels document, and it refuses it
133/// whatever else the document holds. Dropping the shared names instead would
134/// refuse a real BMOPF feeder built only from them, which the reader parses
135/// and which this classifier used to accept.
136///
137/// These names do not separate BMOPF from PMD: the two share most of their
138/// element vocabulary. `data_model` does that, and it is checked first.
139const DISTRIBUTION_ELEMENT_TABLES: &[&str] = &[
140    "capacitor",
141    // `control_profile` and `ibr` are typed dispatch tables of the reader,
142    // though schema 0.1.0 moved them under `extras`: a pre-0.1.0 document
143    // still declares them at the top level, and what the reader accepts the
144    // classifier must identify.
145    "control_profile",
146    "generator",
147    "ibr",
148    "line",
149    "linecode",
150    "load",
151    "meta",
152    "shunt",
153    "switch",
154    "terminal_conventions",
155    "transformer",
156    "voltage_source",
157];
158
159/// Top level keys no BMOPF document carries, and a PowerModels or MATPOWER
160/// derived document does. One of these refuses the BMOPF reading whatever
161/// else the document holds, so a name that a future BMOPF revision adds
162/// cannot make a PowerModels file classify.
163const NOT_BMOPF_KEYS: &[&str] = &[
164    "baseMVA",
165    "branch",
166    "dcline",
167    "gen",
168    "per_unit",
169    "source_type",
170    "source_version",
171    "storage",
172];
173
174/// The PMD marker. Neither BMOPF nor PowerModels carries this key, so it
175/// identifies the ENGINEERING and MATHEMATICAL models on its own; the PMD
176/// reader then rejects MATHEMATICAL with its own message.
177const PMD_MARKER: &str = "data_model";
178
179/// What the top level of a document holds, for classification only.
180///
181/// The probe reads the top level keys and skips every value, so it never
182/// materializes the document. The old classifier built a whole
183/// `serde_json::Value` and dropped it, which doubled the peak memory of a
184/// parse and did the tokenizing work twice: the chosen reader parses the
185/// same text again. A case file is attacker controlled input, so a reader
186/// sized allocation that serves no purpose is worth removing.
187// Five independent presence flags, which is what a marker probe is. An
188// enum or a builder, the shapes this lint steers toward, would model a
189// choice; these are not exclusive.
190#[allow(clippy::struct_excessive_bools)]
191#[derive(Default)]
192struct TopLevel {
193    /// The document is a JSON object.
194    is_object: bool,
195    pmd_marker: bool,
196    bus: bool,
197    /// A key from [`DISTRIBUTION_ELEMENT_TABLES`].
198    dist_table: bool,
199    /// A key from [`NOT_BMOPF_KEYS`].
200    not_bmopf: bool,
201}
202
203impl<'de> serde::Deserialize<'de> for TopLevel {
204    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
205        struct Probe;
206
207        impl<'de> serde::de::Visitor<'de> for Probe {
208            type Value = TopLevel;
209
210            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
211                f.write_str("a JSON document")
212            }
213
214            fn visit_map<A: serde::de::MapAccess<'de>>(
215                self,
216                mut map: A,
217            ) -> Result<TopLevel, A::Error> {
218                let mut out = TopLevel {
219                    is_object: true,
220                    ..TopLevel::default()
221                };
222                // Keys arrive as borrowed or owned strings. Nothing is kept:
223                // each key sets a flag and each value is discarded, so the
224                // probe holds a constant amount of memory whatever the size
225                // of the document.
226                while let Some(key) = map.next_key::<std::borrow::Cow<'_, str>>()? {
227                    let key = key.as_ref();
228                    out.pmd_marker |= key == PMD_MARKER;
229                    out.bus |= key == "bus";
230                    out.dist_table |= DISTRIBUTION_ELEMENT_TABLES.contains(&key);
231                    out.not_bmopf |= NOT_BMOPF_KEYS.contains(&key);
232                    map.next_value::<serde::de::IgnoredAny>()?;
233                }
234                Ok(out)
235            }
236
237            // A valid document that is not an object cannot be either
238            // format. Record that and let the caller report it, rather than
239            // failing as a parse error, which would send it to the BMOPF
240            // fallback.
241            fn visit_bool<E>(self, _: bool) -> Result<TopLevel, E> {
242                Ok(TopLevel::default())
243            }
244            fn visit_i64<E>(self, _: i64) -> Result<TopLevel, E> {
245                Ok(TopLevel::default())
246            }
247            fn visit_u64<E>(self, _: u64) -> Result<TopLevel, E> {
248                Ok(TopLevel::default())
249            }
250            fn visit_f64<E>(self, _: f64) -> Result<TopLevel, E> {
251                Ok(TopLevel::default())
252            }
253            fn visit_str<E>(self, _: &str) -> Result<TopLevel, E> {
254                Ok(TopLevel::default())
255            }
256            fn visit_unit<E>(self) -> Result<TopLevel, E> {
257                Ok(TopLevel::default())
258            }
259            fn visit_none<E>(self) -> Result<TopLevel, E> {
260                Ok(TopLevel::default())
261            }
262            fn visit_seq<A: serde::de::SeqAccess<'de>>(
263                self,
264                mut seq: A,
265            ) -> Result<TopLevel, A::Error> {
266                while seq.next_element::<serde::de::IgnoredAny>()?.is_some() {}
267                Ok(TopLevel::default())
268            }
269        }
270
271        deserializer.deserialize_any(Probe)
272    }
273}
274
275/// Distribution parser policy for a `.json` input.
276///
277/// Classification is by positive marker, never by "everything else is
278/// BMOPF": an unmarked document used to fall through to the BMOPF reader
279/// and parse into a bogus near-empty network.
280///
281/// - PMD carries `data_model`, which no other family here carries. The marker
282///   decides on its own: every real PMD ENGINEERING document also carries the
283///   element tables BMOPF uses, so a document holding both is the normal case
284///   and not a contradiction.
285/// - BMOPF carries a `bus` table beside at least one distribution element
286///   table, and no key that marks a PowerModels or MATPOWER derived document.
287/// - Anything else is refused with a message naming both rules.
288///
289/// Malformed JSON still routes to BMOPF so its reader reports the parse
290/// error, which names the byte offset. The probe never materializes the
291/// document, so classification costs one pass and constant memory.
292pub fn classify_distribution_json(text: &str) -> crate::Result<DistTargetFormat> {
293    // A leading byte order mark would fail the parse here and silently send
294    // a PMD document down the BMOPF fallback; classify without it.
295    let text = text.trim_start_matches('\u{feff}');
296    let unrecognized = |detail: &str| crate::Error::Json {
297        format: "distribution",
298        message: format!(
299            "not a recognized distribution document: {detail}. PMD ENGINEERING JSON \
300             carries `data_model`; BMOPF JSON carries a `bus` table beside one of \
301             {DISTRIBUTION_ELEMENT_TABLES:?}. Pass the format explicitly to override."
302        ),
303    };
304
305    let Ok(top) = serde_json::from_str::<TopLevel>(text) else {
306        return Ok(DistTargetFormat::BmopfJson);
307    };
308    if !top.is_object {
309        return Err(unrecognized("the top level is not an object"));
310    }
311
312    // `data_model` is authoritative. PMD ENGINEERING and BMOPF share most
313    // of their element table names (`line`, `linecode`, `transformer`, and
314    // the rest), so the marker separates them, not the table set. The PMD
315    // reader is what judges the marker's value, and it rejects the
316    // MATHEMATICAL model with its own message.
317    if top.pmd_marker {
318        return Ok(DistTargetFormat::PmdJson);
319    }
320    if top.bus && top.dist_table && !top.not_bmopf {
321        return Ok(DistTargetFormat::BmopfJson);
322    }
323    Err(if top.bus && top.not_bmopf {
324        unrecognized(
325            "it carries a `bus` table with PowerModels keys beside it, so it is a \
326             transmission document; read it through the transmission hub",
327        )
328    } else if top.bus {
329        unrecognized("its `bus` table has no distribution element table beside it")
330    } else {
331        unrecognized("it carries no marker of either format")
332    })
333}
334
335/// Parse one source into a compiled distribution module. The typed
336/// [`MulticonductorNetwork`] is the module's value; the reader's findings are
337/// the module's diagnostics; the source itself is retained on the module, so
338/// a same format emission echoes the input bytes exactly.
339///
340/// The format comes from the source's declared format when one was selected,
341/// the `.dss` extension otherwise, and for `.json` from
342/// [`classify_distribution_json`].
343///
344/// # Errors
345/// The operation failure carries the reader's findings up to the failure and
346/// retains the source for span interpretation.
347///
348pub fn parse(
349    source: powerio_core::Source,
350) -> std::result::Result<powerio_core::PioModule<MulticonductorNetwork>, powerio_core::Error> {
351    let mut warnings = crate::collect::Diagnostics::new();
352    match parse_to_network(&source, &mut warnings) {
353        Ok(network) => {
354            let format = *network.source_format();
355            // Record the detected format before the common constructor builds
356            // descriptors and the coarse `/value` source map.
357            let source =
358                match format.and_then(|format| powerio_core::FormatId::new(format.name()).ok()) {
359                    Some(format) => source.with_format(format),
360                    None => source,
361                };
362            powerio_core::PioModule::parsed(network, source, warnings.into_records())
363        }
364        Err(error) => {
365            let core = powerio_core::Error::new(error.code(), error.to_string());
366            Err(core
367                .with_diagnostics(warnings.into_records())
368                .with_cause(error)
369                .with_source(source))
370        }
371    }
372}
373
374/// The format dispatch behind [`parse`].
375fn parse_to_network(
376    source: &powerio_core::Source,
377    warnings: &mut crate::collect::Diagnostics,
378) -> crate::Result<MulticonductorNetwork> {
379    if source.is_directory() {
380        return Err(crate::Error::UnknownFormat(format!(
381            "{} is a directory, and no distribution case format is a directory; pass a case file",
382            source.name()
383        )));
384    }
385    let declared = source
386        .format()
387        .map(powerio_core::FormatId::as_str)
388        .map(|name| {
389            parse_dist_target_format(name)
390                .ok_or_else(|| crate::Error::UnknownFormat(name.to_string()))
391        })
392        .transpose()?;
393    let format = if let Some(format) = declared {
394        format
395    } else {
396        let ext = std::path::Path::new(source.name())
397            .extension()
398            .and_then(|e| e.to_str())
399            .unwrap_or_default()
400            .to_ascii_lowercase();
401        match ext.as_str() {
402            "dss" => DistTargetFormat::Dss,
403            "json" => {
404                let buffer = primary(source)?;
405                classify_distribution_json(source_text(&buffer)?)?
406            }
407            other => return Err(crate::Error::UnknownFormat(other.to_string())),
408        }
409    };
410    match format {
411        DistTargetFormat::Dss => crate::dss::read::parse_dss_collecting(source, warnings),
412        DistTargetFormat::BmopfJson => {
413            let buffer = primary(source)?;
414            crate::bmopf::read::parse_bmopf_collecting(source_text(&buffer)?, warnings)
415        }
416        DistTargetFormat::PmdJson => {
417            let buffer = primary(source)?;
418            crate::pmd::read::parse_pmd_collecting(source_text(&buffer)?, warnings)
419        }
420    }
421}
422
423/// The retained primary buffer of a file or in-memory source.
424fn primary(source: &powerio_core::Source) -> crate::Result<powerio_core::SourceBuffer> {
425    source
426        .primary_buffer()
427        .map_err(|error| crate::Error::FormatRead {
428            format: "case text",
429            message: error.to_string(),
430        })
431}
432
433/// The buffer's decode slice as UTF-8 text: the retained bytes with a leading
434/// byte order mark skipped, so the mark survives for the echo and never
435/// reaches a reader.
436fn source_text(buffer: &powerio_core::SourceBuffer) -> crate::Result<&str> {
437    std::str::from_utf8(buffer.content_bytes()).map_err(|e| crate::Error::FormatRead {
438        format: "case text",
439        message: format!("not valid UTF-8: {e}"),
440    })
441}
442
443impl DistTargetFormat {
444    fn matches(self, source: Option<DistSourceFormat>) -> bool {
445        matches!(
446            (self, source),
447            (DistTargetFormat::Dss, Some(DistSourceFormat::Dss))
448                | (
449                    DistTargetFormat::BmopfJson,
450                    Some(DistSourceFormat::BmopfJson)
451                )
452                | (DistTargetFormat::PmdJson, Some(DistSourceFormat::PmdJson))
453        )
454    }
455}
456
457/// Prepare exact retained-source text or validated canonical output.
458pub(crate) fn emit_text_with_options(
459    module: &powerio_core::PioModule<MulticonductorNetwork>,
460    format: DistTargetFormat,
461    options: &EmitOptions,
462) -> Result<TextEmission, powerio_core::Error> {
463    let output = if options.is_default_for(format)
464        && let Some(text) = echo_text(module, format)
465    {
466        TextEmission::faithful(text)
467    } else {
468        crate::readiness::require_resolved_geometry(module.value())?;
469        emit_value_text_with_options(module.value(), format, options)
470    };
471    Ok(output)
472}
473
474/// The retained source text when emitting `module` back to its source format:
475/// the echo that reproduces the input byte for byte. `None` sends the emission
476/// down the semantic path.
477fn echo_text(
478    module: &powerio_core::PioModule<MulticonductorNetwork>,
479    target: DistTargetFormat,
480) -> Option<String> {
481    let source = module.source()?;
482    let buffer = source.primary_buffer().ok()?;
483    // Both the retained source's declared format and the value's own must be
484    // the target: a deserialized module retains the PowerIO IR document, not
485    // the case its value came from.
486    let declared = source
487        .format()
488        .and_then(|format| parse_dist_target_format(format.as_str()))?;
489    if declared != target || !target.matches(*module.value().source_format()) {
490        return None;
491    }
492    let text = std::str::from_utf8(buffer.bytes()).ok()?;
493    Some(text.to_owned())
494}
495
496/// Serialize a typed network to `format` with no source echo.
497pub(crate) fn emit_value_text_with_options(
498    net: &MulticonductorNetwork,
499    format: DistTargetFormat,
500    options: &EmitOptions,
501) -> TextEmission {
502    let mut conv = match format {
503        DistTargetFormat::Dss => crate::dss::emit_dss_text_with_options(net, &options.dss),
504        DistTargetFormat::BmopfJson => {
505            crate::bmopf::emit_bmopf_json_text_with_options(net, options.bmopf)
506        }
507        DistTargetFormat::PmdJson => crate::pmd::emit_pmd_json_text(net),
508    };
509    // No distribution format carries line routes; report the loss the
510    // way bus locations already do (`.pio.json` keeps them).
511    let routed = net
512        .lines()
513        .iter()
514        .filter(|line| line.route.is_some())
515        .count();
516    if routed > 0 {
517        conv.push(
518            &crate::diagnostics::codes::EMIT_MULTICONDUCTOR_ROUTE_DROPPED,
519            format!(
520                "{routed} line route(s) dropped: {} has no polyline field",
521                format.name()
522            ),
523        );
524    }
525    conv
526}
527
528#[cfg(test)]
529pub(crate) fn emit_value_text(
530    net: &MulticonductorNetwork,
531    format: DistTargetFormat,
532) -> TextEmission {
533    emit_value_text_with_options(net, format, &EmitOptions::default())
534}
535
536/// Emit a parsed module to `format` through a destination.
537///
538/// The JSON targets commit a single artifact — a path destination names the
539/// exact file, a memory destination names the artifact. The dss target is a
540/// directory inventory: the destination names the output root, the case text
541/// commits as `case.dss`, and every companion file the emitter produced
542/// (OpenDSS `Buscoords` CSV) commits beside it, so nothing the case text
543/// refers to is missing from the output.
544///
545/// # Errors
546/// The destination's own collision and staging failures.
547///
548/// # Panics
549/// Never on external input: the fixed artifact names are valid by
550/// construction.
551pub fn emit(
552    module: &powerio_core::PioModule<MulticonductorNetwork>,
553    format: DistTargetFormat,
554    destination: powerio_core::Destination,
555) -> std::result::Result<powerio_core::EmitResult, powerio_core::Error> {
556    emit_with_options(module, format, &EmitOptions::default(), destination)
557}
558
559/// [`emit`] with format specific policies.
560///
561/// # Errors
562/// The destination refused the output inventory.
563///
564/// # Panics
565/// Never on external input: the fixed artifact names are valid by
566/// construction.
567pub fn emit_with_options(
568    module: &powerio_core::PioModule<MulticonductorNetwork>,
569    format: DistTargetFormat,
570    options: &EmitOptions,
571    destination: powerio_core::Destination,
572) -> std::result::Result<powerio_core::EmitResult, powerio_core::Error> {
573    let conv = emit_text_with_options(module, format, options)?;
574    match format {
575        DistTargetFormat::Dss => {
576            let mut artifacts = vec![powerio_core::MemoryArtifact::new(
577                powerio_core::ArtifactPath::new("case.dss")
578                    .expect("static name is a valid artifact path"),
579                conv.text.into_bytes(),
580            )];
581            for sidecar in conv.sidecars {
582                artifacts.push(powerio_core::MemoryArtifact::new(
583                    powerio_core::ArtifactPath::new(sidecar.path)?,
584                    sidecar.text.into_bytes(),
585                ));
586            }
587            destination.__commit_artifacts(true, conv.fidelity, artifacts, conv.diagnostics)
588        }
589        DistTargetFormat::BmopfJson | DistTargetFormat::PmdJson => {
590            let artifact = powerio_core::MemoryArtifact::new(
591                powerio_core::ArtifactPath::new("case")
592                    .expect("static name is a valid artifact path"),
593                conv.text.into_bytes(),
594            );
595            destination.__commit_artifacts(false, conv.fidelity, vec![artifact], conv.diagnostics)
596        }
597    }
598}
599
600#[cfg(test)]
601mod tests {
602    use super::*;
603
604    #[test]
605    fn distribution_json_classifier_preserves_pmd_marker_and_bmopf_fallback() {
606        for doc in [
607            r#"{"data_model": "ENGINEERING"}"#,
608            r#"{"data_model": "MATHEMATICAL"}"#,
609            // The marker identifies the family whatever its value is; the
610            // PMD reader is what judges the value.
611            r#"{"data_model": 7}"#,
612            r#"{"data_model": null}"#,
613        ] {
614            assert_eq!(
615                classify_distribution_json(doc).unwrap(),
616                DistTargetFormat::PmdJson,
617                "{doc}"
618            );
619        }
620        for doc in [
621            r#"{"bus": {}, "voltage_source": {}}"#,
622            // A pre-0.1.0 feeder fragment: no `voltage_source`, but the
623            // reader accepts it, so the classifier must too.
624            r#"{"bus": {}, "line": {}, "linecode": {}}"#,
625            r#"{"bus": {}, "transformer": {}}"#,
626            r#"{"bus": {}, "capacitor": {}}"#,
627            r#"{"bus": {}, "generator": {}}"#,
628            // The pre-0.1.0 top-level spellings of the tables 0.1.0 moved
629            // under `extras`; the reader dispatches both.
630            r#"{"bus": {}, "ibr": {}}"#,
631            r#"{"bus": {}, "control_profile": {}}"#,
632        ] {
633            assert_eq!(
634                classify_distribution_json(doc).unwrap(),
635                DistTargetFormat::BmopfJson,
636                "{doc}"
637            );
638        }
639        // Malformed JSON still routes to BMOPF so its reader reports the
640        // parse error, which names the byte offset.
641        assert_eq!(
642            classify_distribution_json("{not json").unwrap(),
643            DistTargetFormat::BmopfJson
644        );
645        // A byte order mark must not push a PMD document down the BMOPF
646        // fallback.
647        assert_eq!(
648            classify_distribution_json("\u{feff}{\"data_model\": \"ENGINEERING\"}").unwrap(),
649            DistTargetFormat::PmdJson
650        );
651    }
652
653    /// A PowerModels document shares `bus`, `load`, `shunt`, `switch`, and
654    /// `name` with BMOPF, so none of those can be the discriminator. This
655    /// is the exact document family that used to parse into a bogus
656    /// near-empty `MulticonductorNetwork`.
657    #[test]
658    fn a_powermodels_document_never_classifies_as_bmopf() {
659        // The real key set a powerio PowerModels write produces.
660        let powermodels = r#"{"baseMVA": 100.0, "branch": {}, "bus": {}, "dcline": {},
661            "gen": {}, "load": {}, "name": "case14", "per_unit": true, "shunt": {},
662            "source_type": "matpower", "source_version": "2", "storage": {},
663            "switch": {}}"#;
664        assert!(classify_distribution_json(powermodels).is_err());
665
666        // Each PowerModels marker refuses the BMOPF reading on its own, even
667        // beside a real BMOPF table. A future BMOPF revision that adds a
668        // colliding table name therefore cannot make this document classify.
669        for marker in NOT_BMOPF_KEYS {
670            let doc = format!("{{\"bus\": {{}}, \"linecode\": {{}}, \"{marker}\": 1}}");
671            assert!(
672                classify_distribution_json(&doc).is_err(),
673                "`{marker}` must refuse the BMOPF reading: {doc}"
674            );
675        }
676    }
677
678    /// The two rules pull in opposite directions and this classifier has
679    /// swung both ways: dropping the shared table names refuses real BMOPF
680    /// feeders, and keeping them without the veto reads PowerModels as BMOPF.
681    /// Pin both ends together so neither correction can undo the other.
682    #[test]
683    fn shared_table_names_classify_as_bmopf_and_the_veto_still_refuses_powermodels() {
684        // A BMOPF feeder built only from names PowerModels also uses. No
685        // veto key is present, so the distribution reading stands.
686        for doc in [
687            r#"{"bus": {}, "load": {}}"#,
688            r#"{"bus": {}, "shunt": {}}"#,
689            r#"{"bus": {}, "switch": {}}"#,
690            r#"{"bus": {}, "meta": {"frequency": 60}}"#,
691        ] {
692            assert_eq!(
693                classify_distribution_json(doc).unwrap(),
694                DistTargetFormat::BmopfJson,
695                "{doc}"
696            );
697        }
698        // The same shared names beside one veto key stay transmission.
699        for doc in [
700            r#"{"bus": {}, "load": {}, "baseMVA": 100.0}"#,
701            r#"{"bus": {}, "shunt": {}, "branch": {}}"#,
702            r#"{"bus": {}, "switch": {}, "per_unit": true}"#,
703        ] {
704            assert!(classify_distribution_json(doc).is_err(), "{doc}");
705        }
706    }
707
708    /// PMD ENGINEERING and BMOPF share most element table names, so a real
709    /// PMD document carries `data_model` beside `line` and `linecode`. The
710    /// marker must win, or every PMD file would be read as BMOPF.
711    #[test]
712    fn the_pmd_marker_wins_over_shared_element_tables() {
713        let both = r#"{"data_model": "ENGINEERING", "bus": {}, "line": {}, "linecode": {}}"#;
714        assert_eq!(
715            classify_distribution_json(both).unwrap(),
716            DistTargetFormat::PmdJson
717        );
718    }
719
720    #[test]
721    fn unclassifiable_documents_are_refused_with_a_reason() {
722        for (doc, needle) in [
723            (
724                r#"{"bus": {"data_model": {}}}"#,
725                "no distribution element table",
726            ),
727            (r#"{"name": "data_model"}"#, "no marker of either format"),
728            ("{}", "no marker of either format"),
729            ("[]", "not an object"),
730            ("null", "not an object"),
731            ("3", "not an object"),
732            (r#""a string""#, "not an object"),
733            ("true", "not an object"),
734        ] {
735            let err = classify_distribution_json(doc).unwrap_err().to_string();
736            assert!(err.contains(needle), "{doc}: got {err}");
737        }
738    }
739
740    /// The probe reads top level keys and skips values, so neither the size
741    /// of a value nor the number of keys can make it allocate the document.
742    /// A deeply nested value hits serde_json's own recursion limit, which
743    /// surfaces as the malformed-JSON route rather than a stack overflow.
744    #[test]
745    fn the_probe_is_bounded_on_adversarial_shapes() {
746        // A huge value under an ignored key: skipped, not materialized.
747        let big = format!(
748            r#"{{"bus": {{}}, "linecode": {{}}, "junk": [{}]}}"#,
749            "0,".repeat(200_000) + "0"
750        );
751        assert_eq!(
752            classify_distribution_json(&big).unwrap(),
753            DistTargetFormat::BmopfJson
754        );
755
756        // Many distinct top level keys: one flag per key, nothing stored.
757        let mut keys = String::new();
758        for i in 0..50_000 {
759            use std::fmt::Write as _;
760            let _ = write!(keys, "\"k{i}\":0,");
761        }
762        let many = format!(r#"{{{keys}"bus":{{}},"linecode":{{}}}}"#);
763        assert_eq!(
764            classify_distribution_json(&many).unwrap(),
765            DistTargetFormat::BmopfJson
766        );
767
768        // Deep nesting under an ignored key: `IgnoredAny` skips a value
769        // without recursion, so depth costs no stack. The old classifier
770        // built a `serde_json::Value`, whose recursive descent refuses past
771        // 128 levels, so a legitimate document nested deeper than that used
772        // to take the malformed route.
773        let deep = format!(
774            r#"{{"bus":{{}},"linecode":{{}},"junk":{}{}}}"#,
775            "[".repeat(20_000),
776            "]".repeat(20_000)
777        );
778        assert_eq!(
779            classify_distribution_json(&deep).unwrap(),
780            DistTargetFormat::BmopfJson
781        );
782
783        // A duplicate marker key is still one marker.
784        assert_eq!(
785            classify_distribution_json(
786                r#"{"data_model":"ENGINEERING","data_model":"ENGINEERING"}"#
787            )
788            .unwrap(),
789            DistTargetFormat::PmdJson
790        );
791    }
792
793    /// The probe skips a value without recursion, so it accepts a document
794    /// nested far deeper than the reader will take. The reader must then
795    /// refuse that document with an error, never with a crash: the
796    /// classifier is what decides which reader sees untrusted input.
797    #[test]
798    fn a_document_the_probe_accepts_is_refused_by_the_reader_not_a_crash() {
799        for depth in [200usize, 20_000, 500_000] {
800            let doc = format!(
801                "{{\"bus\":{{}},\"linecode\":{{}},\"junk\":{}{}}}",
802                "[".repeat(depth),
803                "]".repeat(depth)
804            );
805            let format = classify_distribution_json(&doc).expect("markers are present");
806            assert_eq!(format, DistTargetFormat::BmopfJson);
807            let err = crate::testkit::parse_str(&doc, format.name())
808                .expect_err("the reader refuses past its recursion limit");
809            assert!(
810                err.to_string().contains("recursion limit"),
811                "depth {depth}: {err}"
812            );
813        }
814    }
815
816    /// JSON keys are case sensitive and both formats are machine written,
817    /// so a near miss must not classify. It would pick a reader that then
818    /// fails on every table.
819    #[test]
820    fn marker_matching_is_case_sensitive() {
821        for doc in [
822            r#"{"Data_Model": "ENGINEERING"}"#,
823            r#"{"DATA_MODEL": "ENGINEERING"}"#,
824            r#"{"Bus": {}, "Linecode": {}}"#,
825        ] {
826            assert!(classify_distribution_json(doc).is_err(), "{doc}");
827        }
828    }
829
830    #[test]
831    fn byte_order_mark_is_retained_and_echoed() {
832        let dss = "\u{feff}clear\nnew circuit.c basekv=12.47 bus1=src\n";
833        let net = crate::testkit::parse_dss_str(dss);
834        assert_eq!(net.name().as_deref(), Some("c"));
835        assert!(
836            !net.warnings.iter().any(|w| w.contains("byte order mark")),
837            "retaining the mark is not a loss: {:?}",
838            net.warnings
839        );
840        // The echo returns the input bytes exactly, mark included; the
841        // decode slice the reader saw was mark free.
842        assert_eq!(net.emit(DistTargetFormat::Dss).text, dss);
843        assert!(
844            net.source
845                .as_ref()
846                .is_some_and(|s| !s.starts_with('\u{feff}'))
847        );
848    }
849
850    #[test]
851    fn memory_and_file_sources_parse_the_same_fixture_alike() {
852        let path = concat!(
853            env!("CARGO_MANIFEST_DIR"),
854            "/../tests/data/dist/micro/onephase_zip_load.dss"
855        );
856        let bytes = std::fs::read(path).unwrap();
857        let from_bytes =
858            crate::testkit::parse_str(std::str::from_utf8(&bytes).unwrap(), "dss").unwrap();
859        let from_file = crate::testkit::parse_file(path, None).unwrap();
860        assert_eq!(from_bytes.buses().len(), from_file.buses().len());
861        assert_eq!(from_bytes.loads().len(), from_file.loads().len());
862        assert_eq!(from_bytes.source, from_file.source);
863    }
864
865    #[test]
866    fn non_utf8_bytes_are_refused_and_name_the_encoding() {
867        // 0xE9 is CP1252 é, the classic single byte a Windows editor leaves.
868        let bytes: &[u8] = b"clear\nnew circuit.caf\xE9 basekv=12.47 bus1=src\n";
869        let source = powerio_core::Source::from_memory("<memory>", bytes.to_vec())
870            .unwrap()
871            .with_format(powerio_core::FormatId::new("dss").unwrap());
872        let err = parse(source).unwrap_err();
873        assert!(err.to_string().contains("UTF-8"), "{err}");
874    }
875
876    #[test]
877    fn parse_rejects_unclassifiable_json() {
878        // A PowerModels document used to fall through to the BMOPF reader
879        // and parse into a bogus near-empty network.
880        let dir = tempfile::tempdir().unwrap();
881        let path = dir.path().join("case.json");
882        std::fs::write(
883            &path,
884            r#"{"bus": {}, "branch": {}, "gen": {}, "baseMVA": 100.0}"#,
885        )
886        .unwrap();
887        let err = crate::testkit::parse_file(&path, None).unwrap_err();
888        assert!(
889            err.to_string()
890                .contains("not a recognized distribution document"),
891            "{err}"
892        );
893        // An explicit format still overrides the classifier.
894        assert!(crate::testkit::parse_file(&path, Some("bmopf-json")).is_ok());
895    }
896
897    #[test]
898    fn unknown_format_names_fail_before_any_work() {
899        assert!(matches!(
900            crate::testkit::parse_str("", "matpower"),
901            Err(crate::Error::UnknownFormat(_))
902        ));
903        assert!(matches!(
904            "matpower".parse::<DistTargetFormat>(),
905            Err(crate::Error::UnknownFormat(_))
906        ));
907        assert!(matches!(
908            crate::testkit::parse_file("missing.dss", Some("matpower")),
909            Err(crate::Error::UnknownFormat(_))
910        ));
911    }
912
913    #[test]
914    fn parse_diagnostics_remain_on_the_module_when_it_is_emitted() {
915        let dss = "clear\nnew circuit.w basekv=12.47 bus1=src\n\
916                   new line.l1 bus1=src bus2=b2 length=1 units=furlong\n";
917        let source = powerio_core::Source::from_memory("<memory>", dss.as_bytes().to_vec())
918            .unwrap()
919            .with_format(powerio_core::FormatId::new("dss").unwrap());
920        let module = parse(source).unwrap();
921        let lines = crate::diagnostics::render_diagnostics(module.diagnostics());
922        assert!(
923            lines.iter().any(|w| w.contains("furlong")),
924            "parse diagnostics stay on PioModule: {lines:?}"
925        );
926        emit(
927            &module,
928            DistTargetFormat::BmopfJson,
929            powerio_core::Destination::memory("case.json").unwrap(),
930        )
931        .unwrap();
932    }
933
934    #[test]
935    fn canonical_format_bypasses_same_format_dss_echo() {
936        let src = "Clear\n\
937                   New Circuit.c basekv=12.47 bus1=sourcebus\n\
938                   New Load.l1 bus1=sourcebus.1 phases=1 conn=wye kv=7.2 kw=10 kvar=2\n";
939        let net = crate::testkit::parse_dss_str(src);
940        assert_eq!(net.emit(DistTargetFormat::Dss).text, src);
941
942        let canonical = net.emit_value(DistTargetFormat::Dss);
943        assert_ne!(canonical.text, src);
944        assert!(
945            canonical
946                .text
947                .lines()
948                .any(|l| l.contains("Load.l1") && l.contains("vminpu=0")),
949            "{}",
950            canonical.text
951        );
952    }
953}