use std::fmt;
use std::str::FromStr;
use serde::Serialize;
use serde::Serializer;
use serde_json::Value;
use thiserror::Error;
pub const CURRENT_SCHEMA_VERSION_TEXT: &str = extract_major_minor(env!("CARGO_PKG_VERSION"));
const fn extract_major_minor(version: &str) -> &str {
let bytes = version.as_bytes();
let mut remaining = bytes;
let mut index = 0;
let mut dots = 0;
while let Some((byte, rest)) = remaining.split_first() {
if *byte == b'.' {
dots += 1;
if dots == 2 {
break;
}
}
remaining = rest;
index += 1;
}
version.split_at(index).0
}
#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash)]
pub struct SchemaVersion {
major: u64,
minor: u64,
}
impl SchemaVersion {
#[must_use]
pub const fn new(major: u64, minor: u64) -> Self {
Self { major, minor }
}
#[must_use]
pub const fn major(self) -> u64 {
self.major
}
#[must_use]
pub const fn minor(self) -> u64 {
self.minor
}
pub fn from_package_version(package_version: &str) -> Result<Self, SchemaVersionParseError> {
let (major, remainder) = package_version
.split_once('.')
.ok_or(SchemaVersionParseError::MissingMinor)?;
let (minor, patch) = remainder
.split_once('.')
.ok_or(SchemaVersionParseError::MissingPatch)?;
if patch.is_empty()
|| patch.contains('.')
|| !patch.chars().all(|character| character.is_ascii_digit())
{
return Err(SchemaVersionParseError::InvalidPatch(patch.to_string()));
}
let major = parse_component(major, SchemaVersionParseError::InvalidMajor)?;
let minor = parse_component(minor, SchemaVersionParseError::InvalidMinor)?;
Ok(Self { major, minor })
}
}
impl fmt::Display for SchemaVersion {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{}.{}", self.major, self.minor)
}
}
impl FromStr for SchemaVersion {
type Err = SchemaVersionParseError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
let (major, minor) = value
.split_once('.')
.ok_or(SchemaVersionParseError::MissingSeparator)?;
if minor.contains('.') {
return Err(SchemaVersionParseError::TooManyComponents);
}
let major = parse_component(major, SchemaVersionParseError::InvalidMajor)?;
let minor = parse_component(minor, SchemaVersionParseError::InvalidMinor)?;
Ok(Self { major, minor })
}
}
impl Serialize for SchemaVersion {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
fn parse_component(
component: &str,
make_error: fn(String) -> SchemaVersionParseError,
) -> Result<u64, SchemaVersionParseError> {
if component.is_empty()
|| !component
.chars()
.all(|character| character.is_ascii_digit())
{
return Err(make_error(component.to_string()));
}
component
.parse::<u64>()
.map_err(|_| make_error(component.to_string()))
}
pub fn current_schema_version() -> Result<SchemaVersion, SchemaVersionParseError> {
SchemaVersion::from_str(CURRENT_SCHEMA_VERSION_TEXT)
}
fn current_schema_version_for_error() -> SchemaVersion {
current_schema_version().unwrap_or_else(|_| SchemaVersion::new(0, 0))
}
#[derive(Debug, Clone, Eq, Error, PartialEq)]
pub enum SchemaVersionParseError {
#[error("missing `.` separator")]
MissingSeparator,
#[error("missing major component")]
MissingMajor,
#[error("missing minor component")]
MissingMinor,
#[error("missing patch component")]
MissingPatch,
#[error("expected exactly major.minor")]
TooManyComponents,
#[error("invalid major component `{0}`")]
InvalidMajor(String),
#[error("invalid minor component `{0}`")]
InvalidMinor(String),
#[error("invalid patch component `{0}`")]
InvalidPatch(String),
}
#[derive(Debug, Error)]
pub enum SchemaError {
#[error("artifact is not a JSON object")]
NotObject,
#[error("artifact is missing required `kind`")]
MissingKind,
#[error("artifact uses unsupported kind `{actual}`; expected `{expected}`")]
UnsupportedKind {
actual: String,
expected: &'static str,
},
#[error("artifact is missing required schema version field `v`")]
MissingVersion,
#[error("artifact schema version field `v` must be a string")]
NonStringVersion,
#[error("artifact uses invalid schema version `{version}`: {source}")]
InvalidVersion {
version: String,
source: SchemaVersionParseError,
},
#[error("current schema version `{version}` is invalid: {source}")]
InvalidCurrentVersion {
version: &'static str,
source: SchemaVersionParseError,
},
#[error(
"artifact uses unsupported schema version `{actual}`; current supported version is `{current}`"
)]
UnsupportedVersion {
actual: String,
current: SchemaVersion,
},
#[error("artifact json error: {0}")]
Json(#[from] serde_json::Error),
}
fn object_mut(value: &mut Value) -> Result<&mut serde_json::Map<String, Value>, SchemaError> {
value.as_object_mut().ok_or(SchemaError::NotObject)
}
fn validate_kind(
object: &serde_json::Map<String, Value>,
expected: &'static str,
) -> Result<(), SchemaError> {
let actual = object
.get("kind")
.and_then(Value::as_str)
.ok_or(SchemaError::MissingKind)?;
if actual != expected {
return Err(SchemaError::UnsupportedKind {
actual: actual.to_string(),
expected,
});
}
Ok(())
}
fn parse_current_version(value: &Value) -> Result<SchemaVersion, SchemaError> {
let version = value.as_str().ok_or(SchemaError::NonStringVersion)?;
SchemaVersion::from_str(version).map_err(|source| {
SchemaError::InvalidVersion {
version: version.to_string(),
source,
}
})
}
pub mod release_record {
use serde_json::Value;
use crate::CURRENT_SCHEMA_VERSION_TEXT;
use crate::SchemaError;
use crate::SchemaVersion;
use crate::current_schema_version_for_error;
use crate::object_mut;
use crate::parse_current_version;
use crate::validate_kind;
pub const KIND: &str = "monochange.releaseRecord";
const INTERNAL_SCHEMA_VERSION_FIELD: &str = "schemaVersion";
pub fn current_version() -> Result<SchemaVersion, SchemaError> {
Ok(current_schema_version_for_error())
}
pub fn render_current_value(mut value: Value) -> Result<Value, SchemaError> {
let object = object_mut(&mut value)?;
validate_kind(object, KIND)?;
object.remove(INTERNAL_SCHEMA_VERSION_FIELD);
object.insert(
"v".to_string(),
Value::String(CURRENT_SCHEMA_VERSION_TEXT.to_string()),
);
Ok(value)
}
pub fn migrate_value(mut value: Value) -> Result<Value, SchemaError> {
let object = object_mut(&mut value)?;
validate_kind(object, KIND)?;
let version_value = object.get("v").ok_or(SchemaError::MissingVersion)?;
let version = parse_current_version(version_value)?;
let current = current_version()?;
if version != current {
return Err(SchemaError::UnsupportedVersion {
actual: version.to_string(),
current,
});
}
Ok(value)
}
}
pub mod migration_changelog {
use serde::Serialize;
use crate::SchemaVersion;
pub const ENTRIES: &[MigrationChangelogEntry] = &[];
#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MigrationChangelogEntry {
pub artifact: &'static str,
pub from: MigrationSource,
pub to: SchemaVersion,
pub operation: MigrationOperation,
pub changes: &'static [MigrationChange],
pub noop: bool,
pub reason: Option<&'static str>,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum MigrationSource {
Version {
v: SchemaVersion,
},
}
#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum MigrationOperation {
RenameField,
AddField,
RemoveField,
Noop,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MigrationChange {
pub operation: MigrationOperation,
pub path: &'static str,
pub replacement: Option<&'static str>,
pub reason: Option<&'static str>,
}
#[must_use]
pub fn entries_for_artifact(artifact: &str) -> Vec<&'static MigrationChangelogEntry> {
ENTRIES
.iter()
.filter(|entry| entry.artifact == artifact)
.collect()
}
pub fn to_json_pretty() -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(ENTRIES)
}
}
#[cfg(test)]
#[path = "__tests__/lib_tests.rs"]
mod tests;