1use crate::model::{DistNetwork, DistSourceFormat};
4
5#[derive(Debug, Clone)]
9#[non_exhaustive]
10pub struct Conversion {
11 pub text: String,
12 pub warnings: Vec<String>,
13}
14
15#[derive(Clone, Copy, Debug, PartialEq, Eq)]
17#[non_exhaustive]
18pub enum DistTargetFormat {
19 Dss,
20 BmopfJson,
21 PmdJson,
22}
23
24pub fn dist_target_from_name(name: &str) -> Option<DistTargetFormat> {
26 let key = canonical_key(name);
27 match key.as_str() {
28 "dss" | "opendss" => Some(DistTargetFormat::Dss),
29 "pmd" | "pmdjson" | "engineering" => Some(DistTargetFormat::PmdJson),
30 "bmopf" | "bmopfjson" => Some(DistTargetFormat::BmopfJson),
31 _ => None,
32 }
33}
34
35impl std::str::FromStr for DistTargetFormat {
36 type Err = crate::Error;
37
38 fn from_str(s: &str) -> crate::Result<Self> {
41 dist_target_from_name(s).ok_or_else(|| crate::Error::UnknownFormat(s.to_string()))
42 }
43}
44
45impl DistTargetFormat {
46 pub fn name(self) -> &'static str {
49 match self {
50 DistTargetFormat::Dss => "dss",
51 DistTargetFormat::PmdJson => "pmd-json",
52 DistTargetFormat::BmopfJson => "bmopf-json",
53 }
54 }
55}
56
57fn read(path: &std::path::Path) -> crate::Result<String> {
58 std::fs::read_to_string(path).map_err(|source| crate::Error::Io {
59 path: path.display().to_string(),
60 source,
61 })
62}
63
64fn canonical_key(name: &str) -> String {
65 name.to_ascii_lowercase()
66 .chars()
67 .filter(|c| *c != '-' && *c != '_')
68 .collect()
69}
70
71fn has_top_level_key(text: &str, key: &str) -> bool {
72 serde_json::from_str::<serde_json::Value>(text).is_ok_and(|value| {
73 value
74 .as_object()
75 .is_some_and(|shape| shape.contains_key(key))
76 })
77}
78
79fn infer_distribution_json_format(text: &str) -> DistTargetFormat {
82 if has_top_level_key(text, "data_model") {
83 DistTargetFormat::PmdJson
84 } else {
85 DistTargetFormat::BmopfJson
86 }
87}
88
89pub fn parse_str(text: &str, format: &str) -> crate::Result<DistNetwork> {
91 match format.parse::<DistTargetFormat>()? {
92 DistTargetFormat::Dss => Ok(crate::dss::parse_dss_str(text)),
93 DistTargetFormat::BmopfJson => crate::bmopf::parse_bmopf_str(text),
94 DistTargetFormat::PmdJson => crate::pmd::parse_pmd_str(text),
95 }
96}
97
98pub fn parse_file(
101 path: impl AsRef<std::path::Path>,
102 from: Option<&str>,
103) -> crate::Result<DistNetwork> {
104 let path = path.as_ref();
105 let format = if let Some(from) = from {
108 from.parse::<DistTargetFormat>()?
109 } else {
110 let ext = path
111 .extension()
112 .and_then(|e| e.to_str())
113 .unwrap_or_default()
114 .to_ascii_lowercase();
115 match ext.as_str() {
116 "dss" => DistTargetFormat::Dss,
117 "json" => {
118 let text = read(path)?;
119 return if infer_distribution_json_format(&text) == DistTargetFormat::PmdJson {
120 crate::pmd::parse_pmd_str(&text)
121 } else {
122 crate::bmopf::parse_bmopf_str(&text)
123 };
124 }
125 other => return Err(crate::Error::UnknownFormat(other.to_string())),
126 }
127 };
128 match format {
129 DistTargetFormat::Dss => crate::dss::parse_dss_file(path),
130 DistTargetFormat::BmopfJson => crate::bmopf::parse_bmopf_str(&read(path)?),
131 DistTargetFormat::PmdJson => crate::pmd::parse_pmd_str(&read(path)?),
132 }
133}
134
135fn convert(net: &DistNetwork, target: DistTargetFormat) -> Conversion {
139 let conv = net.to_format(target);
140 let mut warnings = net.warnings.clone();
141 warnings.extend(conv.warnings);
142 Conversion {
143 text: conv.text,
144 warnings,
145 }
146}
147
148pub fn convert_str(text: &str, to: DistTargetFormat, format: &str) -> crate::Result<Conversion> {
151 Ok(convert(&parse_str(text, format)?, to))
152}
153
154pub fn convert_file(
158 path: impl AsRef<std::path::Path>,
159 to: DistTargetFormat,
160 from: Option<&str>,
161) -> crate::Result<Conversion> {
162 Ok(convert(&parse_file(path, from)?, to))
163}
164
165impl DistTargetFormat {
166 fn matches(self, source: DistSourceFormat) -> bool {
167 matches!(
168 (self, source),
169 (DistTargetFormat::Dss, DistSourceFormat::Dss)
170 | (DistTargetFormat::BmopfJson, DistSourceFormat::BmopfJson)
171 | (DistTargetFormat::PmdJson, DistSourceFormat::PmdJson)
172 )
173 }
174}
175
176impl DistNetwork {
177 pub fn to_format(&self, format: DistTargetFormat) -> Conversion {
188 if let (Some(source), Some(source_format)) = (&self.source, self.source_format) {
189 if format.matches(source_format) {
190 return Conversion {
191 text: source.as_ref().clone(),
192 warnings: Vec::new(),
193 };
194 }
195 }
196 match format {
197 DistTargetFormat::Dss => crate::dss::write_dss(self),
198 DistTargetFormat::BmopfJson => crate::bmopf::write_bmopf_json(self),
199 DistTargetFormat::PmdJson => crate::pmd::write_pmd_json(self),
200 }
201 }
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207
208 #[test]
209 fn distribution_json_classifier_preserves_pmd_marker_and_bmopf_fallback() {
210 assert_eq!(
211 infer_distribution_json_format(r#"{"data_model": "ENGINEERING"}"#),
212 DistTargetFormat::PmdJson
213 );
214 assert_eq!(
215 infer_distribution_json_format(r#"{"bus": {"data_model": {}}}"#),
216 DistTargetFormat::BmopfJson
217 );
218 assert_eq!(
219 infer_distribution_json_format(r#"{"name": "data_model"}"#),
220 DistTargetFormat::BmopfJson
221 );
222 assert_eq!(
223 infer_distribution_json_format("{not json"),
224 DistTargetFormat::BmopfJson
225 );
226 }
227
228 #[test]
229 fn unknown_format_names_fail_before_any_work() {
230 assert!(matches!(
231 parse_str("", "matpower"),
232 Err(crate::Error::UnknownFormat(_))
233 ));
234 assert!(matches!(
235 "matpower".parse::<DistTargetFormat>(),
236 Err(crate::Error::UnknownFormat(_))
237 ));
238 assert!(matches!(
239 parse_file("missing.dss", Some("matpower")),
240 Err(crate::Error::UnknownFormat(_))
241 ));
242 }
243
244 #[test]
245 fn one_shot_convert_carries_parse_warnings() {
246 let dss = "clear\nnew circuit.w basekv=12.47 bus1=src\n\
247 new line.l1 bus1=src bus2=b2 length=1 units=furlong\n";
248 let conv = convert_str(dss, DistTargetFormat::BmopfJson, "dss").unwrap();
249 assert!(
250 conv.warnings.iter().any(|w| w.contains("furlong")),
251 "parse warnings must surface through the one-shot converter: {:?}",
252 conv.warnings
253 );
254 }
255}