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 = include_str!("../SCHEMA_VERSION").trim_ascii_end();
mod migrations;
#[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 `schemaVersion`")]
MissingVersion,
#[error("artifact schema version field `schemaVersion` 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 `{artifact}` has no migration path from schema version `{from}` to `{to}`")]
MissingMigrationPath {
artifact: &'static str,
from: SchemaVersion,
to: 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::migrations;
use crate::object_mut;
use crate::parse_current_version;
use crate::validate_kind;
pub const KIND: &str = "monochange.releaseRecord";
pub(crate) const SCHEMA_VERSION_FIELD: &str = "schemaVersion";
pub(crate) const LEGACY_VERSION_FIELD: &str = "v";
pub fn current_version() -> Result<SchemaVersion, SchemaError> {
Ok(current_schema_version_for_error())
}
#[must_use]
pub fn current_populated_artifact_json() -> String {
populated_artifact_json(CURRENT_SCHEMA_VERSION_TEXT)
}
#[must_use]
pub fn populated_artifact_json(version: &str) -> String {
let release_version = format!("{version}.0");
let tag_name = format!("v{release_version}");
let schema_tag_name = format!("monochange_schema/v{release_version}");
let artifact = serde_json::json!({
"schemaVersion": version,
"kind": KIND,
"createdAt": "2026-01-01T00:00:00Z",
"command": "mc release --commit",
"version": release_version.as_str(),
"versions": {
"main": release_version.as_str(),
"monochange_schema": release_version.as_str()
},
"releaseTargets": [
{
"id": "main",
"kind": "group",
"version": release_version.as_str(),
"versionFormat": "primary",
"tag": true,
"release": true,
"tagName": tag_name.as_str(),
"members": ["monochange", "monochange_core"]
},
{
"id": "monochange_schema",
"kind": "package",
"version": release_version.as_str(),
"versionFormat": "namespaced",
"tag": false,
"release": false,
"tagName": schema_tag_name.as_str(),
"members": []
}
],
"releasedPackages": ["monochange", "monochange_core", "monochange_schema"],
"changedFiles": [
"Cargo.toml",
"crates/monochange_schema/Cargo.toml",
"crates/monochange_schema/schemas/artifacts/current/release-record/01.json"
],
"updatedChangelogs": ["changelog.md"],
"deletedChangesets": [".changeset/release-record-schema-compat.md"],
"changesets": [
{
"path": ".changeset/release-record-schema-compat.md",
"summary": "Keep release record schema compatibility checks stable",
"details": "Generated release-record artifact fixtures are parsed through the same migration path as commit-embedded records.",
"targets": [
{
"id": "monochange_schema",
"kind": "package",
"bump": "major",
"origin": "frontmatter",
"evidenceRefs": ["crates/monochange_schema/src/lib.rs"],
"changeType": "fix",
"causedBy": ["release-record-schema-compat"]
}
]
}
],
"provider": {
"kind": "github",
"owner": "monochange",
"repo": "monochange",
"host": "github.com"
}
});
serde_json::to_string_pretty(&artifact)
.unwrap_or_else(|error| panic!("serialize release-record artifact fixture: {error}"))
}
pub fn render_current_value(mut value: Value) -> Result<Value, SchemaError> {
let object = object_mut(&mut value)?;
validate_kind(object, KIND)?;
migrations::remove_top_level_field(&mut value, LEGACY_VERSION_FIELD)?;
let object = object_mut(&mut value)?;
object.insert(
SCHEMA_VERSION_FIELD.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(SCHEMA_VERSION_FIELD)
.or_else(|| object.get(LEGACY_VERSION_FIELD))
.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,
});
}
migrations::apply_release_record_edges(&mut value, version, current)?;
migrations::remove_top_level_field(&mut value, LEGACY_VERSION_FIELD)?;
let object = object_mut(&mut value)?;
object.insert(
SCHEMA_VERSION_FIELD.to_string(),
Value::String(CURRENT_SCHEMA_VERSION_TEXT.to_string()),
);
Ok(value)
}
}
pub mod config {
#[must_use]
pub fn populated_artifact_json() -> String {
let artifact = serde_json::json!({
"source": {
"owner": "monochange",
"repo": "monochange",
"provider": "github"
}
});
serde_json::to_string_pretty(&artifact)
.unwrap_or_else(|error| panic!("serialize config artifact fixture: {error}"))
}
}
#[cfg(test)]
#[path = "__tests__/lib_tests.rs"]
mod tests;