1use std::collections::BTreeSet;
14use std::fmt;
15
16const MAX_DYR_BYTES: usize = 16 * 1024 * 1024;
17const MAX_RECORDS: usize = 100_000;
18const MAX_FIELDS_PER_RECORD: usize = 4_096;
19const MAX_FIELD_BYTES: usize = 64 * 1024;
20
21#[derive(Clone, Debug, PartialEq, Eq)]
23pub struct DynamicCase {
24 source: String,
25 records: Vec<DynamicRecord>,
26}
27
28impl DynamicCase {
29 #[must_use]
31 pub fn records(&self) -> &[DynamicRecord] {
32 &self.records
33 }
34
35 #[must_use]
40 pub fn to_dyr_string(&self) -> &str {
41 &self.source
42 }
43
44 #[must_use]
51 pub fn unresolved_buses(&self, bus_ids: &BTreeSet<u32>) -> Vec<UnresolvedBusReference> {
52 self.records
53 .iter()
54 .filter_map(|record| {
55 let bus_id = record.bus_id?;
57 (!bus_ids.contains(&bus_id)).then(|| UnresolvedBusReference {
58 line: record.line,
59 model_name: record.model_name.clone(),
60 bus_id,
61 })
62 })
63 .collect()
64 }
65
66 #[must_use]
71 pub fn unresolved_buses_in(
72 &self,
73 network: &crate::BalancedNetwork,
74 ) -> Vec<UnresolvedBusReference> {
75 let bus_ids: BTreeSet<u32> = network
76 .buses()
77 .iter()
78 .filter_map(|bus| u32::try_from(bus.id.0).ok())
79 .collect();
80 self.unresolved_buses(&bus_ids)
81 }
82}
83
84#[derive(Clone, Debug)]
90pub struct DynamicStudy {
91 network: crate::BalancedNetwork,
92 dynamics: DynamicCase,
93}
94
95impl DynamicStudy {
96 #[must_use]
98 pub fn new(network: crate::BalancedNetwork, dynamics: DynamicCase) -> Self {
99 Self { network, dynamics }
100 }
101
102 #[must_use]
104 pub fn network(&self) -> &crate::BalancedNetwork {
105 &self.network
106 }
107
108 #[must_use]
110 pub fn dynamics(&self) -> &DynamicCase {
111 &self.dynamics
112 }
113
114 #[must_use]
116 pub fn unresolved_buses(&self) -> Vec<UnresolvedBusReference> {
117 self.dynamics.unresolved_buses_in(&self.network)
118 }
119
120 #[must_use]
122 pub fn into_parts(self) -> (crate::BalancedNetwork, DynamicCase) {
123 (self.network, self.dynamics)
124 }
125}
126
127#[derive(Clone, Debug, PartialEq, Eq)]
130pub struct DynamicRecord {
131 model_name: String,
132 key: String,
133 bus_id: Option<u32>,
134 device_id: Option<String>,
135 parameters: Vec<String>,
136 line: usize,
137 raw_record: String,
138}
139
140impl DynamicRecord {
141 #[must_use]
143 pub fn model_name(&self) -> &str {
144 &self.model_name
145 }
146
147 #[must_use]
151 pub fn key(&self) -> &str {
152 &self.key
153 }
154
155 #[must_use]
159 pub fn bus_id(&self) -> Option<u32> {
160 self.bus_id
161 }
162
163 #[must_use]
165 pub fn device_id(&self) -> Option<&str> {
166 self.device_id.as_deref()
167 }
168
169 #[must_use]
171 pub fn parameters(&self) -> &[String] {
172 &self.parameters
173 }
174
175 #[must_use]
177 pub fn line(&self) -> usize {
178 self.line
179 }
180
181 #[must_use]
183 pub fn raw_record(&self) -> &str {
184 &self.raw_record
185 }
186}
187
188#[derive(Clone, Debug, PartialEq, Eq)]
190#[non_exhaustive]
191pub struct UnresolvedBusReference {
192 pub line: usize,
193 pub model_name: String,
194 pub bus_id: u32,
195}
196
197#[derive(Clone, Debug, PartialEq, Eq)]
199#[non_exhaustive]
200pub enum DyrError {
201 InputTooLarge { actual: usize, maximum: usize },
202 InvalidUtf8,
203 UnterminatedQuote { line: usize },
204 UnterminatedRecord { line: usize },
205 MissingModelName { line: usize },
206 InvalidBusId { line: usize, token: String },
207 TooManyRecords { maximum: usize },
208 TooManyFields { line: usize, maximum: usize },
209 FieldTooLong { line: usize, maximum: usize },
210}
211
212impl fmt::Display for DyrError {
213 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
214 match self {
215 Self::InputTooLarge { actual, maximum } => {
216 write!(f, "DYR input is {actual} bytes; maximum is {maximum}")
217 }
218 Self::InvalidUtf8 => f.write_str("DYR input is not valid UTF-8"),
219 Self::UnterminatedQuote { line } => {
220 write!(f, "unterminated quoted field starting at line {line}")
221 }
222 Self::UnterminatedRecord { line } => {
223 write!(
224 f,
225 "DYR record starting at line {line} has no terminating slash"
226 )
227 }
228 Self::MissingModelName { line } => {
229 write!(f, "DYR record at line {line} has no model name")
230 }
231 Self::InvalidBusId { line, token } => {
232 write!(f, "invalid DYR bus identifier {token:?} at line {line}")
233 }
234 Self::TooManyRecords { maximum } => {
235 write!(f, "DYR input exceeds the maximum of {maximum} records")
236 }
237 Self::TooManyFields { line, maximum } => {
238 write!(f, "DYR record at line {line} exceeds {maximum} fields")
239 }
240 Self::FieldTooLong { line, maximum } => {
241 write!(f, "DYR field at line {line} exceeds {maximum} bytes")
242 }
243 }
244 }
245}
246
247impl std::error::Error for DyrError {}
248
249pub fn parse_dyr(text: &str) -> Result<DynamicCase, DyrError> {
257 if text.len() > MAX_DYR_BYTES {
258 return Err(DyrError::InputTooLarge {
259 actual: text.len(),
260 maximum: MAX_DYR_BYTES,
261 });
262 }
263
264 let uncommented = strip_comments(text)?;
265 let mut records = Vec::new();
266 let mut start = 0;
267 let mut start_line = 1;
268 let mut line = 1;
269 let mut in_quote = false;
270 let mut record_started = false;
271 let bytes = uncommented.as_bytes();
272 let mut index = 0;
273
274 while index < bytes.len() {
275 match bytes[index] {
276 b'\n' => {
277 line += 1;
278 if !record_started {
279 start_line = line;
280 }
281 index += 1;
282 }
283 b'/' if !in_quote => {
284 if record_started {
285 if records.len() >= MAX_RECORDS {
286 return Err(DyrError::TooManyRecords {
287 maximum: MAX_RECORDS,
288 });
289 }
290 records.push(parse_record(uncommented[start..index].trim(), start_line)?);
291 }
292 start = index + 1;
293 start_line = line;
294 record_started = false;
295 index += 1;
296 }
297 b'\'' => {
298 if !record_started {
299 record_started = true;
300 start_line = line;
301 }
302 if in_quote && index + 1 < bytes.len() && bytes[index + 1] == b'\'' {
303 index += 2;
304 } else {
305 in_quote = !in_quote;
306 index += 1;
307 }
308 }
309 byte => {
310 if !record_started && !byte.is_ascii_whitespace() {
311 record_started = true;
312 start_line = line;
313 }
314 index += 1;
315 }
316 }
317 }
318
319 if record_started {
320 return Err(DyrError::UnterminatedRecord { line: start_line });
321 }
322
323 Ok(DynamicCase {
324 source: text.to_owned(),
325 records,
326 })
327}
328
329pub fn parse_dyr_bytes(bytes: &[u8]) -> Result<DynamicCase, DyrError> {
331 if bytes.len() > MAX_DYR_BYTES {
332 return Err(DyrError::InputTooLarge {
333 actual: bytes.len(),
334 maximum: MAX_DYR_BYTES,
335 });
336 }
337 let text = std::str::from_utf8(bytes).map_err(|_| DyrError::InvalidUtf8)?;
338 parse_dyr(text)
339}
340
341fn strip_comments(text: &str) -> Result<String, DyrError> {
342 let mut output = String::with_capacity(text.len());
343 let mut in_quote = false;
344 let mut in_comment = false;
345 let mut line = 1;
346 let mut quote_start_line = 1;
347 let mut chars = text.chars().peekable();
348
349 while let Some(character) = chars.next() {
350 if character == '\n' {
351 line += 1;
352 in_comment = false;
353 output.push('\n');
354 continue;
355 }
356 if in_comment {
357 output.push(' ');
358 continue;
359 }
360 if character == '\'' {
361 output.push(character);
362 if in_quote && chars.peek() == Some(&'\'') {
363 output.push(chars.next().unwrap_or('\''));
364 continue;
365 }
366 if !in_quote {
367 quote_start_line = line;
368 }
369 in_quote = !in_quote;
370 continue;
371 }
372 if character == '!' && !in_quote {
373 in_comment = true;
374 output.push(' ');
375 continue;
376 }
377 output.push(character);
378 }
379
380 if in_quote {
381 return Err(DyrError::UnterminatedQuote {
382 line: quote_start_line,
383 });
384 }
385 Ok(output)
386}
387
388fn parse_record(raw: &str, line: usize) -> Result<DynamicRecord, DyrError> {
389 let fields = split_fields(raw, line)?;
390 if fields.len() < 2 || fields[1].is_empty() {
391 return Err(DyrError::MissingModelName { line });
392 }
393 let bus_id = if raw.trim_start().starts_with('\'') {
396 None
397 } else {
398 Some(
399 fields[0]
400 .parse::<u32>()
401 .map_err(|_| DyrError::InvalidBusId {
402 line,
403 token: fields[0].clone(),
404 })?,
405 )
406 };
407 let key = fields[0].clone();
408 let model_name = fields[1].clone();
409 let device_id = fields.get(2).cloned().filter(|value| !value.is_empty());
410 let parameters = fields.into_iter().skip(3).collect();
411
412 Ok(DynamicRecord {
413 model_name,
414 key,
415 bus_id,
416 device_id,
417 parameters,
418 line,
419 raw_record: raw.to_owned(),
420 })
421}
422
423fn split_fields(raw: &str, line: usize) -> Result<Vec<String>, DyrError> {
424 let mut fields = Vec::new();
425 let mut token_start = None;
426 let mut in_quote = false;
427 let mut pending_comma = false;
428 let bytes = raw.as_bytes();
429 let mut index = 0;
430
431 while index < bytes.len() {
432 match bytes[index] {
433 b'\'' => {
434 if token_start.is_none() {
435 token_start = Some(index);
436 pending_comma = false;
437 }
438 if in_quote && index + 1 < bytes.len() && bytes[index + 1] == b'\'' {
439 index += 2;
440 continue;
441 }
442 in_quote = !in_quote;
443 index += 1;
444 }
445 b',' if !in_quote => {
446 if let Some(start) = token_start.take() {
447 push_decoded_field(&mut fields, &raw[start..index], line)?;
448 } else if pending_comma || fields.is_empty() {
449 push_decoded_field(&mut fields, "", line)?;
450 }
451 pending_comma = true;
452 index += 1;
453 }
454 byte if byte.is_ascii_whitespace() && !in_quote => {
455 if let Some(start) = token_start.take() {
456 push_decoded_field(&mut fields, &raw[start..index], line)?;
457 pending_comma = false;
458 }
459 index += 1;
460 }
461 _ => {
462 if token_start.is_none() {
463 token_start = Some(index);
464 pending_comma = false;
465 }
466 index += 1;
467 }
468 }
469 }
470
471 if in_quote {
472 return Err(DyrError::UnterminatedQuote { line });
473 }
474 if let Some(start) = token_start {
475 push_decoded_field(&mut fields, &raw[start..], line)?;
476 } else if pending_comma {
477 push_decoded_field(&mut fields, "", line)?;
478 }
479 Ok(fields)
480}
481
482fn push_decoded_field(fields: &mut Vec<String>, raw: &str, line: usize) -> Result<(), DyrError> {
483 fields.push(decode_field(raw, line)?);
484 if fields.len() > MAX_FIELDS_PER_RECORD {
485 return Err(DyrError::TooManyFields {
486 line,
487 maximum: MAX_FIELDS_PER_RECORD,
488 });
489 }
490 Ok(())
491}
492
493fn decode_field(raw: &str, line: usize) -> Result<String, DyrError> {
494 let token = raw.trim();
495 if token.len() > MAX_FIELD_BYTES {
496 return Err(DyrError::FieldTooLong {
497 line,
498 maximum: MAX_FIELD_BYTES,
499 });
500 }
501 if token.len() >= 2 && token.starts_with('\'') && token.ends_with('\'') {
502 Ok(token[1..token.len() - 1].replace("''", "'"))
503 } else {
504 Ok(token.to_owned())
505 }
506}
507
508#[cfg(test)]
509mod tests {
510 use super::*;
511
512 #[test]
513 fn parses_known_and_unknown_models_and_multiline_records() {
514 let source = "! machine models\n\
515 1, 'GENCLS', '1', 3.5, 0.2 /\n\
516 1, 'USERMODEL', '2', 'alpha, beta', 0.4 /\n\
517 2, 'EXDC1', '1', 0.1,\n\
518 0.2, 5.0 /\n";
519 let case = parse_dyr(source).expect("valid DYR");
520 assert_eq!(case.records().len(), 3);
521 assert_eq!(case.records()[0].model_name(), "GENCLS");
522 assert_eq!(case.records()[0].bus_id(), Some(1));
523 assert_eq!(case.records()[0].device_id(), Some("1"));
524 assert_eq!(case.records()[0].parameters(), ["3.5", "0.2"]);
525 assert_eq!(case.records()[1].model_name(), "USERMODEL");
526 assert_eq!(case.records()[1].parameters()[0], "alpha, beta");
527 assert_eq!(case.records()[2].parameters(), ["0.1", "0.2", "5.0"]);
528 assert_eq!(case.to_dyr_string(), source);
529 }
530
531 #[test]
532 fn quoted_slash_and_comment_markers_do_not_end_a_record() {
533 let source = "1, 'USER/EXC', '1', 'not ! a comment', 1.0 / ! tail\n";
534 let case = parse_dyr(source).expect("quoted delimiters");
535 assert_eq!(case.records()[0].model_name(), "USER/EXC");
536 assert_eq!(case.records()[0].parameters()[0], "not ! a comment");
537 assert_eq!(case.to_dyr_string(), source);
538 }
539
540 #[test]
541 fn preserves_empty_parameter_slots() {
542 let case = parse_dyr("1, 'MODEL', '1', 1.0, , 3.0 /\n").unwrap();
543 assert_eq!(case.records()[0].parameters(), ["1.0", "", "3.0"]);
544 }
545
546 #[test]
547 fn reports_record_start_lines_after_blank_lines() {
548 let case = parse_dyr("\n1, 'GENCLS', '1', 3.5 /\n\n2, 'EXDC1', '1', 1.0 /\n").unwrap();
549 assert_eq!(case.records()[0].line(), 2);
550 assert_eq!(case.records()[1].line(), 4);
551 }
552
553 #[test]
554 fn reports_unresolved_bus_references() {
555 let case = parse_dyr("1, 'GENCLS', '1', 3.5, 0.2 /\n2, 'EXDC1', '1', 1.0 /\n").unwrap();
556 let ids = BTreeSet::from([1]);
557 let unresolved = case.unresolved_buses(&ids);
558 assert_eq!(unresolved.len(), 1);
559 assert_eq!(unresolved[0].bus_id, 2);
560 assert_eq!(unresolved[0].model_name, "EXDC1");
561 }
562
563 #[test]
564 fn rejects_truncated_records_and_bad_bus_ids() {
565 assert!(matches!(
566 parse_dyr("1, 'GENCLS', '1', 3.5"),
567 Err(DyrError::UnterminatedRecord { .. })
568 ));
569 assert!(matches!(
570 parse_dyr("bus, 'GENCLS', '1', 3.5 /"),
571 Err(DyrError::InvalidBusId { .. })
572 ));
573 }
574
575 #[test]
576 fn quoted_device_keys_are_names_not_buses() {
577 let source = "'DC1' 'CDC4T' 0.1 0.2 /\n\
580 '1', 'USRDCL', 'CHVDC2U1', 18, 0.5 /\n\
581 3, 'GENCLS', '1', 3.5 /\n";
582 let case = parse_dyr(source).expect("named dc line records parse");
583 let records = case.records();
584 assert_eq!((records[0].key(), records[0].bus_id()), ("DC1", None));
585 assert_eq!(records[0].model_name(), "CDC4T");
586 assert_eq!((records[1].key(), records[1].bus_id()), ("1", None));
587 assert_eq!((records[2].key(), records[2].bus_id()), ("3", Some(3)));
588 let unresolved = case.unresolved_buses(&BTreeSet::from([1]));
590 assert_eq!(unresolved.len(), 1);
591 assert_eq!(unresolved[0].bus_id, 3);
592 assert_eq!(case.to_dyr_string(), source);
593 }
594
595 #[test]
596 fn rejects_unterminated_quotes_and_invalid_utf8() {
597 assert!(matches!(
598 parse_dyr("1, 'GENCLS, '1', 3.5 /"),
599 Err(DyrError::UnterminatedQuote { .. })
600 ));
601 assert_eq!(parse_dyr_bytes(&[0xff]), Err(DyrError::InvalidUtf8));
602 }
603
604 #[test]
605 fn enforces_input_size_limit_before_parsing() {
606 let text = " ".repeat(MAX_DYR_BYTES + 1);
607 assert!(matches!(
608 parse_dyr(&text),
609 Err(DyrError::InputTooLarge { .. })
610 ));
611 }
612 #[test]
613 fn accepts_comma_whitespace_mixed_tab_and_multiline_dyr_records() {
614 let cases = [
615 "1, 'GENCLS', '1', 13.0000, 0.000000 /",
616 "1 'GENCLS' '1' 13.0000 0.000000 /",
617 "1, 'GENCLS', '1', 13.0000 0.000000 /",
618 "1\t'GENCLS'\t'1'\t13.0000\t0.000000 /",
619 "1, 'GENCLS',\n '1', 13.0000\n 0.000000 /",
620 ];
621 for source in cases {
622 let case = parse_dyr(source).unwrap_or_else(|error| panic!("{source:?}: {error}"));
623 assert_eq!(case.records().len(), 1, "{source:?}");
624 let record = &case.records()[0];
625 assert_eq!(record.bus_id(), Some(1), "{source:?}");
626 assert_eq!(record.model_name(), "GENCLS", "{source:?}");
627 assert_eq!(record.device_id(), Some("1"), "{source:?}");
628 assert_eq!(record.parameters(), ["13.0000", "0.000000"], "{source:?}");
629 assert_eq!(case.to_dyr_string(), source);
630 }
631 }
632
633 #[test]
634 fn quoted_whitespace_commas_and_explicit_empty_fields_are_preserved() {
635 let source = "1, 'USERMODEL', '1', 'alpha, beta', 1.0, , 3.0 /";
636 let case = parse_dyr(source).unwrap();
637 assert_eq!(case.records()[0].model_name(), "USERMODEL");
638 assert_eq!(
639 case.records()[0].parameters(),
640 ["alpha, beta", "1.0", "", "3.0"]
641 );
642 assert_eq!(case.to_dyr_string(), source);
643
644 let quoted = "1, 'USERMODEL', '1', 'O''Brien, alpha', 1.0 /";
645 let case = parse_dyr(quoted).unwrap();
646 assert_eq!(case.records()[0].parameters(), ["O'Brien, alpha", "1.0"]);
647 assert_eq!(case.to_dyr_string(), quoted);
648 }
649
650 #[test]
651 fn long_blank_and_comment_prefixes_are_scanned_once() {
652 let source = format!(
653 "{}{}1 'GENCLS' '1' 13.0 0.0 /\n",
654 "\n".repeat(100_000),
655 "! comment\n".repeat(100_000)
656 );
657 let case = parse_dyr(&source).expect("long blank/comment prefix");
658 assert_eq!(case.records().len(), 1);
659 assert_eq!(case.records()[0].line(), 200_001);
660 }
661
662 #[test]
663 fn paired_dynamic_study_checks_bus_ids_but_keeps_device_keys_opaque() {
664 let network = crate::BalancedNetwork::in_memory(
665 "static",
666 100.0,
667 vec![crate::Bus::new(crate::BusId(1), crate::BusType::Ref, 230.0)],
668 Vec::new(),
669 );
670 let dynamics =
671 parse_dyr("1, 'GENCLS', '1', 13.0, 0.0 /\n2, 'USERMODEL', 'CUSTOM-A', 1.0 /\n")
672 .expect("DYR parses");
673 let study = DynamicStudy::new(network, dynamics);
674 let unresolved = study.unresolved_buses();
675 assert_eq!(unresolved.len(), 1);
676 assert_eq!(unresolved[0].bus_id, 2);
677 assert_eq!(unresolved[0].model_name, "USERMODEL");
678 assert_eq!(study.dynamics().records()[1].device_id(), Some("CUSTOM-A"));
679 }
680}