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}