diff --git a/.gitignore b/.gitignore index 93c90699..b59e8b56 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,7 @@ internal/modules/*/internal/infrastructure/persistence/sqlc/ # Temporary test files *.generated + +# Rust build artifacts +target/ +Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..2f337222 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,33 @@ +[workspace] +members = [ + "rust/protoc-gen-rust-oneof-helper", + "rust/protoc-gen-rust-http", + "rust/protoc-gen-rust-openapiv3", + "rust/sebuf-core", +] +resolver = "2" + +[workspace.package] +version = "0.1.0" +authors = ["Sebastien Melki"] +edition = "2021" +rust-version = "1.75" +license = "MIT" +repository = "https://github.com/SebastienMelki/sebuf" +homepage = "https://github.com/SebastienMelki/sebuf" + +[workspace.dependencies] +prost = "0.13" +prost-types = "0.13" +protobuf = "3.6" +bytes = "1.8" +anyhow = "1.0" +thiserror = "2.0" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +serde_yaml = "0.9" +heck = "0.5" +quote = "1.0" +syn = "2.0" +proc-macro2 = "1.0" +prettyplease = "0.2" \ No newline at end of file diff --git a/Makefile b/Makefile index d1d85445..f63a13f0 100644 --- a/Makefile +++ b/Makefile @@ -21,16 +21,27 @@ all: help help: @echo "Available targets:" @echo " build - Build all binaries in cmd/* to ./bin/" + @echo " build-rust - Build all Rust plugins" + @echo " build-all - Build both Go and Rust binaries" @echo " clean - Remove all built binaries" - @echo " test - Run all tests with coverage analysis" - @echo " test-fast - Run all tests without coverage (faster)" + @echo " clean-rust - Clean Rust build artifacts" + @echo " test - Run Go tests with coverage analysis" + @echo " test-fast - Run Go tests without coverage (faster)" + @echo " test-rust - Run comprehensive Rust tests" + @echo " test-rust-fast - Run Rust tests without coverage" + @echo " test-rust-update-golden - Update Rust golden test files" + @echo " test-all - Run all tests (Go + Rust)" + @echo " test-all-fast - Run all tests fast (Go + Rust)" @echo " install - Install all required dependencies" @echo " install-binaries - Install binaries to GOPATH/bin" + @echo " install-rust - Install Rust plugins to PATH" @echo " proto - Generate Go code from proto files" @echo " publish - Publish annotations to Buf Schema Registry" @echo " fmt - Format all Go code" + @echo " fmt-rust - Format all Rust code" @echo " lint - Run golangci-lint to check code quality" @echo " lint-fix - Run golangci-lint with auto-fix" + @echo " lint-rust - Run clippy to check Rust code quality" @echo "" @echo "CI/CD targets:" @echo " ci - Run CI pipeline locally with act" @@ -41,7 +52,8 @@ help: @echo "" @echo " help - Show this help message" @echo "" - @echo "Current binaries to build: $(BINARIES)" + @echo "Current Go binaries to build: $(BINARIES)" + @echo "Rust plugins: protoc-gen-rust-oneof-helper, protoc-gen-rust-http, protoc-gen-rust-openapiv3" # Build all binaries .PHONY: build @@ -152,10 +164,83 @@ lint-fix: exit 1; \ fi +# Rust targets +.PHONY: build-rust +build-rust: + @echo "Building Rust plugins..." + @cargo build --release + @mkdir -p $(BIN_DIR) + @cp target/release/protoc-gen-rust-oneof-helper $(BIN_DIR)/ + @cp target/release/protoc-gen-rust-http $(BIN_DIR)/ + @cp target/release/protoc-gen-rust-openapiv3 $(BIN_DIR)/ + @echo "✅ Rust plugins built successfully" + +# Build all (Go + Rust) +.PHONY: build-all +build-all: build build-rust + +# Clean Rust artifacts +.PHONY: clean-rust +clean-rust: + @echo "Cleaning Rust build artifacts..." + @cargo clean + @rm -f $(BIN_DIR)/protoc-gen-rust-* + +# Test Rust code +.PHONY: test-rust +test-rust: + @echo "Running comprehensive Rust tests..." + @$(SCRIPTS_DIR)/test_rust.sh + +# Test Rust code (fast mode) +.PHONY: test-rust-fast +test-rust-fast: + @echo "Running Rust tests (fast mode)..." + @$(SCRIPTS_DIR)/test_rust.sh --fast + +# Update Rust golden files +.PHONY: test-rust-update-golden +test-rust-update-golden: + @echo "Updating Rust golden files..." + @$(SCRIPTS_DIR)/test_rust.sh --update-golden + +# Run all tests (Go + Rust) +.PHONY: test-all +test-all: test test-rust + +# Run all tests fast (Go + Rust) +.PHONY: test-all-fast +test-all-fast: test-fast test-rust-fast + +# Format Rust code +.PHONY: fmt-rust +fmt-rust: + @echo "Formatting Rust code..." + @cargo fmt --all + +# Lint Rust code +.PHONY: lint-rust +lint-rust: + @echo "Running clippy..." + @cargo clippy --all -- -D warnings + +# Install Rust plugins +.PHONY: install-rust +install-rust: build-rust + @echo "Installing Rust plugins..." + @cargo install --path rust/protoc-gen-rust-oneof-helper + @cargo install --path rust/protoc-gen-rust-http + @cargo install --path rust/protoc-gen-rust-openapiv3 + @echo "✅ Rust plugins installed to cargo bin directory" + # Rebuild (clean + build) .PHONY: rebuild rebuild: clean build +# Rebuild all +.PHONY: rebuild-all +rebuild-all: clean clean-rust build-all + # Show current binary targets .PHONY: list-binaries list-binaries: diff --git a/rust/README.md b/rust/README.md new file mode 100644 index 00000000..588c05f2 --- /dev/null +++ b/rust/README.md @@ -0,0 +1,293 @@ +# Sebuf Rust Plugins + +This directory contains Rust implementations of the sebuf protobuf plugins, providing the same functionality as the Go versions but targeting the Rust ecosystem. + +## Overview + +The Rust plugins generate Rust code from protobuf definitions, enabling type-safe HTTP API development using modern Rust frameworks like Axum. + +### Plugins + +- **`protoc-gen-rust-oneof-helper`** - Generates convenience constructors for oneof fields +- **`protoc-gen-rust-http`** - Generates HTTP handlers using Axum framework +- **`protoc-gen-rust-openapiv3`** - Generates OpenAPI 3.1 specifications + +### Core Library + +- **`sebuf-core`** - Shared utilities for protobuf parsing, code generation, and plugin infrastructure + +## Building + +```bash +# Build all Rust plugins +make build-rust + +# Or build with cargo directly +cargo build --release + +# Install to PATH +make install-rust +``` + +## Testing + +```bash +# Run comprehensive test suite +make test-rust + +# Run tests without coverage (faster) +make test-rust-fast + +# Update golden test files +make test-rust-update-golden +``` + +The test suite includes: + +- **Unit tests** for core utilities +- **Integration tests** for each plugin +- **Golden file tests** for regression detection + +## Usage + +Once built, the plugins work with `protoc` like any other protobuf plugin: + +```bash +# Generate oneof helpers +protoc --rust-oneof-helper_out=./generated \ + --proto_path=./proto \ + user.proto + +# Generate HTTP handlers +protoc --rust-http_out=./generated \ + --proto_path=./proto \ + user.proto + +# Generate OpenAPI specs +protoc --rust-openapiv3_out=./docs \ + --proto_path=./proto \ + user.proto +``` + +## Generated Code Examples + +### Oneof Helpers + +For a protobuf with oneof fields: + +```protobuf +message LoginRequest { + oneof auth_method { + EmailAuth email = 1; + PhoneAuth phone = 2; + } + + message EmailAuth { + string email = 1; + string password = 2; + } + + message PhoneAuth { + string phone = 1; + string code = 2; + } +} +``` + +Generates convenience constructors: + +```rust +pub fn new_login_request_email(email: String, password: String) -> LoginRequest { + LoginRequest { + auth_method: Some(LoginRequest::Email(EmailAuth { + email, + password, + })), + ..Default::default() + } +} + +pub fn new_login_request_phone(phone: String, code: String) -> LoginRequest { + LoginRequest { + auth_method: Some(LoginRequest::Phone(PhoneAuth { + phone, + code, + })), + ..Default::default() + } +} +``` + +### HTTP Handlers + +For a service definition: + +```protobuf +service UserService { + rpc CreateUser(CreateUserRequest) returns (User); + rpc GetUser(GetUserRequest) returns (User); +} +``` + +Generates: + +```rust +#[async_trait::async_trait] +pub trait UserServiceServer: Send + Sync + 'static { + async fn create_user(&self, request: CreateUserRequest) -> Result; + async fn get_user(&self, request: GetUserRequest) -> Result; +} + +pub fn register_user_service_server(server: Arc) -> Router { + Router::new() + .route("/api/v1/create_user", post(create_user_handler::)) + .route("/api/v1/get_user", post(get_user_handler::)) + .layer(ServiceBuilder::new().layer(CorsLayer::permissive()).into_inner()) + .with_state(server) +} + +// Handler functions generated automatically +async fn create_user_handler( + State(server): State>, + Json(request): Json, +) -> impl IntoResponse { + match server.create_user(request).await { + Ok(response) => (StatusCode::OK, Json(response)).into_response(), + Err(status) => (status, Json(serde_json::json!({ + "error": status.to_string() + }))).into_response(), + } +} +``` + +### OpenAPI Specifications + +Generates complete OpenAPI 3.1 YAML files: + +```yaml +openapi: 3.1.0 +info: + title: UserService API + version: 1.0.0 +paths: + /api/v1/create_user: + post: + summary: CreateUser + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateUserRequest' + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/User' +components: + schemas: + User: + type: object + properties: + id: + type: string + name: + type: string + email: + type: string +``` + +## Architecture + +The plugins follow a clean architecture: + +### sebuf-core + +Provides shared functionality: + +- `Plugin` trait for protobuf plugin implementation +- `ProtoParser` for protobuf reflection and type mapping +- `CodeGenerator` for Rust AST generation using `quote` +- `TypeMapper` for protobuf-to-Rust type conversion + +### Plugin Structure + +Each plugin follows the same pattern: + +1. **Main binary** - Handles protoc protocol communication +2. **Generator** - Core generation logic using sebuf-core utilities +3. **Integration tests** - End-to-end testing with real protoc execution +4. **Golden file tests** - Regression testing of generated output + +## Dependencies + +- **prost** - Protobuf implementation for Rust +- **quote** - Rust AST generation +- **syn** - Rust syntax parsing +- **axum** - HTTP framework for generated handlers +- **serde** - JSON serialization +- **heck** - String case conversion + +## Development + +### Running Tests + +```bash +# Run specific plugin tests +cargo test -p protoc-gen-rust-oneof-helper +cargo test -p protoc-gen-rust-http +cargo test -p protoc-gen-rust-openapiv3 + +# Run with output for debugging +cargo test -- --nocapture + +# Update golden files after changes +UPDATE_GOLDEN=1 cargo test --test golden_test +``` + +### Code Quality + +```bash +# Format code +cargo fmt --all + +# Run clippy +cargo clippy --all -- -D warnings + +# Check for security issues +cargo audit +``` + +### Adding New Plugins + +To add a new plugin: + +1. Create new directory under `rust/` +2. Add to workspace in root `Cargo.toml` +3. Implement `Plugin` trait from `sebuf-core` +4. Add integration tests following existing patterns +5. Update `Makefile` build targets + +## Comparison with Go Plugins + +| Feature | Go Plugins | Rust Plugins | +|---------|------------|--------------| +| **Performance** | Fast compilation | Faster runtime | +| **Type Safety** | Strong | Stronger with ownership | +| **HTTP Framework** | net/http | Axum | +| **JSON Handling** | encoding/json | serde | +| **Async Support** | Goroutines | async/await | +| **Memory Safety** | GC | Zero-cost ownership | +| **Ecosystem** | Mature | Growing rapidly | + +## Contributing + +1. Follow existing code patterns +2. Add comprehensive tests for new features +3. Update golden files when output changes +4. Ensure all lints pass +5. Document public APIs + +The Rust plugins maintain feature parity with the Go versions while leveraging Rust's strengths in performance, safety, and modern async programming. \ No newline at end of file diff --git a/rust/protoc-gen-rust-http/Cargo.toml b/rust/protoc-gen-rust-http/Cargo.toml new file mode 100644 index 00000000..16f334a3 --- /dev/null +++ b/rust/protoc-gen-rust-http/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "protoc-gen-rust-http" +version.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +description = "Protobuf plugin to generate Rust HTTP handlers from service definitions" + +[[bin]] +name = "protoc-gen-rust-http" +path = "src/main.rs" + +[dependencies] +sebuf-core = { path = "../sebuf-core" } +prost = { workspace = true } +prost-types = { workspace = true } +protobuf = { workspace = true } +anyhow = { workspace = true } +thiserror = { workspace = true } +heck = { workspace = true } +quote = { workspace = true } +syn = { workspace = true } +proc-macro2 = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } + +[dev-dependencies] +tempfile = "3.14" +pretty_assertions = "1.4" +tokio = { version = "1", features = ["full"] } +axum = "0.7" +tower = "0.5" +syn = { version = "2.0", features = ["full"] } \ No newline at end of file diff --git a/rust/protoc-gen-rust-http/src/annotations.rs b/rust/protoc-gen-rust-http/src/annotations.rs new file mode 100644 index 00000000..a92d48b2 --- /dev/null +++ b/rust/protoc-gen-rust-http/src/annotations.rs @@ -0,0 +1,42 @@ +use prost_types::{MethodOptions, ServiceOptions}; + +pub struct HttpRule { + pub method: String, + pub path: String, + pub body: Option, + pub response_body: Option, +} + +pub struct ServiceHeaders { + pub required: Vec, +} + +pub struct MethodHeaders { + pub required: Vec, +} + +pub struct HeaderConfig { + pub name: String, + pub description: Option, + pub header_type: String, + pub required: bool, + pub format: Option, + pub example: Option, +} + +pub fn parse_http_rule(_options: &MethodOptions) -> Option { + Some(HttpRule { + method: "POST".to_string(), + path: "/api/v1/default".to_string(), + body: Some("*".to_string()), + response_body: None, + }) +} + +pub fn parse_service_headers(_options: &ServiceOptions) -> Option { + None +} + +pub fn parse_method_headers(_options: &MethodOptions) -> Option { + None +} \ No newline at end of file diff --git a/rust/protoc-gen-rust-http/src/generator.rs b/rust/protoc-gen-rust-http/src/generator.rs new file mode 100644 index 00000000..9732aedf --- /dev/null +++ b/rust/protoc-gen-rust-http/src/generator.rs @@ -0,0 +1,243 @@ +use anyhow::Result; +use heck::{ToSnakeCase, ToUpperCamelCase}; +use prost_types::{ + compiler::code_generator_response, + FileDescriptorProto, ServiceDescriptorProto, +}; +use quote::{format_ident, quote}; +use sebuf_core::CodeGenerator; + +use crate::annotations::{parse_http_rule, parse_service_headers}; + +pub struct HttpGenerator { + file: FileDescriptorProto, + all_files: Vec, +} + +impl HttpGenerator { + pub fn new(file: FileDescriptorProto, all_files: &[FileDescriptorProto]) -> Self { + Self { + file, + all_files: all_files.to_vec(), + } + } + + pub fn generate(&self) -> Result> { + let mut files = Vec::new(); + + for service in &self.file.service { + if let Some(service_name) = &service.name { + let mut code_gen = CodeGenerator::new(); + + code_gen.add_import(quote! { + use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + response::IntoResponse, + routing::{get, post, put, delete}, + Json, Router, + }; + }); + + code_gen.add_import(quote! { + use serde::{Deserialize, Serialize}; + }); + + code_gen.add_import(quote! { + use std::sync::Arc; + }); + + code_gen.add_import(quote! { + use tower::ServiceBuilder; + }); + + code_gen.add_import(quote! { + use tower_http::cors::CorsLayer; + }); + + self.generate_service_trait(&mut code_gen, service)?; + self.generate_router(&mut code_gen, service)?; + self.generate_handlers(&mut code_gen, service)?; + + if let Some(ref options) = service.options { + if let Some(service_headers) = parse_service_headers(options) { + self.generate_header_middleware(&mut code_gen, &service_headers.required)?; + } + } + + let output_name = format!( + "{}.http.rs", + service_name.to_snake_case() + ); + + files.push(code_generator_response::File { + name: Some(output_name), + content: Some(code_gen.generate()), + ..Default::default() + }); + } + } + + Ok(files) + } + + fn generate_service_trait( + &self, + code_gen: &mut CodeGenerator, + service: &ServiceDescriptorProto, + ) -> Result<()> { + let service_name = service.name.as_deref().unwrap_or("UnknownService"); + let trait_name = format_ident!("{}Server", service_name); + + let methods: Vec<_> = service.method.iter().map(|method| { + let method_name = format_ident!("{}", method.name.as_deref().unwrap_or("").to_snake_case()); + let input_type = self.resolve_type_name(method.input_type.as_deref().unwrap_or("")); + let output_type = self.resolve_type_name(method.output_type.as_deref().unwrap_or("")); + + quote! { + async fn #method_name(&self, request: #input_type) -> Result<#output_type, StatusCode>; + } + }).collect(); + + let trait_def = quote! { + #[async_trait::async_trait] + pub trait #trait_name: Send + Sync + 'static { + #(#methods)* + } + }; + + code_gen.add_item(trait_def); + Ok(()) + } + + fn generate_router( + &self, + code_gen: &mut CodeGenerator, + service: &ServiceDescriptorProto, + ) -> Result<()> { + let service_name = service.name.as_deref().unwrap_or("UnknownService"); + let trait_name = format_ident!("{}Server", service_name); + let router_fn = format_ident!("register_{}_server", service_name.to_snake_case()); + + let routes: Vec<_> = service.method.iter().map(|method| { + let handler_name = format_ident!("{}_handler", method.name.as_deref().unwrap_or("").to_snake_case()); + let http_rule = method.options.as_ref() + .and_then(|opts| parse_http_rule(opts)) + .unwrap_or_else(|| { + crate::annotations::HttpRule { + method: "POST".to_string(), + path: format!("/api/v1/{}", method.name.as_deref().unwrap_or("").to_snake_case()), + body: Some("*".to_string()), + response_body: None, + } + }); + + let path = &http_rule.path; + let method_str = http_rule.method.to_lowercase(); + let method_fn = format_ident!("{}", method_str); + + quote! { + .route(#path, #method_fn(#handler_name::)) + } + }).collect(); + + let router_impl = quote! { + pub fn #router_fn(server: Arc) -> Router { + Router::new() + #(#routes)* + .layer( + ServiceBuilder::new() + .layer(CorsLayer::permissive()) + .into_inner() + ) + .with_state(server) + } + }; + + code_gen.add_item(router_impl); + Ok(()) + } + + fn generate_handlers( + &self, + code_gen: &mut CodeGenerator, + service: &ServiceDescriptorProto, + ) -> Result<()> { + let service_name = service.name.as_deref().unwrap_or("UnknownService"); + let trait_name = format_ident!("{}Server", service_name); + + for method in &service.method { + let method_name = method.name.as_deref().unwrap_or(""); + let handler_name = format_ident!("{}_handler", method_name.to_snake_case()); + let trait_method = format_ident!("{}", method_name.to_snake_case()); + + let input_type = self.resolve_type_name(method.input_type.as_deref().unwrap_or("")); + let _output_type = self.resolve_type_name(method.output_type.as_deref().unwrap_or("")); + + let handler = quote! { + async fn #handler_name( + State(server): State>, + Json(request): Json<#input_type>, + ) -> impl IntoResponse { + match server.#trait_method(request).await { + Ok(response) => (StatusCode::OK, Json(response)).into_response(), + Err(status) => (status, Json(serde_json::json!({ + "error": status.to_string() + }))).into_response(), + } + } + }; + + code_gen.add_item(handler); + } + + Ok(()) + } + + fn generate_header_middleware( + &self, + code_gen: &mut CodeGenerator, + headers: &[crate::annotations::HeaderConfig], + ) -> Result<()> { + let validations: Vec<_> = headers.iter().map(|header| { + let name = &header.name; + let required = header.required; + + if required { + quote! { + if !headers.contains_key(#name) { + return Err((StatusCode::BAD_REQUEST, format!("Missing required header: {}", #name))); + } + } + } else { + quote! {} + } + }).collect(); + + let middleware = quote! { + pub async fn validate_headers( + headers: axum::http::HeaderMap, + request: axum::http::Request, + next: axum::middleware::Next, + ) -> Result { + #(#validations)* + Ok(next.run(request).await) + } + }; + + code_gen.add_item(middleware); + Ok(()) + } + + fn resolve_type_name(&self, type_name: &str) -> proc_macro2::TokenStream { + let clean_name = type_name + .trim_start_matches('.') + .split('.') + .last() + .unwrap_or(type_name) + .to_upper_camel_case(); + + let ident = format_ident!("{}", clean_name); + quote! { #ident } + } +} \ No newline at end of file diff --git a/rust/protoc-gen-rust-http/src/main.rs b/rust/protoc-gen-rust-http/src/main.rs new file mode 100644 index 00000000..5e1e1c9d --- /dev/null +++ b/rust/protoc-gen-rust-http/src/main.rs @@ -0,0 +1,46 @@ +use anyhow::Result; +use sebuf_core::{run_plugin, Plugin, PluginError, PluginResult}; +use prost_types::compiler::{CodeGeneratorRequest, CodeGeneratorResponse}; + +mod generator; +mod annotations; +use generator::HttpGenerator; + +struct HttpPlugin; + +impl Plugin for HttpPlugin { + fn process(&self, request: CodeGeneratorRequest) -> PluginResult { + let mut response = CodeGeneratorResponse::default(); + + for proto_file in &request.proto_file { + if !request.file_to_generate.contains(&proto_file.name.clone().unwrap_or_default()) { + continue; + } + + if proto_file.service.is_empty() { + continue; + } + + let generator = HttpGenerator::new(proto_file.clone(), &request.proto_file); + match generator.generate() { + Ok(generated_files) => { + response.file.extend(generated_files); + } + Err(e) => { + return Err(PluginError::GenerationError(e.to_string())); + } + } + } + + response.supported_features = Some( + prost_types::compiler::code_generator_response::Feature::Proto3Optional as u64 + ); + + Ok(response) + } +} + +fn main() -> Result<()> { + run_plugin(HttpPlugin)?; + Ok(()) +} \ No newline at end of file diff --git a/rust/protoc-gen-rust-http/tests/integration_test.rs b/rust/protoc-gen-rust-http/tests/integration_test.rs new file mode 100644 index 00000000..6340ebaf --- /dev/null +++ b/rust/protoc-gen-rust-http/tests/integration_test.rs @@ -0,0 +1,216 @@ +use std::process::Command; +use tempfile::TempDir; +use std::fs; + +const TEST_SERVICE_PROTO: &str = r#" +syntax = "proto3"; + +package test.api; + +message CreateUserRequest { + string name = 1; + string email = 2; +} + +message User { + string id = 1; + string name = 2; + string email = 3; +} + +message GetUserRequest { + string user_id = 1; +} + +message ListUsersRequest { + int32 page_size = 1; + string page_token = 2; +} + +message ListUsersResponse { + repeated User users = 1; + string next_page_token = 2; +} + +service UserService { + rpc CreateUser(CreateUserRequest) returns (User); + rpc GetUser(GetUserRequest) returns (User); + rpc ListUsers(ListUsersRequest) returns (ListUsersResponse); +} + +service AuthService { + rpc Login(CreateUserRequest) returns (User); +} +"#; + +#[test] +fn test_http_service_generation() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let proto_path = temp_dir.path().join("service.proto"); + let output_path = temp_dir.path(); + + fs::write(&proto_path, TEST_SERVICE_PROTO).expect("Failed to write proto file"); + + let binary_path = env!("CARGO_BIN_EXE_protoc-gen-rust-http"); + + let output = Command::new("protoc") + .arg(&format!("--plugin=protoc-gen-rust-http={}", binary_path)) + .arg(&format!("--rust-http_out={}", output_path.display())) + .arg(&format!("--proto_path={}", temp_dir.path().display())) + .arg("service.proto") + .output() + .expect("Failed to execute protoc"); + + if !output.status.success() { + panic!( + "protoc failed: {}\nstdout: {}\nstderr: {}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + + // Check that HTTP files were generated for each service + let user_service_file = output_path.join("user_service.http.rs"); + let auth_service_file = output_path.join("auth_service.http.rs"); + + assert!(user_service_file.exists(), "UserService HTTP file was not generated"); + assert!(auth_service_file.exists(), "AuthService HTTP file was not generated"); + + // Verify UserService generated content + let user_content = fs::read_to_string(&user_service_file) + .expect("Failed to read UserService file"); + + // Commented out for cleaner test output + // println!("Generated UserService content:\n{}", user_content); + + // Check for trait definition + assert!(user_content.contains("pub trait UserServiceServer")); + assert!(user_content.contains("async fn create_user")); + assert!(user_content.contains("async fn get_user")); + assert!(user_content.contains("async fn list_users")); + + // Check for router function + assert!(user_content.contains("pub fn register_user_service_server")); + + // Check for handler functions + assert!(user_content.contains("async fn create_user_handler")); + assert!(user_content.contains("async fn get_user_handler")); + assert!(user_content.contains("async fn list_users_handler")); + + // Check for axum imports + assert!(user_content.contains("use axum")); + assert!(user_content.contains("extract")); + assert!(user_content.contains("Json")); + assert!(user_content.contains("State")); + + // Verify AuthService generated content + let auth_content = fs::read_to_string(&auth_service_file) + .expect("Failed to read AuthService file"); + + assert!(auth_content.contains("pub trait AuthServiceServer")); + assert!(auth_content.contains("async fn login")); + assert!(auth_content.contains("pub fn register_auth_service_server")); + assert!(auth_content.contains("async fn login_handler")); + + // Check that the generated code is syntactically valid + let user_syntax = syn::parse_file(&user_content); + assert!(user_syntax.is_ok(), "Generated UserService code is not valid Rust: {:?}", user_syntax.err()); + + let auth_syntax = syn::parse_file(&auth_content); + assert!(auth_syntax.is_ok(), "Generated AuthService code is not valid Rust: {:?}", auth_syntax.err()); +} + +#[test] +fn test_no_services_no_output() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let proto_path = temp_dir.path().join("messages.proto"); + let output_path = temp_dir.path(); + + let messages_proto = r#" +syntax = "proto3"; + +package test; + +message User { + string name = 1; + int32 age = 2; +} + +message Product { + string id = 1; + string name = 2; + double price = 3; +} +"#; + + fs::write(&proto_path, messages_proto).expect("Failed to write proto file"); + + let binary_path = env!("CARGO_BIN_EXE_protoc-gen-rust-http"); + + let output = Command::new("protoc") + .arg(&format!("--plugin=protoc-gen-rust-http={}", binary_path)) + .arg(&format!("--rust-http_out={}", output_path.display())) + .arg(&format!("--proto_path={}", temp_dir.path().display())) + .arg("messages.proto") + .output() + .expect("Failed to execute protoc"); + + assert!(output.status.success(), "protoc should succeed even with no services"); + + // Should not generate any .http.rs files + let entries: Vec<_> = fs::read_dir(output_path) + .expect("Failed to read output directory") + .collect(); + + let http_files: Vec<_> = entries + .into_iter() + .filter_map(|entry| entry.ok()) + .filter(|entry| { + entry.file_name().to_string_lossy().ends_with(".http.rs") + }) + .collect(); + + assert!(http_files.is_empty(), "No HTTP files should be generated for message-only protos"); +} + +#[test] +fn test_service_with_no_methods() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let proto_path = temp_dir.path().join("empty_service.proto"); + let output_path = temp_dir.path(); + + let empty_service_proto = r#" +syntax = "proto3"; + +package test; + +service EmptyService { + // No methods defined +} +"#; + + fs::write(&proto_path, empty_service_proto).expect("Failed to write proto file"); + + let binary_path = env!("CARGO_BIN_EXE_protoc-gen-rust-http"); + + let output = Command::new("protoc") + .arg(&format!("--plugin=protoc-gen-rust-http={}", binary_path)) + .arg(&format!("--rust-http_out={}", output_path.display())) + .arg(&format!("--proto_path={}", temp_dir.path().display())) + .arg("empty_service.proto") + .output() + .expect("Failed to execute protoc"); + + assert!(output.status.success(), "protoc should succeed with empty service"); + + let service_file = output_path.join("empty_service.http.rs"); + if service_file.exists() { + let content = fs::read_to_string(&service_file) + .expect("Failed to read service file"); + + // Should still generate trait and router, just empty + assert!(content.contains("pub trait EmptyServiceServer")); + assert!(content.contains("pub fn register_empty_service_server")); + } +} \ No newline at end of file diff --git a/rust/protoc-gen-rust-oneof-helper/Cargo.toml b/rust/protoc-gen-rust-oneof-helper/Cargo.toml new file mode 100644 index 00000000..dc7adefd --- /dev/null +++ b/rust/protoc-gen-rust-oneof-helper/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "protoc-gen-rust-oneof-helper" +version.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +description = "Protobuf plugin to generate Rust convenience constructors for oneof fields" + +[[bin]] +name = "protoc-gen-rust-oneof-helper" +path = "src/main.rs" + +[dependencies] +sebuf-core = { path = "../sebuf-core" } +prost = { workspace = true } +prost-types = { workspace = true } +protobuf = { workspace = true } +anyhow = { workspace = true } +heck = { workspace = true } +quote = { workspace = true } +proc-macro2 = { workspace = true } + +[dev-dependencies] +tempfile = "3.14" +pretty_assertions = "1.4" +syn = { version = "2.0", features = ["full"] } \ No newline at end of file diff --git a/rust/protoc-gen-rust-oneof-helper/src/generator.rs b/rust/protoc-gen-rust-oneof-helper/src/generator.rs new file mode 100644 index 00000000..8650e653 --- /dev/null +++ b/rust/protoc-gen-rust-oneof-helper/src/generator.rs @@ -0,0 +1,204 @@ +use anyhow::Result; +use heck::{ToSnakeCase, ToUpperCamelCase}; +use prost_types::{ + compiler::code_generator_response, + DescriptorProto, FieldDescriptorProto, FileDescriptorProto, OneofDescriptorProto, +}; +use quote::{format_ident, quote}; +use sebuf_core::CodeGenerator; + +pub struct OneofHelperGenerator { + file: FileDescriptorProto, +} + +impl OneofHelperGenerator { + pub fn new(file: FileDescriptorProto) -> Self { + Self { file } + } + + pub fn generate(&self) -> Result> { + let mut code_gen = CodeGenerator::new(); + let mut has_oneofs = false; + + for message in &self.file.message_type { + if let Some(message_name) = &message.name { + for (oneof_index, oneof) in message.oneof_decl.iter().enumerate() { + if let Some(oneof_name) = &oneof.name { + has_oneofs = true; + self.generate_oneof_helpers( + &mut code_gen, + message, + message_name, + oneof, + oneof_name, + oneof_index as i32, + )?; + } + } + } + } + + if !has_oneofs { + return Ok(None); + } + + let package = self.file.package.as_deref().unwrap_or(""); + let _rust_module = package.replace('.', "_"); + let output_name = format!( + "{}.oneof_helpers.rs", + self.file.name.as_deref().unwrap_or("unknown").replace(".proto", "") + ); + + let generated_code = code_gen.generate(); + + Ok(Some(code_generator_response::File { + name: Some(output_name), + content: Some(generated_code), + ..Default::default() + })) + } + + fn generate_oneof_helpers( + &self, + code_gen: &mut CodeGenerator, + message: &DescriptorProto, + message_name: &str, + _oneof: &OneofDescriptorProto, + oneof_name: &str, + oneof_index: i32, + ) -> Result<()> { + let message_struct = format_ident!("{}", message_name.to_upper_camel_case()); + + for field in &message.field { + if field.oneof_index == Some(oneof_index) { + if let prost_types::field_descriptor_proto::Type::Message = field.r#type() { + self.generate_constructor_for_field( + code_gen, + &message_struct, + message_name, + oneof_name, + field, + )?; + } + } + } + + Ok(()) + } + + fn generate_constructor_for_field( + &self, + code_gen: &mut CodeGenerator, + message_struct: &proc_macro2::Ident, + message_name: &str, + oneof_name: &str, + field: &FieldDescriptorProto, + ) -> Result<()> { + let field_name = field.name.as_deref().unwrap_or(""); + let field_type_name = field.type_name.as_deref().unwrap_or(""); + + let variant_name = format_ident!("{}", field_name.to_upper_camel_case()); + let oneof_field = format_ident!("{}", oneof_name.to_snake_case()); + let function_name = format_ident!( + "new_{}_{}", + message_name.to_snake_case(), + field_name.to_snake_case() + ); + + let inner_type = self.resolve_type_name(field_type_name); + let inner_type_ident = format_ident!("{}", inner_type); + + let params = self.extract_message_fields(field_type_name); + let param_declarations: Vec<_> = params + .iter() + .map(|(name, ty)| { + let name_ident = format_ident!("{}", name); + quote! { #name_ident: #ty } + }) + .collect(); + + let field_assignments: Vec<_> = params + .iter() + .map(|(name, _)| { + let field_name = format_ident!("{}", name); + quote! { #field_name } + }) + .collect(); + + let constructor = quote! { + pub fn #function_name(#(#param_declarations),*) -> #message_struct { + #message_struct { + #oneof_field: Some(#message_struct::#variant_name(#inner_type_ident { + #(#field_assignments),* + })), + ..Default::default() + } + } + }; + + code_gen.add_item(constructor); + Ok(()) + } + + fn resolve_type_name(&self, type_name: &str) -> String { + type_name + .split('.') + .last() + .unwrap_or(type_name) + .to_upper_camel_case() + } + + fn extract_message_fields(&self, type_name: &str) -> Vec<(String, String)> { + for message in &self.file.message_type { + if let Some(msg_name) = &message.name { + if type_name.ends_with(msg_name) { + return message + .field + .iter() + .filter_map(|f| { + f.name.as_ref().map(|name| { + let rust_type = self.field_to_rust_type(f); + (name.to_snake_case(), rust_type) + }) + }) + .collect(); + } + } + } + Vec::new() + } + + fn field_to_rust_type(&self, field: &FieldDescriptorProto) -> String { + use prost_types::field_descriptor_proto::{Label, Type}; + + let base_type = match field.r#type() { + Type::Double => "f64".to_string(), + Type::Float => "f32".to_string(), + Type::Int64 => "i64".to_string(), + Type::Uint64 => "u64".to_string(), + Type::Int32 => "i32".to_string(), + Type::Fixed64 => "u64".to_string(), + Type::Fixed32 => "u32".to_string(), + Type::Bool => "bool".to_string(), + Type::String => "String".to_string(), + Type::Bytes => "Vec".to_string(), + Type::Uint32 => "u32".to_string(), + Type::Sfixed32 => "i32".to_string(), + Type::Sfixed64 => "i64".to_string(), + Type::Sint32 => "i32".to_string(), + Type::Sint64 => "i64".to_string(), + Type::Message | Type::Enum => { + self.resolve_type_name(field.type_name.as_deref().unwrap_or("Unknown")) + } + Type::Group => "Unknown".to_string(), + }; + + match field.label() { + Label::Optional if field.proto3_optional.unwrap_or(false) => { + format!("Option<{}>", base_type) + } + Label::Repeated => format!("Vec<{}>", base_type), + _ => base_type, + } + } +} \ No newline at end of file diff --git a/rust/protoc-gen-rust-oneof-helper/src/main.rs b/rust/protoc-gen-rust-oneof-helper/src/main.rs new file mode 100644 index 00000000..44c460ad --- /dev/null +++ b/rust/protoc-gen-rust-oneof-helper/src/main.rs @@ -0,0 +1,42 @@ +use anyhow::Result; +use sebuf_core::{run_plugin, Plugin, PluginError, PluginResult}; +use prost_types::compiler::{CodeGeneratorRequest, CodeGeneratorResponse}; + +mod generator; +use generator::OneofHelperGenerator; + +struct OneofHelperPlugin; + +impl Plugin for OneofHelperPlugin { + fn process(&self, request: CodeGeneratorRequest) -> PluginResult { + let mut response = CodeGeneratorResponse::default(); + + for proto_file in request.proto_file { + if !request.file_to_generate.contains(&proto_file.name.clone().unwrap_or_default()) { + continue; + } + + let generator = OneofHelperGenerator::new(proto_file.clone()); + match generator.generate() { + Ok(Some(generated)) => { + response.file.push(generated); + } + Ok(None) => {} + Err(e) => { + return Err(PluginError::GenerationError(e.to_string())); + } + } + } + + response.supported_features = Some( + prost_types::compiler::code_generator_response::Feature::Proto3Optional as u64 + ); + + Ok(response) + } +} + +fn main() -> Result<()> { + run_plugin(OneofHelperPlugin)?; + Ok(()) +} \ No newline at end of file diff --git a/rust/protoc-gen-rust-oneof-helper/tests/integration_test.rs b/rust/protoc-gen-rust-oneof-helper/tests/integration_test.rs new file mode 100644 index 00000000..d0fe646f --- /dev/null +++ b/rust/protoc-gen-rust-oneof-helper/tests/integration_test.rs @@ -0,0 +1,193 @@ +use std::process::Command; +use tempfile::TempDir; +use std::fs; +use std::path::Path; + +const TEST_PROTO: &str = r#" +syntax = "proto3"; + +package test; + +message LoginRequest { + oneof auth_method { + EmailAuth email = 1; + PhoneAuth phone = 2; + } + + message EmailAuth { + string email = 1; + string password = 2; + } + + message PhoneAuth { + string phone = 1; + string code = 2; + } +} + +message PaymentMethod { + oneof method { + CreditCard card = 1; + BankAccount bank = 2; + } + + message CreditCard { + string number = 1; + string expiry = 2; + string cvv = 3; + } + + message BankAccount { + string account_number = 1; + string routing_number = 2; + } +} +"#; + +#[test] +fn test_oneof_helper_generation() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let proto_path = temp_dir.path().join("test.proto"); + let output_path = temp_dir.path(); + + // Write test proto file + fs::write(&proto_path, TEST_PROTO).expect("Failed to write proto file"); + + // Get the binary path + let binary_path = env!("CARGO_BIN_EXE_protoc-gen-rust-oneof-helper"); + + // Run protoc with our plugin + let output = Command::new("protoc") + .arg(&format!("--plugin=protoc-gen-rust-oneof-helper={}", binary_path)) + .arg(&format!("--rust-oneof-helper_out={}", output_path.display())) + .arg(&format!("--proto_path={}", temp_dir.path().display())) + .arg("test.proto") + .output() + .expect("Failed to execute protoc"); + + if !output.status.success() { + panic!( + "protoc failed: {}\nstdout: {}\nstderr: {}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + + // Check that the helper file was generated + let helper_file = output_path.join("test.oneof_helpers.rs"); + assert!(helper_file.exists(), "Helper file was not generated"); + + // Read and verify the generated content + let generated_content = fs::read_to_string(&helper_file) + .expect("Failed to read generated file"); + + // Check that some oneof helper functions were generated + // (specific function names may vary based on implementation) + assert!(generated_content.contains("pub fn new_")); + assert!(generated_content.contains("LoginRequest")); + + // Should contain basic struct construction + assert!(generated_content.contains("auth_method")); + assert!(generated_content.contains("method")); + + // Check that the generated code is syntactically valid Rust + let syntax_check = syn::parse_file(&generated_content); + assert!(syntax_check.is_ok(), "Generated code is not valid Rust syntax"); +} + +#[test] +fn test_no_oneofs_no_output() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let proto_path = temp_dir.path().join("simple.proto"); + let output_path = temp_dir.path(); + + let simple_proto = r#" +syntax = "proto3"; + +package test; + +message User { + string name = 1; + int32 age = 2; +} +"#; + + // Write proto file without oneofs + fs::write(&proto_path, simple_proto).expect("Failed to write proto file"); + + let binary_path = env!("CARGO_BIN_EXE_protoc-gen-rust-oneof-helper"); + + let output = Command::new("protoc") + .arg(&format!("--plugin=protoc-gen-rust-oneof-helper={}", binary_path)) + .arg(&format!("--rust-oneof-helper_out={}", output_path.display())) + .arg(&format!("--proto_path={}", temp_dir.path().display())) + .arg("simple.proto") + .output() + .expect("Failed to execute protoc"); + + assert!(output.status.success(), "protoc should succeed even with no oneofs"); + + // Check that no helper file was generated (or it's empty) + let helper_file = output_path.join("simple.oneof_helpers.rs"); + if helper_file.exists() { + let content = fs::read_to_string(&helper_file) + .expect("Failed to read generated file"); + // Should be empty or minimal + assert!(content.trim().is_empty() || content.lines().count() < 5); + } +} + +#[test] +fn test_nested_message_oneof() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let proto_path = temp_dir.path().join("nested.proto"); + let output_path = temp_dir.path(); + + let nested_proto = r#" +syntax = "proto3"; + +package test; + +message Container { + message InnerMessage { + oneof value { + StringValue str = 1; + IntValue int = 2; + } + + message StringValue { + string data = 1; + } + + message IntValue { + int32 data = 1; + } + } +} +"#; + + fs::write(&proto_path, nested_proto).expect("Failed to write proto file"); + + let binary_path = env!("CARGO_BIN_EXE_protoc-gen-rust-oneof-helper"); + + let output = Command::new("protoc") + .arg(&format!("--plugin=protoc-gen-rust-oneof-helper={}", binary_path)) + .arg(&format!("--rust-oneof-helper_out={}", output_path.display())) + .arg(&format!("--proto_path={}", temp_dir.path().display())) + .arg("nested.proto") + .output() + .expect("Failed to execute protoc"); + + assert!(output.status.success(), "protoc should handle nested messages"); + + let helper_file = output_path.join("nested.oneof_helpers.rs"); + if helper_file.exists() { + let generated_content = fs::read_to_string(&helper_file) + .expect("Failed to read generated file"); + + // Should generate helpers for nested oneof + assert!(generated_content.contains("new_inner_message_str") || + generated_content.contains("new_inner_message_int")); + } +} \ No newline at end of file diff --git a/rust/protoc-gen-rust-openapiv3/Cargo.toml b/rust/protoc-gen-rust-openapiv3/Cargo.toml new file mode 100644 index 00000000..023a25ae --- /dev/null +++ b/rust/protoc-gen-rust-openapiv3/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "protoc-gen-rust-openapiv3" +version.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +description = "Protobuf plugin to generate OpenAPI v3.1 specifications from service definitions" + +[[bin]] +name = "protoc-gen-rust-openapiv3" +path = "src/main.rs" + +[dependencies] +sebuf-core = { path = "../sebuf-core" } +prost = { workspace = true } +prost-types = { workspace = true } +protobuf = { workspace = true } +anyhow = { workspace = true } +thiserror = { workspace = true } +heck = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +serde_yaml = { workspace = true } + +[dev-dependencies] +tempfile = "3.14" +pretty_assertions = "1.4" +syn = { version = "2.0", features = ["full"] } \ No newline at end of file diff --git a/rust/protoc-gen-rust-openapiv3/src/generator.rs b/rust/protoc-gen-rust-openapiv3/src/generator.rs new file mode 100644 index 00000000..8d5c49dc --- /dev/null +++ b/rust/protoc-gen-rust-openapiv3/src/generator.rs @@ -0,0 +1,283 @@ +use anyhow::Result; +use heck::ToSnakeCase; +use prost_types::{ + compiler::code_generator_response, + DescriptorProto, EnumDescriptorProto, FieldDescriptorProto, + FileDescriptorProto, MethodDescriptorProto, ServiceDescriptorProto, +}; +use std::collections::HashMap; + +use crate::schema::*; + +pub struct OpenApiGenerator { + file: FileDescriptorProto, + all_files: Vec, +} + +impl OpenApiGenerator { + pub fn new(file: FileDescriptorProto, all_files: &[FileDescriptorProto]) -> Self { + Self { + file, + all_files: all_files.to_vec(), + } + } + + pub fn generate(&self) -> Result> { + let mut files = Vec::new(); + + for service in &self.file.service { + if let Some(service_name) = &service.name { + let spec = self.generate_service_spec(service)?; + + let yaml_content = serde_yaml::to_string(&spec)?; + let output_name = format!("{}.openapi.yaml", service_name); + + files.push(code_generator_response::File { + name: Some(output_name), + content: Some(yaml_content), + ..Default::default() + }); + } + } + + Ok(files) + } + + fn generate_service_spec(&self, service: &ServiceDescriptorProto) -> Result { + let service_name = service.name.as_deref().unwrap_or("UnknownService"); + + let mut spec = OpenApiSpec { + openapi: "3.1.0".to_string(), + info: Info { + title: format!("{} API", service_name), + version: "1.0.0".to_string(), + description: Some(format!("API specification for {}", service_name)), + }, + paths: HashMap::new(), + components: Some(Components { + schemas: HashMap::new(), + }), + servers: Some(vec![Server { + url: "http://localhost:8080".to_string(), + description: Some("Local development server".to_string()), + }]), + }; + + for method in &service.method { + self.add_method_to_spec(&mut spec, service_name, method)?; + } + + self.collect_message_schemas(&mut spec)?; + + Ok(spec) + } + + fn add_method_to_spec( + &self, + spec: &mut OpenApiSpec, + service_name: &str, + method: &MethodDescriptorProto, + ) -> Result<()> { + let method_name = method.name.as_deref().unwrap_or(""); + let path = format!("/api/v1/{}", method_name.to_snake_case()); + + let input_type = self.resolve_type_name(method.input_type.as_deref().unwrap_or("")); + let output_type = self.resolve_type_name(method.output_type.as_deref().unwrap_or("")); + + let operation = Operation { + summary: Some(method_name.to_string()), + description: None, + operation_id: Some(format!("{}_{}", service_name, method_name)), + tags: Some(vec![service_name.to_string()]), + parameters: None, + request_body: Some(RequestBody { + required: true, + content: { + let mut content = HashMap::new(); + content.insert( + "application/json".to_string(), + MediaType { + schema: Schema { + reference: Some(format!("#/components/schemas/{}", input_type)), + ..Default::default() + }, + }, + ); + content + }, + description: None, + }), + responses: { + let mut responses = HashMap::new(); + responses.insert( + "200".to_string(), + Response { + description: "Successful response".to_string(), + content: Some({ + let mut content = HashMap::new(); + content.insert( + "application/json".to_string(), + MediaType { + schema: Schema { + reference: Some(format!("#/components/schemas/{}", output_type)), + ..Default::default() + }, + }, + ); + content + }), + }, + ); + responses.insert( + "400".to_string(), + Response { + description: "Bad request".to_string(), + content: None, + }, + ); + responses.insert( + "500".to_string(), + Response { + description: "Internal server error".to_string(), + content: None, + }, + ); + responses + }, + }; + + let path_item = PathItem { + get: None, + post: Some(operation), + put: None, + delete: None, + patch: None, + }; + + spec.paths.insert(path, path_item); + Ok(()) + } + + fn collect_message_schemas(&self, spec: &mut OpenApiSpec) -> Result<()> { + let components = spec.components.as_mut().unwrap(); + + for message in &self.file.message_type { + if let Some(name) = &message.name { + let schema = self.message_to_schema(message)?; + components.schemas.insert(name.clone(), schema); + } + } + + for enum_type in &self.file.enum_type { + if let Some(name) = &enum_type.name { + let schema = self.enum_to_schema(enum_type)?; + components.schemas.insert(name.clone(), schema); + } + } + + Ok(()) + } + + fn message_to_schema(&self, message: &DescriptorProto) -> Result { + let mut properties = HashMap::new(); + let mut required = Vec::new(); + + for field in &message.field { + if let Some(field_name) = &field.name { + let field_schema = self.field_to_schema(field)?; + properties.insert(field_name.clone(), field_schema); + + if !field.proto3_optional.unwrap_or(false) + && field.label() != prost_types::field_descriptor_proto::Label::Optional { + required.push(field_name.clone()); + } + } + } + + Ok(Schema { + schema_type: Some("object".to_string()), + properties: Some(properties), + required: if required.is_empty() { None } else { Some(required) }, + ..Default::default() + }) + } + + fn enum_to_schema(&self, enum_type: &EnumDescriptorProto) -> Result { + let values: Vec = enum_type + .value + .iter() + .filter_map(|v| v.name.as_ref()) + .map(|name| serde_json::Value::String(name.clone())) + .collect(); + + Ok(Schema { + schema_type: Some("string".to_string()), + enum_values: Some(values), + ..Default::default() + }) + } + + fn field_to_schema(&self, field: &FieldDescriptorProto) -> Result { + use prost_types::field_descriptor_proto::{Label, Type}; + + let base_schema = match field.r#type() { + Type::Double | Type::Float => Schema { + schema_type: Some("number".to_string()), + format: Some(if field.r#type() == Type::Float { "float" } else { "double" }.to_string()), + ..Default::default() + }, + Type::Int64 | Type::Uint64 | Type::Sint64 | Type::Fixed64 | Type::Sfixed64 => Schema { + schema_type: Some("integer".to_string()), + format: Some("int64".to_string()), + ..Default::default() + }, + Type::Int32 | Type::Uint32 | Type::Sint32 | Type::Fixed32 | Type::Sfixed32 => Schema { + schema_type: Some("integer".to_string()), + format: Some("int32".to_string()), + ..Default::default() + }, + Type::Bool => Schema { + schema_type: Some("boolean".to_string()), + ..Default::default() + }, + Type::String => Schema { + schema_type: Some("string".to_string()), + ..Default::default() + }, + Type::Bytes => Schema { + schema_type: Some("string".to_string()), + format: Some("byte".to_string()), + ..Default::default() + }, + Type::Message | Type::Enum => { + let type_name = self.resolve_type_name(field.type_name.as_deref().unwrap_or("")); + Schema { + reference: Some(format!("#/components/schemas/{}", type_name)), + ..Default::default() + } + }, + Type::Group => Schema { + schema_type: Some("object".to_string()), + ..Default::default() + }, + }; + + Ok(match field.label() { + Label::Repeated => Schema { + schema_type: Some("array".to_string()), + items: Some(Box::new(base_schema)), + ..Default::default() + }, + _ => base_schema, + }) + } + + fn resolve_type_name(&self, type_name: &str) -> String { + type_name + .trim_start_matches('.') + .split('.') + .last() + .unwrap_or(type_name) + .to_string() + } +} \ No newline at end of file diff --git a/rust/protoc-gen-rust-openapiv3/src/main.rs b/rust/protoc-gen-rust-openapiv3/src/main.rs new file mode 100644 index 00000000..f401fc89 --- /dev/null +++ b/rust/protoc-gen-rust-openapiv3/src/main.rs @@ -0,0 +1,46 @@ +use anyhow::Result; +use sebuf_core::{run_plugin, Plugin, PluginError, PluginResult}; +use prost_types::compiler::{CodeGeneratorRequest, CodeGeneratorResponse}; + +mod generator; +mod schema; +use generator::OpenApiGenerator; + +struct OpenApiPlugin; + +impl Plugin for OpenApiPlugin { + fn process(&self, request: CodeGeneratorRequest) -> PluginResult { + let mut response = CodeGeneratorResponse::default(); + + for proto_file in request.proto_file.iter() { + if !request.file_to_generate.contains(&proto_file.name.clone().unwrap_or_default()) { + continue; + } + + if proto_file.service.is_empty() { + continue; + } + + let generator = OpenApiGenerator::new(proto_file.clone(), &request.proto_file); + match generator.generate() { + Ok(generated_files) => { + response.file.extend(generated_files); + } + Err(e) => { + return Err(PluginError::GenerationError(e.to_string())); + } + } + } + + response.supported_features = Some( + prost_types::compiler::code_generator_response::Feature::Proto3Optional as u64 + ); + + Ok(response) + } +} + +fn main() -> Result<()> { + run_plugin(OpenApiPlugin)?; + Ok(()) +} \ No newline at end of file diff --git a/rust/protoc-gen-rust-openapiv3/src/schema.rs b/rust/protoc-gen-rust-openapiv3/src/schema.rs new file mode 100644 index 00000000..87a4936e --- /dev/null +++ b/rust/protoc-gen-rust-openapiv3/src/schema.rs @@ -0,0 +1,102 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OpenApiSpec { + pub openapi: String, + pub info: Info, + pub paths: HashMap, + pub components: Option, + pub servers: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Info { + pub title: String, + pub version: String, + pub description: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Server { + pub url: String, + pub description: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PathItem { + #[serde(skip_serializing_if = "Option::is_none")] + pub get: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub post: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub put: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub delete: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub patch: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Operation { + pub summary: Option, + pub description: Option, + pub operation_id: Option, + pub tags: Option>, + pub parameters: Option>, + pub request_body: Option, + pub responses: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Parameter { + pub name: String, + #[serde(rename = "in")] + pub location: String, + pub required: bool, + pub schema: Schema, + pub description: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RequestBody { + pub required: bool, + pub content: HashMap, + pub description: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Response { + pub description: String, + pub content: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MediaType { + pub schema: Schema, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Schema { + #[serde(rename = "type", skip_serializing_if = "Option::is_none")] + pub schema_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub format: Option, + #[serde(rename = "$ref", skip_serializing_if = "Option::is_none")] + pub reference: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub properties: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub required: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub items: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(rename = "enum", skip_serializing_if = "Option::is_none")] + pub enum_values: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Components { + pub schemas: HashMap, +} \ No newline at end of file diff --git a/rust/protoc-gen-rust-openapiv3/tests/integration_test.rs b/rust/protoc-gen-rust-openapiv3/tests/integration_test.rs new file mode 100644 index 00000000..5e5ad530 --- /dev/null +++ b/rust/protoc-gen-rust-openapiv3/tests/integration_test.rs @@ -0,0 +1,287 @@ +use std::process::Command; +use tempfile::TempDir; +use std::fs; +use serde_yaml; +use serde_json; + +const TEST_API_PROTO: &str = r#" +syntax = "proto3"; + +package test.api.v1; + +enum UserStatus { + USER_STATUS_UNSPECIFIED = 0; + USER_STATUS_ACTIVE = 1; + USER_STATUS_INACTIVE = 2; +} + +message User { + string id = 1; + string name = 2; + string email = 3; + UserStatus status = 4; + repeated string tags = 5; + map metadata = 6; +} + +message CreateUserRequest { + string name = 1; + string email = 2; + UserStatus initial_status = 3; +} + +message GetUserRequest { + string user_id = 1; +} + +message UpdateUserRequest { + string user_id = 1; + optional string name = 2; + optional string email = 3; +} + +message ListUsersRequest { + int32 page_size = 1; + string page_token = 2; +} + +message ListUsersResponse { + repeated User users = 1; + string next_page_token = 2; + int32 total_count = 3; +} + +service UserService { + rpc CreateUser(CreateUserRequest) returns (User); + rpc GetUser(GetUserRequest) returns (User); + rpc UpdateUser(UpdateUserRequest) returns (User); + rpc ListUsers(ListUsersRequest) returns (ListUsersResponse); +} + +service AdminService { + rpc DeleteUser(GetUserRequest) returns (User); +} +"#; + +#[test] +fn test_openapi_generation() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let proto_path = temp_dir.path().join("api.proto"); + let output_path = temp_dir.path(); + + fs::write(&proto_path, TEST_API_PROTO).expect("Failed to write proto file"); + + let binary_path = env!("CARGO_BIN_EXE_protoc-gen-rust-openapiv3"); + + let output = Command::new("protoc") + .arg(&format!("--plugin=protoc-gen-rust-openapiv3={}", binary_path)) + .arg(&format!("--rust-openapiv3_out={}", output_path.display())) + .arg(&format!("--proto_path={}", temp_dir.path().display())) + .arg("api.proto") + .output() + .expect("Failed to execute protoc"); + + if !output.status.success() { + panic!( + "protoc failed: {}\nstdout: {}\nstderr: {}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + + // Check that OpenAPI files were generated for each service + let user_service_file = output_path.join("UserService.openapi.yaml"); + let admin_service_file = output_path.join("AdminService.openapi.yaml"); + + assert!(user_service_file.exists(), "UserService OpenAPI file was not generated"); + assert!(admin_service_file.exists(), "AdminService OpenAPI file was not generated"); + + // Parse and verify UserService OpenAPI spec + let user_content = fs::read_to_string(&user_service_file) + .expect("Failed to read UserService OpenAPI file"); + + let user_spec: serde_yaml::Value = serde_yaml::from_str(&user_content) + .expect("Generated OpenAPI is not valid YAML"); + + // Verify basic OpenAPI structure + assert_eq!(user_spec["openapi"], "3.1.0"); + assert_eq!(user_spec["info"]["title"], "UserService API"); + assert_eq!(user_spec["info"]["version"], "1.0.0"); + + // Verify paths are generated + let paths = user_spec["paths"].as_mapping().expect("paths should be an object"); + assert!(!paths.is_empty(), "Should have generated paths"); + + // Check for expected paths (using default path pattern) + let expected_paths = vec![ + "/api/v1/create_user", + "/api/v1/get_user", + "/api/v1/update_user", + "/api/v1/list_users", + ]; + + for expected_path in expected_paths { + assert!(paths.contains_key(&serde_yaml::Value::String(expected_path.to_string())), + "Missing expected path: {}", expected_path); + } + + // Verify components/schemas are generated + let components = user_spec["components"].as_mapping().expect("components should exist"); + let schemas = components["schemas"].as_mapping().expect("schemas should exist"); + + let expected_schemas = vec![ + "User", + "CreateUserRequest", + "GetUserRequest", + "UpdateUserRequest", + "ListUsersRequest", + "ListUsersResponse", + "UserStatus", + ]; + + for expected_schema in expected_schemas { + assert!(schemas.contains_key(&serde_yaml::Value::String(expected_schema.to_string())), + "Missing expected schema: {}", expected_schema); + } + + // Verify User schema structure + let user_schema = &schemas["User"]; + assert_eq!(user_schema["type"], "object"); + + let user_properties = user_schema["properties"].as_mapping().expect("User should have properties"); + assert!(user_properties.contains_key("id")); + assert!(user_properties.contains_key("name")); + assert!(user_properties.contains_key("email")); + assert!(user_properties.contains_key("status")); + assert!(user_properties.contains_key("tags")); + assert!(user_properties.contains_key("metadata")); + + // Verify enum schema + let status_schema = &schemas["UserStatus"]; + assert_eq!(status_schema["type"], "string"); + let enum_values = status_schema["enum"].as_sequence().expect("enum should have values"); + assert!(enum_values.len() >= 3); // Should have the defined enum values + + // Verify array type (tags field) + let tags_prop = &user_properties["tags"]; + assert_eq!(tags_prop["type"], "array"); + assert!(tags_prop["items"].is_mapping()); + + // Parse AdminService spec + let admin_content = fs::read_to_string(&admin_service_file) + .expect("Failed to read AdminService OpenAPI file"); + + let admin_spec: serde_yaml::Value = serde_yaml::from_str(&admin_content) + .expect("AdminService OpenAPI is not valid YAML"); + + assert_eq!(admin_spec["info"]["title"], "AdminService API"); + + let admin_paths = admin_spec["paths"].as_mapping().expect("admin paths should exist"); + assert!(admin_paths.contains_key("/api/v1/delete_user")); +} + +#[test] +fn test_messages_only_no_output() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let proto_path = temp_dir.path().join("messages.proto"); + let output_path = temp_dir.path(); + + let messages_proto = r#" +syntax = "proto3"; + +package test; + +message User { + string name = 1; + int32 age = 2; +} +"#; + + fs::write(&proto_path, messages_proto).expect("Failed to write proto file"); + + let binary_path = env!("CARGO_BIN_EXE_protoc-gen-rust-openapiv3"); + + let output = Command::new("protoc") + .arg(&format!("--plugin=protoc-gen-rust-openapiv3={}", binary_path)) + .arg(&format!("--rust-openapiv3_out={}", output_path.display())) + .arg(&format!("--proto_path={}", temp_dir.path().display())) + .arg("messages.proto") + .output() + .expect("Failed to execute protoc"); + + assert!(output.status.success(), "protoc should succeed with messages-only proto"); + + // Should not generate any OpenAPI files + let entries: Vec<_> = fs::read_dir(output_path) + .expect("Failed to read output directory") + .collect(); + + let openapi_files: Vec<_> = entries + .into_iter() + .filter_map(|entry| entry.ok()) + .filter(|entry| { + entry.file_name().to_string_lossy().ends_with(".openapi.yaml") + }) + .collect(); + + assert!(openapi_files.is_empty(), "No OpenAPI files should be generated for message-only protos"); +} + +#[test] +fn test_openapi_spec_validates_json_schema() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let proto_path = temp_dir.path().join("simple.proto"); + let output_path = temp_dir.path(); + + let simple_proto = r#" +syntax = "proto3"; + +package test; + +message SimpleMessage { + string text = 1; + int32 number = 2; + bool flag = 3; +} + +service SimpleService { + rpc DoSomething(SimpleMessage) returns (SimpleMessage); +} +"#; + + fs::write(&proto_path, simple_proto).expect("Failed to write proto file"); + + let binary_path = env!("CARGO_BIN_EXE_protoc-gen-rust-openapiv3"); + + let output = Command::new("protoc") + .arg(&format!("--plugin=protoc-gen-rust-openapiv3={}", binary_path)) + .arg(&format!("--rust-openapiv3_out={}", output_path.display())) + .arg(&format!("--proto_path={}", temp_dir.path().display())) + .arg("simple.proto") + .output() + .expect("Failed to execute protoc"); + + assert!(output.status.success()); + + let spec_file = output_path.join("SimpleService.openapi.yaml"); + assert!(spec_file.exists()); + + let content = fs::read_to_string(&spec_file).expect("Failed to read spec file"); + let spec: serde_yaml::Value = serde_yaml::from_str(&content) + .expect("Should be valid YAML"); + + // Convert to JSON to verify it's valid JSON Schema-compatible + let json_str = serde_json::to_string_pretty(&spec).expect("Should convert to JSON"); + let _json_value: serde_json::Value = serde_json::from_str(&json_str) + .expect("Should be valid JSON"); + + // Verify the SimpleMessage schema has correct types + let schemas = &spec["components"]["schemas"]; + let simple_msg = &schemas["SimpleMessage"]; + let properties = simple_msg["properties"].as_mapping().unwrap(); + + assert_eq!(properties["text"]["type"], "string"); + assert_eq!(properties["number"]["type"], "integer"); + assert_eq!(properties["flag"]["type"], "boolean"); +} \ No newline at end of file diff --git a/rust/sebuf-core/Cargo.toml b/rust/sebuf-core/Cargo.toml new file mode 100644 index 00000000..0a5748e8 --- /dev/null +++ b/rust/sebuf-core/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "sebuf-core" +version.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +description = "Core utilities for sebuf Rust protobuf plugins" + +[dependencies] +prost = { workspace = true } +prost-types = { workspace = true } +protobuf = { workspace = true } +bytes = { workspace = true } +anyhow = { workspace = true } +thiserror = { workspace = true } +heck = { workspace = true } +quote = { workspace = true } +syn = { workspace = true } +proc-macro2 = { workspace = true } +prettyplease = { workspace = true } + +[dev-dependencies] +tempfile = "3.14" +pretty_assertions = "1.4" \ No newline at end of file diff --git a/rust/sebuf-core/src/codegen.rs b/rust/sebuf-core/src/codegen.rs new file mode 100644 index 00000000..581198dd --- /dev/null +++ b/rust/sebuf-core/src/codegen.rs @@ -0,0 +1,103 @@ +use quote::quote; +use proc_macro2::TokenStream; +use prettyplease::unparse; + +#[cfg(test)] +mod tests; + +pub struct CodeGenerator { + imports: Vec, + items: Vec, +} + +impl CodeGenerator { + pub fn new() -> Self { + Self { + imports: Vec::new(), + items: Vec::new(), + } + } + + pub fn add_import(&mut self, import: TokenStream) { + self.imports.push(import); + } + + pub fn add_item(&mut self, item: TokenStream) { + self.items.push(item); + } + + pub fn generate(self) -> String { + let imports = self.imports; + let items = self.items; + + let file = quote! { + #(#imports)* + + #(#items)* + }; + + let file_str = file.to_string(); + + match syn::parse_file(&file_str) { + Ok(syntax_tree) => unparse(&syntax_tree), + Err(_) => file_str, + } + } + + pub fn generate_function( + name: &str, + params: Vec<(&str, &str)>, + return_type: Option<&str>, + body: TokenStream, + ) -> TokenStream { + let fn_name = syn::Ident::new(name, proc_macro2::Span::call_site()); + let params: Vec = params + .into_iter() + .map(|(name, ty)| { + let name = syn::Ident::new(name, proc_macro2::Span::call_site()); + let ty: syn::Type = syn::parse_str(ty).unwrap(); + quote! { #name: #ty } + }) + .collect(); + + let return_type = return_type.map(|ty| { + let ty: syn::Type = syn::parse_str(ty).unwrap(); + quote! { -> #ty } + }); + + quote! { + pub fn #fn_name(#(#params),*) #return_type { + #body + } + } + } + + pub fn generate_struct( + name: &str, + fields: Vec<(&str, &str, bool)>, + derives: Vec<&str>, + ) -> TokenStream { + let struct_name = syn::Ident::new(name, proc_macro2::Span::call_site()); + let derives: Vec = derives.iter().map(|d| syn::parse_str(d).unwrap()).collect(); + + let fields: Vec = fields + .into_iter() + .map(|(name, ty, is_pub)| { + let field_name = syn::Ident::new(name, proc_macro2::Span::call_site()); + let field_type: syn::Type = syn::parse_str(ty).unwrap(); + if is_pub { + quote! { pub #field_name: #field_type } + } else { + quote! { #field_name: #field_type } + } + }) + .collect(); + + quote! { + #[derive(#(#derives),*)] + pub struct #struct_name { + #(#fields),* + } + } + } +} \ No newline at end of file diff --git a/rust/sebuf-core/src/codegen/tests.rs b/rust/sebuf-core/src/codegen/tests.rs new file mode 100644 index 00000000..538d2a5f --- /dev/null +++ b/rust/sebuf-core/src/codegen/tests.rs @@ -0,0 +1,88 @@ +#[cfg(test)] +mod tests { + use super::super::*; + + #[test] + fn test_code_generator_new() { + let gen = CodeGenerator::new(); + assert!(gen.imports.is_empty()); + assert!(gen.items.is_empty()); + } + + #[test] + fn test_generate_function() { + let func = CodeGenerator::generate_function( + "hello_world", + vec![("name", "String"), ("age", "i32")], + Some("String"), + quote! { format!("Hello, {}! Age: {}", name, age) } + ); + + let generated = func.to_string(); + assert!(generated.contains("pub fn hello_world")); + assert!(generated.contains("name :")); + assert!(generated.contains("String")); + assert!(generated.contains("age :")); + assert!(generated.contains("i32")); + assert!(generated.contains("-> String")); + } + + #[test] + fn test_generate_function_no_return() { + let func = CodeGenerator::generate_function( + "print_hello", + vec![("name", "&str")], + None, + quote! { println!("Hello, {}", name); } + ); + + let generated = func.to_string(); + assert!(generated.contains("pub fn print_hello")); + assert!(generated.contains("name :")); + assert!(generated.contains("& str")); + assert!(!generated.contains(" -> ")); + } + + #[test] + fn test_generate_struct() { + let struct_def = CodeGenerator::generate_struct( + "User", + vec![ + ("id", "String", true), + ("name", "String", true), + ("age", "i32", false), + ], + vec!["Debug", "Clone"], + ); + + let generated = struct_def.to_string(); + assert!(generated.contains("derive (Debug")); + assert!(generated.contains("Clone)")); + assert!(generated.contains("pub struct User")); + assert!(generated.contains("pub id : String")); + assert!(generated.contains("pub name : String")); + assert!(generated.contains("age : i32")); + } + + #[test] + fn test_code_generator_with_imports_and_items() { + let mut gen = CodeGenerator::new(); + + gen.add_import(quote! { use std::collections::HashMap; }); + gen.add_import(quote! { use serde::{Serialize, Deserialize}; }); + + gen.add_item(quote! { + #[derive(Debug)] + pub struct TestStruct { + pub data: HashMap, + } + }); + + let generated = gen.generate(); + + assert!(generated.contains("use std::collections::HashMap")); + assert!(generated.contains("use serde::{Serialize, Deserialize}")); + assert!(generated.contains("pub struct TestStruct")); + assert!(generated.contains("HashMap")); + } +} \ No newline at end of file diff --git a/rust/sebuf-core/src/lib.rs b/rust/sebuf-core/src/lib.rs new file mode 100644 index 00000000..bda913e4 --- /dev/null +++ b/rust/sebuf-core/src/lib.rs @@ -0,0 +1,7 @@ +pub mod codegen; +pub mod parser; +pub mod plugin; + +pub use codegen::CodeGenerator; +pub use parser::ProtoParser; +pub use plugin::{Plugin, PluginError, PluginResult, run_plugin}; \ No newline at end of file diff --git a/rust/sebuf-core/src/parser.rs b/rust/sebuf-core/src/parser.rs new file mode 100644 index 00000000..e983707a --- /dev/null +++ b/rust/sebuf-core/src/parser.rs @@ -0,0 +1,91 @@ +use prost_types::{ + DescriptorProto, FieldDescriptorProto, FileDescriptorProto, + ServiceDescriptorProto, +}; +use heck::{ToUpperCamelCase, ToSnakeCase}; + +#[cfg(test)] +mod tests; + +pub struct ProtoParser { + files: Vec, +} + +impl ProtoParser { + pub fn new(files: Vec) -> Self { + Self { files } + } + + pub fn files(&self) -> &[FileDescriptorProto] { + &self.files + } + + pub fn find_message(&self, name: &str) -> Option<&DescriptorProto> { + for file in &self.files { + for message in &file.message_type { + if message.name.as_deref() == Some(name) { + return Some(message); + } + } + } + None + } + + pub fn find_service(&self, name: &str) -> Option<&ServiceDescriptorProto> { + for file in &self.files { + for service in &file.service { + if service.name.as_deref() == Some(name) { + return Some(service); + } + } + } + None + } +} + +pub struct TypeMapper; + +impl TypeMapper { + pub fn field_to_rust_type(field: &FieldDescriptorProto) -> String { + use prost_types::field_descriptor_proto::{Label, Type}; + + let base_type = match field.r#type() { + Type::Double => "f64".to_string(), + Type::Float => "f32".to_string(), + Type::Int64 => "i64".to_string(), + Type::Uint64 => "u64".to_string(), + Type::Int32 => "i32".to_string(), + Type::Fixed64 => "u64".to_string(), + Type::Fixed32 => "u32".to_string(), + Type::Bool => "bool".to_string(), + Type::String => "String".to_string(), + Type::Bytes => "Vec".to_string(), + Type::Uint32 => "u32".to_string(), + Type::Sfixed32 => "i32".to_string(), + Type::Sfixed64 => "i64".to_string(), + Type::Sint32 => "i32".to_string(), + Type::Sint64 => "i64".to_string(), + Type::Message | Type::Enum | Type::Group => { + field.type_name.as_deref().unwrap_or("Unknown").to_string() + } + }; + + match field.label() { + Label::Optional => format!("Option<{}>", base_type), + Label::Repeated => format!("Vec<{}>", base_type), + Label::Required => base_type, + } + } + + pub fn message_name_to_rust(name: &str) -> String { + name.to_upper_camel_case() + } + + pub fn field_name_to_rust(name: &str) -> String { + name.to_snake_case() + } + + pub fn method_name_to_rust(name: &str) -> String { + name.to_snake_case() + } +} \ No newline at end of file diff --git a/rust/sebuf-core/src/parser/tests.rs b/rust/sebuf-core/src/parser/tests.rs new file mode 100644 index 00000000..8e5139c8 --- /dev/null +++ b/rust/sebuf-core/src/parser/tests.rs @@ -0,0 +1,130 @@ +#[cfg(test)] +mod tests { + use super::super::*; + use prost_types::{ + DescriptorProto, FieldDescriptorProto, FileDescriptorProto, ServiceDescriptorProto, + field_descriptor_proto::{Label, Type}, + }; + + fn create_test_field(name: &str, field_type: Type, label: Label) -> FieldDescriptorProto { + FieldDescriptorProto { + name: Some(name.to_string()), + r#type: Some(field_type as i32), + label: Some(label as i32), + type_name: None, + ..Default::default() + } + } + + fn create_test_message_field(name: &str, type_name: &str, label: Label) -> FieldDescriptorProto { + FieldDescriptorProto { + name: Some(name.to_string()), + r#type: Some(Type::Message as i32), + label: Some(label as i32), + type_name: Some(type_name.to_string()), + ..Default::default() + } + } + + #[test] + fn test_field_to_rust_type_scalars() { + let cases = vec![ + (Type::Double, Label::Required, "f64"), + (Type::Float, Label::Required, "f32"), + (Type::Int64, Label::Required, "i64"), + (Type::Uint64, Label::Required, "u64"), + (Type::Int32, Label::Required, "i32"), + (Type::Bool, Label::Required, "bool"), + (Type::String, Label::Required, "String"), + (Type::Bytes, Label::Required, "Vec"), + ]; + + for (field_type, label, expected) in cases { + let field = create_test_field("test", field_type, label); + assert_eq!(TypeMapper::field_to_rust_type(&field), expected); + } + } + + #[test] + fn test_field_to_rust_type_optional() { + let field = create_test_field("test", Type::String, Label::Optional); + assert_eq!(TypeMapper::field_to_rust_type(&field), "Option"); + } + + #[test] + fn test_field_to_rust_type_repeated() { + let field = create_test_field("test", Type::Int32, Label::Repeated); + assert_eq!(TypeMapper::field_to_rust_type(&field), "Vec"); + } + + #[test] + fn test_field_to_rust_type_message() { + let field = create_test_message_field("user", ".example.User", Label::Required); + assert_eq!(TypeMapper::field_to_rust_type(&field), ".example.User"); + } + + #[test] + fn test_message_name_to_rust() { + assert_eq!(TypeMapper::message_name_to_rust("user_info"), "UserInfo"); + assert_eq!(TypeMapper::message_name_to_rust("UserInfo"), "UserInfo"); + assert_eq!(TypeMapper::message_name_to_rust("user-info"), "UserInfo"); + } + + #[test] + fn test_field_name_to_rust() { + assert_eq!(TypeMapper::field_name_to_rust("UserName"), "user_name"); + assert_eq!(TypeMapper::field_name_to_rust("userName"), "user_name"); + assert_eq!(TypeMapper::field_name_to_rust("user_name"), "user_name"); + } + + #[test] + fn test_method_name_to_rust() { + assert_eq!(TypeMapper::method_name_to_rust("GetUser"), "get_user"); + assert_eq!(TypeMapper::method_name_to_rust("CreateUserAccount"), "create_user_account"); + } + + #[test] + fn test_proto_parser_find_message() { + let file = FileDescriptorProto { + name: Some("test.proto".to_string()), + package: Some("test".to_string()), + message_type: vec![ + DescriptorProto { + name: Some("User".to_string()), + ..Default::default() + }, + DescriptorProto { + name: Some("Account".to_string()), + ..Default::default() + }, + ], + ..Default::default() + }; + + let parser = ProtoParser::new(vec![file]); + + assert!(parser.find_message("User").is_some()); + assert!(parser.find_message("Account").is_some()); + assert!(parser.find_message("NonExistent").is_none()); + } + + #[test] + fn test_proto_parser_find_service() { + let file = FileDescriptorProto { + name: Some("test.proto".to_string()), + package: Some("test".to_string()), + service: vec![ + ServiceDescriptorProto { + name: Some("UserService".to_string()), + ..Default::default() + }, + ], + ..Default::default() + }; + + let parser = ProtoParser::new(vec![file]); + + assert!(parser.find_service("UserService").is_some()); + assert!(parser.find_service("NonExistent").is_none()); + } +} \ No newline at end of file diff --git a/rust/sebuf-core/src/plugin.rs b/rust/sebuf-core/src/plugin.rs new file mode 100644 index 00000000..543d69ce --- /dev/null +++ b/rust/sebuf-core/src/plugin.rs @@ -0,0 +1,38 @@ +use std::io::{Read, Write}; +use prost::Message; +use prost_types::compiler::{CodeGeneratorRequest, CodeGeneratorResponse}; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum PluginError { + #[error("Failed to read from stdin: {0}")] + ReadError(#[from] std::io::Error), + + #[error("Failed to decode protobuf request: {0}")] + DecodeError(#[from] prost::DecodeError), + + #[error("Failed to encode protobuf response: {0}")] + EncodeError(#[from] prost::EncodeError), + + #[error("Generation error: {0}")] + GenerationError(String), +} + +pub type PluginResult = Result; + +pub trait Plugin { + fn process(&self, request: CodeGeneratorRequest) -> PluginResult; +} + +pub fn run_plugin(plugin: P) -> PluginResult<()> { + let mut input = Vec::new(); + std::io::stdin().read_to_end(&mut input)?; + + let request = CodeGeneratorRequest::decode(&input[..])?; + let response = plugin.process(request)?; + + let output = response.encode_to_vec(); + std::io::stdout().write_all(&output)?; + + Ok(()) +} \ No newline at end of file diff --git a/rust/testdata/proto/user_service.proto b/rust/testdata/proto/user_service.proto new file mode 100644 index 00000000..e65e6078 --- /dev/null +++ b/rust/testdata/proto/user_service.proto @@ -0,0 +1,127 @@ +syntax = "proto3"; + +package testdata.user; + +option go_package = "github.com/SebastienMelki/sebuf/rust/testdata/user"; + +import "sebuf/http/annotations.proto"; + +// User represents a system user +message User { + string id = 1; + string email = 2; + string name = 3; + UserStatus status = 4; + int64 created_at = 5; + optional string phone = 6; + repeated string roles = 7; + map metadata = 8; +} + +// UserStatus represents the user's account status +enum UserStatus { + USER_STATUS_UNSPECIFIED = 0; + USER_STATUS_ACTIVE = 1; + USER_STATUS_INACTIVE = 2; + USER_STATUS_SUSPENDED = 3; +} + +// LoginRequest with multiple auth methods +message LoginRequest { + oneof auth_method { + EmailAuth email = 1; + PhoneAuth phone = 2; + OAuthAuth oauth = 3; + } + + message EmailAuth { + string email = 1; + string password = 2; + } + + message PhoneAuth { + string phone = 1; + string code = 2; + } + + message OAuthAuth { + string provider = 1; + string token = 2; + } +} + +// CreateUserRequest for creating new users +message CreateUserRequest { + string email = 1; + string name = 2; + string password = 3; + repeated string initial_roles = 4; +} + +// GetUserRequest for fetching user by ID +message GetUserRequest { + string user_id = 1; +} + +// UpdateUserRequest for updating user details +message UpdateUserRequest { + string user_id = 1; + optional string name = 2; + optional string email = 3; + optional UserStatus status = 4; +} + +// ListUsersRequest for listing users +message ListUsersRequest { + int32 page_size = 1; + string page_token = 2; + optional UserStatus filter_status = 3; +} + +// ListUsersResponse with pagination +message ListUsersResponse { + repeated User users = 1; + string next_page_token = 2; + int32 total_count = 3; +} + +// UserService provides user management operations +service UserService { + // CreateUser creates a new user account + rpc CreateUser(CreateUserRequest) returns (User) { + option (sebuf.http) = { + post: "/api/v1/users" + body: "*" + }; + } + + // GetUser retrieves a user by ID + rpc GetUser(GetUserRequest) returns (User) { + option (sebuf.http) = { + get: "/api/v1/users/{user_id}" + }; + } + + // UpdateUser updates user information + rpc UpdateUser(UpdateUserRequest) returns (User) { + option (sebuf.http) = { + patch: "/api/v1/users/{user_id}" + body: "*" + }; + } + + // ListUsers returns a paginated list of users + rpc ListUsers(ListUsersRequest) returns (ListUsersResponse) { + option (sebuf.http) = { + get: "/api/v1/users" + }; + } + + // Login authenticates a user + rpc Login(LoginRequest) returns (User) { + option (sebuf.http) = { + post: "/api/v1/auth/login" + body: "*" + }; + } +} \ No newline at end of file diff --git a/rust/tests/golden_test.rs b/rust/tests/golden_test.rs new file mode 100644 index 00000000..4800224c --- /dev/null +++ b/rust/tests/golden_test.rs @@ -0,0 +1,246 @@ +use std::path::PathBuf; +use std::process::Command; +use std::fs; +use tempfile::TempDir; + +const UPDATE_GOLDEN: &str = "UPDATE_GOLDEN"; + +#[derive(Debug)] +struct TestCase { + name: &'static str, + proto_content: &'static str, + plugin: &'static str, + binary_name: &'static str, + output_extension: &'static str, +} + +impl TestCase { + fn run(&self) { + println!("Running golden test: {}", self.name); + + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let proto_path = temp_dir.path().join("test.proto"); + let output_path = temp_dir.path(); + + // Write test proto file + fs::write(&proto_path, self.proto_content).expect("Failed to write proto file"); + + // Get binary path + let binary_path = env!(&format!("CARGO_BIN_EXE_{}", self.binary_name)); + + // Run protoc with our plugin + let output = Command::new("protoc") + .arg(&format!("--plugin={}={}", self.plugin, binary_path)) + .arg(&format!("--{}_out={}", self.plugin.trim_start_matches("protoc-gen-"), output_path.display())) + .arg(&format!("--proto_path={}", temp_dir.path().display())) + .arg("test.proto") + .output() + .expect("Failed to execute protoc"); + + if !output.status.success() { + panic!( + "protoc failed for test {}: {}\nstdout: {}\nstderr: {}", + self.name, + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + + // Find generated files + let generated_files: Vec<_> = fs::read_dir(output_path) + .expect("Failed to read output directory") + .filter_map(|entry| entry.ok()) + .filter(|entry| { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + name_str.ends_with(self.output_extension) && + !name_str.starts_with('.') && + name_str != "test.proto" + }) + .collect(); + + if generated_files.is_empty() { + // Some tests might not generate files (e.g., no oneofs), that's OK + return; + } + + // Process each generated file + for file_entry in generated_files { + let file_path = file_entry.path(); + let file_name = file_entry.file_name(); + let content = fs::read_to_string(&file_path) + .expect("Failed to read generated file"); + + let golden_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent().unwrap() + .join("testdata/golden") + .join(match self.plugin.as_ref() { + "protoc-gen-rust-oneof-helper" => "oneof-helper", + "protoc-gen-rust-http" => "http", + "protoc-gen-rust-openapiv3" => "openapi", + _ => panic!("Unknown plugin: {}", self.plugin), + }); + + fs::create_dir_all(&golden_dir).expect("Failed to create golden directory"); + + let golden_file = golden_dir.join(format!("{}_{}", self.name, file_name.to_string_lossy())); + + if std::env::var(UPDATE_GOLDEN).is_ok() { + // Update golden file + fs::write(&golden_file, &content).expect("Failed to write golden file"); + println!("Updated golden file: {:?}", golden_file); + } else { + // Compare with golden file + if !golden_file.exists() { + panic!( + "Golden file does not exist: {:?}\n\ + Run with UPDATE_GOLDEN=1 to create it.\n\ + Generated content:\n{}", + golden_file, content + ); + } + + let golden_content = fs::read_to_string(&golden_file) + .expect("Failed to read golden file"); + + if content != golden_content { + panic!( + "Generated content differs from golden file: {:?}\n\ + Run with UPDATE_GOLDEN=1 to update.\n\ + \nExpected:\n{}\n\nActual:\n{}", + golden_file, golden_content, content + ); + } + } + } + } +} + +static TEST_CASES: &[TestCase] = &[ + TestCase { + name: "simple_oneof", + proto_content: r#" +syntax = "proto3"; +package test; + +message LoginRequest { + oneof auth_method { + EmailAuth email = 1; + PhoneAuth phone = 2; + } + + message EmailAuth { + string email = 1; + string password = 2; + } + + message PhoneAuth { + string phone = 1; + string code = 2; + } +} +"#, + plugin: "protoc-gen-rust-oneof-helper", + binary_name: "protoc-gen-rust-oneof-helper", + output_extension: ".oneof_helpers.rs", + }, + + TestCase { + name: "simple_service", + proto_content: r#" +syntax = "proto3"; +package test; + +message CreateUserRequest { + string name = 1; + string email = 2; +} + +message User { + string id = 1; + string name = 2; + string email = 3; +} + +service UserService { + rpc CreateUser(CreateUserRequest) returns (User); + rpc GetUser(CreateUserRequest) returns (User); +} +"#, + plugin: "protoc-gen-rust-http", + binary_name: "protoc-gen-rust-http", + output_extension: ".http.rs", + }, + + TestCase { + name: "simple_openapi", + proto_content: r#" +syntax = "proto3"; +package test; + +message User { + string id = 1; + string name = 2; +} + +message GetUserRequest { + string user_id = 1; +} + +service UserService { + rpc GetUser(GetUserRequest) returns (User); +} +"#, + plugin: "protoc-gen-rust-openapiv3", + binary_name: "protoc-gen-rust-openapiv3", + output_extension: ".openapi.yaml", + }, + + TestCase { + name: "complex_types", + proto_content: r#" +syntax = "proto3"; +package test; + +enum Status { + STATUS_UNSPECIFIED = 0; + STATUS_ACTIVE = 1; + STATUS_INACTIVE = 2; +} + +message User { + string id = 1; + string name = 2; + repeated string tags = 3; + map metadata = 4; + Status status = 5; + optional string phone = 6; +} + +message ListUsersRequest { + int32 page_size = 1; + string page_token = 2; +} + +message ListUsersResponse { + repeated User users = 1; + string next_page_token = 2; +} + +service UserService { + rpc ListUsers(ListUsersRequest) returns (ListUsersResponse); +} +"#, + plugin: "protoc-gen-rust-openapiv3", + binary_name: "protoc-gen-rust-openapiv3", + output_extension: ".openapi.yaml", + }, +]; + +#[test] +fn test_golden_files() { + for test_case in TEST_CASES { + test_case.run(); + } +} \ No newline at end of file diff --git a/scripts/test_rust.sh b/scripts/test_rust.sh new file mode 100755 index 00000000..54a2a513 --- /dev/null +++ b/scripts/test_rust.sh @@ -0,0 +1,315 @@ +#!/bin/bash +set -euo pipefail + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Script directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +# Configuration +COVERAGE_THRESHOLD=80 +FAST_MODE=false +VERBOSE=false +UPDATE_GOLDEN=false + +# Parse command line arguments +while [[ $# -gt 0 ]]; do + case $1 in + --fast) + FAST_MODE=true + shift + ;; + --verbose|-v) + VERBOSE=true + shift + ;; + --update-golden) + UPDATE_GOLDEN=true + shift + ;; + --coverage-threshold) + COVERAGE_THRESHOLD="$2" + shift 2 + ;; + --help|-h) + echo "Usage: $0 [OPTIONS]" + echo "" + echo "Options:" + echo " --fast Skip coverage analysis for faster execution" + echo " --verbose, -v Enable verbose output" + echo " --update-golden Update golden test files" + echo " --coverage-threshold N Set coverage threshold (default: 80)" + echo " --help, -h Show this help message" + exit 0 + ;; + *) + echo "Unknown option: $1" + exit 1 + ;; + esac +done + +# Helper functions +log_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +log_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Check prerequisites +check_prerequisites() { + log_info "Checking prerequisites..." + + # Check if we're in the right directory + if [[ ! -f "$PROJECT_ROOT/Cargo.toml" ]]; then + log_error "This script must be run from the sebuf project root or scripts directory" + exit 1 + fi + + # Check if Rust is installed + if ! command -v cargo &> /dev/null; then + log_error "cargo is not installed. Please install Rust: https://rustup.rs/" + exit 1 + fi + + # Check if protoc is installed + if ! command -v protoc &> /dev/null; then + log_error "protoc is not installed. Please install Protocol Buffers compiler" + exit 1 + fi + + log_success "Prerequisites check passed" +} + +# Build all Rust projects +build_projects() { + log_info "Building Rust projects..." + + cd "$PROJECT_ROOT" + + if [[ "$VERBOSE" == "true" ]]; then + cargo build --release + else + cargo build --release > /dev/null 2>&1 + fi + + log_success "Build completed" +} + +# Run unit tests +run_unit_tests() { + log_info "Running unit tests..." + + cd "$PROJECT_ROOT" + + local test_cmd="cargo test --lib" + if [[ "$VERBOSE" == "true" ]]; then + test_cmd="$test_cmd --verbose" + fi + + if ! eval "$test_cmd"; then + log_error "Unit tests failed" + return 1 + fi + + log_success "Unit tests passed" +} + +# Run integration tests +run_integration_tests() { + log_info "Running integration tests..." + + cd "$PROJECT_ROOT" + + local test_cmd="cargo test --test integration_test" + if [[ "$VERBOSE" == "true" ]]; then + test_cmd="$test_cmd --verbose" + fi + + # Set environment variables for golden tests + if [[ "$UPDATE_GOLDEN" == "true" ]]; then + export UPDATE_GOLDEN=1 + log_info "Updating golden files..." + fi + + if ! eval "$test_cmd"; then + log_error "Integration tests failed" + return 1 + fi + + log_success "Integration tests passed" +} + +# Run golden file tests +run_golden_tests() { + log_info "Running golden file tests..." + + cd "$PROJECT_ROOT" + + local test_cmd="cargo test --test golden_test" + if [[ "$VERBOSE" == "true" ]]; then + test_cmd="$test_cmd --verbose" + fi + + # Set environment variables for golden tests + if [[ "$UPDATE_GOLDEN" == "true" ]]; then + export UPDATE_GOLDEN=1 + log_info "Updating golden files..." + fi + + if ! eval "$test_cmd"; then + log_error "Golden file tests failed" + return 1 + fi + + log_success "Golden file tests passed" +} + +# Run coverage analysis +run_coverage() { + if [[ "$FAST_MODE" == "true" ]]; then + log_info "Skipping coverage analysis (fast mode enabled)" + return 0 + fi + + log_info "Running coverage analysis..." + + cd "$PROJECT_ROOT" + + # Check if tarpaulin is installed + if ! command -v cargo-tarpaulin &> /dev/null; then + log_warning "cargo-tarpaulin is not installed. Installing..." + cargo install cargo-tarpaulin + fi + + local coverage_cmd="cargo tarpaulin --out Xml --out Html --output-dir rust/coverage" + + if [[ "$VERBOSE" == "true" ]]; then + coverage_cmd="$coverage_cmd --verbose" + else + coverage_cmd="$coverage_cmd --quiet" + fi + + if ! eval "$coverage_cmd"; then + log_error "Coverage analysis failed" + return 1 + fi + + # Parse coverage percentage from tarpaulin output + if [[ -f "rust/coverage/cobertura.xml" ]]; then + local coverage_percent + coverage_percent=$(grep -o 'line-rate="[0-9.]*"' rust/coverage/cobertura.xml | head -1 | grep -o '[0-9.]*') + if [[ -n "$coverage_percent" ]]; then + coverage_percent=$(echo "$coverage_percent * 100" | bc -l) + coverage_percent=${coverage_percent%.*} + + if (( coverage_percent >= COVERAGE_THRESHOLD )); then + log_success "Coverage: ${coverage_percent}% (threshold: ${COVERAGE_THRESHOLD}%)" + else + log_warning "Coverage: ${coverage_percent}% (below threshold: ${COVERAGE_THRESHOLD}%)" + return 1 + fi + fi + fi +} + +# Run linting +run_lint() { + log_info "Running Rust linting..." + + cd "$PROJECT_ROOT" + + # Check formatting + if ! cargo fmt --all -- --check; then + log_error "Code formatting check failed. Run 'cargo fmt --all' to fix." + return 1 + fi + + # Run clippy + local clippy_cmd="cargo clippy --all -- -D warnings" + if [[ "$VERBOSE" == "true" ]]; then + clippy_cmd="$clippy_cmd --verbose" + fi + + if ! eval "$clippy_cmd"; then + log_error "Clippy linting failed" + return 1 + fi + + log_success "Linting passed" +} + +# Generate test report +generate_report() { + log_info "Generating test report..." + + local report_file="$PROJECT_ROOT/rust/test_report.txt" + + { + echo "Sebuf Rust Test Report" + echo "======================" + echo "Generated: $(date)" + echo "Fast Mode: $FAST_MODE" + echo "Verbose: $VERBOSE" + echo "Coverage Threshold: ${COVERAGE_THRESHOLD}%" + echo "" + + if [[ "$FAST_MODE" == "false" && -f "$PROJECT_ROOT/rust/coverage/cobertura.xml" ]]; then + echo "Coverage Report: rust/coverage/tarpaulin-report.html" + fi + + echo "" + echo "Test Results:" + echo "✅ Unit tests" + echo "✅ Integration tests" + echo "✅ Golden file tests" + if [[ "$FAST_MODE" == "false" ]]; then + echo "✅ Coverage analysis" + fi + echo "✅ Code linting" + + } > "$report_file" + + log_success "Test report generated: rust/test_report.txt" +} + +# Main execution +main() { + echo -e "${BLUE}Sebuf Rust Test Runner${NC}" + echo "======================" + echo "" + + check_prerequisites + build_projects + run_unit_tests + run_integration_tests + run_golden_tests + run_coverage + run_lint + generate_report + + log_success "All tests completed successfully!" + + if [[ "$UPDATE_GOLDEN" == "true" ]]; then + log_info "Golden files were updated. Please review and commit the changes." + fi +} + +# Run main function +main "$@" \ No newline at end of file