Skip to main content

powerio_dist/
convert.rs

1//! Cross format conversion output and the format dispatcher.
2
3use crate::model::{DistNetwork, DistSourceFormat};
4
5/// Extra files a writer generated beside the primary text payload.
6#[derive(Debug, Clone, PartialEq, Eq)]
7#[non_exhaustive]
8pub struct ConversionSidecar {
9    /// Relative file path the primary text refers to.
10    pub path: String,
11    /// File content.
12    pub text: String,
13}
14
15impl ConversionSidecar {
16    /// The fidelity warning for a surface that could not write this
17    /// sidecar. `reason` says why in that surface's terms. All surfaces
18    /// must use this one formatter: consumers match on the warning text.
19    #[must_use]
20    pub fn dropped_warning(&self, reason: &str) -> String {
21        format!(
22            "fidelity: sidecar `{}` was not written: {reason}",
23            self.path
24        )
25    }
26}
27
28/// Text in the target format plus every fidelity loss the writer took.
29/// Nothing drops silently: a field the target cannot represent appears
30/// here as a warning naming the element and field.
31#[derive(Debug, Clone)]
32#[non_exhaustive]
33pub struct Conversion {
34    pub text: String,
35    /// Extra files referenced by `text`, such as OpenDSS `Buscoords` CSV.
36    pub sidecars: Vec<ConversionSidecar>,
37    pub warnings: Vec<String>,
38    /// Structured diagnostics for warning paths with stable codes.
39    ///
40    /// The legacy `warnings` strings remain the compatibility surface for C,
41    /// Python, Julia, and CLI callers. New code should prefer this field when
42    /// it needs stable assertions.
43    pub diagnostics: Vec<crate::diagnostics::StructuredDiagnostic>,
44}
45
46/// A writable distribution format.
47#[derive(Clone, Copy, Debug, PartialEq, Eq)]
48#[non_exhaustive]
49pub enum DistTargetFormat {
50    Dss,
51    BmopfJson,
52    PmdJson,
53}
54
55/// Resolves common names and file extensions to a target format.
56pub fn dist_target_from_name(name: &str) -> Option<DistTargetFormat> {
57    let key = canonical_key(name);
58    match key.as_str() {
59        "dss" | "opendss" => Some(DistTargetFormat::Dss),
60        "pmd" | "pmdjson" | "engineering" => Some(DistTargetFormat::PmdJson),
61        "bmopf" | "bmopfjson" => Some(DistTargetFormat::BmopfJson),
62        _ => None,
63    }
64}
65
66impl std::str::FromStr for DistTargetFormat {
67    type Err = crate::Error;
68
69    /// [`dist_target_from_name`] as a `Result`, matching the transmission
70    /// hub's `TargetFormat: FromStr`.
71    fn from_str(s: &str) -> crate::Result<Self> {
72        dist_target_from_name(s).ok_or_else(|| crate::Error::UnknownFormat(s.to_string()))
73    }
74}
75
76impl DistTargetFormat {
77    /// The canonical format name (`dss`, `pmd-json`, `bmopf-json`), accepted
78    /// back by [`dist_target_from_name`].
79    pub fn name(self) -> &'static str {
80        match self {
81            DistTargetFormat::Dss => "dss",
82            DistTargetFormat::PmdJson => "pmd-json",
83            DistTargetFormat::BmopfJson => "bmopf-json",
84        }
85    }
86}
87
88fn read(path: &std::path::Path) -> crate::Result<String> {
89    std::fs::read_to_string(path).map_err(|source| crate::Error::Io {
90        path: path.display().to_string(),
91        source,
92    })
93}
94
95fn canonical_key(name: &str) -> String {
96    name.to_ascii_lowercase()
97        .chars()
98        .filter(|c| *c != '-' && *c != '_')
99        .collect()
100}
101
102/// Element tables that identify a distribution document beside its `bus`
103/// table.
104///
105/// `load`, `shunt`, and `switch` are shared with PowerModels, so on their own
106/// they cannot tell the two apart. They stay in the list all the same, because
107/// [`NOT_BMOPF_KEYS`] is what refuses a PowerModels document, and it refuses it
108/// whatever else the document holds. Dropping the shared names instead would
109/// refuse a real BMOPF feeder built only from them, which the reader parses
110/// and which this classifier used to accept.
111///
112/// These names do not separate BMOPF from PMD: the two share most of their
113/// element vocabulary. `data_model` does that, and it is checked first.
114const DISTRIBUTION_ELEMENT_TABLES: &[&str] = &[
115    "capacitor",
116    // `control_profile` and `ibr` are typed dispatch tables of the reader,
117    // though schema 0.1.0 moved them under `extras`: a pre-0.1.0 document
118    // still declares them at the top level, and what the reader accepts the
119    // classifier must identify.
120    "control_profile",
121    "generator",
122    "ibr",
123    "line",
124    "linecode",
125    "load",
126    "meta",
127    "shunt",
128    "switch",
129    "terminal_conventions",
130    "transformer",
131    "voltage_source",
132];
133
134/// Top level keys no BMOPF document carries, and a PowerModels or MATPOWER
135/// derived document does. One of these refuses the BMOPF reading whatever
136/// else the document holds, so a name that a future BMOPF revision adds
137/// cannot make a PowerModels file classify.
138const NOT_BMOPF_KEYS: &[&str] = &[
139    "baseMVA",
140    "branch",
141    "dcline",
142    "gen",
143    "per_unit",
144    "source_type",
145    "source_version",
146    "storage",
147];
148
149/// The PMD marker. Neither BMOPF nor PowerModels carries this key, so it
150/// identifies the ENGINEERING and MATHEMATICAL models on its own; the PMD
151/// reader then rejects MATHEMATICAL with its own message.
152const PMD_MARKER: &str = "data_model";
153
154/// What the top level of a document holds, for classification only.
155///
156/// The probe reads the top level keys and skips every value, so it never
157/// materializes the document. The old classifier built a whole
158/// `serde_json::Value` and dropped it, which doubled the peak memory of a
159/// parse and did the tokenizing work twice: the chosen reader parses the
160/// same text again. A case file is attacker controlled input, so a reader
161/// sized allocation that serves no purpose is worth removing.
162// Five independent presence flags, which is what a marker probe is. An
163// enum or a builder, the shapes this lint steers toward, would model a
164// choice; these are not exclusive.
165#[allow(clippy::struct_excessive_bools)]
166#[derive(Default)]
167struct TopLevel {
168    /// The document is a JSON object.
169    is_object: bool,
170    pmd_marker: bool,
171    bus: bool,
172    /// A key from [`DISTRIBUTION_ELEMENT_TABLES`].
173    dist_table: bool,
174    /// A key from [`NOT_BMOPF_KEYS`].
175    not_bmopf: bool,
176}
177
178impl<'de> serde::Deserialize<'de> for TopLevel {
179    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
180        struct Probe;
181
182        impl<'de> serde::de::Visitor<'de> for Probe {
183            type Value = TopLevel;
184
185            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
186                f.write_str("a JSON document")
187            }
188
189            fn visit_map<A: serde::de::MapAccess<'de>>(
190                self,
191                mut map: A,
192            ) -> Result<TopLevel, A::Error> {
193                let mut out = TopLevel {
194                    is_object: true,
195                    ..TopLevel::default()
196                };
197                // Keys arrive as borrowed or owned strings. Nothing is kept:
198                // each key sets a flag and each value is discarded, so the
199                // probe holds a constant amount of memory whatever the size
200                // of the document.
201                while let Some(key) = map.next_key::<std::borrow::Cow<'_, str>>()? {
202                    let key = key.as_ref();
203                    out.pmd_marker |= key == PMD_MARKER;
204                    out.bus |= key == "bus";
205                    out.dist_table |= DISTRIBUTION_ELEMENT_TABLES.contains(&key);
206                    out.not_bmopf |= NOT_BMOPF_KEYS.contains(&key);
207                    map.next_value::<serde::de::IgnoredAny>()?;
208                }
209                Ok(out)
210            }
211
212            // A valid document that is not an object cannot be either
213            // format. Record that and let the caller report it, rather than
214            // failing as a parse error, which would send it to the BMOPF
215            // fallback.
216            fn visit_bool<E>(self, _: bool) -> Result<TopLevel, E> {
217                Ok(TopLevel::default())
218            }
219            fn visit_i64<E>(self, _: i64) -> Result<TopLevel, E> {
220                Ok(TopLevel::default())
221            }
222            fn visit_u64<E>(self, _: u64) -> Result<TopLevel, E> {
223                Ok(TopLevel::default())
224            }
225            fn visit_f64<E>(self, _: f64) -> Result<TopLevel, E> {
226                Ok(TopLevel::default())
227            }
228            fn visit_str<E>(self, _: &str) -> Result<TopLevel, E> {
229                Ok(TopLevel::default())
230            }
231            fn visit_unit<E>(self) -> Result<TopLevel, E> {
232                Ok(TopLevel::default())
233            }
234            fn visit_none<E>(self) -> Result<TopLevel, E> {
235                Ok(TopLevel::default())
236            }
237            fn visit_seq<A: serde::de::SeqAccess<'de>>(
238                self,
239                mut seq: A,
240            ) -> Result<TopLevel, A::Error> {
241                while seq.next_element::<serde::de::IgnoredAny>()?.is_some() {}
242                Ok(TopLevel::default())
243            }
244        }
245
246        deserializer.deserialize_any(Probe)
247    }
248}
249
250/// Distribution parser policy for a `.json` input.
251///
252/// Classification is by positive marker, never by "everything else is
253/// BMOPF": an unmarked document used to fall through to the BMOPF reader
254/// and parse into a bogus near-empty network.
255///
256/// - PMD carries `data_model`, which no other family here carries. The marker
257///   decides on its own: every real PMD ENGINEERING document also carries the
258///   element tables BMOPF uses, so a document holding both is the normal case
259///   and not a contradiction.
260/// - BMOPF carries a `bus` table beside at least one distribution element
261///   table, and no key that marks a PowerModels or MATPOWER derived document.
262/// - Anything else is refused with a message naming both rules.
263///
264/// Malformed JSON still routes to BMOPF so its reader reports the parse
265/// error, which names the byte offset. The probe never materializes the
266/// document, so classification costs one pass and constant memory.
267pub fn classify_distribution_json(text: &str) -> crate::Result<DistTargetFormat> {
268    // A leading byte order mark would fail the parse here and silently send
269    // a PMD document down the BMOPF fallback; classify without it.
270    let text = text.trim_start_matches('\u{feff}');
271    let unrecognized = |detail: &str| crate::Error::Json {
272        format: "distribution",
273        message: format!(
274            "not a recognized distribution document: {detail}. PMD ENGINEERING JSON \
275             carries `data_model`; BMOPF JSON carries a `bus` table beside one of \
276             {DISTRIBUTION_ELEMENT_TABLES:?}. Pass the format explicitly to override."
277        ),
278    };
279
280    let Ok(top) = serde_json::from_str::<TopLevel>(text) else {
281        return Ok(DistTargetFormat::BmopfJson);
282    };
283    if !top.is_object {
284        return Err(unrecognized("the top level is not an object"));
285    }
286
287    // `data_model` is authoritative. PMD ENGINEERING and BMOPF share most
288    // of their element table names (`line`, `linecode`, `transformer`, and
289    // the rest), so the marker separates them, not the table set. The PMD
290    // reader is what judges the marker's value, and it rejects the
291    // MATHEMATICAL model with its own message.
292    if top.pmd_marker {
293        return Ok(DistTargetFormat::PmdJson);
294    }
295    if top.bus && top.dist_table && !top.not_bmopf {
296        return Ok(DistTargetFormat::BmopfJson);
297    }
298    Err(if top.bus && top.not_bmopf {
299        unrecognized(
300            "it carries a `bus` table with PowerModels keys beside it, so it is a \
301             transmission document; read it through the transmission hub",
302        )
303    } else if top.bus {
304        unrecognized("its `bus` table has no distribution element table beside it")
305    } else {
306        unrecognized("it carries no marker of either format")
307    })
308}
309
310/// The warning every parse path pushes when it removes a byte order mark.
311pub(crate) const BOM_WARNING: &str =
312    "leading UTF-8 byte order mark removed; a same-format write returns the text without it";
313
314/// Parse `text` as `format`, stripping a leading UTF-8 byte order mark first:
315/// Windows tooling saves case files with one, and both serde_json and the DSS
316/// tokenizer treat it as garbage in the first token. The retained source loses
317/// the mark, so a same-format echo differs by exactly those three bytes; the
318/// warning itemizes that.
319fn parse_text(text: &str, format: DistTargetFormat) -> crate::Result<DistNetwork> {
320    let stripped = text.trim_start_matches('\u{feff}');
321    let mut net = match format {
322        DistTargetFormat::Dss => crate::dss::parse_dss_str(stripped),
323        DistTargetFormat::BmopfJson => crate::bmopf::parse_bmopf_str(stripped)?,
324        DistTargetFormat::PmdJson => crate::pmd::parse_pmd_str(stripped)?,
325    };
326    if stripped.len() != text.len() {
327        net.warnings.push(BOM_WARNING.to_owned());
328    }
329    Ok(net)
330}
331
332/// Parses `text` in the named format (see [`dist_target_from_name`]).
333pub fn parse_str(text: &str, format: &str) -> crate::Result<DistNetwork> {
334    parse_text(text, format.parse::<DistTargetFormat>()?)
335}
336
337/// Parses `path`, taking the format from `from` when given, the `.dss`
338/// extension otherwise, and for `.json` the shared distribution classifier.
339pub fn parse_file(
340    path: impl AsRef<std::path::Path>,
341    from: Option<&str>,
342) -> crate::Result<DistNetwork> {
343    let path = path.as_ref();
344    // Dss goes through the path-based parser (Redirect/Compile resolve
345    // against the file's directory); the JSON readers take text.
346    let format = if let Some(from) = from {
347        from.parse::<DistTargetFormat>()?
348    } else {
349        let ext = path
350            .extension()
351            .and_then(|e| e.to_str())
352            .unwrap_or_default()
353            .to_ascii_lowercase();
354        match ext.as_str() {
355            "dss" => DistTargetFormat::Dss,
356            "json" => {
357                let text = read(path)?;
358                return parse_text(&text, classify_distribution_json(&text)?);
359            }
360            other => return Err(crate::Error::UnknownFormat(other.to_string())),
361        }
362    };
363    match format {
364        DistTargetFormat::Dss => crate::dss::parse_dss_file(path),
365        DistTargetFormat::BmopfJson | DistTargetFormat::PmdJson => parse_text(&read(path)?, format),
366    }
367}
368
369/// Prepend the reader's parse warnings and diagnostics to the writer's: the
370/// one-shot converters return no handle to query, so this is the only place
371/// the loud half of the parse can surface.
372fn convert(net: &DistNetwork, target: DistTargetFormat) -> Conversion {
373    let conv = net.to_format(target);
374    let mut warnings = net.warnings.clone();
375    warnings.extend(conv.warnings);
376    let mut diagnostics = net.parse_diagnostics.clone();
377    diagnostics.extend(conv.diagnostics);
378    Conversion {
379        text: conv.text,
380        sidecars: conv.sidecars,
381        warnings,
382        diagnostics,
383    }
384}
385
386/// Parses `text` as `format` and writes it as `to` in one call. The warnings
387/// carry both the parse warnings and the writer's fidelity losses.
388pub fn convert_str(text: &str, to: DistTargetFormat, format: &str) -> crate::Result<Conversion> {
389    Ok(convert(&parse_str(text, format)?, to))
390}
391
392/// Parses `path` (format from `from` or the file itself) and writes it as
393/// `to` in one call. The warnings carry both the parse warnings and the
394/// writer's fidelity losses.
395pub fn convert_file(
396    path: impl AsRef<std::path::Path>,
397    to: DistTargetFormat,
398    from: Option<&str>,
399) -> crate::Result<Conversion> {
400    Ok(convert(&parse_file(path, from)?, to))
401}
402
403impl DistTargetFormat {
404    fn matches(self, source: DistSourceFormat) -> bool {
405        matches!(
406            (self, source),
407            (DistTargetFormat::Dss, DistSourceFormat::Dss)
408                | (DistTargetFormat::BmopfJson, DistSourceFormat::BmopfJson)
409                | (DistTargetFormat::PmdJson, DistSourceFormat::PmdJson)
410        )
411    }
412}
413
414impl DistNetwork {
415    /// Writes the network in `format`, bypassing byte exact source echo.
416    pub fn to_canonical_format(&self, format: DistTargetFormat) -> Conversion {
417        let mut conv = match format {
418            DistTargetFormat::Dss => crate::dss::write_dss(self),
419            DistTargetFormat::BmopfJson => crate::bmopf::write_bmopf_json(self),
420            DistTargetFormat::PmdJson => crate::pmd::write_pmd_json(self),
421        };
422        // No distribution format carries line routes; report the loss the
423        // way bus locations already do (`.pio.json` keeps them).
424        let routed = self
425            .lines
426            .iter()
427            .filter(|line| line.route.is_some())
428            .count();
429        if routed > 0 {
430            conv.warnings.push(format!(
431                "{routed} line route(s) dropped: {} has no polyline field",
432                format.name()
433            ));
434        }
435        conv
436    }
437
438    /// Writes the network in `format`.
439    ///
440    /// Writing back to the source format echoes the retained source text
441    /// byte for byte; every cross format write regenerates from the typed
442    /// model and reports each fidelity loss in the warnings. The returned
443    /// warnings hold only the writer's losses: parse warnings stay on
444    /// [`DistNetwork::warnings`] (the one-shot [`convert_str`]/[`convert_file`]
445    /// merge the two). After mutating a parsed model, set `source = None`
446    /// (and `source_format`), or the echo tier returns the original text
447    /// and silently discards the edits.
448    pub fn to_format(&self, format: DistTargetFormat) -> Conversion {
449        if let (Some(source), Some(source_format)) = (&self.source, self.source_format) {
450            if format.matches(source_format) {
451                return Conversion {
452                    text: source.as_ref().clone(),
453                    sidecars: Vec::new(),
454                    warnings: Vec::new(),
455                    diagnostics: Vec::new(),
456                };
457            }
458        }
459        self.to_canonical_format(format)
460    }
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466
467    #[test]
468    fn distribution_json_classifier_preserves_pmd_marker_and_bmopf_fallback() {
469        for doc in [
470            r#"{"data_model": "ENGINEERING"}"#,
471            r#"{"data_model": "MATHEMATICAL"}"#,
472            // The marker identifies the family whatever its value is; the
473            // PMD reader is what judges the value.
474            r#"{"data_model": 7}"#,
475            r#"{"data_model": null}"#,
476        ] {
477            assert_eq!(
478                classify_distribution_json(doc).unwrap(),
479                DistTargetFormat::PmdJson,
480                "{doc}"
481            );
482        }
483        for doc in [
484            r#"{"bus": {}, "voltage_source": {}}"#,
485            // A pre-0.1.0 feeder fragment: no `voltage_source`, but the
486            // reader accepts it, so the classifier must too.
487            r#"{"bus": {}, "line": {}, "linecode": {}}"#,
488            r#"{"bus": {}, "transformer": {}}"#,
489            r#"{"bus": {}, "capacitor": {}}"#,
490            r#"{"bus": {}, "generator": {}}"#,
491            // The pre-0.1.0 top-level spellings of the tables 0.1.0 moved
492            // under `extras`; the reader dispatches both.
493            r#"{"bus": {}, "ibr": {}}"#,
494            r#"{"bus": {}, "control_profile": {}}"#,
495        ] {
496            assert_eq!(
497                classify_distribution_json(doc).unwrap(),
498                DistTargetFormat::BmopfJson,
499                "{doc}"
500            );
501        }
502        // Malformed JSON still routes to BMOPF so its reader reports the
503        // parse error, which names the byte offset.
504        assert_eq!(
505            classify_distribution_json("{not json").unwrap(),
506            DistTargetFormat::BmopfJson
507        );
508        // A byte order mark must not push a PMD document down the BMOPF
509        // fallback.
510        assert_eq!(
511            classify_distribution_json("\u{feff}{\"data_model\": \"ENGINEERING\"}").unwrap(),
512            DistTargetFormat::PmdJson
513        );
514    }
515
516    /// A PowerModels document shares `bus`, `load`, `shunt`, `switch`, and
517    /// `name` with BMOPF, so none of those can be the discriminator. This
518    /// is the exact document family that used to parse into a bogus
519    /// near-empty `DistNetwork`.
520    #[test]
521    fn a_powermodels_document_never_classifies_as_bmopf() {
522        // The real key set a powerio PowerModels write produces.
523        let powermodels = r#"{"baseMVA": 100.0, "branch": {}, "bus": {}, "dcline": {},
524            "gen": {}, "load": {}, "name": "case14", "per_unit": true, "shunt": {},
525            "source_type": "matpower", "source_version": "2", "storage": {},
526            "switch": {}}"#;
527        assert!(classify_distribution_json(powermodels).is_err());
528
529        // Each PowerModels marker refuses the BMOPF reading on its own, even
530        // beside a real BMOPF table. A future BMOPF revision that adds a
531        // colliding table name therefore cannot make this document classify.
532        for marker in NOT_BMOPF_KEYS {
533            let doc = format!("{{\"bus\": {{}}, \"linecode\": {{}}, \"{marker}\": 1}}");
534            assert!(
535                classify_distribution_json(&doc).is_err(),
536                "`{marker}` must refuse the BMOPF reading: {doc}"
537            );
538        }
539    }
540
541    /// The two rules pull in opposite directions and this classifier has
542    /// swung both ways: dropping the shared table names refuses real BMOPF
543    /// feeders, and keeping them without the veto reads PowerModels as BMOPF.
544    /// Pin both ends together so neither correction can undo the other.
545    #[test]
546    fn shared_table_names_classify_as_bmopf_and_the_veto_still_refuses_powermodels() {
547        // A BMOPF feeder built only from names PowerModels also uses. No
548        // veto key is present, so the distribution reading stands.
549        for doc in [
550            r#"{"bus": {}, "load": {}}"#,
551            r#"{"bus": {}, "shunt": {}}"#,
552            r#"{"bus": {}, "switch": {}}"#,
553            r#"{"bus": {}, "meta": {"frequency": 60}}"#,
554        ] {
555            assert_eq!(
556                classify_distribution_json(doc).unwrap(),
557                DistTargetFormat::BmopfJson,
558                "{doc}"
559            );
560        }
561        // The same shared names beside one veto key stay transmission.
562        for doc in [
563            r#"{"bus": {}, "load": {}, "baseMVA": 100.0}"#,
564            r#"{"bus": {}, "shunt": {}, "branch": {}}"#,
565            r#"{"bus": {}, "switch": {}, "per_unit": true}"#,
566        ] {
567            assert!(classify_distribution_json(doc).is_err(), "{doc}");
568        }
569    }
570
571    /// PMD ENGINEERING and BMOPF share most element table names, so a real
572    /// PMD document carries `data_model` beside `line` and `linecode`. The
573    /// marker must win, or every PMD file would be read as BMOPF.
574    #[test]
575    fn the_pmd_marker_wins_over_shared_element_tables() {
576        let both = r#"{"data_model": "ENGINEERING", "bus": {}, "line": {}, "linecode": {}}"#;
577        assert_eq!(
578            classify_distribution_json(both).unwrap(),
579            DistTargetFormat::PmdJson
580        );
581    }
582
583    #[test]
584    fn unclassifiable_documents_are_refused_with_a_reason() {
585        for (doc, needle) in [
586            (
587                r#"{"bus": {"data_model": {}}}"#,
588                "no distribution element table",
589            ),
590            (r#"{"name": "data_model"}"#, "no marker of either format"),
591            ("{}", "no marker of either format"),
592            ("[]", "not an object"),
593            ("null", "not an object"),
594            ("3", "not an object"),
595            (r#""a string""#, "not an object"),
596            ("true", "not an object"),
597        ] {
598            let err = classify_distribution_json(doc).unwrap_err().to_string();
599            assert!(err.contains(needle), "{doc}: got {err}");
600        }
601    }
602
603    /// The probe reads top level keys and skips values, so neither the size
604    /// of a value nor the number of keys can make it allocate the document.
605    /// A deeply nested value hits serde_json's own recursion limit, which
606    /// surfaces as the malformed-JSON route rather than a stack overflow.
607    #[test]
608    fn the_probe_is_bounded_on_adversarial_shapes() {
609        // A huge value under an ignored key: skipped, not materialized.
610        let big = format!(
611            r#"{{"bus": {{}}, "linecode": {{}}, "junk": [{}]}}"#,
612            "0,".repeat(200_000) + "0"
613        );
614        assert_eq!(
615            classify_distribution_json(&big).unwrap(),
616            DistTargetFormat::BmopfJson
617        );
618
619        // Many distinct top level keys: one flag per key, nothing stored.
620        let mut keys = String::new();
621        for i in 0..50_000 {
622            use std::fmt::Write as _;
623            let _ = write!(keys, "\"k{i}\":0,");
624        }
625        let many = format!(r#"{{{keys}"bus":{{}},"linecode":{{}}}}"#);
626        assert_eq!(
627            classify_distribution_json(&many).unwrap(),
628            DistTargetFormat::BmopfJson
629        );
630
631        // Deep nesting under an ignored key: `IgnoredAny` skips a value
632        // without recursion, so depth costs no stack. The old classifier
633        // built a `serde_json::Value`, whose recursive descent refuses past
634        // 128 levels, so a legitimate document nested deeper than that used
635        // to take the malformed route.
636        let deep = format!(
637            r#"{{"bus":{{}},"linecode":{{}},"junk":{}{}}}"#,
638            "[".repeat(20_000),
639            "]".repeat(20_000)
640        );
641        assert_eq!(
642            classify_distribution_json(&deep).unwrap(),
643            DistTargetFormat::BmopfJson
644        );
645
646        // A duplicate marker key is still one marker.
647        assert_eq!(
648            classify_distribution_json(
649                r#"{"data_model":"ENGINEERING","data_model":"ENGINEERING"}"#
650            )
651            .unwrap(),
652            DistTargetFormat::PmdJson
653        );
654    }
655
656    /// The probe skips a value without recursion, so it accepts a document
657    /// nested far deeper than the reader will take. The reader must then
658    /// refuse that document with an error, never with a crash: the
659    /// classifier is what decides which reader sees untrusted input.
660    #[test]
661    fn a_document_the_probe_accepts_is_refused_by_the_reader_not_a_crash() {
662        for depth in [200usize, 20_000, 500_000] {
663            let doc = format!(
664                "{{\"bus\":{{}},\"linecode\":{{}},\"junk\":{}{}}}",
665                "[".repeat(depth),
666                "]".repeat(depth)
667            );
668            let format = classify_distribution_json(&doc).expect("markers are present");
669            assert_eq!(format, DistTargetFormat::BmopfJson);
670            let err = crate::parse_str(&doc, format.name())
671                .expect_err("the reader refuses past its recursion limit");
672            assert!(
673                err.to_string().contains("recursion limit"),
674                "depth {depth}: {err}"
675            );
676        }
677    }
678
679    /// JSON keys are case sensitive and both formats are machine written,
680    /// so a near miss must not classify. It would pick a reader that then
681    /// fails on every table.
682    #[test]
683    fn marker_matching_is_case_sensitive() {
684        for doc in [
685            r#"{"Data_Model": "ENGINEERING"}"#,
686            r#"{"DATA_MODEL": "ENGINEERING"}"#,
687            r#"{"Bus": {}, "Linecode": {}}"#,
688        ] {
689            assert!(classify_distribution_json(doc).is_err(), "{doc}");
690        }
691    }
692
693    #[test]
694    fn byte_order_mark_is_stripped_and_warned() {
695        let dss = "\u{feff}clear\nnew circuit.c basekv=12.47 bus1=src\n";
696        let net = parse_str(dss, "dss").unwrap();
697        assert!(
698            net.warnings.iter().any(|w| w.contains("byte order mark")),
699            "warnings: {:?}",
700            net.warnings
701        );
702        assert!(
703            net.source
704                .as_ref()
705                .is_some_and(|s| !s.starts_with('\u{feff}'))
706        );
707    }
708
709    #[test]
710    fn parse_file_rejects_unclassifiable_json() {
711        // A PowerModels document used to fall through to the BMOPF reader
712        // and parse into a bogus near-empty network.
713        let dir = tempfile::tempdir().unwrap();
714        let path = dir.path().join("case.json");
715        std::fs::write(
716            &path,
717            r#"{"bus": {}, "branch": {}, "gen": {}, "baseMVA": 100.0}"#,
718        )
719        .unwrap();
720        let err = parse_file(&path, None).unwrap_err();
721        assert!(
722            err.to_string()
723                .contains("not a recognized distribution document"),
724            "{err}"
725        );
726        // An explicit format still overrides the classifier.
727        assert!(parse_file(&path, Some("bmopf-json")).is_ok());
728    }
729
730    #[test]
731    fn unknown_format_names_fail_before_any_work() {
732        assert!(matches!(
733            parse_str("", "matpower"),
734            Err(crate::Error::UnknownFormat(_))
735        ));
736        assert!(matches!(
737            "matpower".parse::<DistTargetFormat>(),
738            Err(crate::Error::UnknownFormat(_))
739        ));
740        assert!(matches!(
741            parse_file("missing.dss", Some("matpower")),
742            Err(crate::Error::UnknownFormat(_))
743        ));
744    }
745
746    #[test]
747    fn one_shot_convert_carries_parse_warnings() {
748        let dss = "clear\nnew circuit.w basekv=12.47 bus1=src\n\
749                   new line.l1 bus1=src bus2=b2 length=1 units=furlong\n";
750        let conv = convert_str(dss, DistTargetFormat::BmopfJson, "dss").unwrap();
751        assert!(
752            conv.warnings.iter().any(|w| w.contains("furlong")),
753            "parse warnings must surface through the one-shot converter: {:?}",
754            conv.warnings
755        );
756    }
757
758    #[test]
759    fn canonical_format_bypasses_same_format_dss_echo() {
760        let src = "Clear\n\
761                   New Circuit.c basekv=12.47 bus1=sourcebus\n\
762                   New Load.l1 bus1=sourcebus.1 phases=1 conn=wye kv=7.2 kw=10 kvar=2\n";
763        let net = parse_str(src, "dss").unwrap();
764        assert_eq!(net.to_format(DistTargetFormat::Dss).text, src);
765
766        let canonical = net.to_canonical_format(DistTargetFormat::Dss);
767        assert_ne!(canonical.text, src);
768        assert!(
769            canonical
770                .text
771                .lines()
772                .any(|l| l.contains("Load.l1") && l.contains("vminpu=0")),
773            "{}",
774            canonical.text
775        );
776    }
777}