1use std::fmt::Write as _;
14
15use roxmltree::{Document, Node, ParsingOptions};
16use serde::{Deserialize, Serialize};
17
18use crate::curve::ThrustCurve;
19use crate::delay::DelayList;
20use crate::error::MotorError;
21use crate::text::{ParseWarning, Parsed, WarningKind, check_writable, finite};
22
23const FORMAT: &str = ".rse";
24
25const RENDERING: [&str; 12] = [
27 "tDiv", "tStep", "tFix", "FDiv", "FStep", "FFix", "mDiv", "mStep", "mFix", "cgDiv", "cgStep",
28 "cgFix",
29];
30
31#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
33pub struct RseFile {
34 pub engines: Vec<RseEngine>,
36}
37
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
40pub struct RseEngine {
41 pub manufacturer: String,
43 pub code: String,
45 pub motor_type: Option<String>,
47 pub diameter_mm: f64,
49 pub length_mm: f64,
51 pub initial_mass_g: f64,
53 pub propellant_mass_g: f64,
55 pub delays: Option<String>,
57 pub auto_calc_mass: Option<bool>,
59 pub auto_calc_cg: Option<bool>,
61 pub average_thrust_n: Option<f64>,
63 pub peak_thrust_n: Option<f64>,
65 pub throat_diameter_mm: Option<f64>,
67 pub exit_diameter_mm: Option<f64>,
69 pub total_impulse_ns: Option<f64>,
71 pub burn_time_s: Option<f64>,
73 pub mass_fraction_pct: Option<f64>,
75 pub isp_s: Option<f64>,
77 pub comments: Option<String>,
79 pub points: Vec<RsePoint>,
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
85pub struct RsePoint {
86 pub time_s: f64,
88 pub thrust_n: f64,
90 pub mass_g: Option<f64>,
92 pub cg_mm: Option<f64>,
94}
95
96impl RseEngine {
97 pub fn delays(&self) -> DelayList {
99 DelayList::parse(self.delays.as_deref().unwrap_or(""))
100 }
101
102 pub fn thrust_curve(&self) -> Result<ThrustCurve, MotorError> {
108 let (times, thrusts) = self.points.iter().map(|p| (p.time_s, p.thrust_n)).unzip();
109 ThrustCurve::new(times, thrusts)
110 }
111}
112
113pub fn parse(text: &str) -> Result<Parsed<RseFile>, MotorError> {
127 let text = text.strip_prefix('\u{feff}').unwrap_or(text);
128 check_nesting(text)?;
129 let options = ParsingOptions {
130 allow_dtd: false,
131 ..ParsingOptions::default()
132 };
133 let doc = Document::parse_with_options(text, options).map_err(|error| {
134 syntax(
135 error.pos().row as usize,
136 format!("not well-formed XML: {error}"),
137 )
138 })?;
139 let lines = Lines::new(text);
140 let mut warnings = Vec::new();
141 let mut engines = Vec::new();
142 let mut errors = Vec::new();
143 let is_engine = |node: &Node<'_, '_>| node.has_tag_name("engine");
144 for node in doc.descendants().filter(is_engine) {
145 if node
146 .ancestors()
147 .skip(1)
148 .any(|ancestor| is_engine(&ancestor))
149 {
150 warnings.push(ParseWarning::new(
151 lines.of(node),
152 WarningKind::Dropped,
153 "an <engine> inside another engine is ignored",
154 ));
155 continue;
156 }
157 let mut engine_warnings = Vec::new();
158 match engine(&lines, node, &mut engine_warnings) {
159 Ok(engine) => {
160 engines.push(engine);
161 warnings.append(&mut engine_warnings);
162 }
163 Err(error) => errors.push(error),
164 }
165 }
166 if engines.is_empty() {
167 return Err(errors
168 .into_iter()
169 .next()
170 .unwrap_or_else(|| syntax(1, "no <engine> elements".into())));
171 }
172 for error in errors {
173 let line = match &error {
174 MotorError::Syntax { line, .. } => *line,
175 _ => 0,
176 };
177 warnings.push(ParseWarning::new(
178 line,
179 WarningKind::Skipped,
180 format!("engine skipped: {error}"),
181 ));
182 }
183 warnings.sort_by_key(|warning| warning.line);
184 Ok(Parsed {
185 value: RseFile { engines },
186 warnings,
187 })
188}
189
190struct Lines {
193 starts: Vec<usize>,
194}
195
196impl Lines {
197 fn new(text: &str) -> Self {
198 let starts = std::iter::once(0)
199 .chain(text.match_indices('\n').map(|(i, _)| i + 1))
200 .collect();
201 Self { starts }
202 }
203
204 fn of(&self, node: Node<'_, '_>) -> usize {
206 self.starts
207 .partition_point(|&start| start <= node.range().start)
208 }
209}
210
211pub fn write(file: &RseFile) -> Result<String, MotorError> {
224 if file.engines.is_empty() {
225 return Err(MotorError::Inconsistent(
226 "a .rse file needs an engine".into(),
227 ));
228 }
229 let mut out = format!(
232 "<!-- {} -->\n<engine-database>\n <engine-list>\n",
233 hpr_core::tool::stamp()
234 );
235 for engine in &file.engines {
236 write_engine(&mut out, engine)?;
237 }
238 out.push_str(" </engine-list>\n</engine-database>\n");
239 Ok(out)
240}
241
242fn write_engine(out: &mut String, engine: &RseEngine) -> Result<(), MotorError> {
243 check_dimensions(engine.diameter_mm, engine.length_mm)?;
244 check_writable(engine.initial_mass_g, "initial mass (g)", true)?;
245 check_writable(engine.propellant_mass_g, "propellant mass (g)", true)?;
246 if engine.points.len() < 2 {
247 return Err(MotorError::Inconsistent(format!(
248 "the .rse engine {:?} needs at least two points",
249 engine.code
250 )));
251 }
252 let mut attributes: Vec<(&str, String)> = vec![
253 ("mfg", xml_text(&engine.manufacturer, true)?),
254 ("code", xml_text(&engine.code, true)?),
255 ];
256 let mut text = |name, value: &Option<String>| -> Result<(), MotorError> {
257 if let Some(value) = value {
258 attributes.push((name, xml_text(value, true)?));
259 }
260 Ok(())
261 };
262 text("Type", &engine.motor_type)?;
263 attributes.push(("dia", engine.diameter_mm.to_string()));
264 attributes.push(("len", engine.length_mm.to_string()));
265 attributes.push(("initWt", engine.initial_mass_g.to_string()));
266 attributes.push(("propWt", engine.propellant_mass_g.to_string()));
267 if let Some(delays) = &engine.delays {
268 attributes.push(("delays", xml_text(delays, true)?));
269 }
270 for (name, flag) in [
271 ("auto-calc-mass", engine.auto_calc_mass),
272 ("auto-calc-cg", engine.auto_calc_cg),
273 ] {
274 if let Some(flag) = flag {
275 attributes.push((name, if flag { "1" } else { "0" }.to_owned()));
276 }
277 }
278 for (name, value) in [
279 ("avgThrust", engine.average_thrust_n),
280 ("peakThrust", engine.peak_thrust_n),
281 ("throatDia", engine.throat_diameter_mm),
282 ("exitDia", engine.exit_diameter_mm),
283 ("Itot", engine.total_impulse_ns),
284 ("burn-time", engine.burn_time_s),
285 ("massFrac", engine.mass_fraction_pct),
286 ("Isp", engine.isp_s),
287 ] {
288 if let Some(value) = value {
289 check_writable(value, "optional .rse attribute", false)?;
290 attributes.push((name, value.to_string()));
291 }
292 }
293 out.push_str(" <engine");
294 for (name, value) in &attributes {
295 let _ = write!(out, " {name}=\"{value}\"");
296 }
297 out.push_str(">\n");
298 if let Some(comments) = &engine.comments {
299 let _ = writeln!(
300 out,
301 " <comments>{}</comments>",
302 xml_text(comments, false)?
303 );
304 }
305 out.push_str(" <data>\n");
306 let with_mass = engine.points.iter().filter(|p| p.mass_g.is_some()).count();
307 let with_cg = engine.points.iter().filter(|p| p.cg_mm.is_some()).count();
308 for (count, what) in [(with_mass, "m"), (with_cg, "cg")] {
309 if count != 0 && count != engine.points.len() {
310 return Err(MotorError::Inconsistent(format!(
311 "the .rse engine {:?} gives `{what}` on some points but not all",
312 engine.code
313 )));
314 }
315 }
316 let mut previous = 0.0;
317 for point in &engine.points {
318 check_writable(point.time_s, "time (s)", true)?;
319 check_writable(point.thrust_n, "thrust (N)", false)?;
320 if point.time_s < previous {
321 return Err(MotorError::Domain {
322 what: "time (s), which decreases",
323 value: point.time_s,
324 });
325 }
326 previous = point.time_s;
327 let _ = write!(
328 out,
329 " <eng-data t=\"{}\" f=\"{}\"",
330 point.time_s, point.thrust_n
331 );
332 if let Some(mass) = point.mass_g {
333 check_writable(mass, "point mass (g)", false)?;
334 let _ = write!(out, " m=\"{mass}\"");
335 }
336 if let Some(cg) = point.cg_mm {
337 check_writable(cg, "point CG (mm)", false)?;
338 let _ = write!(out, " cg=\"{cg}\"");
339 }
340 out.push_str("/>\n");
341 }
342 out.push_str(" </data>\n </engine>\n");
343 Ok(())
344}
345
346fn xml_text(value: &str, attribute: bool) -> Result<String, MotorError> {
350 let mut escaped = String::with_capacity(value.len());
351 for c in value.chars() {
352 match c {
353 '&' => escaped.push_str("&"),
354 '<' => escaped.push_str("<"),
355 '>' => escaped.push_str(">"),
356 '"' if attribute => escaped.push_str("""),
357 '\t' if attribute => escaped.push_str("	"),
358 '\n' if attribute => escaped.push_str(" "),
359 '\r' => escaped.push_str(" "),
360 '\t' | '\n' => escaped.push(c),
361 '\u{0}'..='\u{1f}' | '\u{fffe}' | '\u{ffff}' => {
363 return Err(MotorError::Inconsistent(format!(
364 "{value:?} holds a character XML 1.0 can't carry"
365 )));
366 }
367 _ => escaped.push(c),
368 }
369 }
370 Ok(escaped)
371}
372
373fn engine(
374 lines: &Lines,
375 node: Node<'_, '_>,
376 warnings: &mut Vec<ParseWarning>,
377) -> Result<RseEngine, MotorError> {
378 let line = lines.of(node);
379 for attribute in node.attributes() {
380 let name = attribute.name();
381 if !KNOWN.iter().any(|known| known.eq_ignore_ascii_case(name))
382 && !RENDERING
383 .iter()
384 .any(|known| known.eq_ignore_ascii_case(name))
385 {
386 warnings.push(ParseWarning::new(
387 line,
388 WarningKind::Dropped,
389 format!("unknown <engine> attribute `{name}` ignored"),
390 ));
391 }
392 }
393 let code = attr(node, &["code"]).ok_or_else(|| missing(line, "code"))?;
394 let manufacturer = match attr(node, &["mfg"]) {
395 Some(mfg) => mfg.to_owned(),
396 None => {
397 warnings.push(ParseWarning::new(
398 line,
399 WarningKind::Unusual,
400 format!("engine {code:?} has no `mfg`; read as empty"),
401 ));
402 String::new()
403 }
404 };
405 let required = |names: &[&'static str]| -> Result<f64, MotorError> {
406 let text = attr(node, names).ok_or_else(|| missing(line, names[0]))?;
407 number(text, names[0], line)
408 };
409 let optional = |name: &'static str| -> Result<Option<f64>, MotorError> {
410 attr(node, &[name])
411 .map(|text| number(text, name, line))
412 .transpose()
413 };
414 let diameter_mm = required(&["dia"])?;
415 let length_mm = required(&["len"])?;
416 check_dimensions(diameter_mm, length_mm).map_err(|e| syntax(line, e.to_string()))?;
417 let initial_mass_g = required(&["initWt", "initMass"])?;
418 let propellant_mass_g = required(&["propWt", "propMass"])?;
419 for (value, what) in [(initial_mass_g, "initWt"), (propellant_mass_g, "propWt")] {
420 if value < 0.0 {
421 return Err(syntax(line, format!("negative `{what}` {value}")));
422 }
423 }
424 if propellant_mass_g >= initial_mass_g && initial_mass_g > 0.0 {
425 warnings.push(ParseWarning::new(
426 line,
427 WarningKind::Unusual,
428 format!(
429 "engine {code:?}: propellant mass {propellant_mass_g} g is not below the loaded \
430 mass {initial_mass_g} g"
431 ),
432 ));
433 }
434 let mut flag = |name: &'static str| match attr(node, &[name]) {
435 None => None,
436 Some("1") => Some(true),
437 Some("0") => Some(false),
438 Some(other) => {
439 warnings.push(ParseWarning::new(
440 line,
441 WarningKind::Dropped,
442 format!("`{name}` is {other:?}, not 0 or 1; ignored"),
443 ));
444 None
445 }
446 };
447 let auto_calc_mass = flag("auto-calc-mass");
448 let auto_calc_cg = flag("auto-calc-cg");
449
450 let mut comments = None;
451 let mut data = None;
452 for child in node.children().filter(Node::is_element) {
453 let name = child.tag_name().name();
454 let duplicate = match name {
455 "comments" => comments
456 .replace(
457 child
460 .descendants()
461 .filter(Node::is_text)
462 .filter_map(|text| text.text())
463 .collect::<String>(),
464 )
465 .is_some(),
466 "data" => data.replace(child).is_some(),
467 other => {
468 warnings.push(ParseWarning::new(
469 lines.of(child),
470 WarningKind::Dropped,
471 format!("unknown element <{other}> in an engine ignored"),
472 ));
473 false
474 }
475 };
476 if duplicate {
477 warnings.push(ParseWarning::new(
478 lines.of(child),
479 WarningKind::Dropped,
480 format!("engine {code:?} has more than one <{name}>; the last is read"),
481 ));
482 }
483 }
484 let data = data.ok_or_else(|| syntax(line, format!("engine {code:?} has no <data>")))?;
485 let points = points(lines, data, code, warnings)?;
486
487 let engine = RseEngine {
488 manufacturer,
489 code: code.to_owned(),
490 motor_type: attr(node, &["Type"]).map(str::to_owned),
491 diameter_mm,
492 length_mm,
493 initial_mass_g,
494 propellant_mass_g,
495 delays: attr(node, &["delays"]).map(str::to_owned),
496 auto_calc_mass,
497 auto_calc_cg,
498 average_thrust_n: optional("avgThrust")?,
499 peak_thrust_n: optional("peakThrust")?,
500 throat_diameter_mm: optional("throatDia")?,
501 exit_diameter_mm: optional("exitDia")?,
502 total_impulse_ns: optional("Itot")?,
503 burn_time_s: optional("burn-time")?,
504 mass_fraction_pct: optional("massFrac")?,
505 isp_s: optional("Isp")?,
506 comments,
507 points,
508 };
509 summary_warnings(&engine, line, warnings);
510 Ok(engine)
511}
512
513const KNOWN: [&str; 20] = [
515 "mfg",
516 "code",
517 "Type",
518 "dia",
519 "len",
520 "initWt",
521 "initMass",
522 "propWt",
523 "propMass",
524 "delays",
525 "auto-calc-mass",
526 "auto-calc-cg",
527 "avgThrust",
528 "peakThrust",
529 "throatDia",
530 "exitDia",
531 "Itot",
532 "burn-time",
533 "massFrac",
534 "Isp",
535];
536
537fn points(
538 lines: &Lines,
539 data: Node<'_, '_>,
540 code: &str,
541 warnings: &mut Vec<ParseWarning>,
542) -> Result<Vec<RsePoint>, MotorError> {
543 let mut points: Vec<RsePoint> = Vec::new();
544 for node in data.children().filter(Node::is_element) {
545 let line = lines.of(node);
546 if !(node.has_tag_name("eng-data") || node.has_tag_name("point")) {
547 warnings.push(ParseWarning::new(
548 line,
549 WarningKind::Dropped,
550 format!(
551 "unknown element <{}> in <data> ignored",
552 node.tag_name().name()
553 ),
554 ));
555 continue;
556 }
557 let get = |name: &'static str| -> Result<Option<f64>, MotorError> {
558 attr(node, &[name])
559 .map(|text| number(text, name, line))
560 .transpose()
561 };
562 let time_s = get("t")?.ok_or_else(|| missing(line, "t"))?;
563 let thrust_n = get("f")?.ok_or_else(|| missing(line, "f"))?;
564 if time_s < 0.0 {
565 return Err(syntax(line, format!("negative time {time_s}")));
566 }
567 if let Some(previous) = points.last()
568 && time_s < previous.time_s
569 {
570 return Err(syntax(
571 line,
572 format!(
573 "time {time_s} s is before the previous point's {} s",
574 previous.time_s
575 ),
576 ));
577 }
578 points.push(RsePoint {
579 time_s,
580 thrust_n,
581 mass_g: get("m")?,
582 cg_mm: get("cg")?,
583 });
584 }
585 let line = lines.of(data);
586 if points.len() < 2 {
587 return Err(syntax(
588 line,
589 format!("engine {code:?} needs at least two points"),
590 ));
591 }
592 let total = points.len();
593 let partial = |count: usize| count != 0 && count != total;
594 if partial(points.iter().filter(|p| p.mass_g.is_some()).count()) {
595 warnings.push(ParseWarning::new(
596 line,
597 WarningKind::Dropped,
598 format!("engine {code:?} gives `m` on only some points; all dropped"),
599 ));
600 points.iter_mut().for_each(|p| p.mass_g = None);
601 }
602 if partial(points.iter().filter(|p| p.cg_mm.is_some()).count()) {
603 warnings.push(ParseWarning::new(
604 line,
605 WarningKind::Dropped,
606 format!("engine {code:?} gives `cg` on only some points; all dropped"),
607 ));
608 points.iter_mut().for_each(|p| p.cg_mm = None);
609 }
610 Ok(points)
611}
612
613fn summary_warnings(engine: &RseEngine, line: usize, warnings: &mut Vec<ParseWarning>) {
616 let mut warn = |kind: WarningKind, message: String| {
617 warnings.push(ParseWarning::new(
618 line,
619 kind,
620 format!("engine {:?}: {message}", engine.code),
621 ));
622 };
623 for warning in engine.delays().warnings {
624 warn(warning.kind, warning.message);
625 }
626 if engine
627 .motor_type
628 .as_deref()
629 .is_some_and(|kind| kind.trim().eq_ignore_ascii_case("hybrid"))
630 {
631 warn(
632 WarningKind::Unusual,
633 "a hybrid motor: hpr models solid motors only, and a `SolidMotor` built from this \
634 curve would be wrong"
635 .into(),
636 );
637 }
638 if let Some(last) = engine.points.last()
639 && last.thrust_n != 0.0
640 {
641 warn(
642 WarningKind::Unusual,
643 format!("the curve ends at {} N, not zero", last.thrust_n),
644 );
645 }
646 if engine.points.iter().any(|p| p.thrust_n < 0.0) {
647 warn(WarningKind::Unusual, "the curve has negative thrust".into());
648 return;
649 }
650 let Ok(curve) = engine.thrust_curve() else {
651 return;
652 };
653 for (given, computed, name) in [
654 (engine.total_impulse_ns, curve.total_impulse_ns(), "Itot"),
655 (engine.peak_thrust_n, curve.peak_thrust_n(), "peakThrust"),
656 ] {
657 if let Some(given) = given
658 && (given - computed).abs() > 0.01 * computed.abs()
659 {
660 warn(
661 WarningKind::Unusual,
662 format!("`{name}` {given} differs from the curve's {computed} by more than 1%"),
663 );
664 }
665 }
666}
667
668fn attr<'a>(node: Node<'a, '_>, names: &[&str]) -> Option<&'a str> {
670 names
671 .iter()
672 .find_map(|name| node.attribute(*name))
673 .or_else(|| {
674 node.attributes()
675 .find(|a| names.iter().any(|name| a.name().eq_ignore_ascii_case(name)))
676 .map(|a| a.value())
677 })
678}
679
680fn check_dimensions(diameter_mm: f64, length_mm: f64) -> Result<(), MotorError> {
681 for (value, what) in [(diameter_mm, "diameter (mm)"), (length_mm, "length (mm)")] {
682 if !(value.is_finite() && value > 0.0) {
683 return Err(MotorError::Domain { what, value });
684 }
685 }
686 Ok(())
687}
688
689fn number(text: &str, what: &'static str, line: usize) -> Result<f64, MotorError> {
690 finite(text, what).map_err(|_| syntax(line, format!("can't read `{what}` from {text:?}")))
691}
692
693fn missing(line: usize, what: &str) -> MotorError {
694 syntax(line, format!("missing the `{what}` attribute"))
695}
696
697fn syntax(line: usize, message: String) -> MotorError {
698 MotorError::Syntax {
699 format: FORMAT,
700 line,
701 message,
702 }
703}
704
705pub const MAX_ELEMENT_DEPTH: usize = 32;
713
714fn check_nesting(text: &str) -> Result<(), MotorError> {
722 let bytes = text.as_bytes();
723 let find = |from: usize, needle: &[u8]| {
724 bytes[from..]
725 .windows(needle.len())
726 .position(|window| window == needle)
727 .map_or(bytes.len(), |at| from + at + needle.len())
728 };
729 let mut depth = 0_usize;
730 let mut i = 0;
731 while let Some(offset) = bytes[i..].iter().position(|&b| b == b'<') {
732 let start = i + offset;
733 let rest = &bytes[start..];
734 i = if rest.starts_with(b"<!--") {
735 find(start + 4, b"-->")
736 } else if rest.starts_with(b"<![CDATA[") {
737 find(start + 9, b"]]>")
738 } else if rest.starts_with(b"<?") {
739 find(start + 2, b"?>")
740 } else if rest.starts_with(b"<!") || rest.starts_with(b"</") {
741 if rest[1] == b'/' {
742 depth = depth.saturating_sub(1);
743 }
744 find(start + 2, b">")
745 } else {
746 let mut quote = None;
747 let mut end = bytes.len();
748 let mut self_closing = false;
749 for (j, &b) in bytes.iter().enumerate().skip(start + 1) {
750 match (quote, b) {
751 (_, b'<') => {
752 end = j;
753 break;
754 }
755 (None, b'"' | b'\'') => quote = Some(b),
756 (Some(open), _) if b == open => quote = None,
757 (None, b'>') => {
758 self_closing = bytes[j - 1] == b'/';
759 end = j + 1;
760 break;
761 }
762 _ => {}
763 }
764 }
765 if !self_closing {
766 depth += 1;
767 if depth > MAX_ELEMENT_DEPTH {
768 let line = bytes[..start].iter().filter(|&&b| b == b'\n').count() + 1;
769 return Err(syntax(
770 line,
771 format!("elements nested more than {MAX_ELEMENT_DEPTH} deep"),
772 ));
773 }
774 }
775 end
776 };
777 if i >= bytes.len() {
778 break;
779 }
780 }
781 Ok(())
782}
783
784#[cfg(test)]
785pub(crate) mod tests {
786 use proptest::prelude::*;
787
788 use super::*;
789 use crate::delay::Delay;
790
791 const SAMPLE: &str = r#"<engine-database>
792 <engine-list>
793 <engine mfg=" Aerotech" code="H128W" Type="reloadable" dia="29." len="194." initWt="196.6"
794 propWt="90.6" delays="6,10,14" auto-calc-mass="1" auto-calc-cg="1" avgThrust="128."
795 peakThrust="150." throatDia="0." exitDia="0." Itot="10.2" burn-time="0.8" massFrac="46.1"
796 Isp="200." tDiv="10" tStep="-1." tFix="1" FDiv="10" FStep="-1." FFix="1" mDiv="10"
797 mStep="-1." mFix="1" cgDiv="10" cgStep="-1." cgFix="1">
798 <comments>Line one & two
799<![CDATA[raw & data]]></comments>
800 <data>
801 <eng-data t="0." f="0." m="90.6" cg="97."/>
802 <eng-data t="0.02" f="150." m="89.1" cg="97."/>
803 <eng-data t="0.1" f="100." m="80.2" cg="97."/>
804 <eng-data t="0.1" f="50." m="80.2" cg="97."/>
805 <eng-data t="0.2" f="0." m="0." cg="97."/>
806 </data>
807 </engine>
808 </engine-list>
809</engine-database>"#;
810
811 #[test]
812 fn deep_nesting_is_refused_before_the_xml_parser_recurses() {
813 let deep = |n: usize| format!("{}{}", "<a>".repeat(n), "</a>".repeat(n));
817 let result = parse(&deep(100_000));
818 assert!(
819 matches!(&result, Err(MotorError::Syntax { line: 1, message, .. })
820 if message.contains("nested")),
821 "{result:?}"
822 );
823
824 let body = SAMPLE.split_once('\n').unwrap().1;
827 let inner = body.strip_suffix("</engine-database>").unwrap();
828 let wrapped = |extra: usize| {
829 format!(
830 "<engine-database>{}\n{inner}{}</engine-database>",
831 "<!-- <x> --><x a='>'><y/>".repeat(extra),
832 "</x>".repeat(extra)
833 )
834 };
835 let at_limit = wrapped(MAX_ELEMENT_DEPTH - 4);
836 assert_eq!(parse(&at_limit).unwrap().value.engines.len(), 1);
837 let comments_line = at_limit
838 .lines()
839 .position(|l| l.contains("<comments>"))
840 .unwrap()
841 + 1;
842 assert!(matches!(
843 parse(&wrapped(MAX_ELEMENT_DEPTH - 3)),
844 Err(MotorError::Syntax { line, .. }) if line == comments_line
845 ));
846 let hidden = format!(
848 "<?xml version='1.0'?><!-- {0} --><engine-database><![CDATA[{0}]]><?pi {0}?>\n{body}",
849 "<a>".repeat(100)
850 );
851 assert_eq!(parse(&hidden).unwrap().value.engines.len(), 1);
852 }
853
854 #[test]
855 fn reads_the_observed_layout() {
856 let parsed = parse(SAMPLE).unwrap();
857 let engine = &parsed.value.engines[0];
858 assert_eq!(engine.manufacturer, " Aerotech");
859 assert_eq!(engine.code, "H128W");
860 assert_eq!(engine.motor_type.as_deref(), Some("reloadable"));
861 assert_eq!(engine.diameter_mm, 29.0);
862 assert_eq!(engine.initial_mass_g, 196.6);
863 assert_eq!(engine.auto_calc_mass, Some(true));
864 assert_eq!(engine.exit_diameter_mm, Some(0.0));
865 assert_eq!(
866 engine.comments.as_deref(),
867 Some("Line one & two\nraw & data")
868 );
869 assert_eq!(engine.points.len(), 5);
870 assert_eq!(engine.points[1].mass_g, Some(89.1));
871 assert_eq!(engine.points[4].cg_mm, Some(97.0));
872 assert_eq!(
873 engine.delays().delays,
874 [
875 Delay::Seconds(6.0),
876 Delay::Seconds(10.0),
877 Delay::Seconds(14.0)
878 ]
879 );
880 let curve = engine.thrust_curve().unwrap();
881 assert_eq!(curve.times_s()[3], 0.1);
882 assert_eq!(parsed.warnings.len(), 1, "{:?}", parsed.warnings);
884 assert_eq!(parsed.warnings[0].line, 3);
885 }
886
887 #[test]
888 fn reads_the_guides_names_and_nested_engines() {
889 let text = r#"<engine code="A1" mfg="X" type="single-use" dia="18" len="70" initMass="16"
890 propMass="3"><data><point t="0.1" f="2"/><point t="0.5" f="0"/></data></engine>"#;
891 let parsed = parse(text).unwrap();
892 let engine = &parsed.value.engines[0];
893 assert_eq!(engine.motor_type.as_deref(), Some("single-use"));
894 assert_eq!(engine.initial_mass_g, 16.0);
895 assert_eq!(engine.propellant_mass_g, 3.0);
896 assert_eq!(engine.points[0].mass_g, None);
897 assert!(parsed.warnings.is_empty(), "{:?}", parsed.warnings);
898 }
899
900 #[test]
901 fn rejects_malformed_files() {
902 let engine = |attrs: &str, points: &str| {
903 format!(
904 r#"<engine-database><engine-list><engine {attrs}><data>{points}</data></engine></engine-list></engine-database>"#
905 )
906 };
907 let good_attrs = r#"code="A" mfg="X" dia="18" len="70" initWt="16" propWt="3""#;
908 let good_points =
909 r#"<eng-data t="0" f="0"/><eng-data t="0.1" f="2"/><eng-data t="0.5" f="0"/>"#;
910 assert!(parse(&engine(good_attrs, good_points)).is_ok());
911 let bad = [
912 "".to_owned(),
913 "<engine-database/>".to_owned(),
914 "<engine code=\"A\"".to_owned(),
915 "<!DOCTYPE x [<!ENTITY e \"boom\">]><engine-database/>".to_owned(),
916 engine(
917 r#"mfg="X" dia="18" len="70" initWt="16" propWt="3""#,
918 good_points,
919 ),
920 engine(
921 r#"code="A" mfg="X" len="70" initWt="16" propWt="3""#,
922 good_points,
923 ),
924 engine(
925 r#"code="A" mfg="X" dia="0" len="70" initWt="16" propWt="3""#,
926 good_points,
927 ),
928 engine(
929 r#"code="A" mfg="X" dia="18" len="70" initWt="16" propWt="-3""#,
930 good_points,
931 ),
932 engine(
933 r#"code="A" mfg="X" dia="NaN" len="70" initWt="16" propWt="3""#,
934 good_points,
935 ),
936 engine(good_attrs, r#"<eng-data t="0" f="1"/>"#),
937 engine(good_attrs, r#"<eng-data t="0" f="1"/><eng-data t="0.1"/>"#),
938 engine(
939 good_attrs,
940 r#"<eng-data t="0.2" f="1"/><eng-data t="0.1" f="0"/>"#,
941 ),
942 engine(
943 good_attrs,
944 r#"<eng-data t="-0.1" f="1"/><eng-data t="0.1" f="0"/>"#,
945 ),
946 engine(
947 good_attrs,
948 r#"<eng-data t="0" f="inf"/><eng-data t="0.1" f="0"/>"#,
949 ),
950 ];
951 for text in &bad {
952 assert!(parse(text).is_err(), "{text}");
953 }
954 }
955
956 pub(crate) fn bits(file: &RseFile) -> Vec<Option<u64>> {
958 file.engines
959 .iter()
960 .flat_map(|e| {
961 [
962 Some(e.diameter_mm),
963 Some(e.length_mm),
964 Some(e.initial_mass_g),
965 Some(e.propellant_mass_g),
966 e.average_thrust_n,
967 e.peak_thrust_n,
968 e.throat_diameter_mm,
969 e.exit_diameter_mm,
970 e.total_impulse_ns,
971 e.burn_time_s,
972 e.mass_fraction_pct,
973 e.isp_s,
974 ]
975 .into_iter()
976 .chain(
977 e.points
978 .iter()
979 .flat_map(|p| [Some(p.time_s), Some(p.thrust_n), p.mass_g, p.cg_mm]),
980 )
981 .map(|value| value.map(f64::to_bits))
982 .collect::<Vec<_>>()
983 })
984 .collect()
985 }
986
987 #[test]
988 fn comments_keep_only_their_text() {
989 let text = r#"<engine code="A" mfg="X" dia="18" len="70" initWt="16" propWt="3">
990<comments>keep<!-- editor note -->this<?pi x?><b>bold</b><b>a<i>b</i>c</b></comments>
991<comments>second</comments>
992<data><eng-data t="0" f="0"/><eng-data t="0.2" f="2"/><eng-data t="0.3" f="0"/></data>
993<data><eng-data t="0" f="0"/><eng-data t="0.2" f="3"/><eng-data t="0.3" f="0"/></data>
994<comments><engine code="B" mfg="X" dia="18" len="70" initWt="16" propWt="3"><data>
995<eng-data t="0" f="0"/><eng-data t="0.2" f="2"/></data></engine></comments>
996</engine>"#;
997 let parsed = parse(text).unwrap();
998 assert_eq!(
999 parsed.value.engines.len(),
1000 1,
1001 "the nested engine is not read"
1002 );
1003 let engine = &parsed.value.engines[0];
1004 assert_eq!(engine.comments.as_deref(), Some("\n"));
1006 assert_eq!(engine.points[1].thrust_n, 3.0);
1007 let dropped: Vec<usize> = parsed
1008 .warnings
1009 .iter()
1010 .filter(|w| w.kind == WarningKind::Dropped)
1011 .map(|w| w.line)
1012 .collect();
1013 assert_eq!(dropped, [3, 5, 6, 6], "{:?}", parsed.warnings);
1014 let single = r#"<engine code="A" mfg="X" dia="18" len="70" initWt="16" propWt="3">
1015<comments>keep<!-- editor note -->this<?pi x?><b>bold</b><b>a<i>b</i>c</b></comments>
1016<data><eng-data t="0" f="0"/><eng-data t="0.2" f="2"/><eng-data t="0.3" f="0"/></data></engine>"#;
1017 let engine = &parse(single).unwrap().value.engines[0];
1018 assert_eq!(engine.comments.as_deref(), Some("keepthisboldabc"));
1019 }
1020
1021 #[test]
1022 fn large_files_read_in_linear_time() {
1023 let points: String = (0..500)
1026 .map(|i| {
1027 format!(
1028 "<eng-data t=\"{}\" f=\"{}\" m=\"1\" cg=\"35\"/>\n",
1029 f64::from(i) * 0.01,
1030 10.0
1031 )
1032 })
1033 .collect();
1034 let engine = format!(
1035 "<engine code=\"A\" mfg=\"X\" dia=\"18\" len=\"70\" initWt=\"16\" propWt=\"3\">\n<data>\n{points}</data></engine>\n"
1036 );
1037 let text = format!(
1038 "<engine-database><engine-list>\n{}</engine-list></engine-database>",
1039 engine.repeat(200)
1040 );
1041 let parsed = parse(&text).unwrap();
1042 assert_eq!(parsed.value.engines.len(), 200);
1043 let last = text.lines().count();
1044 assert!(parsed.warnings.iter().all(|w| w.line <= last));
1045 }
1046
1047 #[test]
1048 fn a_broken_engine_is_skipped_with_a_warning() {
1049 let text = r#"<engine-database><engine-list>
1050<engine code="A" mfg="X" dia="18" len="70" initWt="16" propWt="3"><data>
1051 <eng-data t="0" f="0"/><eng-data t="0.2" f="2"/><eng-data t="0.1" f="0"/></data></engine>
1052<engine code="B" mfg="X" dia="18" len="70" initWt="16" propWt="3"><data>
1053 <eng-data t="0" f="0"/><eng-data t="0.2" f="2"/><eng-data t="0.3" f="0"/></data></engine>
1054</engine-list></engine-database>"#;
1055 let parsed = parse(text).unwrap();
1056 assert_eq!(parsed.value.engines.len(), 1);
1057 assert_eq!(parsed.value.engines[0].code, "B");
1058 assert_eq!(parsed.warnings.len(), 1);
1059 assert_eq!(parsed.warnings[0].line, 3);
1060 assert_eq!(parsed.warnings[0].kind, WarningKind::Skipped);
1061 }
1062
1063 #[test]
1064 fn partial_mass_columns_are_dropped_with_a_warning() {
1065 let text = r#"<engine code="A" mfg="X" dia="18" len="70" initWt="16" propWt="3"><data>
1066 <eng-data t="0" f="0" m="3"/><eng-data t="0.1" f="2"/><eng-data t="0.5" f="0" m="0"/>
1067 </data></engine>"#;
1068 let parsed = parse(text).unwrap();
1069 assert!(
1070 parsed.value.engines[0]
1071 .points
1072 .iter()
1073 .all(|p| p.mass_g.is_none())
1074 );
1075 assert_eq!(parsed.warnings.len(), 1);
1076 }
1077
1078 #[test]
1079 fn a_hybrid_is_read_with_a_warning() {
1080 let hybrid = |kind: &str| {
1081 format!(
1082 r#"<engine code="H" mfg="X" Type="{kind}" dia="38" len="300" initWt="900" propWt="200"><data>
1083 <eng-data t="0" f="0"/><eng-data t="0.5" f="200"/><eng-data t="1" f="0"/>
1084 </data></engine>"#
1085 )
1086 };
1087 let parsed = parse(&hybrid(" Hybrid")).unwrap();
1088 assert_eq!(parsed.value.engines.len(), 1);
1089 assert!(matches!(
1090 parsed.warnings.as_slice(),
1091 [w] if w.kind == WarningKind::Unusual && w.message.contains("hybrid")
1092 ));
1093 assert!(parse(&hybrid("reloadable")).unwrap().warnings.is_empty());
1094 }
1095
1096 #[test]
1097 fn writes_what_it_reads() {
1098 let file = parse(SAMPLE).unwrap().value;
1099 let text = write(&file).unwrap();
1100 assert_eq!(parse(&text).unwrap().value, file);
1101 assert_eq!(write(&parse(&text).unwrap().value).unwrap(), text);
1102 }
1103
1104 #[test]
1108 fn the_first_line_names_the_program() {
1109 let file = parse(SAMPLE).unwrap().value;
1110 let text = write(&file).unwrap();
1111 let first = format!(
1112 "<!-- hpr-sim {} · FS · SW · TOOL 005 -->\n<engine-database>\n",
1113 env!("CARGO_PKG_VERSION")
1114 );
1115 assert!(text.starts_with(&first), "{text}");
1116 assert!(text.contains(&hpr_core::tool::stamp()));
1117 let read = parse(&text).unwrap();
1118 let messages = |warnings: &[ParseWarning]| {
1120 warnings
1121 .iter()
1122 .map(|w| (w.kind, w.message.clone()))
1123 .collect::<Vec<_>>()
1124 };
1125 assert_eq!(
1126 messages(&read.warnings),
1127 messages(&parse(SAMPLE).unwrap().warnings)
1128 );
1129 assert_eq!(read.value, file);
1130 let again = write(&read.value).unwrap();
1131 assert_eq!(again, text);
1132 assert_eq!(again.matches("TOOL 005").count(), 1);
1133 }
1134
1135 #[test]
1137 fn stamp_is_a_comment_xml_can_hold() {
1138 let stamp = hpr_core::tool::stamp();
1139 assert!(!stamp.contains("--") && !stamp.ends_with('-'), "{stamp}");
1140 }
1141
1142 #[test]
1143 fn writer_refuses_what_it_cannot_represent() {
1144 let mut engine = parse(SAMPLE).unwrap().value.engines[0].clone();
1145 engine.points[1].mass_g = None;
1146 assert!(
1147 write(&RseFile {
1148 engines: vec![engine.clone()]
1149 })
1150 .is_err()
1151 );
1152 engine.points.iter_mut().for_each(|p| p.mass_g = None);
1153 assert!(
1154 write(&RseFile {
1155 engines: vec![engine.clone()]
1156 })
1157 .is_ok()
1158 );
1159 engine.code = "bell\u{7}".into();
1160 assert!(
1161 write(&RseFile {
1162 engines: vec![engine.clone()]
1163 })
1164 .is_err()
1165 );
1166 engine.code = "A".into();
1167 engine.points.truncate(1);
1168 assert!(
1169 write(&RseFile {
1170 engines: vec![engine]
1171 })
1172 .is_err()
1173 );
1174 assert!(write(&RseFile { engines: vec![] }).is_err());
1175 }
1176
1177 fn text(max: usize) -> impl Strategy<Value = String> {
1178 prop::collection::vec(
1179 prop_oneof![
1180 Just('&'),
1181 Just('<'),
1182 Just('>'),
1183 Just('"'),
1184 Just('\''),
1185 Just('\t'),
1186 Just('\n'),
1187 Just('\r'),
1188 Just(' '),
1189 Just('é'),
1190 Just(']'),
1191 proptest::char::range('a', 'z'),
1192 ],
1193 0..max,
1194 )
1195 .prop_map(|chars| chars.into_iter().collect())
1196 }
1197
1198 fn engines() -> impl Strategy<Value = RseEngine> {
1199 let number = || prop_oneof![Just(-0.0), -1e6..1e6f64];
1200 let option = move || prop::option::of(number());
1201 (
1202 (
1203 text(12),
1204 text(12),
1205 prop::option::of(text(8)),
1206 prop::option::of(text(8)),
1207 ),
1208 (1e-3..1e4f64, 1e-3..1e5f64, 0.0..1e5f64, 0.0..1e5f64),
1209 (
1210 prop::option::of(any::<bool>()),
1211 prop::option::of(any::<bool>()),
1212 ),
1213 (
1214 option(),
1215 option(),
1216 option(),
1217 option(),
1218 option(),
1219 option(),
1220 option(),
1221 option(),
1222 ),
1223 prop::option::of(text(40)),
1224 (any::<bool>(), any::<bool>()),
1225 prop::collection::vec((0.0..10.0f64, 0.0..1e5f64, number(), number()), 2..20),
1226 )
1227 .prop_map(
1228 |(strings, envelope, flags, summary, comments, columns, mut samples)| {
1229 samples.sort_by(|a, b| a.0.total_cmp(&b.0));
1230 RseEngine {
1231 manufacturer: strings.0,
1232 code: strings.1,
1233 motor_type: strings.2,
1234 delays: strings.3,
1235 diameter_mm: envelope.0,
1236 length_mm: envelope.1,
1237 initial_mass_g: envelope.2,
1238 propellant_mass_g: envelope.3,
1239 auto_calc_mass: flags.0,
1240 auto_calc_cg: flags.1,
1241 average_thrust_n: summary.0,
1242 peak_thrust_n: summary.1,
1243 throat_diameter_mm: summary.2,
1244 exit_diameter_mm: summary.3,
1245 total_impulse_ns: summary.4,
1246 burn_time_s: summary.5,
1247 mass_fraction_pct: summary.6,
1248 isp_s: summary.7,
1249 comments,
1250 points: samples
1251 .into_iter()
1252 .map(|(t, f, m, cg)| RsePoint {
1253 time_s: t,
1254 thrust_n: f,
1255 mass_g: columns.0.then_some(m),
1256 cg_mm: columns.1.then_some(cg),
1257 })
1258 .collect(),
1259 }
1260 },
1261 )
1262 }
1263
1264 proptest! {
1265 #[test]
1266 fn write_parse_round_trips_bit_for_bit(engines in prop::collection::vec(engines(), 1..4)) {
1267 let file = RseFile { engines };
1268 let written = write(&file).unwrap();
1269 let back = parse(&written).unwrap().value;
1270 prop_assert_eq!(&back, &file);
1271 prop_assert_eq!(bits(&back), bits(&file));
1272 }
1273 }
1274}