use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Eq, Error, Serialize, Deserialize)]
pub enum RegistryError {
#[error("Version '{0}' not found in registry")]
VersionNotFound(String),
#[error("Schema SDL cannot be empty")]
EmptySdl,
#[error("Internal error: {0}")]
Internal(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SchemaVersion {
pub id: u64,
pub version: String,
pub schema_sdl: String,
pub created_at: u64,
}
impl SchemaVersion {
pub fn created_at_display(&self) -> String {
let secs = self.created_at;
let days = secs / 86400;
let rem = secs % 86400;
let hours = rem / 3600;
let minutes = (rem % 3600) / 60;
let seconds = rem % 60;
let year = 1970 + days / 365;
format!("{year}-xx-xx {hours:02}:{minutes:02}:{seconds:02} UTC")
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SchemaDiff {
pub added_types: Vec<String>,
pub removed_types: Vec<String>,
pub modified_types: Vec<String>,
pub added_fields: HashMap<String, Vec<String>>,
pub removed_fields: HashMap<String, Vec<String>>,
pub modified_fields: HashMap<String, Vec<String>>,
}
impl SchemaDiff {
pub fn is_empty(&self) -> bool {
self.added_types.is_empty()
&& self.removed_types.is_empty()
&& self.modified_types.is_empty()
&& self.added_fields.is_empty()
&& self.removed_fields.is_empty()
&& self.modified_fields.is_empty()
}
pub fn total_changes(&self) -> usize {
self.added_types.len()
+ self.removed_types.len()
+ self.modified_types.len()
+ self.added_fields.values().map(Vec::len).sum::<usize>()
+ self.removed_fields.values().map(Vec::len).sum::<usize>()
+ self.modified_fields.values().map(Vec::len).sum::<usize>()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChangeEntry {
pub version: String,
pub description: String,
pub is_breaking: bool,
pub timestamp: u64,
}
#[derive(Debug, Clone)]
struct ParsedType {
kind: String,
name: String,
fields: HashMap<String, String>,
enum_values: Vec<String>,
}
fn parse_sdl(sdl: &str) -> HashMap<String, ParsedType> {
let mut types: HashMap<String, ParsedType> = HashMap::new();
let mut chars = sdl.chars().peekable();
let mut current_type: Option<ParsedType> = None;
let mut brace_depth = 0i32;
let mut current_token = String::new();
while let Some(&ch) = chars.peek() {
chars.next();
match ch {
'#' => {
for c in chars.by_ref() {
if c == '\n' {
break;
}
}
}
'{' => {
let trimmed = current_token.trim().to_owned();
current_token.clear();
if !trimmed.is_empty() && brace_depth == 0 {
let parts: Vec<&str> = trimmed.splitn(3, char::is_whitespace).collect();
let kind = parts.first().copied().unwrap_or("").to_lowercase();
if matches!(
kind.as_str(),
"type" | "interface" | "enum" | "union" | "scalar" | "input"
) {
let name = parts.get(1).copied().unwrap_or("").to_owned();
if !name.is_empty() {
current_type = Some(ParsedType {
kind: kind.clone(),
name,
fields: HashMap::new(),
enum_values: Vec::new(),
});
}
}
}
brace_depth += 1;
}
'}' => {
brace_depth -= 1;
if brace_depth == 0 {
if let Some(t) = current_type.take() {
types.insert(t.name.clone(), t);
}
}
current_token.clear();
}
'\n' | '\r' => {
let trimmed = current_token.trim().to_owned();
if !trimmed.is_empty() {
if brace_depth == 0 {
let parts: Vec<&str> = trimmed.splitn(3, char::is_whitespace).collect();
let kind = parts.first().copied().unwrap_or("").to_lowercase();
if matches!(
kind.as_str(),
"type" | "interface" | "enum" | "union" | "scalar" | "input"
) {
let name = parts.get(1).copied().unwrap_or("").to_owned();
if !name.is_empty() {
current_type = Some(ParsedType {
kind: kind.clone(),
name,
fields: HashMap::new(),
enum_values: Vec::new(),
});
}
}
} else if brace_depth == 1 {
if let Some(ref mut t) = current_type {
parse_sdl_field_line(&trimmed, t);
}
}
}
current_token.clear();
}
_ => {
current_token.push(ch);
}
}
}
let trimmed = current_token.trim().to_owned();
if !trimmed.is_empty() && brace_depth == 1 {
if let Some(ref mut t) = current_type {
parse_sdl_field_line(&trimmed, t);
}
}
if let Some(t) = current_type {
types.insert(t.name.clone(), t);
}
types
}
fn parse_sdl_field_line(line: &str, t: &mut ParsedType) {
if line.trim().is_empty() || line.trim().starts_with('#') {
return;
}
if t.kind == "enum" {
let val = line.split_whitespace().next().unwrap_or("").to_owned();
if !val.is_empty() {
t.enum_values.push(val);
}
} else if t.kind == "union" {
} else {
if let Some(colon_pos) = line.find(':') {
let field_name = line[..colon_pos].trim().to_owned();
let field_name = field_name
.split('(')
.next()
.unwrap_or(&field_name)
.trim()
.to_owned();
let type_sig = line[colon_pos + 1..].trim().to_owned();
if !field_name.is_empty() {
t.fields.insert(field_name, type_sig);
}
}
}
}
fn compute_diff(
old_types: &HashMap<String, ParsedType>,
new_types: &HashMap<String, ParsedType>,
) -> SchemaDiff {
let mut diff = SchemaDiff::default();
let old_names: HashSet<&str> = old_types.keys().map(String::as_str).collect();
let new_names: HashSet<&str> = new_types.keys().map(String::as_str).collect();
for name in new_names.difference(&old_names) {
diff.added_types.push(name.to_string());
}
for name in old_names.difference(&new_names) {
diff.removed_types.push(name.to_string());
}
for name in old_names.intersection(&new_names) {
let old_t = &old_types[*name];
let new_t = &new_types[*name];
let added = field_diff_added(old_t, new_t);
let removed = field_diff_removed(old_t, new_t);
let modified = field_diff_modified(old_t, new_t);
if !added.is_empty() {
diff.added_fields.insert(name.to_string(), added);
}
if !removed.is_empty() {
diff.removed_fields
.insert(name.to_string(), removed.clone());
}
if !modified.is_empty() {
diff.modified_fields.insert(name.to_string(), modified);
}
if diff.removed_fields.contains_key(*name)
|| diff.modified_fields.contains_key(*name)
|| old_t.kind != new_t.kind
{
diff.modified_types.push(name.to_string());
}
}
diff
}
fn field_diff_added(old_t: &ParsedType, new_t: &ParsedType) -> Vec<String> {
let old_fields: HashSet<&str> = old_t.fields.keys().map(String::as_str).collect();
let new_fields: HashSet<&str> = new_t.fields.keys().map(String::as_str).collect();
new_fields
.difference(&old_fields)
.map(|f| f.to_string())
.collect()
}
fn field_diff_removed(old_t: &ParsedType, new_t: &ParsedType) -> Vec<String> {
let old_fields: HashSet<&str> = old_t.fields.keys().map(String::as_str).collect();
let new_fields: HashSet<&str> = new_t.fields.keys().map(String::as_str).collect();
old_fields
.difference(&new_fields)
.map(|f| f.to_string())
.collect()
}
fn field_diff_modified(old_t: &ParsedType, new_t: &ParsedType) -> Vec<String> {
let mut modified = Vec::new();
for (fname, old_sig) in &old_t.fields {
if let Some(new_sig) = new_t.fields.get(fname) {
if old_sig != new_sig {
modified.push(fname.clone());
}
}
}
modified
}
fn next_semver(last: Option<&str>) -> String {
match last {
None => "1.0.0".to_owned(),
Some(v) => {
let parts: Vec<u64> = v.split('.').filter_map(|s| s.parse().ok()).collect();
if parts.len() == 3 {
format!("{}.{}.{}", parts[0], parts[1], parts[2] + 1)
} else {
"1.0.0".to_owned()
}
}
}
}
fn monotonic_timestamp() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct SchemaRegistry {
versions: Vec<SchemaVersion>,
change_log: Vec<ChangeEntry>,
}
impl SchemaRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn register(&mut self, sdl: &str) -> SchemaVersion {
let id = self.versions.len() as u64 + 1;
let version = next_semver(self.versions.last().map(|v| v.version.as_str()));
let created_at = monotonic_timestamp();
let sv = SchemaVersion {
id,
version: version.clone(),
schema_sdl: sdl.to_owned(),
created_at,
};
self.versions.push(sv.clone());
if self.versions.len() > 1 {
let prev = &self.versions[self.versions.len() - 2];
let diff = self.diff_sdl(&prev.schema_sdl, sdl);
let breaking = self.is_breaking_change(&diff);
let description = summarize_diff(&diff);
self.change_log.push(ChangeEntry {
version,
description,
is_breaking: breaking,
timestamp: created_at,
});
} else {
self.change_log.push(ChangeEntry {
version: sv.version.clone(),
description: "Initial schema registration".to_owned(),
is_breaking: false,
timestamp: created_at,
});
}
sv
}
pub fn latest(&self) -> Option<&SchemaVersion> {
self.versions.last()
}
pub fn get(&self, version_str: &str) -> Option<&SchemaVersion> {
self.versions.iter().find(|v| v.version == version_str)
}
pub fn get_by_id(&self, id: u64) -> Option<&SchemaVersion> {
self.versions.iter().find(|v| v.id == id)
}
pub fn len(&self) -> usize {
self.versions.len()
}
pub fn is_empty(&self) -> bool {
self.versions.is_empty()
}
pub fn all_versions(&self) -> &[SchemaVersion] {
&self.versions
}
pub fn diff(&self, v1_str: &str, v2_str: &str) -> Result<SchemaDiff, RegistryError> {
let v1 = self
.get(v1_str)
.ok_or_else(|| RegistryError::VersionNotFound(v1_str.to_owned()))?;
let v2 = self
.get(v2_str)
.ok_or_else(|| RegistryError::VersionNotFound(v2_str.to_owned()))?;
Ok(self.diff_sdl(&v1.schema_sdl, &v2.schema_sdl))
}
pub fn diff_sdl(&self, old_sdl: &str, new_sdl: &str) -> SchemaDiff {
let old_types = parse_sdl(old_sdl);
let new_types = parse_sdl(new_sdl);
compute_diff(&old_types, &new_types)
}
pub fn is_breaking_change(&self, diff: &SchemaDiff) -> bool {
!diff.removed_types.is_empty()
|| !diff.removed_fields.is_empty()
|| !diff.modified_fields.is_empty()
}
pub fn changelog(&self) -> &[ChangeEntry] {
&self.change_log
}
}
fn summarize_diff(diff: &SchemaDiff) -> String {
let mut parts = Vec::new();
if !diff.added_types.is_empty() {
parts.push(format!("added types: {}", diff.added_types.join(", ")));
}
if !diff.removed_types.is_empty() {
parts.push(format!("removed types: {}", diff.removed_types.join(", ")));
}
if !diff.added_fields.is_empty() {
let count: usize = diff.added_fields.values().map(Vec::len).sum();
parts.push(format!("added {count} field(s)"));
}
if !diff.removed_fields.is_empty() {
let count: usize = diff.removed_fields.values().map(Vec::len).sum();
parts.push(format!("removed {count} field(s)"));
}
if !diff.modified_fields.is_empty() {
let count: usize = diff.modified_fields.values().map(Vec::len).sum();
parts.push(format!("modified {count} field(s)"));
}
if parts.is_empty() {
"No structural changes".to_owned()
} else {
parts.join("; ")
}
}
#[cfg(test)]
mod tests {
use super::*;
fn v1_sdl() -> &'static str {
"type Query {\n hello: String\n}\n"
}
fn v2_sdl() -> &'static str {
"type Query {\n hello: String\n world: Int\n}\n"
}
fn v3_sdl() -> &'static str {
"type Query {\n world: Int\n}\n"
}
#[test]
fn test_new_registry_is_empty() {
let reg = SchemaRegistry::new();
assert!(reg.is_empty());
assert_eq!(reg.len(), 0);
}
#[test]
fn test_default_is_empty() {
let reg = SchemaRegistry::default();
assert!(reg.is_empty());
}
#[test]
fn test_register_first_version_is_1_0_0() {
let mut reg = SchemaRegistry::new();
let sv = reg.register(v1_sdl());
assert_eq!(sv.version, "1.0.0");
assert_eq!(sv.id, 1);
}
#[test]
fn test_register_second_version_is_1_0_1() {
let mut reg = SchemaRegistry::new();
reg.register(v1_sdl());
let sv2 = reg.register(v2_sdl());
assert_eq!(sv2.version, "1.0.1");
assert_eq!(sv2.id, 2);
}
#[test]
fn test_register_increments_len() {
let mut reg = SchemaRegistry::new();
assert_eq!(reg.len(), 0);
reg.register(v1_sdl());
assert_eq!(reg.len(), 1);
reg.register(v2_sdl());
assert_eq!(reg.len(), 2);
}
#[test]
fn test_register_stores_sdl() {
let mut reg = SchemaRegistry::new();
let sv = reg.register(v1_sdl());
assert_eq!(sv.schema_sdl, v1_sdl());
}
#[test]
fn test_register_sets_created_at() {
let mut reg = SchemaRegistry::new();
let sv = reg.register(v1_sdl());
let _ = sv.created_at;
}
#[test]
fn test_latest_returns_last_registered() {
let mut reg = SchemaRegistry::new();
reg.register(v1_sdl());
let sv2 = reg.register(v2_sdl());
let latest = reg.latest().expect("should succeed");
assert_eq!(latest.version, sv2.version);
}
#[test]
fn test_latest_empty_is_none() {
let reg = SchemaRegistry::new();
assert!(reg.latest().is_none());
}
#[test]
fn test_get_by_version_string() {
let mut reg = SchemaRegistry::new();
reg.register(v1_sdl());
reg.register(v2_sdl());
let v = reg.get("1.0.0").expect("should succeed");
assert_eq!(v.id, 1);
assert_eq!(v.schema_sdl, v1_sdl());
}
#[test]
fn test_get_nonexistent_version_is_none() {
let reg = SchemaRegistry::new();
assert!(reg.get("99.0.0").is_none());
}
#[test]
fn test_get_by_id() {
let mut reg = SchemaRegistry::new();
reg.register(v1_sdl());
reg.register(v2_sdl());
let sv = reg.get_by_id(1).expect("should succeed");
assert_eq!(sv.version, "1.0.0");
}
#[test]
fn test_get_by_id_nonexistent() {
let reg = SchemaRegistry::new();
assert!(reg.get_by_id(42).is_none());
}
#[test]
fn test_all_versions_count() {
let mut reg = SchemaRegistry::new();
reg.register(v1_sdl());
reg.register(v2_sdl());
reg.register(v3_sdl());
assert_eq!(reg.all_versions().len(), 3);
}
#[test]
fn test_diff_added_field() {
let mut reg = SchemaRegistry::new();
reg.register(v1_sdl()); reg.register(v2_sdl()); let diff = reg.diff("1.0.0", "1.0.1").expect("should succeed");
let added = diff.added_fields.get("Query").cloned().unwrap_or_default();
assert!(added.contains(&"world".to_owned()));
}
#[test]
fn test_diff_removed_field() {
let mut reg = SchemaRegistry::new();
reg.register(v2_sdl()); reg.register(v3_sdl()); let diff = reg.diff("1.0.0", "1.0.1").expect("should succeed");
let removed = diff
.removed_fields
.get("Query")
.cloned()
.unwrap_or_default();
assert!(removed.contains(&"hello".to_owned()));
}
#[test]
fn test_diff_added_type() {
let sdl_old = "type Query {\n hello: String\n}\n";
let sdl_new = "type Query {\n hello: String\n}\ntype Mutation {\n doThing: Boolean\n}\n";
let mut reg = SchemaRegistry::new();
reg.register(sdl_old);
reg.register(sdl_new);
let diff = reg.diff("1.0.0", "1.0.1").expect("should succeed");
assert!(diff.added_types.contains(&"Mutation".to_owned()));
}
#[test]
fn test_diff_removed_type() {
let sdl_old = "type Query {\n hello: String\n}\ntype Mutation {\n doThing: Boolean\n}\n";
let sdl_new = "type Query {\n hello: String\n}\n";
let mut reg = SchemaRegistry::new();
reg.register(sdl_old);
reg.register(sdl_new);
let diff = reg.diff("1.0.0", "1.0.1").expect("should succeed");
assert!(diff.removed_types.contains(&"Mutation".to_owned()));
}
#[test]
fn test_diff_unknown_version_error() {
let mut reg = SchemaRegistry::new();
reg.register(v1_sdl());
let err = reg.diff("1.0.0", "99.0.0").unwrap_err();
assert!(matches!(err, RegistryError::VersionNotFound(_)));
}
#[test]
fn test_diff_no_changes() {
let mut reg = SchemaRegistry::new();
reg.register(v1_sdl());
reg.register(v1_sdl()); let diff = reg.diff("1.0.0", "1.0.1").expect("should succeed");
assert!(diff.is_empty());
}
#[test]
fn test_adding_field_is_non_breaking() {
let mut reg = SchemaRegistry::new();
reg.register(v1_sdl());
reg.register(v2_sdl());
let diff = reg.diff("1.0.0", "1.0.1").expect("should succeed");
assert!(!reg.is_breaking_change(&diff));
}
#[test]
fn test_removing_field_is_breaking() {
let mut reg = SchemaRegistry::new();
reg.register(v2_sdl());
reg.register(v3_sdl());
let diff = reg.diff("1.0.0", "1.0.1").expect("should succeed");
assert!(reg.is_breaking_change(&diff));
}
#[test]
fn test_removing_type_is_breaking() {
let sdl_old = "type Query {\n hello: String\n}\ntype Extra {\n x: Int\n}\n";
let sdl_new = "type Query {\n hello: String\n}\n";
let mut reg = SchemaRegistry::new();
reg.register(sdl_old);
reg.register(sdl_new);
let diff = reg.diff("1.0.0", "1.0.1").expect("should succeed");
assert!(reg.is_breaking_change(&diff));
}
#[test]
fn test_adding_type_is_non_breaking() {
let sdl_old = "type Query {\n hello: String\n}\n";
let sdl_new = "type Query {\n hello: String\n}\ntype Extra {\n x: Int\n}\n";
let mut reg = SchemaRegistry::new();
reg.register(sdl_old);
reg.register(sdl_new);
let diff = reg.diff("1.0.0", "1.0.1").expect("should succeed");
assert!(!reg.is_breaking_change(&diff));
}
#[test]
fn test_empty_diff_is_non_breaking() {
let diff = SchemaDiff::default();
let reg = SchemaRegistry::new();
assert!(!reg.is_breaking_change(&diff));
}
#[test]
fn test_changelog_first_entry_is_initial() {
let mut reg = SchemaRegistry::new();
reg.register(v1_sdl());
let log = reg.changelog();
assert_eq!(log.len(), 1);
assert!(log[0].description.contains("Initial"));
}
#[test]
fn test_changelog_grows_with_versions() {
let mut reg = SchemaRegistry::new();
reg.register(v1_sdl());
reg.register(v2_sdl());
reg.register(v3_sdl());
assert_eq!(reg.changelog().len(), 3);
}
#[test]
fn test_changelog_breaking_flag() {
let mut reg = SchemaRegistry::new();
reg.register(v2_sdl()); reg.register(v3_sdl()); let log = reg.changelog();
assert_eq!(log.len(), 2);
assert!(log[1].is_breaking);
}
#[test]
fn test_changelog_non_breaking_flag() {
let mut reg = SchemaRegistry::new();
reg.register(v1_sdl());
reg.register(v2_sdl()); let log = reg.changelog();
assert!(!log[1].is_breaking);
}
#[test]
fn test_schema_diff_total_changes_empty() {
let diff = SchemaDiff::default();
assert_eq!(diff.total_changes(), 0);
assert!(diff.is_empty());
}
#[test]
fn test_schema_diff_total_changes_non_empty() {
let mut diff = SchemaDiff::default();
diff.added_types.push("Foo".to_owned());
assert_eq!(diff.total_changes(), 1);
assert!(!diff.is_empty());
}
#[test]
fn test_serde_roundtrip() {
let mut reg = SchemaRegistry::new();
reg.register(v1_sdl());
reg.register(v2_sdl());
let json = serde_json::to_string(®).expect("should succeed");
let restored: SchemaRegistry = serde_json::from_str(&json).expect("should succeed");
assert_eq!(restored.len(), 2);
}
#[test]
fn test_schema_version_created_at_display() {
let sv = SchemaVersion {
id: 1,
version: "1.0.0".to_owned(),
schema_sdl: "type Query { hello: String }".to_owned(),
created_at: 0,
};
let display = sv.created_at_display();
assert!(!display.is_empty());
}
#[test]
fn test_diff_sdl_directly() {
let reg = SchemaRegistry::new();
let diff = reg.diff_sdl(v1_sdl(), v2_sdl());
let added = diff.added_fields.get("Query").cloned().unwrap_or_default();
assert!(added.contains(&"world".to_owned()));
}
}