#![forbid(unsafe_code)]
use crate::util::Verbosity;
use clap::Args;
use std::{collections::HashMap, path::PathBuf};
#[derive(Args)]
pub struct BreakingArgs {
#[arg(long, required = true, value_name = "FILE")]
pub old: Vec<PathBuf>,
#[arg(long = "old-include", short = 'I', value_name = "DIR")]
pub old_include: Vec<PathBuf>,
#[arg(long, required = true, value_name = "FILE")]
pub new: Vec<PathBuf>,
#[arg(long = "new-include", short = 'J', value_name = "DIR")]
pub new_include: Vec<PathBuf>,
}
pub fn run(args: BreakingArgs, verbosity: Verbosity) -> Result<(), Box<dyn std::error::Error>> {
for p in &args.old {
if !p.exists() {
return Err(format!("old proto file not found: {}", p.display()).into());
}
}
for p in &args.new {
if !p.exists() {
return Err(format!("new proto file not found: {}", p.display()).into());
}
}
verbosity.verbose("Compiling old proto set...");
let fds_old = oxiproto_build::compile_to_fds(&args.old, &args.old_include)?;
verbosity.verbose("Compiling new proto set...");
let fds_new = oxiproto_build::compile_to_fds(&args.new, &args.new_include)?;
let old_registry = build_registry(&fds_old);
let new_registry = build_registry(&fds_new);
let mut findings: Vec<String> = Vec::new();
for fqn in old_registry.keys() {
if !new_registry.contains_key(fqn.as_str()) {
findings.push(format!("BREAKING: message `{}` removed", fqn));
}
}
for (fqn, old_info) in &old_registry {
if let Some(new_info) = new_registry.get(fqn.as_str()) {
diff_message(fqn, old_info, new_info, &mut findings);
}
}
if findings.is_empty() {
verbosity.info("No breaking changes detected.");
Ok(())
} else {
let count = findings.len();
for f in &findings {
println!("{f}");
}
Err(format!("{count} breaking change(s) detected").into())
}
}
struct FieldInfo {
name: String,
type_val: i32,
type_name: String,
label: i32,
}
struct EnumValueInfo {
name: String,
}
struct MessageInfo {
fields_by_number: HashMap<i32, FieldInfo>,
enum_values_by_number: HashMap<i32, EnumValueInfo>,
}
fn build_registry(fds: &prost_types::FileDescriptorSet) -> HashMap<String, MessageInfo> {
let mut registry = HashMap::new();
for file in &fds.file {
let pkg = file.package.as_deref().unwrap_or("");
for msg in &file.message_type {
collect_message(pkg, msg, &mut registry);
}
}
registry
}
fn collect_message(
prefix: &str,
msg: &prost_types::DescriptorProto,
registry: &mut HashMap<String, MessageInfo>,
) {
let name = msg.name.as_deref().unwrap_or("");
let fqn = if prefix.is_empty() {
name.to_string()
} else {
format!("{prefix}.{name}")
};
let is_map_entry = msg.options.as_ref().map(|o| o.map_entry()).unwrap_or(false);
if !is_map_entry {
let mut fields_by_number = HashMap::new();
for field in &msg.field {
if let Some(num) = field.number {
fields_by_number.insert(
num,
FieldInfo {
name: field.name.as_deref().unwrap_or("").to_string(),
type_val: field.r#type.unwrap_or(0),
type_name: field.type_name.as_deref().unwrap_or("").to_string(),
label: field.label.unwrap_or(0),
},
);
}
}
let mut enum_values_by_number = HashMap::new();
for en in &msg.enum_type {
for val in &en.value {
if let Some(num) = val.number {
enum_values_by_number.insert(
num,
EnumValueInfo {
name: val.name.as_deref().unwrap_or("").to_string(),
},
);
}
}
}
registry.insert(
fqn.clone(),
MessageInfo {
fields_by_number,
enum_values_by_number,
},
);
}
for nested in &msg.nested_type {
collect_message(&fqn, nested, registry);
}
}
fn diff_message(fqn: &str, old: &MessageInfo, new: &MessageInfo, findings: &mut Vec<String>) {
for (num, old_field) in &old.fields_by_number {
match new.fields_by_number.get(num) {
None => {
findings.push(format!(
"BREAKING: field `{}` (#{}) removed from message `{}`",
old_field.name, num, fqn
));
}
Some(new_field) => {
if old_field.type_val != new_field.type_val
|| old_field.type_name != new_field.type_name
{
findings.push(format!(
"BREAKING: field `{}` (#{}) type changed in message `{}`",
old_field.name, num, fqn
));
}
use prost_types::field_descriptor_proto::Label;
let old_repeated = old_field.label == Label::Repeated as i32;
let new_repeated = new_field.label == Label::Repeated as i32;
if old_repeated != new_repeated {
findings.push(format!(
"BREAKING: field `{}` (#{}) repeated status changed in message `{}`",
old_field.name, num, fqn
));
}
}
}
}
for (num, old_val) in &old.enum_values_by_number {
if !new.enum_values_by_number.contains_key(num) {
findings.push(format!(
"BREAKING: enum value `{}` (#{}) removed from message `{}`",
old_val.name, num, fqn
));
}
}
}