1use serde::{Deserialize, Serialize};
58use thiserror::Error;
59
60pub mod compatibility;
61pub mod fingerprint;
62pub mod types;
63
64pub use actr_protocol::{ServiceSpec, service_spec::Protobuf as ProtoFileSpec};
66
67pub use compatibility::{BreakingChange, CompatibilityAnalysis, ServiceCompatibility};
69pub use fingerprint::Fingerprint;
70pub use types::{CompatibilityLevel, ProtoFile};
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct CompatibilityAnalysisResult {
75 pub level: CompatibilityLevel,
77 pub changes: Vec<ProtocolChange>,
79 pub breaking_changes: Vec<BreakingChange>,
81 pub base_semantic_fingerprint: String,
83 pub candidate_semantic_fingerprint: String,
85 pub analyzed_at: chrono::DateTime<chrono::Utc>,
87}
88
89impl CompatibilityAnalysisResult {
90 pub fn is_compatible(&self) -> bool {
92 !matches!(self.level, CompatibilityLevel::BreakingChanges)
93 }
94
95 pub fn summary(&self) -> String {
97 match self.level {
98 CompatibilityLevel::FullyCompatible => "No changes detected".to_string(),
99 CompatibilityLevel::BackwardCompatible => {
100 format!("{} backward compatible changes", self.changes.len())
101 }
102 CompatibilityLevel::BreakingChanges => {
103 format!("{} breaking changes detected", self.breaking_changes.len())
104 }
105 }
106 }
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct ProtocolChange {
112 pub change_type: String,
114 pub file_name: String,
116 pub location: String,
118 pub description: String,
120 pub is_breaking: bool,
122}
123
124#[derive(Error, Debug)]
126pub enum CompatibilityError {
127 #[error("Failed to parse proto file: {file_name}: {source}")]
128 ProtoParseError {
129 file_name: String,
130 #[source]
131 source: anyhow::Error,
132 },
133
134 #[error("Proto-sign analysis failed: {0}")]
135 ProtoSignError(#[from] anyhow::Error),
136
137 #[error("Service has no proto files: {service_name}")]
138 NoProtoFiles { service_name: String },
139
140 #[error("Invalid service structure: {0}")]
141 InvalidService(String),
142}
143
144pub type Result<T> = std::result::Result<T, CompatibilityError>;
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149
150 #[test]
151 fn test_compatibility_levels() {
152 assert_eq!(
154 CompatibilityLevel::FullyCompatible,
155 CompatibilityLevel::FullyCompatible
156 );
157 assert_ne!(
158 CompatibilityLevel::FullyCompatible,
159 CompatibilityLevel::BackwardCompatible
160 );
161 }
162
163 #[test]
164 fn test_protocol_change_creation() {
165 let change = ProtocolChange {
166 change_type: "FIELD_REMOVED".to_string(),
167 file_name: "user.proto".to_string(),
168 location: "User.email".to_string(),
169 description: "Field 'email' was removed from message 'User'".to_string(),
170 is_breaking: true,
171 };
172
173 assert_eq!(change.change_type, "FIELD_REMOVED");
174 assert!(change.is_breaking);
175 }
176
177 #[test]
178 fn test_compatibility_result_structure() {
179 let result = CompatibilityAnalysisResult {
180 level: CompatibilityLevel::BackwardCompatible,
181 changes: vec![],
182 breaking_changes: vec![],
183 base_semantic_fingerprint: "abc123".to_string(),
184 candidate_semantic_fingerprint: "def456".to_string(),
185 analyzed_at: chrono::Utc::now(),
186 };
187
188 assert_eq!(result.level, CompatibilityLevel::BackwardCompatible);
189 assert_eq!(result.changes.len(), 0);
190 }
191
192 #[test]
193 fn test_compatibility_result_methods() {
194 let breaking_result = CompatibilityAnalysisResult {
195 level: CompatibilityLevel::BreakingChanges,
196 changes: vec![ProtocolChange {
197 change_type: "FIELD_REMOVED".to_string(),
198 file_name: "user.proto".to_string(),
199 location: "User.email".to_string(),
200 description: "Field removed".to_string(),
201 is_breaking: true,
202 }],
203 breaking_changes: vec![BreakingChange {
204 rule: "FIELD_REMOVED".to_string(),
205 file: "user.proto".to_string(),
206 location: "User.email".to_string(),
207 message: "Field email was removed".to_string(),
208 }],
209 base_semantic_fingerprint: "base_fp".to_string(),
210 candidate_semantic_fingerprint: "candidate_fp".to_string(),
211 analyzed_at: chrono::Utc::now(),
212 };
213
214 assert!(!breaking_result.is_compatible());
215 assert!(breaking_result.summary().contains("breaking changes"));
216
217 let compatible_result = CompatibilityAnalysisResult {
218 level: CompatibilityLevel::FullyCompatible,
219 changes: vec![],
220 breaking_changes: vec![],
221 base_semantic_fingerprint: "fp".to_string(),
222 candidate_semantic_fingerprint: "fp".to_string(),
223 analyzed_at: chrono::Utc::now(),
224 };
225
226 assert!(compatible_result.is_compatible());
227 assert!(compatible_result.summary().contains("No changes"));
228 }
229
230 #[test]
231 fn test_compatibility_error_display() {
232 let parse_error = CompatibilityError::ProtoParseError {
234 file_name: "test.proto".to_string(),
235 source: anyhow::anyhow!("syntax error"),
236 };
237 let error_msg = format!("{parse_error}");
238 assert!(error_msg.contains("test.proto"));
239 assert!(error_msg.contains("syntax error"));
240
241 let sign_error = CompatibilityError::ProtoSignError(anyhow::anyhow!("proto-sign failed"));
243 let error_msg = format!("{sign_error}");
244 assert!(error_msg.contains("proto-sign failed"));
245
246 let no_files_error = CompatibilityError::NoProtoFiles {
248 service_name: "empty-service".to_string(),
249 };
250 let error_msg = format!("{no_files_error}");
251 assert!(error_msg.contains("empty-service"));
252
253 let invalid_error = CompatibilityError::InvalidService("missing fields".to_string());
255 let error_msg = format!("{invalid_error}");
256 assert!(error_msg.contains("missing fields"));
257 }
258
259 #[test]
260 fn test_error_conversion() {
261 let anyhow_error = anyhow::anyhow!("some error");
263 let compat_error: CompatibilityError = anyhow_error.into();
264 assert!(matches!(
265 compat_error,
266 CompatibilityError::ProtoSignError(_)
267 ));
268 }
269}