1use crate::VERSION;
11
12pub const VERSION_KEY: &str = "powerio_version";
14
15const FROZEN_LINEAGE: (u64, u64) = (0, 9);
22
23#[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
41fn 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#[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#[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#[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 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 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 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 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 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}