proto_sign/
compatibility.rs1use serde::Serialize;
4use std::collections::BTreeSet;
5
6#[derive(Debug, Default, Serialize, PartialEq, Eq, PartialOrd, Ord)]
12pub struct CompatibilityModel {
13 #[serde(skip_serializing_if = "BTreeSet::is_empty")]
14 pub messages: BTreeSet<CompatibilityMessage>,
15 #[serde(skip_serializing_if = "BTreeSet::is_empty")]
16 pub services: BTreeSet<CompatibilityService>,
17}
18
19#[derive(Debug, Default, Serialize, PartialEq, Eq, PartialOrd, Ord)]
21pub struct CompatibilityMessage {
22 pub name: String,
23 #[serde(skip_serializing_if = "BTreeSet::is_empty")]
24 pub fields: BTreeSet<CompatibilityField>,
25}
26
27#[derive(Debug, Default, Serialize, PartialEq, Eq)]
30pub struct CompatibilityField {
31 pub number: i32,
32 pub type_name: String,
33}
34
35impl Ord for CompatibilityField {
37 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
38 self.number
39 .cmp(&other.number)
40 .then_with(|| self.type_name.cmp(&other.type_name))
41 }
42}
43
44impl PartialOrd for CompatibilityField {
45 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
46 Some(self.cmp(other))
47 }
48}
49
50#[derive(Debug, Default, Serialize, PartialEq, Eq, PartialOrd, Ord)]
52pub struct CompatibilityService {
53 pub name: String,
54 #[serde(skip_serializing_if = "BTreeSet::is_empty")]
55 pub methods: BTreeSet<CompatibilityMethod>,
56}
57
58#[derive(Debug, Default, Serialize, PartialEq, Eq, PartialOrd, Ord)]
60pub struct CompatibilityMethod {
61 pub name: String,
62 pub input_type: String,
63 pub output_type: String,
64}
65
66use crate::normalize;
71use anyhow::Context;
72use protobuf_parse::Parser;
73
74pub fn get_compatibility_model(proto_content: &str) -> anyhow::Result<CompatibilityModel> {
76 let temp_dir = tempfile::tempdir().context("Failed to create temp directory")?;
77 let file_name = "input.proto";
78 let temp_path = temp_dir.path().join(file_name);
79 std::fs::write(&temp_path, proto_content).context("Failed to write to temp file")?;
80
81 for line in proto_content.lines() {
82 if line.trim().starts_with("import ") {
83 let path_str = line
84 .trim()
85 .trim_start_matches("import ")
86 .trim_start_matches("public ")
87 .trim_matches(|c| c == '"' || c == ';');
88
89 if !path_str.starts_with("google/protobuf/") {
90 let import_path = temp_dir.path().join(path_str);
91 if let Some(parent) = import_path.parent() {
92 std::fs::create_dir_all(parent).context(format!(
93 "Failed to create parent dirs for import: {path_str}"
94 ))?;
95 }
96 std::fs::write(&import_path, "syntax = \"proto3\";")
97 .context(format!("Failed to create dummy import file: {path_str}"))?;
98 }
99 }
100 }
101
102 let parsed = Parser::new()
103 .pure()
104 .include(temp_dir.path())
105 .input(&temp_path)
106 .file_descriptor_set()
107 .context("Protobuf parsing failed")?;
108
109 let file_descriptor = parsed
110 .file
111 .into_iter()
112 .find(|d| d.name() == file_name)
113 .context("Could not find the parsed file descriptor for the input file")?;
114
115 Ok(normalize::normalize_compatibility_file(&file_descriptor))
116}
117
118pub fn is_compatible(old_model: &CompatibilityModel, new_model: &CompatibilityModel) -> bool {
121 for old_msg in &old_model.messages {
126 if let Some(new_msg) = new_model.messages.iter().find(|m| m.name == old_msg.name) {
128 if !old_msg.fields.is_subset(&new_msg.fields) {
130 return false; }
132 } else {
133 return false; }
135 }
136
137 for old_svc in &old_model.services {
139 if let Some(new_svc) = new_model.services.iter().find(|s| s.name == old_svc.name) {
141 if !old_svc.methods.is_subset(&new_svc.methods) {
143 return false; }
145 } else {
146 return false; }
148 }
149
150 true
151}