1use std::collections::BTreeMap;
7
8#[derive(Debug, Clone, PartialEq)]
11pub struct ProtoField {
12 pub name: String,
13 pub ty: String,
14 pub tag: u32,
15}
16
17pub fn parse_proto_message(text: &str, message: &str) -> Result<Vec<ProtoField>, String> {
21 let stripped = strip_comments(text);
22 let needle = format!("message {message}");
23 let start = stripped
24 .find(&needle)
25 .ok_or_else(|| format!("no `message {message}` in proto text"))?;
26 let brace_start = stripped[start..]
27 .find('{')
28 .map(|i| start + i)
29 .ok_or_else(|| format!("message {message} has no body"))?;
30
31 let end = matching_brace(&stripped, brace_start)
32 .ok_or_else(|| format!("message {message} body never closes"))?;
33 let body = &stripped[brace_start + 1..end];
34
35 let mut fields = Vec::new();
36 for stmt in body.split(';') {
37 let stmt = stmt.trim();
38 if stmt.is_empty() || stmt.contains('{') || stmt.contains('}') {
40 continue;
41 }
42 let Some((decl, rest)) = stmt.split_once('=') else {
43 continue; };
45 let mut tokens: Vec<&str> = decl.split_whitespace().collect();
46 tokens.retain(|t| !matches!(*t, "repeated" | "optional" | "required"));
47 let Some(name) = tokens.pop() else { continue };
48 let ty = tokens.join(" ");
49 if ty.is_empty() {
50 continue;
51 }
52 let tag_str = rest.split('[').next().unwrap_or("").trim();
53 let Ok(tag) = tag_str.parse::<u32>() else {
54 continue;
55 };
56 fields.push(ProtoField {
57 name: name.to_string(),
58 ty,
59 tag,
60 });
61 }
62 if fields.is_empty() {
63 return Err(format!("message {message} declared no scalar fields"));
64 }
65 Ok(fields)
66}
67
68fn strip_comments(text: &str) -> String {
69 let mut out = String::with_capacity(text.len());
70 let mut chars = text.chars().peekable();
71 while let Some(c) = chars.next() {
72 if c == '/' && chars.peek() == Some(&'/') {
73 for c in chars.by_ref() {
74 if c == '\n' {
75 out.push('\n');
76 break;
77 }
78 }
79 } else if c == '/' && chars.peek() == Some(&'*') {
80 chars.next();
81 let mut prev = ' ';
82 for c in chars.by_ref() {
83 if prev == '*' && c == '/' {
84 break;
85 }
86 prev = c;
87 }
88 } else {
89 out.push(c);
90 }
91 }
92 out
93}
94
95pub fn parse_struct_prost_fields(
100 source: &str,
101 struct_name: &str,
102) -> Result<Vec<ProtoField>, String> {
103 let stripped = strip_comments(source);
104 let needle = format!("struct {struct_name}");
105 let start = stripped
106 .find(&needle)
107 .ok_or_else(|| format!("no `struct {struct_name}` found"))?;
108 let brace_start = stripped[start..]
109 .find('{')
110 .map(|i| start + i)
111 .ok_or_else(|| format!("struct {struct_name} has no body"))?;
112 let end = matching_brace(&stripped, brace_start)
113 .ok_or_else(|| format!("struct {struct_name} body never closes"))?;
114 let body = &stripped[brace_start + 1..end];
115
116 let mut fields = Vec::new();
117 let mut pos = 0;
118 while let Some(rel) = body[pos..].find("#[prost(") {
119 let args_start = pos + rel + "#[prost(".len();
120 let args_end = matching_paren(body, args_start)
121 .ok_or_else(|| format!("unterminated #[prost(..)] in struct {struct_name}"))?;
122 let after_attr = body[args_end..]
123 .find(']')
124 .map(|i| args_end + i + 1)
125 .ok_or_else(|| format!("unterminated #[prost(..)] in struct {struct_name}"))?;
126 if let (Some(ty), Some(tag)) = parse_prost_args(&body[args_start..args_end])
127 && let Some(name) = next_field_name(&body[after_attr..])
128 {
129 fields.push(ProtoField { name, ty, tag });
130 }
131 pos = after_attr;
132 }
133 Ok(fields)
134}
135
136fn matching_paren(text: &str, after_open: usize) -> Option<usize> {
139 let mut depth = 1i32;
140 for (i, c) in text[after_open..].char_indices() {
141 match c {
142 '(' => depth += 1,
143 ')' => {
144 depth -= 1;
145 if depth == 0 {
146 return Some(after_open + i);
147 }
148 }
149 _ => {}
150 }
151 }
152 None
153}
154
155fn matching_brace(text: &str, brace_start: usize) -> Option<usize> {
157 let mut depth = 0i32;
158 for (i, c) in text[brace_start..].char_indices() {
159 match c {
160 '{' => depth += 1,
161 '}' => {
162 depth -= 1;
163 if depth == 0 {
164 return Some(brace_start + i);
165 }
166 }
167 _ => {}
168 }
169 }
170 None
171}
172
173fn parse_prost_args(args: &str) -> (Option<String>, Option<u32>) {
176 let mut ty = None;
177 let mut tag = None;
178 for token in args.split(',') {
179 let token = token.trim();
180 if token.is_empty() {
181 continue;
182 }
183 match token.split_once('=') {
184 Some((key, val)) if key.trim() == "tag" => {
185 tag = val.trim().trim_matches('"').parse::<u32>().ok();
186 }
187 Some((key, _)) => ty = Some(key.trim().to_string()),
188 None => ty = Some(token.to_string()),
189 }
190 }
191 (ty, tag)
192}
193
194fn next_field_name(rest: &str) -> Option<String> {
198 let mut s = rest.trim_start();
199 loop {
200 if let Some(after) = s.strip_prefix("#[") {
201 let close = after.find(']')?;
202 s = after[close + 1..].trim_start();
203 } else if let Some(after) = s.strip_prefix("pub(") {
204 let close = after.find(')')?;
205 s = after[close + 1..].trim_start();
206 } else if s.starts_with("pub") && s[3..].starts_with(char::is_whitespace) {
207 s = s[3..].trim_start();
208 } else {
209 break;
210 }
211 }
212 let name = &s[..s.find(':')?];
213 let name = name.trim();
214 (!name.is_empty() && name.chars().all(|c| c.is_alphanumeric() || c == '_'))
215 .then(|| name.to_string())
216}
217
218fn types_compatible(proto_ty: &str, code_ty: &str) -> bool {
221 proto_ty == code_ty || (code_ty == "enumeration" && matches!(proto_ty, "int32" | "uint32"))
222}
223
224#[derive(Debug, Default)]
226pub struct ProtoReconciliation {
227 pub missing_code: Vec<ProtoField>,
229 pub missing_spec: Vec<ProtoField>,
231 pub mismatched: Vec<(ProtoField, ProtoField, String)>,
234 pub matched: usize,
236}
237
238impl ProtoReconciliation {
239 pub fn is_clean(&self) -> bool {
242 self.missing_code.is_empty() && self.missing_spec.is_empty() && self.mismatched.is_empty()
243 }
244}
245
246pub fn reconcile_proto(
249 spec_fields: &[ProtoField],
250 code_fields: &[ProtoField],
251) -> ProtoReconciliation {
252 let mut rec = ProtoReconciliation::default();
253 let by_tag: BTreeMap<u32, &ProtoField> = code_fields.iter().map(|f| (f.tag, f)).collect();
254
255 for spec in spec_fields {
256 match by_tag.get(&spec.tag) {
257 None => rec.missing_code.push(spec.clone()),
258 Some(code) => {
259 let mut problems = Vec::new();
260 if !types_compatible(&spec.ty, &code.ty) {
261 problems.push(format!("type {} != spec {}", code.ty, spec.ty));
262 }
263 if spec.name != code.name {
264 problems.push(format!("name {} != spec {}", code.name, spec.name));
265 }
266 if problems.is_empty() {
267 rec.matched += 1;
268 } else {
269 rec.mismatched
270 .push((spec.clone(), (*code).clone(), problems.join("; ")));
271 }
272 }
273 }
274 }
275
276 let declared_tags: std::collections::BTreeSet<u32> =
277 spec_fields.iter().map(|f| f.tag).collect();
278 for code in code_fields {
279 if !declared_tags.contains(&code.tag) {
280 rec.missing_spec.push(code.clone());
281 }
282 }
283 rec
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289
290 const PROTO: &str = r#"
291 syntax = "proto3";
292 package yahoo;
293
294 message PricingData {
295 string id = 1;
296 float price = 2;
297 sint64 time = 3;
298 int32 quote_type = 6;
299 sint64 options_type = 20;
300 }
301 "#;
302
303 const SOURCE: &str = r#"
304 #[derive(Clone, PartialEq, Message)]
305 pub(crate) struct PricingData {
306 #[prost(string, tag = "1")]
307 pub id: String,
308
309 #[prost(float, tag = "2")]
310 pub price: f32,
311
312 #[prost(sint64, tag = "3")]
313 pub time: i64,
314
315 #[prost(enumeration = "QuoteTypeProto", tag = "6")]
316 pub quote_type: i32,
317
318 #[prost(enumeration = "OptionTypeProto", tag = "20")]
319 pub options_type: i32,
320 }
321 "#;
322
323 #[test]
324 fn parses_proto_message_fields_by_tag() {
325 let fields = parse_proto_message(PROTO, "PricingData").unwrap();
326 assert_eq!(fields.len(), 5);
327 assert_eq!(
328 fields[4],
329 ProtoField {
330 name: "options_type".into(),
331 ty: "sint64".into(),
332 tag: 20
333 }
334 );
335 }
336
337 #[test]
338 fn parses_struct_prost_fields() {
339 let fields = parse_struct_prost_fields(SOURCE, "PricingData").unwrap();
340 assert_eq!(fields.len(), 5);
341 assert_eq!(
342 fields[3],
343 ProtoField {
344 name: "quote_type".into(),
345 ty: "enumeration".into(),
346 tag: 6
347 }
348 );
349 }
350
351 #[test]
352 fn enumeration_is_compatible_with_plain_int32_but_not_sint64() {
353 let spec = parse_proto_message(PROTO, "PricingData").unwrap();
354 let code = parse_struct_prost_fields(SOURCE, "PricingData").unwrap();
355 let rec = reconcile_proto(&spec, &code);
356 assert_eq!(rec.matched, 4);
359 assert_eq!(rec.mismatched.len(), 1);
360 assert_eq!(rec.mismatched[0].0.tag, 20);
361 assert!(rec.mismatched[0].2.contains("sint64"));
362 }
363
364 #[test]
365 fn flags_missing_tags_on_either_side() {
366 let spec = vec![
367 ProtoField {
368 name: "a".into(),
369 ty: "string".into(),
370 tag: 1,
371 },
372 ProtoField {
373 name: "b".into(),
374 ty: "int32".into(),
375 tag: 2,
376 },
377 ];
378 let code = vec![
379 ProtoField {
380 name: "a".into(),
381 ty: "string".into(),
382 tag: 1,
383 },
384 ProtoField {
385 name: "c".into(),
386 ty: "bool".into(),
387 tag: 3,
388 },
389 ];
390 let rec = reconcile_proto(&spec, &code);
391 assert_eq!(rec.matched, 1);
392 assert_eq!(rec.missing_code.len(), 1); assert_eq!(rec.missing_spec.len(), 1); }
395
396 #[test]
397 fn is_clean_when_everything_lines_up() {
398 let fields = vec![ProtoField {
399 name: "a".into(),
400 ty: "string".into(),
401 tag: 1,
402 }];
403 assert!(reconcile_proto(&fields, &fields).is_clean());
404 }
405}