Skip to main content

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;