Skip to main content

hpr_motor/
eng.rs

1//! RASP `.eng` motor files: reading and writing, built from the public spec
2//! (`docs/format/eng.md`).
3//!
4//! A file holds one or more entries. Each entry is optional `;` comments, a seven-field header
5//! (name, diameter in mm, length in mm, delays, propellant mass in kg, total mass in kg,
6//! manufacturer) and `time thrust` points, and entries are separated by comment lines.
7//!
8//! The file model keeps the file's units (mm and kg) and its points exactly as listed, with no
9//! implicit origin, so a read-write-read cycle reproduces every value bit for bit
10//! ([`EngEntry::thrust_curve`] adds the origin). The reader is lenient and reports what it
11//! accepted as [`ParseWarning`]s; the writer refuses anything the reader would read differently.
12
13use 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/// A parsed `.eng` file.
25#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
26pub struct EngFile {
27    /// The entries, in file order.
28    pub entries: Vec<EngEntry>,
29    /// Comments after the last entry's data, without the leading `;`.
30    pub trailing_comments: Vec<String>,
31}
32
33/// One motor in a `.eng` file.
34#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
35pub struct EngEntry {
36    /// Comments before the header, without the leading `;`.
37    pub comments: Vec<String>,
38    /// The motor's name, such as `F32` or `1266-J760-WT-19A`.
39    pub name: String,
40    /// Casing diameter, mm.
41    pub diameter_mm: f64,
42    /// Casing length, mm.
43    pub length_mm: f64,
44    /// The delay string as written, such as `6-10-14` or `P`; see [`EngEntry::delays`].
45    pub delays: String,
46    /// Propellant mass, kg.
47    pub propellant_mass_kg: f64,
48    /// Loaded motor mass, kg.
49    pub total_mass_kg: f64,
50    /// Manufacturer abbreviation, such as `AT` or `Cesaroni_Technology`.
51    pub manufacturer: String,
52    /// The points as listed: `(time s, thrust N)`.
53    pub points: Vec<(f64, f64)>,
54}
55
56impl EngEntry {
57    /// The delay settings read from [`EngEntry::delays`].
58    pub fn delays(&self) -> DelayList {
59        DelayList::parse(&self.delays)
60    }
61
62    /// The entry's thrust curve, starting from the implicit `(0, 0)` when the first point is after
63    /// ignition.
64    ///
65    /// # Errors
66    ///
67    /// As [`ThrustCurve::new`]: negative thrust, decreasing time, or no thrust.
68    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
74/// Where the reader is within an entry.
75enum State {
76    /// Before a header: collecting comments.
77    Header,
78    /// After a header: reading points.
79    Points(EngEntry, usize),
80    /// After an error in an entry: skipping to the next comment line.
81    Skip,
82}
83
84/// Whether a comment's text is the line naming the program that wrote the file, as [`write`]
85/// puts first: `hpr-sim`, a version and the designation ([`hpr_core::tool::stamp`]), from this
86/// version or any other. It names who wrote the file, not anything about the motor, so the reader
87/// doesn't keep it and the writer doesn't carry an earlier one over.
88fn 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
95/// Reads a `.eng` file.
96///
97/// An entry with an error is skipped, with the error as a warning, when other entries in the file
98/// read. The reader resumes at the next comment line. A comment naming the program that wrote the
99/// file, the line [`write()`] puts first, ends an entry as any comment does but is not kept: so a
100/// file read, written and read again holds the same comments.
101///
102/// # Errors
103///
104/// When no entry reads, the first entry's error: [`MotorError::Syntax`], with the line number,
105/// for a header without seven fields, a bad number, a line that is neither a point nor a header,
106/// an entry with no points, negative or decreasing time, a non-finite value, a non-positive
107/// diameter or length, or a negative mass. A file with no entries at all is also an error.
108pub 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                // A header has a name and a manufacturer; a line of numbers is a corrupt point.
146                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
210/// Writes a `.eng` file: a first comment naming the program that wrote it, its version and its
211/// designation (`; hpr-sim 0.1.0 · FS · SW · TOOL 005`, [`hpr_core::tool::stamp`]); then each
212/// entry's comments, header and points, then a `;` separator; and the trailing comments last.
213/// A comment carried over that names an earlier program the same way, as a file hpr wrote before
214/// holds, is left out, so converting a file again writes one such line, not two. The reader
215/// doesn't keep that line ([`parse`]).
216///
217/// # Errors
218///
219/// - [`MotorError::Inconsistent`] for a name that is empty, holds whitespace or starts with `;`;
220///   delays that are empty or hold whitespace; a manufacturer that is empty or isn't its words
221///   joined by single spaces; a comment that is empty, holds a line break or ends in whitespace;
222///   an entry with no points; or a file with no entries. The reader would not read these back
223///   unchanged.
224/// - [`MotorError::Domain`] for the values the reader rejects: non-finite numbers, a
225///   non-positive diameter or length, negative masses or times, and decreasing times.
226pub 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        // The name starts the line, so `;` there would make it a comment. The reader joins the
239        // manufacturer's words with single spaces.
240        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        // `{}` prints the shortest digits that read back to the same f64, without an exponent.
284        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
366/// The header's dimensions must be positive and its masses non-negative.
367fn 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
396/// Ends an entry, warning about what the reader accepted but finds odd: masses, delays and the
397/// curve's end. The warnings point at the header line.
398fn 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    /// Loft lesson L36: Loft read only the first header and appended the next entry's points to
475    /// the first curve.
476    #[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        // A header straight after the data also starts a new entry, with a warning.
491        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    /// Loft lesson L37: Loft split delays on `-` only, lost `P` and comma lists, and read `100` and
499    /// `1000` (plugged markers) as seconds.
500    #[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        // The raw string survives for writing.
518        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    /// Loft lesson L39: Loft never checked that times increase.
525    #[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        // Equal consecutive times are a step, and are kept.
534        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        // The model refuses decreasing times too.
538        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        // A corrupt data line of seven numbers is not a header for a motor named "0.2": its entry
568        // is skipped, and reading resumes at the next comment.
569        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        // Names can start with digits: an unseparated header still starts a new entry.
586        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        // More than seven header fields: the rest is the manufacturer.
612        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        // Exponents and signs read.
625        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        // Negative zero keeps its sign bit.
664        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    /// The first line names the program, its version and its designation, as every file hpr
671    /// writes does. The reader takes the line, middle dots and all, as a comment and doesn't keep
672    /// it; a comment that only looks like part of one is kept; and a file holding earlier stamps,
673    /// read or built in code, is written with one stamp, this build's.
674    #[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        // The stamp adds no warning: the same ones as the source's, from its values.
687        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        // A comment with a middle dot that isn't a stamp reads and writes back as it was.
700        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        // Stamps of other versions, carried over in a file's comments, are left out.
711        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        // And a file written by an earlier version reads without its stamp.
723        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    /// Every f64 in a file, as bits: `==` alone treats `-0.0` as `0.0`.
730    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}