Skip to main content

spikard_cli/codegen/protobuf/
spec_parser.rs

1//! Protobuf (.proto) specification parsing and extraction.
2//!
3//! This module handles parsing Protocol Buffer specifications (proto3 syntax only)
4//! and extracting structured data for code generation, including messages, services,
5//! enums, and field definitions.
6
7use anyhow::{Context, Result, anyhow, bail};
8use serde::{Deserialize, Serialize};
9use std::collections::{HashMap, HashSet};
10use std::fs;
11use std::path::{Path, PathBuf};
12
13/// Parsed Protobuf schema representation
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct ProtobufSchema {
16    /// Package name (e.g., "com.example.service")
17    pub package: Option<String>,
18    /// Map of message names to their definitions
19    pub messages: HashMap<String, MessageDef>,
20    /// Map of service names to their definitions
21    pub services: HashMap<String, ServiceDef>,
22    /// Map of enum names to their definitions
23    pub enums: HashMap<String, EnumDef>,
24    /// List of imported proto files
25    pub imports: Vec<String>,
26    /// Proto file syntax version (enforced to be "proto3")
27    pub syntax: String,
28    /// Schema description/comments
29    pub description: Option<String>,
30}
31
32/// Protobuf message definition
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct MessageDef {
35    /// Message name
36    pub name: String,
37    /// Message fields
38    pub fields: Vec<FieldDef>,
39    /// Nested message definitions
40    pub nested_messages: HashMap<String, Self>,
41    /// Nested enum definitions
42    pub nested_enums: HashMap<String, EnumDef>,
43    /// Message description from comments
44    pub description: Option<String>,
45}
46
47/// Protobuf service definition
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct ServiceDef {
50    /// Service name
51    pub name: String,
52    /// Service methods/RPCs
53    pub methods: Vec<MethodDef>,
54    /// Service description from comments
55    pub description: Option<String>,
56}
57
58/// Protobuf RPC method definition
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct MethodDef {
61    /// Method name
62    pub name: String,
63    /// Input message type name
64    pub input_type: String,
65    /// Output message type name
66    pub output_type: String,
67    /// Whether input is a stream
68    pub input_streaming: bool,
69    /// Whether output is a stream
70    pub output_streaming: bool,
71    /// Method description from comments
72    pub description: Option<String>,
73}
74
75/// Protobuf field definition
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct FieldDef {
78    /// Field name
79    pub name: String,
80    /// Field number (1-536870911)
81    pub number: u32,
82    /// Field type
83    pub field_type: ProtoType,
84    /// Field label (optional, repeated, or neither for required)
85    pub label: FieldLabel,
86    /// Default value (if applicable)
87    pub default_value: Option<String>,
88    /// Field description from comments
89    pub description: Option<String>,
90}
91
92/// Protocol Buffer field label
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
94pub enum FieldLabel {
95    /// No label (proto3 default: optional for scalars, required for messages)
96    None,
97    /// Repeated field (becomes a list)
98    Repeated,
99    /// Optional field (may be unset)
100    Optional,
101}
102
103/// Protobuf enum definition
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct EnumDef {
106    /// Enum name
107    pub name: String,
108    /// Enum values
109    pub values: Vec<EnumValue>,
110    /// Enum description from comments
111    pub description: Option<String>,
112}
113
114/// Protobuf enum value
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct EnumValue {
117    /// Value name
118    pub name: String,
119    /// Numeric value
120    pub number: i32,
121    /// Value description from comments
122    pub description: Option<String>,
123}
124
125/// Protocol Buffer type enumeration
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127pub enum ProtoType {
128    Double,
129    Float,
130    Int32,
131    Int64,
132    Uint32,
133    Uint64,
134    Sint32,
135    Sint64,
136    Fixed32,
137    Fixed64,
138    Sfixed32,
139    Sfixed64,
140    Bool,
141    String,
142    Bytes,
143    Message(String),
144    Enum(String),
145}
146
147impl ProtoType {
148    /// Get the string representation of a proto type
149    #[must_use]
150    pub fn as_str(&self) -> String {
151        match self {
152            Self::Double => "double".to_string(),
153            Self::Float => "float".to_string(),
154            Self::Int32 => "int32".to_string(),
155            Self::Int64 => "int64".to_string(),
156            Self::Uint32 => "uint32".to_string(),
157            Self::Uint64 => "uint64".to_string(),
158            Self::Sint32 => "sint32".to_string(),
159            Self::Sint64 => "sint64".to_string(),
160            Self::Fixed32 => "fixed32".to_string(),
161            Self::Fixed64 => "fixed64".to_string(),
162            Self::Sfixed32 => "sfixed32".to_string(),
163            Self::Sfixed64 => "sfixed64".to_string(),
164            Self::Bool => "bool".to_string(),
165            Self::String => "string".to_string(),
166            Self::Bytes => "bytes".to_string(),
167            Self::Message(name) => name.clone(),
168            Self::Enum(name) => name.clone(),
169        }
170    }
171}
172
173/// Parse a Protobuf schema from a .proto file
174///
175/// # Arguments
176/// * `path` - Path to .proto file
177///
178/// # Returns
179/// Parsed `ProtobufSchema` or error (rejects proto2 syntax)
180pub fn parse_proto_schema(path: &Path) -> Result<ProtobufSchema> {
181    let content = fs::read_to_string(path).with_context(|| format!("Failed to read proto file: {}", path.display()))?;
182
183    parse_proto_schema_string(&content).with_context(|| format!("Failed to parse proto schema from {}", path.display()))
184}
185
186/// Parse a Protobuf schema from a .proto file and recursively merge import dependencies.
187///
188/// Imported files are resolved relative to the source file first and then against
189/// any additional include paths supplied by the caller.
190pub fn parse_proto_schema_with_includes(path: &Path, include_paths: &[PathBuf]) -> Result<ProtobufSchema> {
191    let mut visited = HashSet::new();
192    parse_proto_schema_recursive(path, include_paths, &mut visited)
193}
194
195/// Parse a Protobuf schema from a string
196pub fn parse_proto_schema_string(content: &str) -> Result<ProtobufSchema> {
197    let mut schema = ProtobufSchema {
198        package: None,
199        messages: HashMap::new(),
200        services: HashMap::new(),
201        enums: HashMap::new(),
202        imports: Vec::new(),
203        syntax: String::new(),
204        description: None,
205    };
206
207    schema.syntax = extract_syntax_declaration(content).unwrap_or_else(|| "proto3".to_string());
208
209    if schema.syntax != "proto3" {
210        return Err(anyhow!(
211            "Only proto3 syntax is supported. Found: {}\n\
212             Please convert your proto file to proto3 syntax or use proto3-compatible definitions.\n\
213             See: https://developers.google.com/protocol-buffers/docs/proto3",
214            schema.syntax
215        ));
216    }
217
218    schema.package = extract_package_name(content);
219
220    schema.imports = extract_imports(content);
221
222    parse_top_level_definitions(content, &mut schema)?;
223
224    Ok(schema)
225}
226
227fn parse_proto_schema_recursive(
228    path: &Path,
229    include_paths: &[PathBuf],
230    visited: &mut HashSet<PathBuf>,
231) -> Result<ProtobufSchema> {
232    let visit_key = canonical_or_original(path);
233    if !visited.insert(visit_key) {
234        return Ok(ProtobufSchema {
235            package: None,
236            messages: HashMap::new(),
237            services: HashMap::new(),
238            enums: HashMap::new(),
239            imports: Vec::new(),
240            syntax: "proto3".to_string(),
241            description: None,
242        });
243    }
244
245    let content = fs::read_to_string(path).with_context(|| format!("Failed to read proto file: {}", path.display()))?;
246    let mut schema = parse_proto_schema_string(&content)
247        .with_context(|| format!("Failed to parse proto schema from {}", path.display()))?;
248
249    for import in schema.imports.clone() {
250        let Some(import_path) = resolve_import_path(path, &import, include_paths) else {
251            continue;
252        };
253        let imported_schema = parse_proto_schema_recursive(&import_path, include_paths, visited)?;
254        merge_schema(&mut schema, imported_schema)?;
255    }
256
257    Ok(schema)
258}
259
260fn resolve_import_path(path: &Path, import: &str, include_paths: &[PathBuf]) -> Option<PathBuf> {
261    let mut relative_candidates = path
262        .parent()
263        .into_iter()
264        .map(|parent| parent.join(import))
265        .chain(include_paths.iter().map(|include| include.join(import)));
266
267    relative_candidates
268        .find(|candidate| candidate.is_file())
269        .map(|candidate| canonical_or_original(&candidate))
270}
271
272fn canonical_or_original(path: &Path) -> PathBuf {
273    fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
274}
275
276fn merge_schema(target: &mut ProtobufSchema, imported: ProtobufSchema) -> Result<()> {
277    merge_named_defs("message", &mut target.messages, imported.messages)?;
278    merge_named_defs("enum", &mut target.enums, imported.enums)?;
279    merge_named_defs("service", &mut target.services, imported.services)?;
280
281    for import in imported.imports {
282        if !target.imports.contains(&import) {
283            target.imports.push(import);
284        }
285    }
286
287    Ok(())
288}
289
290fn merge_named_defs<T>(kind: &str, target: &mut HashMap<String, T>, source: HashMap<String, T>) -> Result<()> {
291    for (name, def) in source {
292        if target.contains_key(&name) {
293            bail!("Duplicate {kind} definition found while resolving imports: {name}");
294        }
295        target.insert(name, def);
296    }
297    Ok(())
298}
299
300/// Helper function to extract syntax declaration from proto content
301fn extract_syntax_declaration(content: &str) -> Option<String> {
302    for line in content.lines() {
303        let trimmed = line.trim();
304        if trimmed.starts_with("syntax") {
305            let quote_start = trimmed.find('"')?;
306            let remaining = &trimmed[quote_start + 1..];
307            let quote_end = remaining.find('"')?;
308            return Some(remaining[..quote_end].to_string());
309        }
310    }
311    None
312}
313
314/// Helper function to extract package name from proto content
315fn extract_package_name(content: &str) -> Option<String> {
316    for line in content.lines() {
317        let trimmed = line.trim();
318        if trimmed.starts_with("package") && !trimmed.starts_with("package ") {
319            continue;
320        }
321        if let Some(package_part) = trimmed.strip_prefix("package ") {
322            let semicolon_pos = package_part.find(';')?;
323            let package_name = package_part[..semicolon_pos].trim();
324            return Some(package_name.to_string());
325        }
326    }
327    None
328}
329
330/// Helper function to extract imports from proto content
331fn extract_imports(content: &str) -> Vec<String> {
332    let mut imports = Vec::new();
333    for line in content.lines() {
334        let trimmed = line.trim();
335        if trimmed.starts_with("import ") && trimmed.contains('"') {
336            if let Some(quote_start) = trimmed.find('"') {
337                let remaining = &trimmed[quote_start + 1..];
338                if let Some(quote_end) = remaining.find('"') {
339                    imports.push(remaining[..quote_end].to_string());
340                }
341            }
342        }
343    }
344    imports
345}
346
347fn parse_top_level_definitions(content: &str, schema: &mut ProtobufSchema) -> Result<()> {
348    let lines: Vec<&str> = content.lines().collect();
349    let mut index = 0;
350    let mut pending_comment: Vec<String> = Vec::new();
351
352    while index < lines.len() {
353        let trimmed = strip_inline_comment(lines[index]).trim();
354
355        if trimmed.is_empty() {
356            if !pending_comment.is_empty() {
357                pending_comment.clear();
358            }
359            index += 1;
360            continue;
361        }
362
363        if let Some(comment) = lines[index].trim().strip_prefix("//") {
364            pending_comment.push(comment.trim().to_string());
365            index += 1;
366            continue;
367        }
368
369        if trimmed.starts_with("message ") {
370            let (message, next_index) = parse_message_block(&lines, index, take_comment(&mut pending_comment))?;
371            schema.messages.insert(message.name.clone(), message);
372            index = next_index;
373            continue;
374        }
375
376        if trimmed.starts_with("enum ") {
377            let (enum_def, next_index) = parse_enum_block(&lines, index, take_comment(&mut pending_comment))?;
378            schema.enums.insert(enum_def.name.clone(), enum_def);
379            index = next_index;
380            continue;
381        }
382
383        if trimmed.starts_with("service ") {
384            let (service, next_index) = parse_service_block(&lines, index, take_comment(&mut pending_comment))?;
385            schema.services.insert(service.name.clone(), service);
386            index = next_index;
387            continue;
388        }
389
390        pending_comment.clear();
391        index += 1;
392    }
393
394    Ok(())
395}
396
397fn parse_message_block(lines: &[&str], start: usize, description: Option<String>) -> Result<(MessageDef, usize)> {
398    let header = strip_inline_comment(lines[start]).trim();
399    let name = extract_block_name(header, "message")
400        .ok_or_else(|| anyhow!("Invalid message declaration: {}", lines[start].trim()))?;
401
402    let mut message = MessageDef {
403        name,
404        fields: Vec::new(),
405        nested_messages: HashMap::new(),
406        nested_enums: HashMap::new(),
407        description,
408    };
409
410    let mut index = start + 1;
411    let mut depth = usize::from(header.contains('{'));
412    let mut pending_comment: Vec<String> = Vec::new();
413
414    while index < lines.len() {
415        let raw_line = lines[index];
416        let line = strip_inline_comment(raw_line);
417        let trimmed = line.trim();
418
419        if trimmed.starts_with("//") {
420            if let Some(comment) = raw_line.trim().strip_prefix("//") {
421                pending_comment.push(comment.trim().to_string());
422            }
423            index += 1;
424            continue;
425        }
426
427        let opens = trimmed.matches('{').count();
428        let closes = trimmed.matches('}').count();
429
430        if depth == 1 && !trimmed.is_empty() && !trimmed.starts_with("message ") && !trimmed.starts_with("enum ") {
431            if let Some(field) = parse_field(trimmed, take_comment(&mut pending_comment))? {
432                message.fields.push(field);
433            }
434        }
435
436        depth += opens;
437        depth = depth.saturating_sub(closes);
438        index += 1;
439
440        if depth == 0 {
441            break;
442        }
443    }
444
445    Ok((message, index))
446}
447
448fn parse_enum_block(lines: &[&str], start: usize, description: Option<String>) -> Result<(EnumDef, usize)> {
449    let header = strip_inline_comment(lines[start]).trim();
450    let name = extract_block_name(header, "enum")
451        .ok_or_else(|| anyhow!("Invalid enum declaration: {}", lines[start].trim()))?;
452
453    let mut enum_def = EnumDef {
454        name,
455        values: Vec::new(),
456        description,
457    };
458
459    let mut index = start + 1;
460    let mut depth = usize::from(header.contains('{'));
461    let mut pending_comment: Vec<String> = Vec::new();
462
463    while index < lines.len() {
464        let raw_line = lines[index];
465        let line = strip_inline_comment(raw_line);
466        let trimmed = line.trim();
467
468        if trimmed.starts_with("//") {
469            if let Some(comment) = raw_line.trim().strip_prefix("//") {
470                pending_comment.push(comment.trim().to_string());
471            }
472            index += 1;
473            continue;
474        }
475
476        let opens = trimmed.matches('{').count();
477        let closes = trimmed.matches('}').count();
478
479        if depth == 1 && trimmed.contains('=') && trimmed.ends_with(';') {
480            if let Some(value) = parse_enum_value(trimmed, take_comment(&mut pending_comment))? {
481                enum_def.values.push(value);
482            }
483        }
484
485        depth += opens;
486        depth = depth.saturating_sub(closes);
487        index += 1;
488
489        if depth == 0 {
490            break;
491        }
492    }
493
494    Ok((enum_def, index))
495}
496
497fn parse_service_block(lines: &[&str], start: usize, description: Option<String>) -> Result<(ServiceDef, usize)> {
498    let header = strip_inline_comment(lines[start]).trim();
499    let name = extract_block_name(header, "service")
500        .ok_or_else(|| anyhow!("Invalid service declaration: {}", lines[start].trim()))?;
501
502    let mut service = ServiceDef {
503        name,
504        methods: Vec::new(),
505        description,
506    };
507
508    let mut index = start + 1;
509    let mut depth = usize::from(header.contains('{'));
510    let mut pending_comment: Vec<String> = Vec::new();
511
512    while index < lines.len() {
513        let raw_line = lines[index];
514        let line = strip_inline_comment(raw_line);
515        let trimmed = line.trim();
516
517        if trimmed.starts_with("//") {
518            if let Some(comment) = raw_line.trim().strip_prefix("//") {
519                pending_comment.push(comment.trim().to_string());
520            }
521            index += 1;
522            continue;
523        }
524
525        let opens = trimmed.matches('{').count();
526        let closes = trimmed.matches('}').count();
527
528        if depth == 1 && trimmed.starts_with("rpc ") {
529            if let Some(method) = parse_rpc_method(trimmed, take_comment(&mut pending_comment))? {
530                service.methods.push(method);
531            }
532        }
533
534        depth += opens;
535        depth = depth.saturating_sub(closes);
536        index += 1;
537
538        if depth == 0 {
539            break;
540        }
541    }
542
543    Ok((service, index))
544}
545
546fn parse_field(line: &str, description: Option<String>) -> Result<Option<FieldDef>> {
547    if !line.ends_with(';') || line.starts_with("option ") || line.starts_with("reserved ") {
548        return Ok(None);
549    }
550
551    let without_semicolon = line.trim_end_matches(';');
552    let declaration = without_semicolon.split('[').next().unwrap_or(without_semicolon).trim();
553    let parts: Vec<&str> = declaration.split_whitespace().collect();
554
555    if parts.len() < 4 {
556        return Ok(None);
557    }
558
559    let (label, type_index) = match parts[0] {
560        "repeated" => (FieldLabel::Repeated, 1),
561        "optional" => (FieldLabel::Optional, 1),
562        _ => (FieldLabel::None, 0),
563    };
564
565    if parts.len() <= type_index + 2 {
566        return Ok(None);
567    }
568
569    let field_type = parse_proto_type(parts[type_index]);
570    let field_name = parts[type_index + 1].to_string();
571    let number = parts[type_index + 3]
572        .parse::<u32>()
573        .with_context(|| format!("Invalid field number in line: {line}"))?;
574
575    Ok(Some(FieldDef {
576        name: field_name,
577        number,
578        field_type,
579        label,
580        default_value: None,
581        description,
582    }))
583}
584
585fn parse_enum_value(line: &str, description: Option<String>) -> Result<Option<EnumValue>> {
586    let without_semicolon = line.trim_end_matches(';').trim();
587    let (name, number) = without_semicolon
588        .split_once('=')
589        .ok_or_else(|| anyhow!("Invalid enum value declaration: {line}"))?;
590
591    Ok(Some(EnumValue {
592        name: name.trim().to_string(),
593        number: number
594            .trim()
595            .parse::<i32>()
596            .with_context(|| format!("Invalid enum value number in line: {line}"))?,
597        description,
598    }))
599}
600
601fn parse_rpc_method(line: &str, description: Option<String>) -> Result<Option<MethodDef>> {
602    let without_semicolon = line.trim_end_matches(';').trim();
603    let after_rpc = without_semicolon
604        .strip_prefix("rpc ")
605        .ok_or_else(|| anyhow!("Invalid RPC declaration: {line}"))?;
606    let method_name_end = after_rpc
607        .find('(')
608        .ok_or_else(|| anyhow!("Invalid RPC declaration: {line}"))?;
609    let method_name = after_rpc[..method_name_end].trim().to_string();
610    let rest = &after_rpc[method_name_end + 1..];
611    let request_end = rest
612        .find(')')
613        .ok_or_else(|| anyhow!("Invalid RPC request declaration: {line}"))?;
614    let request_decl = rest[..request_end].trim();
615    let after_request = rest[request_end + 1..].trim();
616    let returns_decl = after_request
617        .strip_prefix("returns")
618        .ok_or_else(|| anyhow!("Invalid RPC returns declaration: {line}"))?
619        .trim();
620    let returns_decl = returns_decl
621        .strip_prefix('(')
622        .ok_or_else(|| anyhow!("Invalid RPC returns declaration: {line}"))?;
623    let response_end = returns_decl
624        .find(')')
625        .ok_or_else(|| anyhow!("Invalid RPC returns declaration: {line}"))?;
626    let response_decl = returns_decl[..response_end].trim();
627
628    let (input_streaming, input_type) = parse_streaming_type(request_decl);
629    let (output_streaming, output_type) = parse_streaming_type(response_decl);
630
631    Ok(Some(MethodDef {
632        name: method_name,
633        input_type,
634        output_type,
635        input_streaming,
636        output_streaming,
637        description,
638    }))
639}
640
641fn parse_streaming_type(declaration: &str) -> (bool, String) {
642    if let Some(rest) = declaration.strip_prefix("stream ") {
643        (true, rest.trim().to_string())
644    } else {
645        (false, declaration.trim().to_string())
646    }
647}
648
649fn extract_block_name(header: &str, keyword: &str) -> Option<String> {
650    header
651        .strip_prefix(keyword)?
652        .trim()
653        .strip_suffix('{')
654        .unwrap_or_else(|| header.strip_prefix(keyword).unwrap().trim())
655        .split_whitespace()
656        .next()
657        .map(std::string::ToString::to_string)
658}
659
660fn strip_inline_comment(line: &str) -> &str {
661    if let Some((before, _)) = line.split_once("//") {
662        before
663    } else {
664        line
665    }
666}
667
668fn take_comment(pending_comment: &mut Vec<String>) -> Option<String> {
669    if pending_comment.is_empty() {
670        None
671    } else {
672        let comment = pending_comment.join(" ");
673        pending_comment.clear();
674        Some(comment)
675    }
676}
677
678/// Helper function to parse type name from proto syntax
679#[allow(dead_code)]
680fn parse_proto_type(type_str: &str) -> ProtoType {
681    match type_str {
682        "double" => ProtoType::Double,
683        "float" => ProtoType::Float,
684        "int32" => ProtoType::Int32,
685        "int64" => ProtoType::Int64,
686        "uint32" => ProtoType::Uint32,
687        "uint64" => ProtoType::Uint64,
688        "sint32" => ProtoType::Sint32,
689        "sint64" => ProtoType::Sint64,
690        "fixed32" => ProtoType::Fixed32,
691        "fixed64" => ProtoType::Fixed64,
692        "sfixed32" => ProtoType::Sfixed32,
693        "sfixed64" => ProtoType::Sfixed64,
694        "bool" => ProtoType::Bool,
695        "string" => ProtoType::String,
696        "bytes" => ProtoType::Bytes,
697        _ => ProtoType::Message(type_str.to_string()),
698    }
699}
700
701#[cfg(test)]
702mod tests {
703    use super::*;
704    use tempfile::tempdir;
705
706    #[test]
707    fn test_parse_simple_proto3_schema() {
708        let proto = r#"syntax = "proto3";
709
710package example;
711
712message User {
713  string id = 1;
714  string name = 2;
715  string email = 3;
716}
717"#;
718
719        let schema = parse_proto_schema_string(proto).expect("Failed to parse proto");
720        assert_eq!(schema.syntax, "proto3");
721        assert_eq!(schema.package, Some("example".to_string()));
722        let user = schema.messages.get("User").expect("message should be parsed");
723        assert_eq!(user.fields.len(), 3);
724        assert_eq!(user.fields[0].name, "id");
725    }
726
727    #[test]
728    fn test_parse_proto_with_imports() {
729        let proto = r#"syntax = "proto3";
730
731import "google/protobuf/timestamp.proto";
732import "other.proto";
733
734package example;
735"#;
736
737        let schema = parse_proto_schema_string(proto).expect("Failed to parse proto");
738        assert_eq!(schema.imports.len(), 2);
739        assert!(schema.imports.contains(&"google/protobuf/timestamp.proto".to_string()));
740        assert!(schema.imports.contains(&"other.proto".to_string()));
741    }
742
743    #[test]
744    fn test_parse_proto_schema_with_includes_merges_imported_messages() {
745        let temp_dir = tempdir().expect("temp dir");
746        let shared_dir = temp_dir.path().join("common");
747        fs::create_dir_all(&shared_dir).expect("create include dir");
748
749        let shared_proto = shared_dir.join("types.proto");
750        fs::write(
751            &shared_proto,
752            r#"syntax = "proto3";
753
754package common;
755
756message SharedType {
757  string id = 1;
758}
759"#,
760        )
761        .expect("write shared proto");
762
763        let root_proto = temp_dir.path().join("service.proto");
764        fs::write(
765            &root_proto,
766            r#"syntax = "proto3";
767
768import "common/types.proto";
769
770package example;
771
772message UsesShared {
773  SharedType shared = 1;
774}
775"#,
776        )
777        .expect("write root proto");
778
779        let schema = parse_proto_schema_with_includes(&root_proto, &[temp_dir.path().to_path_buf()])
780            .expect("schema should resolve imports");
781
782        assert!(schema.messages.contains_key("UsesShared"));
783        assert!(schema.messages.contains_key("SharedType"));
784        assert!(schema.imports.contains(&"common/types.proto".to_string()));
785    }
786
787    #[test]
788    fn test_reject_proto2_syntax() {
789        let proto = r#"syntax = "proto2";
790
791package example;
792
793message User {
794  required string id = 1;
795}
796"#;
797
798        let result = parse_proto_schema_string(proto);
799        assert!(result.is_err());
800        let error_msg = format!("{}", result.unwrap_err());
801        assert!(error_msg.contains("Only proto3 syntax is supported"));
802        assert!(error_msg.contains("proto2"));
803    }
804
805    #[test]
806    fn test_parse_proto_type_scalars() {
807        assert_eq!(parse_proto_type("double"), ProtoType::Double);
808        assert_eq!(parse_proto_type("float"), ProtoType::Float);
809        assert_eq!(parse_proto_type("int32"), ProtoType::Int32);
810        assert_eq!(parse_proto_type("int64"), ProtoType::Int64);
811        assert_eq!(parse_proto_type("bool"), ProtoType::Bool);
812        assert_eq!(parse_proto_type("string"), ProtoType::String);
813        assert_eq!(parse_proto_type("bytes"), ProtoType::Bytes);
814    }
815
816    #[test]
817    fn test_parse_proto_type_message() {
818        match parse_proto_type("User") {
819            ProtoType::Message(name) => assert_eq!(name, "User"),
820            _ => panic!("Expected Message type"),
821        }
822    }
823
824    #[test]
825    fn test_parse_service_and_enum() {
826        let proto = r#"syntax = "proto3";
827
828package example;
829
830enum Status {
831  STATUS_UNKNOWN = 0;
832  STATUS_ACTIVE = 1;
833}
834
835service UserService {
836  rpc GetUser (GetUserRequest) returns (User);
837  rpc ListUsers (ListUsersRequest) returns (stream User);
838}
839"#;
840
841        let schema = parse_proto_schema_string(proto).expect("Failed to parse proto");
842        let status = schema.enums.get("Status").expect("enum should be parsed");
843        assert_eq!(status.values.len(), 2);
844
845        let service = schema.services.get("UserService").expect("service should be parsed");
846        assert_eq!(service.methods.len(), 2);
847        assert_eq!(service.methods[0].name, "GetUser");
848        assert!(service.methods[1].output_streaming);
849    }
850
851    #[test]
852    fn test_proto_type_as_str() {
853        assert_eq!(ProtoType::Double.as_str(), "double");
854        assert_eq!(ProtoType::String.as_str(), "string");
855        assert_eq!(ProtoType::Message("User".to_string()).as_str(), "User");
856    }
857}