hpr_format/lib.rs
1//! The hpr open design format: a rocket design as one JSON document, with its JSON Schema.
2//!
3//! **Guide:** [the format's page][guide-format] says what a document holds, how it is versioned,
4//! and how it was checked. Version 0.1 is a draft until hpr's first release: it can change in
5//! place, so keep the source `.ork` too.
6//!
7//! A document is a [`DesignFile`]: a header naming the format ([`FORMAT`]), its version
8//! ([`VERSION`]) and where the design came from ([`Provenance`]), then the design as
9//! [`hpr_io::ork`] reads it from a `.ork`, then every other entry of that file's archive, such as
10//! an embedded thrust curve or a decal image. The design holds the rocket, every motor
11//! configuration, the recovery events, the simulations stored with it, and what the source file
12//! holds that hpr does not model, kept under a namespaced extension (`x-openrocket`). A `.ork`
13//! written from a document is the `.ork` hpr writes from the file it was read from, byte for
14//! byte, as checked on the 73 designs hpr's `.ork` checks read ([ADR-111][adr-111]).
15//!
16//! [`to_json`] writes the canonical text: two-space indents, keys in the order the types declare
17//! them, and a final newline, so the same design always gives the same bytes and a change shows as
18//! a small diff. [`from_json`] reads it back to the same value, takes a document of an older
19//! version to the current one ([`migrate`]), and refuses one of another format or a newer version
20//! with the reason. [`schema`] is the document's JSON Schema, committed as
21//! [`schema/format/hpr-design-0.2.schema.json`][schema-file] beside the older versions' schemas.
22//! A [`container`] (`.hprz`) carries a design with other files, such as its flight logs.
23//!
24//! Generated TypeScript and Python types come with the format's third step, [M3.3c][m3-3c].
25//!
26//! ```
27//! use hpr_format::{DesignFile, Provenance, from_json, to_json};
28//!
29//! let bytes = include_bytes!("../../../validation/fixtures/ork/loft-demo/demo-stable.ork");
30//! let read = DesignFile::from_ork(bytes)?;
31//! let text = to_json(&read.value)?;
32//! assert!(text.starts_with("{\n \"format\": \"hpr-design\",\n \"version\": \"0.2\","));
33//! assert_eq!(from_json(&text)?, read.value);
34//! # Ok::<(), Box<dyn std::error::Error>>(())
35//! ```
36//!
37//! [guide-format]: https://nrdptel.github.io/hpr-sim/format/hpr.html
38//! [adr-111]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-111-m33-the-hpr-design-format-its-extensions-versions-and-crate-2026-09-29
39//! [schema-file]: https://github.com/nrdptel/hpr-sim/blob/main/schema/format/hpr-design-0.2.schema.json
40//! [m3-3c]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#m3-3c
41
42use std::collections::BTreeSet;
43use std::fmt;
44
45use base64::Engine as _;
46use base64::engine::general_purpose::STANDARD as BASE64;
47use hpr_design::Rocket;
48use hpr_io::ork::{
49 self, Attachment, Curve, Design, Extensions, Imported, Motors, OrkError, Recovery,
50 StoredSimulation,
51};
52use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
53use serde::{Deserialize, Serialize};
54use sha2::{Digest, Sha256};
55
56/// The format's name, the value of every document's `format` key.
57pub const FORMAT: &str = "hpr-design";
58
59/// The version this crate writes and reads.
60pub const VERSION: Version = Version { major: 0, minor: 2 };
61
62/// The extension of a design written as plain JSON ([ADR-111][adr-111]): `.hpr`.
63///
64/// [adr-111]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-111-m33-the-hpr-design-format-its-extensions-versions-and-crate-2026-09-29
65pub const EXTENSION: &str = "hpr";
66
67/// The extension of the zip container of a design and its attachments ([`container`]): `.hprz`.
68pub const CONTAINER_EXTENSION: &str = "hprz";
69
70/// A design as one document of the hpr design format.
71///
72/// The keys are written in this order. The design's own parts are those of [`hpr_io::ork::Design`],
73/// whose documentation says what each holds.
74#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
75#[serde(deny_unknown_fields)]
76#[non_exhaustive]
77#[schemars(
78 title = "hpr design",
79 description = "A rocket design in the hpr design format."
80)]
81pub struct DesignFile {
82 /// Always `hpr-design`.
83 pub format: Format,
84 /// The format's version, `major.minor`.
85 pub version: Version,
86 /// Which program wrote the document, and from what.
87 pub provenance: Provenance,
88 /// The rocket: its stages and their parts, with every configuration that flies.
89 pub rocket: Rocket,
90 /// Every motor configuration, flown or not, with why one is not.
91 pub motors: Motors,
92 /// When each parachute and streamer opens and each stage separates.
93 pub recovery: Recovery,
94 /// The simulations the source file stored, with their conditions and results.
95 pub simulations: Vec<StoredSimulation>,
96 /// What the source file holds that hpr does not model, by namespace, kept for writing it
97 /// back.
98 pub extensions: Extensions,
99 /// The source file's other files, in the order it held them: a `.ork` archive's entries
100 /// besides the design, such as embedded thrust curves and decal images. Version 0.1 called
101 /// them `attachments`.
102 pub source_files: Vec<SourceFile>,
103}
104
105impl DesignFile {
106 /// The document of `design`, with its provenance and the source file's other files.
107 ///
108 /// `source_files` must hold each thrust curve the design's motors name as embedded
109 /// ([`Curve::Embedded`]): the file's own
110 /// [`OrkFile::attachments`](hpr_io::ork::OrkFile::attachments) do. Without one, [`to_json`]
111 /// refuses the document.
112 ///
113 /// A design read from a `.ork` should name, in its provenance's
114 /// [`Source::airframe_not_as_written`], why the file's airframe was not read exactly as written,
115 /// if it wasn't: without it, a program flies another motor in a rocket its `.ork` refuses.
116 /// [`DesignFile::from_ork`] sets it.
117 pub fn new(design: Design, provenance: Provenance, source_files: &[Attachment]) -> Self {
118 Self {
119 format: Format::HprDesign,
120 version: VERSION,
121 provenance,
122 rocket: design.rocket,
123 motors: design.motors,
124 recovery: design.recovery,
125 simulations: design.simulations,
126 extensions: design.extensions,
127 source_files: source_files.iter().map(SourceFile::of).collect(),
128 }
129 }
130
131 /// Reads a `.ork` file's bytes (zip, gzip or raw XML) into a document whose provenance names
132 /// this program, the file's SHA-256 and why its airframe was not read exactly as written, if
133 /// it wasn't, with the reader's warnings.
134 ///
135 /// The design is [`hpr_io::ork::design`]'s: a motor configuration flies with the curve the
136 /// file itself holds.
137 ///
138 /// # Errors
139 ///
140 /// [`OrkError`] when the bytes are not a `.ork` hpr can read.
141 pub fn from_ork(bytes: &[u8]) -> Result<Imported<Self>, OrkError> {
142 let file = ork::read(bytes)?;
143 let design = ork::design(&file.value);
144 let mut warnings = file.warnings;
145 warnings.extend(design.warnings);
146 let mut source = Source::of(SourceFormat::Ork, bytes);
147 source.airframe_not_as_written = ork::airframe_not_as_written(&file.value);
148 let provenance = Provenance::hpr(Some(source));
149 Ok(Imported {
150 value: Self::new(design.value, provenance, &file.value.attachments),
151 warnings,
152 })
153 }
154
155 /// The document as this program writes it again: its provenance names this build, and
156 /// where the design came from is kept exactly as read ([`Provenance::rewritten`]). A
157 /// conversion writes a new file, so the program that wrote it is this one; the design's
158 /// source doesn't change.
159 #[must_use]
160 pub fn stamped(self) -> Self {
161 Self {
162 provenance: self.provenance.rewritten(),
163 ..self
164 }
165 }
166
167 /// The design the document holds, as [`hpr_io::ork`] would have read it.
168 pub fn design(&self) -> Design {
169 Design::new(
170 self.rocket.clone(),
171 self.motors.clone(),
172 self.recovery.clone(),
173 self.simulations.clone(),
174 self.extensions.clone(),
175 )
176 }
177
178 /// The source file's other files, as bytes.
179 ///
180 /// # Errors
181 ///
182 /// [`FormatError::Invalid`] when a file's base64 does not decode; [`from_json`] refuses such a
183 /// document, so only one built in code can hold one.
184 pub fn source_files(&self) -> Result<Vec<Attachment>, FormatError> {
185 self.source_files
186 .iter()
187 .map(|file| {
188 Ok(Attachment {
189 name: file.name.clone(),
190 bytes: file.bytes()?,
191 })
192 })
193 .collect()
194 }
195
196 /// The design written as a `.ork` ([`hpr_io::ork::export::write`]) with the source file's
197 /// other files, so it is the `.ork` hpr writes from the source file itself.
198 ///
199 /// # Errors
200 ///
201 /// [`FormatError::Invalid`] for a source file that does not decode, and
202 /// [`FormatError::Ork`] when the archive cannot be written.
203 pub fn to_ork(&self) -> Result<Imported<Vec<u8>>, FormatError> {
204 ork::export::write(&self.design(), &self.source_files()?)
205 .map_err(|error| FormatError::Ork(error.to_string()))
206 }
207
208 /// What in a document read from JSON the types alone don't hold to: the source's SHA-256 has
209 /// the schema's pattern ([`Source::check`]); every source file decodes, has its own name, and
210 /// is a file a `.ork` can hold besides its design; and every thrust curve embedded in the
211 /// source file is among them.
212 fn check(&self) -> Result<(), FormatError> {
213 if let Some(source) = &self.provenance.source {
214 source.check()?;
215 }
216 let mut names = BTreeSet::new();
217 for file in &self.source_files {
218 file.bytes()?;
219 if !names.insert(file.name.as_str()) {
220 return Err(FormatError::Invalid(format!(
221 "two source files are named {:?}",
222 shortened(&file.name)
223 )));
224 }
225 // The design's own entry, and a directory's name, don't come back from a `.ork`.
226 if file.name.is_empty() || file.name == "rocket.ork" || file.name.ends_with(['/', '\\'])
227 {
228 return Err(FormatError::Invalid(format!(
229 "a source file is named {:?}, which a .ork can't hold besides its design",
230 shortened(&file.name)
231 )));
232 }
233 }
234 let entries = self
235 .motors
236 .configurations
237 .iter()
238 .flat_map(|configuration| &configuration.motors)
239 .filter_map(|motor| match &motor.curve {
240 Curve::Embedded { entry, .. } => Some(entry),
241 _ => None,
242 });
243 for entry in entries {
244 if !names.contains(entry.as_str()) {
245 return Err(FormatError::Invalid(format!(
246 "a motor's curve is embedded as {:?}, which the source files don't hold",
247 shortened(entry)
248 )));
249 }
250 }
251 Ok(())
252 }
253}
254
255/// One of the source file's other files: its name, and its contents as text where they are UTF-8,
256/// or else as base64 ([RFC 4648][rfc-4648], section 4).
257///
258/// Beyond the schema, the reader holds a document's source files to four rules: base64 decodes; no
259/// two share a name; each thrust curve a motor names as embedded is among them; and a name is not
260/// empty, not `rocket.ork` (the design's own entry) and not a directory's, ending in `/` or `\`.
261///
262/// [rfc-4648]: https://www.rfc-editor.org/rfc/rfc4648#section-4
263#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
264#[serde(deny_unknown_fields)]
265#[non_exhaustive]
266pub struct SourceFile {
267 /// Its name in the source file, such as `thrustcurves/<digest>.rse`.
268 pub name: String,
269 /// Its contents.
270 pub content: Content,
271}
272
273impl SourceFile {
274 /// `attachment`, an entry of the source file's archive, as text if it is UTF-8.
275 pub fn of(attachment: &Attachment) -> Self {
276 let content = match std::str::from_utf8(&attachment.bytes) {
277 Ok(text) => Content::Text(text.to_owned()),
278 Err(_) => Content::Base64(BASE64.encode(&attachment.bytes)),
279 };
280 Self {
281 name: attachment.name.clone(),
282 content,
283 }
284 }
285
286 /// Its bytes.
287 ///
288 /// # Errors
289 ///
290 /// [`FormatError::Invalid`] when its base64 does not decode.
291 pub fn bytes(&self) -> Result<Vec<u8>, FormatError> {
292 match &self.content {
293 Content::Text(text) => Ok(text.clone().into_bytes()),
294 Content::Base64(encoded) => BASE64.decode(encoded).map_err(|error| {
295 FormatError::Invalid(format!("source file {:?}: {error}", shortened(&self.name)))
296 }),
297 }
298 }
299}
300
301/// A file's contents: text, or base64 for bytes that are not UTF-8.
302#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
303#[serde(rename_all = "snake_case", deny_unknown_fields)]
304#[non_exhaustive]
305pub enum Content {
306 /// UTF-8 text, as the file holds it.
307 Text(String),
308 /// Any other bytes, in standard base64 with padding, which must decode.
309 Base64(String),
310}
311
312/// The format's name: a document holds only `hpr-design`.
313#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
314pub enum Format {
315 /// A rocket design.
316 #[serde(rename = "hpr-design")]
317 HprDesign,
318}
319
320/// A version of the format, written `major.minor` ([ADR-111][adr-111]).
321///
322/// While the major version is 0, every minor version may change the document in ways an older
323/// reader cannot follow, so a reader takes only its own version and those it can migrate from.
324///
325/// [adr-111]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-111-m33-the-hpr-design-format-its-extensions-versions-and-crate-2026-09-29
326#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
327#[serde(try_from = "String", into = "String")]
328pub struct Version {
329 /// The major version.
330 pub major: u32,
331 /// The minor version.
332 pub minor: u32,
333}
334
335impl fmt::Display for Version {
336 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
337 write!(f, "{}.{}", self.major, self.minor)
338 }
339}
340
341impl From<Version> for String {
342 fn from(version: Version) -> Self {
343 version.to_string()
344 }
345}
346
347impl TryFrom<String> for Version {
348 type Error = String;
349
350 fn try_from(text: String) -> Result<Self, Self::Error> {
351 let number = |part: &str| {
352 // Digits only: no sign, no spaces, no leading zero but a lone "0".
353 let plain = !part.is_empty()
354 && part.bytes().all(|byte| byte.is_ascii_digit())
355 && (part == "0" || !part.starts_with('0'));
356 plain.then(|| part.parse::<u32>().ok()).flatten()
357 };
358 text.split_once('.')
359 .and_then(|(major, minor)| Some((number(major)?, number(minor)?)))
360 .map(|(major, minor)| Self { major, minor })
361 .ok_or_else(|| format!("{text:?} is not a version: it is written major.minor, as 0.1"))
362 }
363}
364
365impl JsonSchema for Version {
366 fn schema_name() -> std::borrow::Cow<'static, str> {
367 "Version".into()
368 }
369
370 fn json_schema(_: &mut SchemaGenerator) -> Schema {
371 json_schema!({
372 "description": "A version of the format, `major.minor`.",
373 "type": "string",
374 "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$",
375 })
376 }
377}
378
379/// Which program wrote a document, and from what.
380#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
381#[serde(deny_unknown_fields)]
382#[non_exhaustive]
383pub struct Provenance {
384 /// The program, such as `hpr-sim`.
385 pub tool: String,
386 /// Its version.
387 pub tool_version: String,
388 /// The program's designation in the FusionSpace product system, such as
389 /// `FS · SW · TOOL 005`; absent in a document written before hpr-sim 0.1's first release.
390 #[serde(default, skip_serializing_if = "Option::is_none")]
391 pub designation: Option<String>,
392 /// The file the design was read from, if it was read from one.
393 #[serde(default, skip_serializing_if = "Option::is_none")]
394 pub source: Option<Source>,
395}
396
397impl Provenance {
398 /// This program, at this version, reading `source`: [`hpr_core::tool::NAME`],
399 /// [`hpr_core::tool::VERSION`] and [`hpr_core::tool::DESIGNATION`].
400 pub fn hpr(source: Option<Source>) -> Self {
401 Self {
402 tool: hpr_core::tool::NAME.to_owned(),
403 tool_version: hpr_core::tool::VERSION.to_owned(),
404 designation: Some(hpr_core::tool::DESIGNATION.to_owned()),
405 source,
406 }
407 }
408
409 /// This provenance as a document this program writes again records it: the program fields
410 /// name this build ([`Provenance::hpr`]), and [`Provenance::source`] stays exactly as read.
411 /// A conversion writes a new file, so the program that wrote it is this one; where the
412 /// design came from doesn't change.
413 ///
414 /// ```
415 /// use hpr_format::{DesignFile, from_json, to_json};
416 ///
417 /// let bytes = include_bytes!("../../../validation/fixtures/ork/loft-demo/demo-stable.ork");
418 /// let mut read = DesignFile::from_ork(bytes)?.value;
419 /// read.provenance.tool_version = "0.0.1".to_owned();
420 /// read.provenance.designation = None;
421 ///
422 /// let rewritten = read.provenance.rewritten();
423 /// assert_eq!(rewritten.tool_version, hpr_core::tool::VERSION);
424 /// assert_eq!(rewritten.designation.as_deref(), Some(hpr_core::tool::DESIGNATION));
425 /// assert_eq!(rewritten.source, read.provenance.source);
426 /// # Ok::<(), Box<dyn std::error::Error>>(())
427 /// ```
428 #[must_use]
429 pub fn rewritten(&self) -> Self {
430 Self::hpr(self.source.clone())
431 }
432}
433
434/// The file a design was read from: its format and its SHA-256, which name it without its path,
435/// and whether its rocket was read exactly as written.
436#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
437#[serde(deny_unknown_fields)]
438#[non_exhaustive]
439pub struct Source {
440 /// The file's format.
441 pub format: SourceFormat,
442 /// The SHA-256 of the file's bytes, as 64 lowercase hexadecimal digits.
443 #[schemars(regex(pattern = "^[0-9a-f]{64}$"))]
444 pub sha256: String,
445 /// Why the file's airframe or a motor mount was not read exactly as written, if it wasn't: a
446 /// part left out, a value dropped or simplified, or something assumed
447 /// ([`hpr_io::ork::airframe_not_as_written`]). No configuration of such a rocket flies, and
448 /// `hpr sim` flies no other motor in it. Absent when the rocket was read as written. A document
449 /// migrated from 0.1 that doesn't show which holds exactly [`migrate::UNKNOWN`], a fixed text a
450 /// program can compare against.
451 #[serde(default, skip_serializing_if = "Option::is_none")]
452 pub airframe_not_as_written: Option<String>,
453}
454
455impl Source {
456 /// The schema's pattern for [`Source::sha256`], `^[0-9a-f]{64}$`, which a `String` alone
457 /// doesn't hold to: exactly 64 lowercase hexadecimal digits, so a document hpr reads passes
458 /// the schema.
459 fn check(&self) -> Result<(), FormatError> {
460 let digits = self.sha256.len() == 64
461 && self
462 .sha256
463 .bytes()
464 .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'));
465 if digits {
466 Ok(())
467 } else {
468 Err(FormatError::Invalid(format!(
469 "provenance.source.sha256 is {:?}, not 64 lowercase hexadecimal digits",
470 shortened(&self.sha256)
471 )))
472 }
473 }
474
475 /// The source `bytes` of `format`, its rocket read as written; set
476 /// [`Source::airframe_not_as_written`] when it wasn't.
477 pub fn of(format: SourceFormat, bytes: &[u8]) -> Self {
478 let digest = Sha256::digest(bytes);
479 let sha256 = digest
480 .iter()
481 .fold(String::with_capacity(64), |mut hex, byte| {
482 hex.push_str(&format!("{byte:02x}"));
483 hex
484 });
485 Self {
486 format,
487 sha256,
488 airframe_not_as_written: None,
489 }
490 }
491}
492
493/// The formats a design can be read from.
494#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
495#[serde(rename_all = "snake_case")]
496#[non_exhaustive]
497pub enum SourceFormat {
498 /// OpenRocket's `.ork`.
499 Ork,
500 /// A document of this format.
501 HprDesign,
502}
503
504/// Why a document could not be written or read.
505#[derive(Debug, Clone, PartialEq, thiserror::Error)]
506#[non_exhaustive]
507pub enum FormatError {
508 /// The text is not JSON.
509 #[error("not JSON: {0}")]
510 Json(String),
511 /// The JSON is not a document of this format: its `format` is missing or another.
512 #[error("not an hpr design: the document's \"format\" is {found}, not \"hpr-design\"")]
513 NotADesign {
514 /// What the document holds, as JSON, or `missing`.
515 found: String,
516 },
517 /// The document is of a version this reader neither takes nor migrates from.
518 #[error(
519 "the document is version {found}, and this reader takes version {supported}{}",
520 if found > supported {
521 ": it was written by a newer program".to_owned()
522 } else {
523 format!(" and migrates from {} on", migrate::OLDEST)
524 }
525 )]
526 Unsupported {
527 /// The document's version.
528 found: Version,
529 /// The version this reader takes and writes.
530 supported: Version,
531 },
532 /// The document names the format and version but does not follow the schema.
533 #[error("not a valid hpr design {VERSION}: {0}")]
534 Invalid(String),
535 /// The design holds a value JSON cannot carry exactly, such as a number that is not finite,
536 /// so the text would not read back as the same design.
537 #[error("the design does not read back the same from its JSON: {0}")]
538 NotRepresentable(String),
539 /// The `.ork` could not be written.
540 #[error("the .ork could not be written: {0}")]
541 Ork(String),
542 /// A `.hprz` container could not be read or written ([`container`]).
543 #[error("not a valid .hprz: {0}")]
544 Container(String),
545}
546
547/// The canonical JSON text of `document`: two-space indents, keys in declared order, a final
548/// newline.
549///
550/// # Errors
551///
552/// [`FormatError::Invalid`] for a document of another version, which this crate doesn't write;
553/// [`FormatError::NotRepresentable`] when the text would not read back as `document`,
554/// including when [`from_json`] would refuse it (a rule of [`SourceFile`]): every
555/// document written is read again and compared, so a value JSON cannot carry (a number that is
556/// not finite) is refused here rather than lost.
557pub fn to_json(document: &DesignFile) -> Result<String, FormatError> {
558 if document.version != VERSION {
559 return Err(FormatError::Invalid(format!(
560 "its \"version\" is {}, and this program writes only {VERSION}",
561 document.version
562 )));
563 }
564 let mut text = serde_json::to_string_pretty(document)
565 .map_err(|error| FormatError::NotRepresentable(error.to_string()))?;
566 text.push('\n');
567 match from_json(&text) {
568 Ok(back) if back == *document => Ok(text),
569 Ok(_) => Err(FormatError::NotRepresentable(
570 "a value reads back different".to_owned(),
571 )),
572 Err(error) => Err(FormatError::NotRepresentable(error.to_string())),
573 }
574}
575
576/// Reads a document from its JSON text, taking one of an older version to the current one.
577///
578/// [`read_json`] with the version the text was written in dropped.
579///
580/// # Errors
581///
582/// [`FormatError`]: not JSON, not an hpr design, a version this reader doesn't take, or not
583/// valid.
584pub fn from_json(text: &str) -> Result<DesignFile, FormatError> {
585 read_json(text).map(|opened| opened.value)
586}
587
588/// A document read, and the version it was written in.
589#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
590#[non_exhaustive]
591pub struct Opened<T> {
592 /// What was read, in the current version.
593 pub value: T,
594 /// The version the text was written in: older than [`VERSION`] when it was migrated.
595 pub written_as: Version,
596}
597
598/// Reads a document from its JSON text, and says which version it was written in.
599///
600/// The format and version are checked first, so a file of another kind or version is refused with
601/// that reason rather than with the first field the reader does not know. A document of an older
602/// version this reader migrates from ([`migrate`]) is rewritten into the current version's shape
603/// and then read as one, so it is held to every rule a current document is.
604///
605/// A document this reader takes passes the [`schema`]: beyond the types, it refuses a source's
606/// SHA-256 that isn't 64 lowercase hexadecimal digits, and an array where the schema has an object
607/// (which serde alone reads as a struct's fields in order). It also refuses a few documents the
608/// schema takes, by the rules of [`SourceFile`].
609///
610/// # Errors
611///
612/// [`FormatError`]: not JSON, not an hpr design, a version this reader doesn't take, or not
613/// valid.
614pub fn read_json(text: &str) -> Result<Opened<DesignFile>, FormatError> {
615 // A byte-order mark, which some Windows editors write at the start of UTF-8, is not JSON.
616 let text = text.strip_prefix('\u{feff}').unwrap_or(text);
617 let value: serde_json::Value =
618 serde_json::from_str(text).map_err(|error| FormatError::Json(error.to_string()))?;
619 let serde_json::Value::Object(mut object) = value else {
620 return Err(FormatError::NotADesign {
621 found: format!("absent: the JSON is {}, not an object", kind(&value)),
622 });
623 };
624 match object.get("format") {
625 Some(serde_json::Value::String(format)) if format == FORMAT => {}
626 Some(other) => {
627 return Err(FormatError::NotADesign {
628 found: shortened(&other.to_string()),
629 });
630 }
631 None => {
632 return Err(FormatError::NotADesign {
633 found: "missing".to_owned(),
634 });
635 }
636 }
637 let found = match object.get("version") {
638 Some(serde_json::Value::String(version)) => {
639 Version::try_from(shortened(version)).map_err(FormatError::Invalid)?
640 }
641 Some(other) => {
642 return Err(FormatError::Invalid(format!(
643 "its \"version\" is {}, not a string such as \"{VERSION}\"",
644 shortened(&other.to_string())
645 )));
646 }
647 None => return Err(FormatError::Invalid("it has no \"version\"".to_owned())),
648 };
649 let (document, read): (DesignFile, _) = if found == VERSION {
650 // Read from the text, not the value, so an error names its line and column.
651 let document =
652 serde_json::from_str(text).map_err(|error| FormatError::Invalid(error.to_string()))?;
653 (document, serde_json::Value::Object(object))
654 } else if migrate::migrates_from(found) {
655 migrate::migrate(&mut object, found).map_err(FormatError::Invalid)?;
656 let read = serde_json::Value::Object(object);
657 let document = DesignFile::deserialize(&read)
658 .map_err(|error| FormatError::Invalid(format!("migrated from {found}: {error}")))?;
659 (document, read)
660 } else {
661 return Err(FormatError::Unsupported {
662 found,
663 supported: VERSION,
664 });
665 };
666 no_array_for_an_object(&read, &document)?;
667 document.check()?;
668 Ok(Opened {
669 value: document,
670 written_as: found,
671 })
672}
673
674/// Refuses a document whose JSON holds an array where `document`, written back, holds an object.
675///
676/// serde reads a struct, or a variant of an enum tagged inside its object, from an array of its
677/// fields in order (`"provenance": ["hpr-sim", "0.1.0"]`), and a struct whose fields all have
678/// defaults from `[]`. The schema wants an object there, so the reader refuses it too: every
679/// object hpr writes is one in the text it reads.
680///
681/// # Errors
682///
683/// [`FormatError::Invalid`] naming the first such place, as a path such as
684/// `rocket.stages[0].components[2].motor_mount`.
685fn no_array_for_an_object(
686 read: &serde_json::Value,
687 document: &DesignFile,
688) -> Result<(), FormatError> {
689 let written =
690 serde_json::to_value(document).map_err(|error| FormatError::Invalid(error.to_string()))?;
691 let mut path = Vec::new();
692 if array_for_an_object(read, &written, &mut path) {
693 return Err(FormatError::Invalid(format!(
694 "{} is an array, where the schema has an object",
695 path_text(&path)
696 )));
697 }
698 Ok(())
699}
700
701/// A step from a JSON value into one it holds.
702enum Step<'a> {
703 /// The value under a key.
704 Key(&'a str),
705 /// An item of an array.
706 Index(usize),
707}
708
709/// Whether `read` holds an array where `written` holds an object, leaving `path` at the first
710/// such place. Only keys and items both hold are followed.
711fn array_for_an_object<'a>(
712 read: &'a serde_json::Value,
713 written: &serde_json::Value,
714 path: &mut Vec<Step<'a>>,
715) -> bool {
716 use serde_json::Value;
717 match (read, written) {
718 (Value::Array(_), Value::Object(_)) => true,
719 (Value::Object(read), Value::Object(written)) => {
720 for (key, value) in read {
721 if let Some(back) = written.get(key) {
722 path.push(Step::Key(key));
723 if array_for_an_object(value, back, path) {
724 return true;
725 }
726 path.pop();
727 }
728 }
729 false
730 }
731 (Value::Array(read), Value::Array(written)) => {
732 for (index, (value, back)) in read.iter().zip(written).enumerate() {
733 path.push(Step::Index(index));
734 if array_for_an_object(value, back, path) {
735 return true;
736 }
737 path.pop();
738 }
739 false
740 }
741 _ => false,
742 }
743}
744
745/// `path` as text, `rocket.stages[0].name`, each key [`shortened`] and the whole cut to its last
746/// 200 characters, so a file's long or deeply nested keys don't make a long message.
747fn path_text(path: &[Step]) -> String {
748 const MAX_CHARS: usize = 200;
749 if path.is_empty() {
750 return "the document".to_owned();
751 }
752 let mut text = String::new();
753 for step in path {
754 match step {
755 Step::Key(key) if text.is_empty() => text.push_str(&shortened(key)),
756 Step::Key(key) => {
757 text.push('.');
758 text.push_str(&shortened(key));
759 }
760 Step::Index(index) => text.push_str(&format!("[{index}]")),
761 }
762 }
763 let count = text.chars().count();
764 if count > MAX_CHARS {
765 let tail: String = text.chars().skip(count - MAX_CHARS).collect();
766 text = format!("…{tail}");
767 }
768 text
769}
770
771/// What kind of JSON value `value` is, in words.
772fn kind(value: &serde_json::Value) -> &'static str {
773 match value {
774 serde_json::Value::Null => "null",
775 serde_json::Value::Bool(_) => "a boolean",
776 serde_json::Value::Number(_) => "a number",
777 serde_json::Value::String(_) => "a string",
778 serde_json::Value::Array(_) => "an array",
779 serde_json::Value::Object(_) => "an object",
780 }
781}
782
783/// `text` cut to its first 40 characters, so a message quoting a file's value stays short.
784fn shortened(text: &str) -> String {
785 const MAX_CHARS: usize = 40;
786 match text.char_indices().nth(MAX_CHARS) {
787 Some((cut, _)) => format!("{}…", &text[..cut]),
788 None => text.to_owned(),
789 }
790}
791
792/// The document's JSON Schema (draft 2020-12), as committed under `schema/format/`.
793pub fn schema() -> serde_json::Value {
794 schemars::schema_for!(DesignFile).to_value()
795}
796
797/// [`schema`] as the canonical text committed: two-space indents and a final newline.
798pub fn schema_json() -> String {
799 let mut text = serde_json::to_string_pretty(&schema())
800 // A schema is a JSON value built from strings and maps, which always serialises.
801 .unwrap_or_default();
802 text.push('\n');
803 text
804}
805
806pub mod container;
807pub mod migrate;
808
809#[cfg(test)]
810mod tests;