Skip to main content

hpr_format/
migrate.rs

1//! Migrations: a document of an older version rewritten into the current version's shape.
2//!
3//! Each step takes a document of one version to the next, working on its JSON before any type
4//! reads it, since the older version's types are gone. A document is taken through every step from
5//! its own version to [`VERSION`], then read as a document of the current version,
6//! so everything the current reader checks, it still checks ([ADR-112][adr-112]).
7//!
8//! | from | to | what changes |
9//! |---|---|---|
10//! | 0.1 | 0.2 | `attachments`, the source file's other files, is renamed `source_files`, leaving "attachment" to mean a file carried beside the design in a [`.hprz`](crate::container); a `.ork` source's `airframe_not_as_written`, which 0.1 didn't record, is worked out from the configurations, or set to [`UNKNOWN`] when they don't show it |
11//!
12//! [adr-112]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-112-m33b-the-hprz-container-and-migrations-2026-09-29
13
14use serde_json::{Map, Value};
15
16use crate::{VERSION, Version};
17
18/// The oldest version this reader migrates from.
19pub const OLDEST: Version = Version { major: 0, minor: 1 };
20
21/// One step: a document of `from` rewritten as a document of `to`.
22struct Step {
23    from: Version,
24    to: Version,
25    /// Rewrites the document's top-level object, or says why the document isn't one of `from`.
26    apply: fn(&mut Map<String, Value>) -> Result<(), String>,
27}
28
29/// Every step, oldest first; each `to` is the next `from`, and the last `to` is [`VERSION`].
30const STEPS: &[Step] = &[Step {
31    from: Version { major: 0, minor: 1 },
32    to: Version { major: 0, minor: 2 },
33    apply: source_files,
34}];
35
36/// Whether this reader migrates a document of `version` to the current one.
37pub fn migrates_from(version: Version) -> bool {
38    STEPS.iter().any(|step| step.from == version)
39}
40
41/// Takes `document`, of `version`, through every step to [`VERSION`], setting its `version` at
42/// each.
43///
44/// Returns why not when the document doesn't hold what its version's step needs; a version no step
45/// starts from is left as it is, for the caller to refuse.
46pub(crate) fn migrate(document: &mut Map<String, Value>, version: Version) -> Result<(), String> {
47    let mut at = version;
48    for step in STEPS.iter().skip_while(|step| step.from != version) {
49        debug_assert_eq!(step.from, at, "the steps follow each other");
50        (step.apply)(document).map_err(|why| format!("as a {} document, {why}", step.from))?;
51        document.insert("version".to_owned(), Value::String(step.to.to_string()));
52        at = step.to;
53    }
54    debug_assert!(
55        at == version || at == VERSION,
56        "the last step ends at VERSION"
57    );
58    Ok(())
59}
60
61/// 0.1 to 0.2: `attachments` becomes `source_files`, and a `.ork` source's
62/// `airframe_not_as_written` is worked out from the configurations.
63///
64/// 0.1 kept why a `.ork`'s airframe was not read exactly as written only in a configuration left
65/// out for it. The `.ork` reader asks that after a configuration's motors and their ignition and
66/// before its stages' separation, and a rocket whose airframe it can't read flies no
67/// configuration ([`hpr_io::ork::NotFlown`]). So a configuration left out for the airframe gives
68/// the reason; one that flies, or is left out for its separation, shows the airframe was read as
69/// written; and when every configuration was left out for an earlier reason, or there is none, it
70/// can't be known, and the reason says so ([`UNKNOWN`]), so that nothing flies the rocket with a
71/// motor of another's choosing on a guess.
72fn source_files(document: &mut Map<String, Value>) -> Result<(), String> {
73    if document.contains_key("source_files") {
74        return Err("it has a \"source_files\", which 0.1 doesn't define".to_owned());
75    }
76    let files = document
77        .remove("attachments")
78        .ok_or_else(|| "it has no \"attachments\"".to_owned())?;
79    document.insert("source_files".to_owned(), files);
80    let configurations = document
81        .get("motors")
82        .and_then(|motors| motors.get("configurations"))
83        .and_then(Value::as_array)
84        .map(Vec::as_slice)
85        .unwrap_or_default();
86    let reason = configurations
87        .iter()
88        .find(|configuration| why(configuration) == Some(AIRFRAME))
89        .and_then(|configuration| configuration.get("left_out")?.get("message")?.as_str())
90        .map(|message| message.strip_prefix(PREFIX).unwrap_or(message).to_owned());
91    let as_written = configurations
92        .iter()
93        .any(|configuration| matches!(why(configuration), None | Some(SEPARATION)));
94    let source = document
95        .get_mut("provenance")
96        .and_then(|provenance| provenance.get_mut("source"))
97        .and_then(Value::as_object_mut);
98    if let Some(source) = source {
99        if source.contains_key("airframe_not_as_written") {
100            return Err(
101                "its source has an \"airframe_not_as_written\", which 0.1 doesn't define"
102                    .to_owned(),
103            );
104        }
105        if source.get("format").and_then(Value::as_str) == Some("ork") {
106            let reason = match (reason, as_written) {
107                (Some(reason), _) => Some(reason),
108                (None, true) => None,
109                (None, false) => Some(UNKNOWN.to_owned()),
110            };
111            if let Some(reason) = reason {
112                source.insert("airframe_not_as_written".to_owned(), Value::String(reason));
113            }
114        }
115    }
116    Ok(())
117}
118
119/// Why a 0.1 configuration was left out, as 0.1 writes it; `None` if it flies, and `""` if its
120/// reason isn't a string, which the reader refuses once migrated.
121fn why(configuration: &Value) -> Option<&str> {
122    configuration
123        .get("left_out")
124        .filter(|left_out| !left_out.is_null())
125        .map(|left_out| left_out.get("why").and_then(Value::as_str).unwrap_or(""))
126}
127
128/// How 0.1 writes [`NotFlown::AirframeNotAsWritten`](hpr_io::ork::NotFlown::AirframeNotAsWritten).
129const AIRFRAME: &str = "airframe_not_as_written";
130
131/// How 0.1 writes [`NotFlown::SeparationNotFlown`](hpr_io::ork::NotFlown::SeparationNotFlown),
132/// the one reason the `.ork` reader asks after the airframe.
133const SEPARATION: &str = "separation_not_flown";
134
135/// What the `.ork` reader puts before the reason in such a configuration's message.
136const PREFIX: &str = "the airframe was not read exactly as written: ";
137
138/// The reason a 0.1 document migrates with when none of its configurations shows whether its
139/// `.ork` airframe was read exactly as written.
140pub const UNKNOWN: &str = "unknown: the document is from version 0.1 of the format, which \
141                           didn't record it, and none of its motor configurations shows it; \
142                           convert the .ork again to find out";
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    #[test]
149    fn the_steps_run_from_the_oldest_to_the_current_version() {
150        assert_eq!(STEPS[0].from, OLDEST);
151        for pair in STEPS.windows(2) {
152            assert_eq!(pair[0].to, pair[1].from);
153        }
154        assert_eq!(STEPS.last().map(|step| step.to), Some(VERSION));
155        assert!(migrates_from(OLDEST));
156        assert!(!migrates_from(VERSION));
157    }
158}