use console::{Alignment, pad_str};
use owo_colors::OwoColorize;
use std::{
fs,
io::{self, Read},
path::Path,
};
use anyhow::{Context, Result};
use clap::{Args, Parser, Subcommand, ValueEnum};
use jsoncompat as backcompat;
use rand::Rng;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use json_schema_fuzz::{GenerateError, GenerationConfig, ValueGenerator};
#[derive(Debug)]
struct SchemaDoc {
schema: backcompat::SchemaDocument,
}
impl SchemaDoc {
fn load(path: &str) -> Result<Self> {
let raw = read_to_string(path)?;
let json: Value = serde_json::from_str(&raw).with_context(|| format!("parsing {path}"))?;
let schema = backcompat::SchemaDocument::from_json(&json)
.with_context(|| format!("building schema for {path}"))?;
Ok(Self { schema })
}
#[inline]
fn is_valid(&self, v: &Value) -> Result<bool> {
Ok(self.schema.is_valid(v)?)
}
fn gen_value<R: Rng>(
&self,
rng: &mut R,
depth: u8,
) -> std::result::Result<Value, GenerateError> {
ValueGenerator::generate(&self.schema, GenerationConfig::new(depth), rng)
}
}
fn read_to_string(path: &str) -> Result<String> {
if path == "-" {
let mut buf = String::new();
io::stdin().read_to_string(&mut buf)?;
Ok(buf)
} else {
fs::read_to_string(Path::new(path)).with_context(|| format!("reading {path}"))
}
}
fn sample_incompat<R: Rng>(
old: &SchemaDoc,
new: &SchemaDoc,
role: backcompat::Role,
attempts: usize,
depth: u8,
rng: &mut R,
) -> Result<Option<Value>> {
let mut try_once = |src: &SchemaDoc, dst: &SchemaDoc| -> Result<Option<Value>> {
for _ in 0..attempts {
let v = match src.gen_value(rng, depth) {
Ok(value) => value,
Err(GenerateError::Unsatisfiable | GenerateError::ExhaustedAttempts { .. }) => {
return Ok(None);
}
Err(error) => return Err(error.into()),
};
if src.is_valid(&v)? && !dst.is_valid(&v)? {
return Ok(Some(v));
}
}
Ok(None)
};
match role {
backcompat::Role::Serializer => try_once(new, old),
backcompat::Role::Deserializer => try_once(old, new),
backcompat::Role::Both => try_once(new, old).and_then(|result| match result {
Some(value) => Ok(Some(value)),
None => try_once(old, new),
}),
}
}
#[derive(Parser)]
#[command(
name = "jsoncompat",
about = "Schema utility toolbox: generation & compatibility checks",
author,
version
)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Generate(GenerateArgs),
Canonicalize(CanonicalizeArgs),
Compat(CompatArgs),
CI(CiArgs),
}
#[derive(Args)]
struct GenerateArgs {
schema: String,
#[arg(short, long, default_value_t = 1)]
count: u32,
#[arg(short, long, default_value_t = 8)]
depth: u8,
#[arg(short, long)]
pretty: bool,
}
#[derive(Args)]
struct CanonicalizeArgs {
schema: String,
#[arg(short, long)]
pretty: bool,
}
#[derive(Args)]
struct CompatArgs {
old: String,
new: String,
#[arg(long, value_enum, default_value_t = RoleCli::Both)]
role: RoleCli,
#[arg(short = 'f', long, value_name = "N", default_value_t = 0)]
fuzz: u32,
#[arg(short, long, default_value_t = 8)]
depth: u8,
}
#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Deserialize)]
#[serde(rename_all = "lowercase")]
enum DisplayMode {
Table,
Json,
}
#[derive(Args)]
struct CiArgs {
old: String,
new: String,
#[arg(short, long, value_enum, default_value_t = DisplayMode::Table)]
display: DisplayMode,
}
#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
enum RoleCli {
Serializer,
Deserializer,
Both,
}
impl From<RoleCli> for backcompat::Role {
fn from(r: RoleCli) -> Self {
match r {
RoleCli::Serializer => backcompat::Role::Serializer,
RoleCli::Deserializer => backcompat::Role::Deserializer,
RoleCli::Both => backcompat::Role::Both,
}
}
}
fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Command::Generate(a) => cmd_generate(a),
Command::Canonicalize(a) => cmd_canonicalize(a),
Command::Compat(a) => cmd_compat(a),
Command::CI(a) => cmd_ci(a),
}
}
fn cmd_generate(args: GenerateArgs) -> Result<()> {
let schema = SchemaDoc::load(&args.schema)?;
let mut rng = rand::rng();
for _ in 0..args.count {
let v = schema.gen_value(&mut rng, args.depth)?;
if args.pretty {
println!("{}", serde_json::to_string_pretty(&v)?);
} else {
println!("{}", serde_json::to_string(&v)?);
}
}
Ok(())
}
fn cmd_canonicalize(args: CanonicalizeArgs) -> Result<()> {
let schema = SchemaDoc::load(&args.schema)?;
let canonical_schema = schema.schema.canonical_schema_json()?;
if args.pretty {
println!("{}", serde_json::to_string_pretty(canonical_schema)?);
} else {
println!("{}", serde_json::to_string(canonical_schema)?);
}
Ok(())
}
fn cmd_compat(args: CompatArgs) -> Result<()> {
let old = SchemaDoc::load(&args.old)?;
let new = SchemaDoc::load(&args.new)?;
let role: backcompat::Role = args.role.into();
let ok_static = backcompat::check_compat(&old.schema, &new.schema, role)?;
let offender = if args.fuzz > 0 && !ok_static {
let mut rng = rand::rng();
sample_incompat(&old, &new, role, args.fuzz as usize, args.depth, &mut rng)?
} else {
None
};
if ok_static && offender.is_none() {
eprintln!(
"{} Schemas seem backward-compatible (role = {:?})",
"✔".green(),
role
);
return Ok(());
}
eprintln!(
"{} Schemas are NOT backward-compatible (role = {:?})",
"✘".red(),
role
);
if let Some(ex) = offender {
let pretty =
serde_json::to_string_pretty(&ex).unwrap_or_else(|_| "<unserializable>".into());
eprintln!("{} Counter-example:\n{}", "•".yellow(), pretty);
let old_valid = old.is_valid(&ex)?;
let new_valid = new.is_valid(&ex)?;
eprintln!(
"{} Old schema: {}",
"•".yellow(),
if old_valid { "accepts" } else { "rejects" }
);
eprintln!(
"{} New schema: {}",
"•".yellow(),
if new_valid { "accepts" } else { "rejects" }
);
}
std::process::exit(1);
}
#[derive(Deserialize)]
struct RawGoldenEntry {
mode: RoleCli,
schema: serde_json::Value,
stable_id: String,
}
struct GoldenEntry {
mode: RoleCli,
schema: Value,
stable_id: String,
}
type GoldenFile = std::collections::HashMap<String, GoldenEntry>;
fn load_golden_file(path: &str) -> Result<GoldenFile> {
let raw = read_to_string(path)?;
let golden: std::collections::HashMap<String, RawGoldenEntry> =
serde_json::from_str(&raw).with_context(|| format!("parsing golden file {path}"))?;
golden
.into_iter()
.map(|(id, entry)| {
Ok((
id,
GoldenEntry {
mode: entry.mode,
schema: entry.schema,
stable_id: entry.stable_id,
},
))
})
.collect()
}
#[derive(Debug, PartialEq, Serialize)]
enum Status {
Ok,
MissingOld,
MissingNew,
ModeChanged,
Incompatible { example: Option<Value> },
Invalid,
Identical,
}
#[derive(Debug, PartialEq, Serialize)]
struct Grade {
id: String,
mode: RoleCli,
status: Status,
}
fn grade_entry(old: Option<&GoldenEntry>, new: Option<&GoldenEntry>) -> Grade {
match (old, new) {
(Some(old), Some(new)) => {
let (old_schema, new_schema) = (
backcompat::SchemaDocument::from_json(&old.schema),
backcompat::SchemaDocument::from_json(&new.schema),
);
match (old_schema, new_schema) {
(Ok(old_schema), Ok(new_schema)) => {
if old.mode == new.mode && old.schema == new.schema {
return Grade {
id: new.stable_id.clone(),
mode: old.mode,
status: Status::Identical,
};
}
let Ok(ok) =
backcompat::check_compat(&old_schema, &new_schema, old.mode.into())
else {
return Grade {
id: new.stable_id.clone(),
mode: old.mode,
status: Status::Invalid,
};
};
if !ok {
let mut rng = rand::rng();
let example = match sample_incompat(
&SchemaDoc { schema: old_schema },
&SchemaDoc { schema: new_schema },
old.mode.into(),
100,
8,
&mut rng,
) {
Ok(example) => example,
Err(_) => {
return Grade {
id: new.stable_id.clone(),
mode: old.mode,
status: Status::Invalid,
};
}
};
Grade {
id: new.stable_id.clone(),
mode: old.mode,
status: Status::Incompatible { example },
}
} else if old.mode != new.mode {
Grade {
id: new.stable_id.clone(),
mode: old.mode,
status: Status::ModeChanged,
}
} else {
Grade {
id: new.stable_id.clone(),
mode: old.mode,
status: Status::Ok,
}
}
}
_ => Grade {
id: new.stable_id.clone(),
mode: old.mode,
status: Status::Invalid,
},
}
}
(Some(old), None) => Grade {
id: old.stable_id.clone(),
mode: old.mode,
status: Status::MissingNew,
},
(None, Some(new)) => Grade {
id: new.stable_id.clone(),
mode: new.mode,
status: Status::MissingOld,
},
(None, None) => unreachable!(
"grade_entry called with both old and new as None; this should never happen"
),
}
}
fn print_grades_table(grades: &Vec<Grade>) -> Result<()> {
let header_id = "ID";
let header_mode = "Mode";
let header_status = "Status";
let header_example = "Example";
let id_width = grades
.iter()
.map(|g| g.id.len())
.max()
.unwrap_or(2)
.max(header_id.len());
let mode_width = grades
.iter()
.map(|g| format!("{:?}", g.mode).len())
.max()
.unwrap_or(4)
.max(header_mode.len());
let status_width = grades
.iter()
.map(|g| match &g.status {
Status::Ok => "Ok".len(),
Status::MissingOld => "MissingOld".len(),
Status::MissingNew => "MissingNew".len(),
Status::ModeChanged => "ModeChanged".len(),
Status::Incompatible { .. } => "Incompatible".len(),
Status::Invalid => "Invalid".len(),
Status::Identical => "Identical".len(),
})
.max()
.unwrap_or(6)
.max(header_status.len());
let no_example = "Could not find example";
let example_width = grades
.iter()
.map(|g| match &g.status {
Status::Incompatible { example } => {
if let Some(example) = example {
let s = example.to_string();
s.len()
} else {
no_example.len()
}
}
_ => "N/A".len(),
})
.max()
.unwrap_or(7)
.max(header_example.len());
println!(
"{} {} {} {}",
pad_str(
&header_id.bold().to_string(),
id_width,
Alignment::Left,
None
),
pad_str(
&header_mode.bold().to_string(),
mode_width,
Alignment::Left,
None
),
pad_str(
&header_status.bold().to_string(),
status_width,
Alignment::Left,
None
),
pad_str(
&header_example.bold().to_string(),
example_width,
Alignment::Left,
None
)
);
println!(
"{} {} {} {}",
pad_str("", id_width, Alignment::Left, Some("-")),
pad_str("", mode_width, Alignment::Left, Some("-")),
pad_str("", status_width, Alignment::Left, Some("-")),
pad_str("", example_width, Alignment::Left, Some("-"))
);
for grade in grades {
let (status_str, example_str) = match &grade.status {
Status::Ok => ("Ok".green().to_string(), "N/A".to_string()),
Status::MissingOld => ("MissingOld".yellow().to_string(), "N/A".to_string()),
Status::MissingNew => ("MissingNew".yellow().to_string(), "N/A".to_string()),
Status::ModeChanged => ("ModeChanged".yellow().to_string(), "N/A".to_string()),
Status::Incompatible { example } => {
let status = "Incompatible".red().to_string();
let example_str = if let Some(example) = example {
example.to_string()
} else {
no_example.to_string()
};
(status, example_str)
}
Status::Invalid => ("Invalid".red().to_string(), "N/A".to_string()),
Status::Identical => ("Identical".green().to_string(), "N/A".to_string()),
};
let mode = grade.mode;
let mode_str = format!("{mode:?}");
println!(
"{} {} {} {}",
pad_str(&grade.id, id_width, Alignment::Left, None),
pad_str(
&mode_str.cyan().to_string(),
mode_width,
Alignment::Left,
None
),
pad_str(&status_str, status_width, Alignment::Left, None),
pad_str(
&example_str.bright_black().to_string(),
example_width,
Alignment::Left,
None
)
);
}
Ok(())
}
fn print_grades_json(grades: &Vec<Grade>) -> Result<()> {
let json = serde_json::to_string_pretty(&grades)?;
println!("{json}");
Ok(())
}
fn print_grades(grades: &Vec<Grade>, display: DisplayMode) -> Result<()> {
match display {
DisplayMode::Table => print_grades_table(grades),
DisplayMode::Json => print_grades_json(grades),
}
}
fn cmd_ci(args: CiArgs) -> Result<()> {
let old = load_golden_file(&args.old)?;
let new = load_golden_file(&args.new)?;
let all_ids = old
.keys()
.chain(new.keys())
.collect::<std::collections::HashSet<_>>();
let grades: Vec<Grade> = all_ids
.iter()
.map(|id| {
let old_entry = old.get(*id);
let new_entry = new.get(*id);
grade_entry(old_entry, new_entry)
})
.collect();
print_grades(&grades, args.display)?;
if grades
.iter()
.any(|g| matches!(g.status, Status::Incompatible { .. } | Status::Invalid))
{
println!("\nError: Found incompatible or invalid grades");
std::process::exit(1);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use rand::{SeedableRng, rngs::StdRng};
use serde_json::json;
use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};
#[test]
fn role_conversion() {
let r: backcompat::Role = RoleCli::Serializer.into();
assert!(matches!(r, backcompat::Role::Serializer));
}
#[test]
fn ci_command_accepts_identical_canonicalized_golden_files() {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir();
let old_path = dir.join(format!("jsoncompat-ci-old-{unique}.json"));
let new_path = dir.join(format!("jsoncompat-ci-new-{unique}.json"));
let golden = r##"{
"example": {
"mode": "serializer",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema#",
"type": "integer",
"minimum": 1
},
"stable_id": "example"
}
}"##;
fs::write(&old_path, golden).unwrap();
fs::write(&new_path, golden).unwrap();
let result = cmd_ci(CiArgs {
old: old_path.to_string_lossy().into_owned(),
new: new_path.to_string_lossy().into_owned(),
display: DisplayMode::Json,
});
fs::remove_file(old_path).unwrap();
fs::remove_file(new_path).unwrap();
result.unwrap();
}
#[test]
fn ci_grade_reports_incompatible_when_unique_items_is_relaxed_for_serializer() {
let old = GoldenEntry {
mode: RoleCli::Serializer,
schema: serde_json::json!({
"type": "array",
"uniqueItems": true
}),
stable_id: "example".to_owned(),
};
let new = GoldenEntry {
mode: RoleCli::Serializer,
schema: serde_json::json!({
"type": "array",
"uniqueItems": false
}),
stable_id: "example".to_owned(),
};
let grade = grade_entry(Some(&old), Some(&new));
assert!(matches!(grade.status, Status::Incompatible { .. }));
}
#[test]
fn compat_command_rejects_invalid_old_schema_before_reporting_a_verdict() {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir();
let old_path = dir.join(format!("jsoncompat-invalid-old-{unique}.json"));
let new_path = dir.join(format!("jsoncompat-invalid-new-{unique}.json"));
fs::write(&old_path, r#"{"type":"string","maxLength":"x"}"#).unwrap();
fs::write(&new_path, r#"{"type":"string"}"#).unwrap();
let error = cmd_compat(CompatArgs {
old: old_path.to_string_lossy().into_owned(),
new: new_path.to_string_lossy().into_owned(),
role: RoleCli::Serializer,
fuzz: 0,
depth: 8,
})
.unwrap_err();
fs::remove_file(old_path).unwrap();
fs::remove_file(new_path).unwrap();
let message = format!("{error:#}");
assert!(
message.contains("building schema"),
"unexpected error: {message}"
);
assert!(
message.contains("keyword 'maxLength' at '#/maxLength' must be a non-negative integer"),
"unexpected error: {message}"
);
}
#[test]
fn gen_value_retries_until_raw_schema_accepts_the_candidate() {
let schema = SchemaDoc {
schema: backcompat::SchemaDocument::from_json(&json!({
"type": "integer",
"minimum": 1
}))
.unwrap(),
};
let mut rng = StdRng::seed_from_u64(7);
let value = schema.gen_value(&mut rng, 4).unwrap();
assert!(
schema.is_valid(&value).unwrap(),
"generated invalid value: {value}"
);
}
#[test]
fn gen_value_returns_unsatisfiable_for_false_schema() {
let schema = SchemaDoc {
schema: backcompat::SchemaDocument::from_json(&json!(false)).unwrap(),
};
let mut rng = StdRng::seed_from_u64(7);
let error = schema.gen_value(&mut rng, 4).unwrap_err();
assert!(matches!(error, GenerateError::Unsatisfiable));
}
#[test]
fn sample_incompat_with_role_both_continues_after_exhausting_the_first_direction() {
let old = SchemaDoc {
schema: backcompat::SchemaDocument::from_json(&json!({})).unwrap(),
};
let new = SchemaDoc {
schema: backcompat::SchemaDocument::from_json(&json!(false)).unwrap(),
};
let mut rng = StdRng::seed_from_u64(7);
let offender = sample_incompat(&old, &new, backcompat::Role::Both, 3, 4, &mut rng).unwrap();
let offender = offender.expect("expected a deserializer counterexample");
assert!(
old.is_valid(&offender).unwrap(),
"old schema must accept {offender}"
);
assert!(
!new.is_valid(&offender).unwrap(),
"new schema must reject {offender}"
);
}
}