use crate::VERSION;
pub const VERSION_KEY: &str = "powerio_version";
const FROZEN_LINEAGE: (u64, u64) = (0, 10);
#[must_use]
pub fn supports(version: &str) -> bool {
let Some(document) = lineage(version) else {
return false;
};
reads(current_lineage(), document)
}
fn reads(build: (u64, u64), document: (u64, u64)) -> bool {
let ((build_major, build_minor), (major, minor)) = (build, document);
if major == build_major {
return major != 0 || minor == build_minor;
}
build_major == 1 && document == FROZEN_LINEAGE
}
#[must_use]
pub fn reject(document: &str, version: &str) -> String {
reject_as(document, version, &lineage_label())
}
#[must_use]
pub fn reject_as(document: &str, version: &str, lineage: &str) -> String {
let version = bounded_version(version);
let states = if version.is_empty() {
format!("{document} states no `{VERSION_KEY}`, so it was written before powerio 0.9.0")
} else {
format!("{document} states `{VERSION_KEY}` {version}")
};
format!("{states}; this build reads {lineage}")
}
pub const MAX_REJECTED_VERSION_BYTES: usize = 64;
fn bounded_version(version: &str) -> std::borrow::Cow<'_, str> {
if !version.chars().any(char::is_control) && version.len() <= MAX_REJECTED_VERSION_BYTES {
return std::borrow::Cow::Borrowed(version);
}
let mut line: String = version
.chars()
.map(|character| {
if character.is_control() {
' '
} else {
character
}
})
.collect();
if line.len() > MAX_REJECTED_VERSION_BYTES {
let mut end = MAX_REJECTED_VERSION_BYTES;
while !line.is_char_boundary(end) {
end -= 1;
}
line.truncate(end);
line.push('\u{2026}');
}
std::borrow::Cow::Owned(line)
}
#[must_use]
pub fn lineage_label() -> String {
match current_lineage() {
(0, minor) => format!("0.{minor}.x"),
(1, _) => format!(
"major version 1 and {}.{}.x",
FROZEN_LINEAGE.0, FROZEN_LINEAGE.1
),
(major, _) => format!("major version {major}"),
}
}
#[must_use]
pub fn lineage_path() -> String {
match current_lineage() {
(0, minor) => format!("0.{minor}"),
(major, _) => major.to_string(),
}
}
fn current_lineage() -> (u64, u64) {
lineage(VERSION).expect("the crate version is valid semver")
}
fn lineage(version: &str) -> Option<(u64, u64)> {
let (rest, build) = match version.split_once('+') {
Some((rest, build)) => (rest, Some(build)),
None => (version, None),
};
let (core, pre) = match rest.split_once('-') {
Some((core, pre)) => (core, Some(pre)),
None => (rest, None),
};
if pre.is_some_and(|s| !valid_suffix(s)) || build.is_some_and(|s| !valid_suffix(s)) {
return None;
}
let mut parts = core.split('.');
let major = parts.next()?;
let minor = parts.next()?;
let patch = parts.next()?;
if parts.next().is_some() {
return None;
}
let major = parse_number(major)?;
let minor = parse_number(minor)?;
parse_number(patch)?;
Some((major, minor))
}
fn parse_number(s: &str) -> Option<u64> {
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) || (s.len() > 1 && s.starts_with('0'))
{
return None;
}
s.parse().ok()
}
fn valid_suffix(s: &str) -> bool {
!s.is_empty()
&& s.split('.').all(|part| {
!part.is_empty() && part.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lineage_parses_semver_suffixes() {
assert_eq!(lineage("1.2.3"), Some((1, 2)));
assert_eq!(lineage("1.0.0-rc.1"), Some((1, 0)));
assert_eq!(lineage("1.0.0+build-x"), Some((1, 0)));
assert_eq!(lineage("0.9.0"), Some((0, 9)));
}
#[test]
fn lineage_rejects_what_is_not_semver() {
for bad in [
"", "1", "1.2", "1.2.3.4", "01.2.3", "1.x.3", "1.2.3-", "1.2.3+",
] {
assert_eq!(lineage(bad), None, "{bad}");
}
}
#[test]
fn this_build_reads_its_own_version() {
assert!(supports(VERSION));
}
#[test]
fn a_zero_x_minor_is_its_own_lineage() {
assert!(reads((0, 9), (0, 9)));
assert!(!reads((0, 9), (0, 10)));
assert!(!reads((0, 9), (0, 8)));
assert!(!reads((0, 9), (1, 0)));
}
#[test]
fn this_build_reads_its_own_version_and_the_frozen_lineage() {
assert!(supports(VERSION));
assert!(supports("0.10.0"));
assert!(supports("0.10.99"));
assert!(!supports("0.9.0"));
assert!(!supports("0.8.0"));
assert!(!supports("2.0.0"));
}
#[test]
fn one_x_reads_the_lineage_it_froze() {
assert!(reads((1, 0), FROZEN_LINEAGE));
assert!(reads((1, 7), FROZEN_LINEAGE));
assert!(reads((1, 0), (1, 4)), "a 1.x build reads any 1.x document");
}
#[test]
fn the_frozen_lineage_is_never_behind_this_build() {
let (major, minor) = current_lineage();
if major == 0 {
assert!(
(major, minor) <= FROZEN_LINEAGE,
"0.{minor} ships past the frozen lineage 0.{}",
FROZEN_LINEAGE.1
);
}
}
#[test]
fn nothing_else_crosses_a_major_boundary() {
assert!(!reads((1, 0), (0, 8)), "only the frozen lineage crosses");
assert!(!reads((2, 0), FROZEN_LINEAGE), "2.0 froze nothing");
assert!(!reads((0, 9), (1, 0)), "0.9 cannot read the future");
assert!(!reads((0, 9), (0, 8)), "a 0.x minor is its own lineage");
}
#[test]
fn reject_is_one_bounded_line_whatever_the_document_states() {
let huge = "9".repeat(1 << 20);
let message = reject(".pio.json", &huge);
assert!(
message.len() < MAX_REJECTED_VERSION_BYTES + 160,
"{}",
message.len()
);
let hostile = "1.0\n\rfake diagnostic: \x1b[31mred";
let message = reject(".pio.json", hostile);
assert!(!message.contains('\n'), "{message}");
assert!(!message.contains('\r'), "{message}");
assert!(!message.contains('\x1b'), "{message}");
let message = reject(".pio.json", "0.2.1");
assert!(message.contains("0.2.1"), "{message}");
assert!(message.contains(&lineage_label()), "{message}");
}
#[test]
fn reject_states_the_diagnosis_and_no_remedy() {
let message = reject(".pio.json", "0.2.1");
assert!(message.contains(".pio.json"), "{message}");
assert!(message.contains("0.2.1"), "{message}");
assert!(message.contains(&lineage_label()), "{message}");
assert!(!message.contains("regenerate"), "{message}");
}
}