Skip to main content

powerio/
version.rs

1//! The version every powerio authored document carries, and the rule a reader
2//! applies to it.
3//!
4//! powerio implements case formats and authors none. A file it writes for a
5//! foreign format carries that format's own version, which powerio reproduces
6//! and never sets. A document powerio authors instead carries [`VERSION`] under
7//! the key `powerio_version`, and [`supports`] decides whether this build reads
8//! it.
9
10use crate::VERSION;
11
12/// The key every powerio authored document uses for [`VERSION`].
13pub const VERSION_KEY: &str = "powerio_version";
14
15/// The 0.x lineage a 1.x build also reads.
16///
17/// 0.9.0 shipped the documents 1.0 freezes, on purpose: its formats are what
18/// 1.0.0 publishes, and 1.0.0 removes deprecated items rather than changing
19/// what is written. A gate that refused `0.9.0` at 1.0.0 would make every
20/// consumer regenerate an archive whose bytes did not change.
21const FROZEN_LINEAGE: (u64, u64) = (0, 9);
22
23/// Whether this build reads a document stamped `version`.
24///
25/// A document loads when it shares this build's lineage: the major version once
26/// it reaches 1, and the major and minor pair while the major is 0, which is
27/// what cargo and Pkg already mean by a 0.x bump. A version this function
28/// cannot parse as semver never loads.
29///
30/// One lineage crosses a major boundary: a 1.x build also reads a `0.9`
31/// document, because 0.9.0 shipped the formats 1.0 freezes. Nothing else
32/// crosses, and 2.0 reads neither.
33#[must_use]
34pub fn supports(version: &str) -> bool {
35    let Some(document) = lineage(version) else {
36        return false;
37    };
38    reads(current_lineage(), document)
39}
40
41/// [`supports`], with the build's own lineage supplied rather than read from
42/// [`VERSION`]. Split out so the 1.x behavior is testable before 1.x exists.
43fn reads(build: (u64, u64), document: (u64, u64)) -> bool {
44    let ((build_major, build_minor), (major, minor)) = (build, document);
45    if major == build_major {
46        return major != 0 || minor == build_minor;
47    }
48    build_major == 1 && document == FROZEN_LINEAGE
49}
50
51/// The message for a document this build does not read.
52///
53/// `document` names the artifact, spelled as a caller would recognize it
54/// (`.pio.json`, `the DC OPF bundle manifest`). An empty `version` is the
55/// document that states none, which every release before 0.9.0 wrote.
56#[must_use]
57pub fn reject(document: &str, version: &str) -> String {
58    let states = if version.is_empty() {
59        format!("{document} states no `{VERSION_KEY}`, so it was written before powerio 0.9.0")
60    } else {
61        format!("{document} states `{VERSION_KEY}` {version}")
62    };
63    format!(
64        "{states}; this build reads {}; regenerate it with powerio {VERSION}",
65        lineage_label()
66    )
67}
68
69/// The lineage this build reads, spelled for a message: `0.9.x` while the major
70/// is 0, `major version N` afterwards. A 1.x build names the 0.x lineage it also
71/// reads, so a caller holding a `0.9.0` document is not told to regenerate it.
72#[must_use]
73pub fn lineage_label() -> String {
74    match current_lineage() {
75        (0, minor) => format!("0.{minor}.x"),
76        (1, _) => format!(
77            "major version 1 and {}.{}.x",
78            FROZEN_LINEAGE.0, FROZEN_LINEAGE.1
79        ),
80        (major, _) => format!("major version {major}"),
81    }
82}
83
84/// The lineage as a path segment: `0.9` while the major is 0, `1` afterwards.
85///
86/// Names the directory a served JSON Schema lives under, so the published
87/// location moves when and only when a document stops loading.
88#[must_use]
89pub fn lineage_path() -> String {
90    match current_lineage() {
91        (0, minor) => format!("0.{minor}"),
92        (major, _) => major.to_string(),
93    }
94}
95
96fn current_lineage() -> (u64, u64) {
97    lineage(VERSION).expect("the crate version is valid semver")
98}
99
100fn lineage(version: &str) -> Option<(u64, u64)> {
101    // Accept a semver core `MAJOR.MINOR.PATCH` with an optional prerelease
102    // (`-...`) or build (`+...`) tag, so a forward compatible writer that
103    // stamps e.g. `0.9.1-rc.1` is not rejected. Split the build tag off first:
104    // `+` cannot appear in a prerelease, but a hyphen is legal inside build
105    // metadata (`1.0.0+build-x`), so splitting on `-` first would cut inside
106    // the build tag and reject a valid version.
107    let (rest, build) = match version.split_once('+') {
108        Some((rest, build)) => (rest, Some(build)),
109        None => (version, None),
110    };
111    let (core, pre) = match rest.split_once('-') {
112        Some((core, pre)) => (core, Some(pre)),
113        None => (rest, None),
114    };
115    if pre.is_some_and(|s| !valid_suffix(s)) || build.is_some_and(|s| !valid_suffix(s)) {
116        return None;
117    }
118    let mut parts = core.split('.');
119    let major = parts.next()?;
120    let minor = parts.next()?;
121    let patch = parts.next()?;
122    if parts.next().is_some() {
123        return None;
124    }
125    let major = parse_number(major)?;
126    let minor = parse_number(minor)?;
127    parse_number(patch)?;
128    Some((major, minor))
129}
130
131fn parse_number(s: &str) -> Option<u64> {
132    if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) || (s.len() > 1 && s.starts_with('0'))
133    {
134        return None;
135    }
136    s.parse().ok()
137}
138
139fn valid_suffix(s: &str) -> bool {
140    !s.is_empty()
141        && s.split('.').all(|part| {
142            !part.is_empty() && part.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
143        })
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    #[test]
151    fn lineage_parses_semver_suffixes() {
152        assert_eq!(lineage("1.2.3"), Some((1, 2)));
153        assert_eq!(lineage("1.0.0-rc.1"), Some((1, 0)));
154        // A hyphen inside build metadata is legal semver; splitting on `-`
155        // first used to cut inside the build tag and reject the version.
156        assert_eq!(lineage("1.0.0+build-x"), Some((1, 0)));
157        assert_eq!(lineage("0.9.0"), Some((0, 9)));
158    }
159
160    #[test]
161    fn lineage_rejects_what_is_not_semver() {
162        for bad in [
163            "", "1", "1.2", "1.2.3.4", "01.2.3", "1.x.3", "1.2.3-", "1.2.3+",
164        ] {
165            assert_eq!(lineage(bad), None, "{bad}");
166        }
167    }
168
169    #[test]
170    fn this_build_reads_its_own_version() {
171        assert!(supports(VERSION));
172    }
173
174    #[test]
175    fn a_zero_x_minor_is_its_own_lineage() {
176        // While the major is 0 a minor bump is incompatible, so 0.8 and 0.9
177        // do not read each other. Both are read by their own patches.
178        let (major, minor) = current_lineage();
179        assert_eq!(major, 0, "update this test at 1.0.0");
180        assert!(supports(&format!("0.{minor}.0")));
181        assert!(supports(&format!("0.{minor}.99")));
182        assert!(!supports(&format!("0.{}.0", minor + 1)));
183        assert!(!supports(&format!("0.{}.0", minor - 1)));
184        assert!(!supports("1.0.0"));
185    }
186
187    #[test]
188    fn one_x_reads_the_lineage_it_froze() {
189        // 0.9.0 ships the documents 1.0 publishes, so a 1.x build reads a 0.9
190        // document rather than making every consumer regenerate an archive
191        // whose bytes did not change. Without this, the release goal — that
192        // 0.9.0's formats are 1.0.0's — is false for every document at rest.
193        assert!(reads((1, 0), FROZEN_LINEAGE));
194        assert!(reads((1, 7), FROZEN_LINEAGE));
195        assert!(reads((1, 0), (1, 4)), "a 1.x build reads any 1.x document");
196    }
197
198    #[test]
199    fn the_frozen_lineage_is_never_behind_this_build() {
200        // FROZEN_LINEAGE is the last 0.x, the one a 1.x build also reads. It is
201        // a constant with nothing tying it to the crate version, so a 0.x
202        // released past it would be a lineage no 1.x build reads. Whoever cuts
203        // that release moves the constant or drops the carve-out.
204        let (major, minor) = current_lineage();
205        if major == 0 {
206            assert!(
207                (major, minor) <= FROZEN_LINEAGE,
208                "0.{minor} ships past the frozen lineage 0.{}",
209                FROZEN_LINEAGE.1
210            );
211        }
212    }
213
214    #[test]
215    fn nothing_else_crosses_a_major_boundary() {
216        assert!(!reads((1, 0), (0, 8)), "only the frozen lineage crosses");
217        assert!(!reads((2, 0), FROZEN_LINEAGE), "2.0 froze nothing");
218        assert!(!reads((0, 9), (1, 0)), "0.9 cannot read the future");
219        assert!(!reads((0, 9), (0, 8)), "a 0.x minor is its own lineage");
220    }
221
222    #[test]
223    fn reject_names_the_document_and_both_versions() {
224        let message = reject(".pio.json", "0.2.1");
225        assert!(message.contains(".pio.json"), "{message}");
226        assert!(message.contains("0.2.1"), "{message}");
227        assert!(message.contains(VERSION), "{message}");
228    }
229}