Skip to main content

actr_web_protoc_codegen/
lib.rs

1//! # actr-web-protoc-codegen
2//!
3//! Protoc code generator for producing actr-web code from Protobuf definitions.
4//!
5//! ## Features
6//!
7//! - Generate Rust WASM actor code from `.proto` files
8//! - Generate TypeScript type definitions
9//! - Generate TypeScript ActorRef wrappers
10//! - Optionally generate React Hooks
11//!
12//! ## Usage
13//!
14//! ### Option 1: use it from `build.rs`
15//!
16//! ```rust,no_run
17//! use actr_web_protoc_codegen::{WebCodegen, WebCodegenConfig};
18//!
19//! let config = WebCodegenConfig {
20//!     proto_files: vec!["proto/echo.proto".into()],
21//!     rust_output_dir: "src/generated".into(),
22//!     ts_output_dir: "../packages/web-sdk/src/generated".into(),
23//!     generate_react_hooks: true,
24//!     includes: vec!["proto".into()],
25//!     custom_templates_dir: None,
26//!     format_code: true,
27//! };
28//!
29//! WebCodegen::new(config)
30//!     .generate()
31//!     .expect("Failed to generate code");
32//! ```
33//!
34//! ### Option 2: use it through `actr-cli`
35//!
36//! ```bash
37//! actr gen --platform web \
38//!   --input proto/ \
39//!   --output crates/actors/src/generated/ \
40//!   --ts-output packages/web-sdk/src/generated/ \
41//!   --react-hooks
42//! ```
43
44use std::path::PathBuf;
45
46pub(crate) mod codegen;
47mod config;
48pub mod descriptor;
49mod error;
50mod generator;
51mod request;
52mod templates;
53mod typescript;
54
55pub use codegen::generate;
56pub use config::{WebCodegenConfig, WebCodegenConfigBuilder};
57pub(crate) use error::Result;
58pub use request::WebCodegenRequest;
59
60/// Code generator for the web platform.
61pub struct WebCodegen {
62    config: WebCodegenConfig,
63}
64
65impl WebCodegen {
66    /// Create a new code generator instance.
67    pub fn new(config: WebCodegenConfig) -> Self {
68        Self { config }
69    }
70
71    /// Generate all outputs: Rust and TypeScript.
72    pub fn generate(&self) -> Result<GeneratedFiles> {
73        tracing::info!("Starting actr-web code generation");
74
75        let mut files = GeneratedFiles::default();
76
77        // 1. Parse proto files.
78        let services = self.parse_proto_files()?;
79        tracing::info!("Parsed {} services", services.len());
80
81        // 2. Generate Rust WASM actor code.
82        tracing::info!("Generating Rust WASM code");
83        files.rust_files = self.generate_rust_actors(&services)?;
84
85        // 3. Generate TypeScript types.
86        tracing::info!("Generating TypeScript types");
87        files.ts_types = self.generate_typescript_types(&services)?;
88
89        // 4. Generate TypeScript ActorRef wrappers.
90        tracing::info!("Generating ActorRef wrappers");
91        files.ts_actor_refs = self.generate_actor_refs(&services)?;
92
93        // 5. Optionally generate React Hooks.
94        if self.config.generate_react_hooks {
95            tracing::info!("Generating React Hooks");
96            files.react_hooks = self.generate_react_hooks(&services)?;
97        }
98
99        // 6. Write files.
100        files.write_to_disk()?;
101
102        // 7. Format generated code.
103        if self.config.format_code {
104            files.format_code()?;
105        }
106
107        tracing::info!(
108            "Code generation finished. Generated {} files",
109            files.total_count()
110        );
111
112        Ok(files)
113    }
114
115    /// Generate Rust output only, intended for `build.rs`.
116    pub fn generate_rust_only(&self) -> Result<Vec<GeneratedFile>> {
117        let services = self.parse_proto_files()?;
118        self.generate_rust_actors(&services)
119    }
120
121    /// Generate TypeScript output only.
122    pub fn generate_typescript_only(&self) -> Result<Vec<GeneratedFile>> {
123        let services = self.parse_proto_files()?;
124        self.generate_typescript_from_services(&services)
125    }
126
127    /// Generate TypeScript output from an already-materialised service list.
128    ///
129    /// Useful for the protoc plugin mode, which receives structured
130    /// descriptors on stdin and can skip the extra `protoc` invocation that
131    /// `parse_proto_files` performs.
132    pub fn generate_typescript_from_services(
133        &self,
134        services: &[ProtoService],
135    ) -> Result<Vec<GeneratedFile>> {
136        let mut files = Vec::new();
137        files.extend(self.generate_typescript_types(services)?);
138        files.extend(self.generate_actor_refs(services)?);
139        Ok(files)
140    }
141
142    /// Parse proto files.
143    fn parse_proto_files(&self) -> Result<Vec<ProtoService>> {
144        generator::parse_proto_files(&self.config)
145    }
146
147    /// Generate Rust actor code.
148    fn generate_rust_actors(&self, services: &[ProtoService]) -> Result<Vec<GeneratedFile>> {
149        generator::generate_rust_actors(&self.config, services)
150    }
151
152    /// Generate TypeScript types.
153    fn generate_typescript_types(&self, services: &[ProtoService]) -> Result<Vec<GeneratedFile>> {
154        typescript::generate_types(&self.config, services)
155    }
156
157    /// Generate ActorRef wrappers.
158    fn generate_actor_refs(&self, services: &[ProtoService]) -> Result<Vec<GeneratedFile>> {
159        typescript::generate_actor_refs(&self.config, services)
160    }
161
162    /// Generate React Hooks.
163    fn generate_react_hooks(&self, services: &[ProtoService]) -> Result<Vec<GeneratedFile>> {
164        typescript::generate_react_hooks(&self.config, services)
165    }
166}
167
168/// All files generated in a run.
169#[derive(Default, Debug)]
170pub struct GeneratedFiles {
171    pub rust_files: Vec<GeneratedFile>,
172    pub ts_types: Vec<GeneratedFile>,
173    pub ts_actor_refs: Vec<GeneratedFile>,
174    pub react_hooks: Vec<GeneratedFile>,
175}
176
177impl GeneratedFiles {
178    /// Return an iterator over all generated files.
179    pub fn all_files(&self) -> impl Iterator<Item = &GeneratedFile> {
180        self.rust_files
181            .iter()
182            .chain(self.ts_types.iter())
183            .chain(self.ts_actor_refs.iter())
184            .chain(self.react_hooks.iter())
185    }
186
187    /// Return the total generated file count.
188    pub fn total_count(&self) -> usize {
189        self.rust_files.len()
190            + self.ts_types.len()
191            + self.ts_actor_refs.len()
192            + self.react_hooks.len()
193    }
194
195    /// Write all generated files to disk.
196    pub fn write_to_disk(&self) -> Result<()> {
197        for file in self.all_files() {
198            file.write_to_disk()?;
199        }
200        Ok(())
201    }
202
203    /// Format all generated code.
204    pub fn format_code(&self) -> Result<()> {
205        tracing::info!("Formatting generated code");
206
207        // Format Rust files.
208        for file in &self.rust_files {
209            if file.path.extension().and_then(|s| s.to_str()) == Some("rs") {
210                format_rust_file(&file.path)?;
211            }
212        }
213
214        // Format TypeScript files.
215        let ts_files: Vec<_> = self
216            .ts_types
217            .iter()
218            .chain(self.ts_actor_refs.iter())
219            .chain(self.react_hooks.iter())
220            .collect();
221
222        for file in ts_files {
223            if file.path.extension().and_then(|s| s.to_str()) == Some("ts") {
224                format_typescript_file(&file.path)?;
225            }
226        }
227
228        tracing::info!("Generated code formatting completed");
229        Ok(())
230    }
231}
232
233/// A single generated file.
234#[derive(Debug, Clone)]
235pub struct GeneratedFile {
236    pub path: PathBuf,
237    pub content: String,
238}
239
240impl GeneratedFile {
241    /// Create a new generated file.
242    pub fn new(path: PathBuf, content: String) -> Self {
243        Self { path, content }
244    }
245
246    /// Write the file to disk.
247    pub fn write_to_disk(&self) -> Result<()> {
248        use std::fs;
249
250        // Create the parent directory first.
251        if let Some(parent) = self.path.parent() {
252            fs::create_dir_all(parent)?;
253        }
254
255        // Write the file.
256        fs::write(&self.path, &self.content)?;
257        tracing::debug!("Wrote file: {}", self.path.display());
258
259        Ok(())
260    }
261}
262
263/// Proto service definition.
264#[derive(Debug, Clone)]
265pub struct ProtoService {
266    pub name: String,
267    pub package: String,
268    pub methods: Vec<ProtoMethod>,
269    pub messages: Vec<ProtoMessage>,
270}
271
272/// Proto method definition.
273#[derive(Debug, Clone)]
274pub struct ProtoMethod {
275    pub name: String,
276    pub input_type: String,
277    pub output_type: String,
278    pub is_streaming: bool,
279}
280
281/// Proto message definition.
282#[derive(Debug, Clone)]
283pub struct ProtoMessage {
284    pub name: String,
285    pub fields: Vec<ProtoField>,
286}
287
288/// Proto field definition.
289#[derive(Debug, Clone)]
290pub struct ProtoField {
291    pub name: String,
292    pub field_type: String,
293    pub number: u32,
294    pub is_repeated: bool,
295    pub is_optional: bool,
296}
297
298/// Format a Rust file.
299fn format_rust_file(path: &std::path::Path) -> Result<()> {
300    use std::process::Command;
301
302    let output = Command::new("rustfmt")
303        .arg("--edition")
304        .arg("2021")
305        .arg(path)
306        .output();
307
308    match output {
309        Ok(output) if output.status.success() => {
310            tracing::debug!("Formatted Rust file: {}", path.display());
311            Ok(())
312        }
313        Ok(output) => {
314            tracing::warn!(
315                "rustfmt failed: {}",
316                String::from_utf8_lossy(&output.stderr)
317            );
318            Ok(()) // Formatting failures must not block code generation.
319        }
320        Err(e) => {
321            tracing::warn!("rustfmt not found or failed to execute: {}", e);
322            Ok(()) // Formatting failures must not block code generation.
323        }
324    }
325}
326
327/// Format a TypeScript file.
328fn format_typescript_file(path: &std::path::Path) -> Result<()> {
329    use std::process::Command;
330
331    // Try prettier first.
332    let output = Command::new("npx")
333        .args(["prettier", "--write", path.to_str().unwrap()])
334        .output();
335
336    match output {
337        Ok(output) if output.status.success() => {
338            tracing::debug!("Formatted TypeScript file: {}", path.display());
339            Ok(())
340        }
341        Ok(output) => {
342            tracing::warn!(
343                "prettier failed: {}",
344                String::from_utf8_lossy(&output.stderr)
345            );
346            Ok(())
347        }
348        Err(_) => {
349            // Fall back to dprint when prettier is unavailable.
350            let output = Command::new("dprint")
351                .args(["fmt", path.to_str().unwrap()])
352                .output();
353
354            match output {
355                Ok(output) if output.status.success() => {
356                    tracing::debug!("Formatted TypeScript file with dprint: {}", path.display());
357                    Ok(())
358                }
359                _ => {
360                    tracing::warn!("No TypeScript formatter found (prettier/dprint)");
361                    Ok(())
362                }
363            }
364        }
365    }
366}