1use std::fmt::Write as _;
14
15use serde::{Deserialize, Serialize};
16
17use crate::curve::ThrustCurve;
18use crate::delay::DelayList;
19use crate::error::MotorError;
20use crate::text::{ParseWarning, Parsed, WarningKind, check_writable, finite};
21
22const FORMAT: &str = ".eng";
23
24#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
26pub struct EngFile {
27 pub entries: Vec<EngEntry>,
29 pub trailing_comments: Vec<String>,
31}
32
33#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
35pub struct EngEntry {
36 pub comments: Vec<String>,
38 pub name: String,
40 pub diameter_mm: f64,
42 pub length_mm: f64,
44 pub delays: String,
46 pub propellant_mass_kg: f64,
48 pub total_mass_kg: f64,
50 pub manufacturer: String,
52 pub points: Vec<(f64, f64)>,
54}
55
56impl EngEntry {
57 pub fn delays(&self) -> DelayList {
59 DelayList::parse(&self.delays)
60 }
61
62 pub fn thrust_curve(&self) -> Result<ThrustCurve, MotorError> {
69 let (times, thrusts) = self.points.iter().copied().unzip();
70 ThrustCurve::new(times, thrusts)
71 }
72}
73
74enum State {
76 Header,
78 Points(EngEntry, usize),
80 Skip,
82}
83
84fn is_stamp(comment: &str) -> bool {
89 let text = comment.trim();
90 text.strip_prefix(hpr_core::tool::NAME)
91 .is_some_and(|rest| rest.starts_with(' '))
92 && text.ends_with(hpr_core::tool::DESIGNATION)
93}
94
95pub fn parse(text: &str) -> Result<Parsed<EngFile>, MotorError> {
109 let text = text.strip_prefix('\u{feff}').unwrap_or(text);
110 let text = text.replace("\r\n", "\n").replace('\r', "\n");
111 let mut entries = Vec::new();
112 let mut warnings = Vec::new();
113 let mut errors = Vec::new();
114 let mut comments = Vec::new();
115 let mut state = State::Header;
116
117 for (index, raw) in text.split('\n').enumerate() {
118 let line = index + 1;
119 let trimmed = raw.trim();
120 if trimmed.is_empty() {
121 continue;
122 }
123 if let Some(comment) = trimmed.strip_prefix(';') {
124 if let State::Points(entry, header_line) = std::mem::replace(&mut state, State::Header)
125 {
126 match finish(entry, header_line, &mut warnings) {
127 Ok(entry) => entries.push(entry),
128 Err(error) => errors.push(error),
129 }
130 }
131 if !comment.is_empty() && !is_stamp(comment) {
132 comments.push(comment.to_owned());
133 }
134 continue;
135 }
136 let fields: Vec<&str> = trimmed.split_whitespace().collect();
137 let next = match std::mem::replace(&mut state, State::Skip) {
138 State::Skip => Ok(State::Skip),
139 State::Header => header(&fields, std::mem::take(&mut comments), line, &mut warnings)
140 .map(|entry| State::Points(entry, line)),
141 State::Points(mut entry, header_line) => match fields.len() {
142 2 => {
143 add_point(&mut entry, &fields, line).map(|()| State::Points(entry, header_line))
144 }
145 n if n >= 7 && !fields.iter().all(|field| field.parse::<f64>().is_ok()) => {
147 match finish(entry, header_line, &mut warnings) {
148 Ok(entry) => entries.push(entry),
149 Err(error) => errors.push(error),
150 }
151 warnings.push(ParseWarning::new(
152 line,
153 WarningKind::Unusual,
154 "a new header with no comment line before it",
155 ));
156 header(&fields, std::mem::take(&mut comments), line, &mut warnings)
157 .map(|entry| State::Points(entry, line))
158 }
159 _ => Err(syntax(
160 line,
161 format!("expected `time thrust` or a header, found {trimmed:?}"),
162 )),
163 },
164 };
165 state = next.unwrap_or_else(|error| {
166 errors.push(error);
167 comments.clear();
168 State::Skip
169 });
170 }
171 if let State::Points(entry, header_line) = state {
172 match finish(entry, header_line, &mut warnings) {
173 Ok(entry) => entries.push(entry),
174 Err(error) => errors.push(error),
175 }
176 }
177 if entries.is_empty() {
178 return Err(errors
179 .into_iter()
180 .next()
181 .unwrap_or_else(|| syntax(1, "no motor entries".into())));
182 }
183 for error in errors {
184 let line = match &error {
185 MotorError::Syntax { line, .. } => *line,
186 _ => 0,
187 };
188 warnings.push(ParseWarning::new(
189 line,
190 WarningKind::Skipped,
191 format!("entry skipped: {error}"),
192 ));
193 }
194 warnings.sort_by_key(|warning| warning.line);
195 Ok(Parsed {
196 value: EngFile {
197 entries,
198 trailing_comments: comments,
199 },
200 warnings,
201 })
202}
203
204fn add_point(entry: &mut EngEntry, fields: &[&str], line: usize) -> Result<(), MotorError> {
205 let time = number(fields[0], "time", line)?;
206 let thrust = number(fields[1], "thrust", line)?;
207 point(entry, time, thrust, line)
208}
209
210pub fn write(file: &EngFile) -> Result<String, MotorError> {
227 if file.entries.is_empty() {
228 return Err(MotorError::Inconsistent(
229 "a .eng file needs an entry".into(),
230 ));
231 }
232 let mut out = String::new();
233 write_comment(&mut out, &format!(" {}", hpr_core::tool::stamp()))?;
234 for entry in &file.entries {
235 for comment in entry.comments.iter().filter(|comment| !is_stamp(comment)) {
236 write_comment(&mut out, comment)?;
237 }
238 let one_token = |field: &str| !field.is_empty() && !field.contains(char::is_whitespace);
241 let words = entry
242 .manufacturer
243 .split_whitespace()
244 .collect::<Vec<_>>()
245 .join(" ");
246 if !one_token(&entry.name) || entry.name.starts_with(';') {
247 return Err(MotorError::Inconsistent(format!(
248 "the .eng name {:?} must be one token that doesn't start with `;`",
249 entry.name
250 )));
251 }
252 if !one_token(&entry.delays) {
253 return Err(MotorError::Inconsistent(format!(
254 "the .eng delays {:?} must be one token",
255 entry.delays
256 )));
257 }
258 if entry.manufacturer.is_empty() || words != entry.manufacturer {
259 return Err(MotorError::Inconsistent(format!(
260 "the .eng manufacturer {:?} must be words separated by single spaces",
261 entry.manufacturer
262 )));
263 }
264 check_envelope(entry)?;
265 if entry.points.is_empty() {
266 return Err(MotorError::Inconsistent(format!(
267 "the .eng entry {:?} has no points",
268 entry.name
269 )));
270 }
271 let mut previous = 0.0;
272 for &(time, thrust) in &entry.points {
273 check_writable(time, "time (s)", true)?;
274 check_writable(thrust, "thrust (N)", false)?;
275 if time < previous {
276 return Err(MotorError::Domain {
277 what: "time (s), which decreases",
278 value: time,
279 });
280 }
281 previous = time;
282 }
283 let _ = writeln!(
285 out,
286 "{} {} {} {} {} {} {}",
287 entry.name,
288 entry.diameter_mm,
289 entry.length_mm,
290 entry.delays,
291 entry.propellant_mass_kg,
292 entry.total_mass_kg,
293 entry.manufacturer
294 );
295 for (time, thrust) in &entry.points {
296 let _ = writeln!(out, " {time} {thrust}");
297 }
298 out.push_str(";\n");
299 }
300 for comment in file
301 .trailing_comments
302 .iter()
303 .filter(|comment| !is_stamp(comment))
304 {
305 write_comment(&mut out, comment)?;
306 }
307 Ok(out)
308}
309
310fn write_comment(out: &mut String, comment: &str) -> Result<(), MotorError> {
311 if comment.trim().is_empty()
312 || comment.contains(['\n', '\r'])
313 || comment.ends_with(char::is_whitespace)
314 {
315 return Err(MotorError::Inconsistent(format!(
316 "the .eng comment {comment:?} must be non-empty, one line, without trailing whitespace"
317 )));
318 }
319 out.push(';');
320 out.push_str(comment);
321 out.push('\n');
322 Ok(())
323}
324
325fn header(
326 fields: &[&str],
327 comments: Vec<String>,
328 line: usize,
329 warnings: &mut Vec<ParseWarning>,
330) -> Result<EngEntry, MotorError> {
331 if fields.len() < 7 {
332 return Err(syntax(
333 line,
334 format!(
335 "a header needs 7 fields (name, diameter, length, delays, propellant mass, total \
336 mass, manufacturer); found {}",
337 fields.len()
338 ),
339 ));
340 }
341 if fields.len() > 7 {
342 warnings.push(ParseWarning::new(
343 line,
344 WarningKind::Unusual,
345 format!(
346 "the header has {} fields; fields 7 onward are read as the manufacturer",
347 fields.len()
348 ),
349 ));
350 }
351 let entry = EngEntry {
352 comments,
353 name: fields[0].to_owned(),
354 diameter_mm: number(fields[1], "diameter", line)?,
355 length_mm: number(fields[2], "length", line)?,
356 delays: fields[3].to_owned(),
357 propellant_mass_kg: number(fields[4], "propellant mass", line)?,
358 total_mass_kg: number(fields[5], "total mass", line)?,
359 manufacturer: fields[6..].join(" "),
360 points: Vec::new(),
361 };
362 check_envelope(&entry).map_err(|error| syntax(line, error.to_string()))?;
363 Ok(entry)
364}
365
366fn check_envelope(entry: &EngEntry) -> Result<(), MotorError> {
368 for (value, what) in [
369 (entry.diameter_mm, "diameter (mm)"),
370 (entry.length_mm, "length (mm)"),
371 ] {
372 if !(value.is_finite() && value > 0.0) {
373 return Err(MotorError::Domain { what, value });
374 }
375 }
376 check_writable(entry.propellant_mass_kg, "propellant mass (kg)", true)?;
377 check_writable(entry.total_mass_kg, "total mass (kg)", true)
378}
379
380fn point(entry: &mut EngEntry, time: f64, thrust: f64, line: usize) -> Result<(), MotorError> {
381 if time < 0.0 {
382 return Err(syntax(line, format!("negative time {time}")));
383 }
384 if let Some(&(previous, _)) = entry.points.last()
385 && time < previous
386 {
387 return Err(syntax(
388 line,
389 format!("time {time} s is before the previous point's {previous} s"),
390 ));
391 }
392 entry.points.push((time, thrust));
393 Ok(())
394}
395
396fn finish(
399 entry: EngEntry,
400 header_line: usize,
401 warnings: &mut Vec<ParseWarning>,
402) -> Result<EngEntry, MotorError> {
403 if entry.points.is_empty() {
404 return Err(syntax(
405 header_line,
406 format!("the entry {:?} has no points", entry.name),
407 ));
408 }
409 let mut warn = |kind: WarningKind, message: String| {
410 warnings.push(ParseWarning::new(
411 header_line,
412 kind,
413 format!("{}: {message}", entry.name),
414 ));
415 };
416 if entry.propellant_mass_kg >= entry.total_mass_kg {
417 warn(
418 WarningKind::Unusual,
419 format!(
420 "propellant mass {} kg is not below the total mass {} kg",
421 entry.propellant_mass_kg, entry.total_mass_kg
422 ),
423 );
424 }
425 for warning in entry.delays().warnings {
426 warn(warning.kind, warning.message);
427 }
428 if let Some(&(_, last)) = entry.points.last()
429 && last != 0.0
430 {
431 warn(
432 WarningKind::Unusual,
433 format!("the curve ends at {last} N, not zero"),
434 );
435 }
436 if entry.points.iter().any(|&(_, thrust)| thrust < 0.0) {
437 warn(WarningKind::Unusual, "the curve has negative thrust".into());
438 }
439 Ok(entry)
440}
441
442fn number(text: &str, what: &'static str, line: usize) -> Result<f64, MotorError> {
443 finite(text, what).map_err(|_| syntax(line, format!("can't read the {what} {text:?}")))
444}
445
446fn syntax(line: usize, message: String) -> MotorError {
447 MotorError::Syntax {
448 format: FORMAT,
449 line,
450 message,
451 }
452}
453
454#[cfg(test)]
455pub(crate) mod tests {
456 use proptest::prelude::*;
457
458 use super::*;
459 use crate::delay::Delay;
460
461 const TWO_MOTORS: &str = "\
462; Two motors in one file.
463A8 18 70 3-5 0.00312 0.01642 Estes
464 0.041 0.512
465 0.3 3.12
466 0.73 0
467;
468; second
469C6 18 70 0-3-5-7 .0108 0.0231 E
470\t0.031\t0.946
471 1.86 0
472";
473
474 #[test]
477 fn multiple_blocks_parse_separately() {
478 let parsed = parse(TWO_MOTORS).unwrap();
479 let file = &parsed.value;
480 assert_eq!(file.entries.len(), 2);
481 let (a8, c6) = (&file.entries[0], &file.entries[1]);
482 assert_eq!(a8.comments, [" Two motors in one file."]);
483 assert_eq!(a8.name, "A8");
484 assert_eq!(a8.points, [(0.041, 0.512), (0.3, 3.12), (0.73, 0.0)]);
485 assert_eq!(c6.comments, [" second"]);
486 assert_eq!(c6.name, "C6");
487 assert_eq!(c6.propellant_mass_kg, 0.0108);
488 assert_eq!(c6.manufacturer, "E");
489 assert_eq!(c6.points, [(0.031, 0.946), (1.86, 0.0)]);
490 let unseparated = "A8 18 70 3 0.003 0.016 Estes\n 0.1 1\n 0.2 0\nB4 18 70 4 0.006 0.02 E\n 0.1 2\n 0.3 0\n";
492 let parsed = parse(unseparated).unwrap();
493 assert_eq!(parsed.value.entries.len(), 2);
494 assert_eq!(parsed.value.entries[1].points, [(0.1, 2.0), (0.3, 0.0)]);
495 assert!(parsed.warnings.iter().any(|w| w.line == 4));
496 }
497
498 #[test]
501 fn delay_lists_and_plugged_markers_parse() {
502 let delays = |raw: &str| {
503 let text = format!("M1 54 400 {raw} 1 2 X\n 0.5 100\n 1 0\n");
504 parse(&text).unwrap().value.entries[0].delays().delays
505 };
506 let s = Delay::Seconds;
507 assert_eq!(delays("6-10-14"), [s(6.0), s(10.0), s(14.0)]);
508 assert_eq!(delays("5,8,11"), [s(5.0), s(8.0), s(11.0)]);
509 assert_eq!(delays("P"), [Delay::Plugged]);
510 assert_eq!(
511 delays("6-10-14-P"),
512 [s(6.0), s(10.0), s(14.0), Delay::Plugged]
513 );
514 assert_eq!(delays("100"), [Delay::Plugged]);
515 assert_eq!(delays("1000"), [Delay::Plugged]);
516 assert_eq!(delays("14-12-10"), [s(14.0), s(12.0), s(10.0)]);
517 let text = "M1 54 400 6-10-14-P 1 2 X\n 0.5 100\n 1 0\n";
519 let file = parse(text).unwrap().value;
520 assert_eq!(file.entries[0].delays, "6-10-14-P");
521 assert_eq!(parse(&write(&file).unwrap()).unwrap().value, file);
522 }
523
524 #[test]
526 fn rejects_non_monotonic_time() {
527 let text = "F1 29 100 P 0.03 0.08 X\n 0.1 20\n 0.5 25\n 0.4 10\n 0.9 0\n";
528 match parse(text) {
529 Err(MotorError::Syntax { line: 4, .. }) => {}
530 other => panic!("expected a syntax error on line 4, got {other:?}"),
531 }
532 assert!(parse("F1 29 100 P 0.03 0.08 X\n -0.1 20\n 0.9 0\n").is_err());
533 let step = parse("F1 29 100 P 0.03 0.08 X\n 0.1 20\n 0.5 20\n 0.5 0\n").unwrap();
535 let curve = step.value.entries[0].thrust_curve().unwrap();
536 assert_eq!(curve.times_s(), &[0.0, 0.1, 0.5, 0.5]);
537 let entry = EngEntry {
539 points: vec![(0.5, 1.0), (0.4, 0.0)],
540 ..step.value.entries[0].clone()
541 };
542 assert!(entry.thrust_curve().is_err());
543 assert!(
544 write(&EngFile {
545 entries: vec![entry],
546 trailing_comments: vec![]
547 })
548 .is_err()
549 );
550 }
551
552 #[test]
553 fn a_broken_entry_is_skipped_with_a_warning() {
554 let text = "; one\nA1 18 70 3 0.003 0.016 X\n 0.1 1\n 0.05 0\n;\n; two\nB4 18 70 4 0.006 0.02 X\n 0.1 2\n 0.3 0\n;\nC6 18 70 x 0.01 0.02 X\n";
555 let parsed = parse(text).unwrap();
556 assert_eq!(parsed.value.entries.len(), 1);
557 assert_eq!(parsed.value.entries[0].name, "B4");
558 assert_eq!(parsed.value.entries[0].comments, [" two"]);
559 let skipped: Vec<usize> = parsed
560 .warnings
561 .iter()
562 .filter(|w| w.message.starts_with("entry skipped"))
563 .map(|w| w.line)
564 .collect();
565 assert_eq!(skipped, [4, 11]);
566
567 let text = "A1 18 70 3 0.003 0.016 X\n 0.1 1\n 0.2 5 1 2 3 4 5\n 0.3 0\n;\nB4 18 70 4 0.006 0.02 X\n 0.1 2\n 0.3 0\n";
570 let parsed = parse(text).unwrap();
571 let names: Vec<&str> = parsed
572 .value
573 .entries
574 .iter()
575 .map(|e| e.name.as_str())
576 .collect();
577 assert_eq!(names, ["B4"]);
578 assert!(
579 parsed
580 .warnings
581 .iter()
582 .any(|w| w.line == 3 && w.kind == WarningKind::Skipped)
583 );
584
585 let text = "A8 18 70 3 0.003 0.016 E\n 0.1 1\n 0.2 0\n1266-J760-WT-19A 54 400 P 0.5 1 AT\n 0.1 900\n 1.5 0\n";
587 let parsed = parse(text).unwrap();
588 let names: Vec<&str> = parsed
589 .value
590 .entries
591 .iter()
592 .map(|e| e.name.as_str())
593 .collect();
594 assert_eq!(names, ["A8", "1266-J760-WT-19A"]);
595 assert!(
596 parsed
597 .warnings
598 .iter()
599 .all(|w| w.kind != WarningKind::Skipped)
600 );
601 }
602
603 #[test]
604 fn reads_real_world_whitespace_and_numbers() {
605 let text = "\u{feff};c\r\n H128W 29 194 6-10-14 .0906 0.1966 AT\r\n\t0.02\t156.\r\n 068.5 0\r\n;\r\n; trailer \r\n";
606 let parsed = parse(text).unwrap();
607 let entry = &parsed.value.entries[0];
608 assert_eq!(entry.propellant_mass_kg, 0.0906);
609 assert_eq!(entry.points, [(0.02, 156.0), (68.5, 0.0)]);
610 assert_eq!(parsed.value.trailing_comments, [" trailer"]);
611 let parsed = parse("K1 54 400 ;P 1 2 Contrail Rockets\n 1 1\n 2 0\n").unwrap();
613 assert_eq!(parsed.value.entries[0].manufacturer, "Contrail Rockets");
614 assert_eq!(parsed.value.entries[0].delays, ";P");
615 assert_eq!(parsed.warnings.len(), 2, "{:?}", parsed.warnings);
616 let written = write(&parsed.value).unwrap();
617 assert_eq!(parse(&written).unwrap().value, parsed.value);
618 let mut spaced = parsed.value.clone();
619 spaced.entries[0].manufacturer = "Contrail Rockets".into();
620 assert!(
621 write(&spaced).is_err(),
622 "a double space would read back as one"
623 );
624 let parsed = parse("K1 54 4e2 P +1 2E0 X\n 1e-1 1.5e3\n 2 0\n").unwrap();
626 assert_eq!(parsed.value.entries[0].length_mm, 400.0);
627 assert_eq!(parsed.value.entries[0].points[0], (0.1, 1500.0));
628 }
629
630 #[test]
631 fn rejects_malformed_files() {
632 let bad = [
633 "",
634 "; only a comment\n",
635 "F1 29 100 P 0.03 0.08\n 0.1 20\n",
636 "F1 29 100 P 0.03 0.08 X\n",
637 "F1 29 100 P 0.03 0.08 X\n;\n 0.1 20\n",
638 "F1 29 100 P 0.03 0.08 X\n 0.1 20 3\n",
639 "F1 29 100 P 0.03 0.08 X\n 0.1 inf\n",
640 "F1 29 100 P 0.03 0.08 X\n NaN 20\n",
641 "F1 0 100 P 0.03 0.08 X\n 0.1 20\n",
642 "F1 29 100 P -0.03 0.08 X\n 0.1 20\n",
643 "F1 29 1e999 P 0.03 0.08 X\n 0.1 20\n",
644 ];
645 for text in bad {
646 assert!(parse(text).is_err(), "{text:?}");
647 }
648 }
649
650 #[test]
651 fn warns_about_accepted_oddities() {
652 let parsed = parse("F1 29 100 4-,6 0.09 0.08 X\n 0.1 20\n 0.2 5\n").unwrap();
653 let messages: Vec<&str> = parsed.warnings.iter().map(|w| w.message.as_str()).collect();
654 assert_eq!(messages.len(), 3, "{messages:?}");
655 }
656
657 #[test]
658 fn writes_what_it_reads() {
659 let file = parse(TWO_MOTORS).unwrap().value;
660 let text = write(&file).unwrap();
661 assert_eq!(parse(&text).unwrap().value, file);
662 assert_eq!(write(&parse(&text).unwrap().value).unwrap(), text);
663 let mut signed = file.clone();
665 signed.entries[0].points[2].1 = -0.0;
666 let back = parse(&write(&signed).unwrap()).unwrap().value;
667 assert!(back.entries[0].points[2].1.is_sign_negative());
668 }
669
670 #[test]
675 fn the_first_line_names_the_program_and_stamps_do_not_pile_up() {
676 let file = parse(TWO_MOTORS).unwrap().value;
677 let text = write(&file).unwrap();
678 let first = format!(
679 "; hpr-sim {} · FS · SW · TOOL 005\n",
680 env!("CARGO_PKG_VERSION")
681 );
682 assert!(text.starts_with(&first), "{text}");
683 assert_eq!(first, format!("; {}\n", hpr_core::tool::stamp()));
684 assert_eq!(text.matches("TOOL 005").count(), 1);
685 let read = parse(&text).unwrap();
686 let messages = |warnings: &[ParseWarning]| {
688 warnings
689 .iter()
690 .map(|w| (w.kind, w.message.clone()))
691 .collect::<Vec<_>>()
692 };
693 assert_eq!(
694 messages(&read.warnings),
695 messages(&parse(TWO_MOTORS).unwrap().warnings)
696 );
697 assert_eq!(read.value, file);
698
699 let mut dotted = file.clone();
701 dotted.entries[0]
702 .comments
703 .push(" 2 · 3 s burn, by hpr-sim".to_owned());
704 dotted.entries[1]
705 .comments
706 .push(" FS · SW · TOOL 005".to_owned());
707 let back = parse(&write(&dotted).unwrap()).unwrap().value;
708 assert_eq!(back, dotted);
709
710 let mut stamped = file.clone();
712 stamped.entries[0]
713 .comments
714 .insert(0, " hpr-sim 0.0.9 · FS · SW · TOOL 005".to_owned());
715 stamped.entries[1]
716 .comments
717 .push(format!(" {}", hpr_core::tool::stamp()));
718 stamped
719 .trailing_comments
720 .push("hpr-sim 9.9 · FS · SW · TOOL 005".to_owned());
721 assert_eq!(write(&stamped).unwrap(), text);
722 let earlier = format!("; hpr-sim 0.0.9 · FS · SW · TOOL 005\n{TWO_MOTORS}");
724 let earlier = parse(&earlier).unwrap().value;
725 assert_eq!(earlier, file);
726 assert_eq!(write(&earlier).unwrap(), text);
727 }
728
729 pub(crate) fn bits(file: &EngFile) -> Vec<u64> {
731 file.entries
732 .iter()
733 .flat_map(|e| {
734 [
735 e.diameter_mm,
736 e.length_mm,
737 e.propellant_mass_kg,
738 e.total_mass_kg,
739 ]
740 .into_iter()
741 .chain(e.points.iter().flat_map(|&(t, f)| [t, f]))
742 .map(f64::to_bits)
743 .collect::<Vec<_>>()
744 })
745 .collect()
746 }
747
748 fn token() -> impl Strategy<Value = String> {
749 "[A-Za-z0-9_./()-]{1,12}".prop_filter("not a comment", |s| !s.starts_with(';'))
750 }
751
752 fn comment() -> impl Strategy<Value = String> {
753 "[ -~]{0,20}[!-~]".prop_map(|s| s)
754 }
755
756 fn masses() -> impl Strategy<Value = f64> {
757 prop_oneof![Just(-0.0), Just(0.0), 1e-300..1e3f64]
758 }
759
760 fn entries() -> impl Strategy<Value = EngEntry> {
761 (
762 prop::collection::vec(comment(), 0..3),
763 (token(), token(), "[A-Za-z;]{1,6}( [A-Za-z0-9;]{1,6}){0,2}"),
764 (1e-3..1e4f64, 1e-3..1e5f64, masses(), masses()),
765 prop::collection::vec(
766 (
767 prop_oneof![Just(-0.0), 0.0..10.0f64],
768 prop_oneof![Just(-0.0), -1.0..1e5f64],
769 ),
770 1..30,
771 ),
772 )
773 .prop_map(
774 |(comments, (name, delays, manufacturer), (d, l, p, t), mut points)| {
775 points.sort_by(|a, b| a.0.total_cmp(&b.0));
776 EngEntry {
777 comments,
778 name,
779 diameter_mm: d,
780 length_mm: l,
781 delays,
782 propellant_mass_kg: p,
783 total_mass_kg: t,
784 manufacturer,
785 points,
786 }
787 },
788 )
789 }
790
791 proptest! {
792 #[test]
793 fn write_parse_round_trips_bit_for_bit(
794 entries in prop::collection::vec(entries(), 1..4),
795 trailing_comments in prop::collection::vec(comment(), 0..2),
796 ) {
797 let file = EngFile { entries, trailing_comments };
798 let text = write(&file).unwrap();
799 let back = parse(&text).unwrap().value;
800 prop_assert_eq!(&back, &file);
801 prop_assert_eq!(bits(&back), bits(&file));
802 }
803 }
804}