actr_cli/commands/codegen/
proto_model.rs1use crate::error::{ActrCliError, Result};
2use crate::utils::to_snake_case;
3use actr_config::ManifestConfig;
4use actr_protocol::ActrType;
5use std::collections::HashMap;
6use std::path::{Path, PathBuf};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum ProtoSide {
10 Local,
11 Remote,
12}
13
14#[derive(Debug, Clone)]
15pub struct ProtoModel {
16 pub files: Vec<ProtoFileModel>,
17 pub local_services: Vec<ServiceModel>,
18 pub remote_services: Vec<ServiceModel>,
19}
20
21#[derive(Debug, Clone)]
22pub struct ProtoFileModel {
23 pub proto_file: PathBuf,
24 pub relative_path: PathBuf,
25 pub package: String,
26 pub side: ProtoSide,
27 pub declared_type_names: Vec<String>,
28 pub services: Vec<ServiceModel>,
29}
30
31#[derive(Debug, Clone)]
32pub struct ServiceModel {
33 pub name: String,
34 pub package: String,
35 pub proto_file: PathBuf,
36 pub relative_path: PathBuf,
37 pub side: ProtoSide,
38 pub methods: Vec<MethodModel>,
39 pub actr_type: Option<String>,
40}
41
42#[derive(Debug, Clone)]
43pub struct MethodModel {
44 pub name: String,
45 pub snake_name: String,
46 pub input_type: String,
47 pub output_type: String,
48 pub route_key: String,
49}
50
51impl ProtoModel {
52 pub fn parse(
53 proto_files: &[PathBuf],
54 input_path: &Path,
55 config: &ManifestConfig,
56 ) -> Result<Self> {
57 let proto_root = if input_path.is_file() {
58 input_path.parent().unwrap_or_else(|| Path::new("."))
59 } else {
60 input_path
61 };
62
63 let dependency_actr_types: HashMap<String, String> = config
64 .dependencies
65 .iter()
66 .filter_map(|dependency| {
67 dependency
68 .actr_type
69 .as_ref()
70 .map(|actr_type| (dependency.alias.clone(), actr_type.to_string_repr()))
71 })
72 .collect();
73
74 let default_manufacturer = config.package.actr_type.manufacturer.clone();
75
76 let mut files = Vec::new();
77 let mut local_services = Vec::new();
78 let mut remote_services = Vec::new();
79
80 for proto_file in proto_files {
81 let relative_path = proto_file
82 .strip_prefix(proto_root)
83 .unwrap_or(proto_file)
84 .to_path_buf();
85 let side = classify_proto_side(&relative_path);
86 let parsed = parse_proto_file(proto_file)?;
87
88 let remote_actr_type = if side == ProtoSide::Remote {
89 infer_remote_actr_type(
90 &relative_path,
91 &dependency_actr_types,
92 &default_manufacturer,
93 parsed.services.first().map(|service| service.name.as_str()),
94 )
95 } else {
96 None
97 };
98
99 let services: Vec<ServiceModel> = parsed
100 .services
101 .into_iter()
102 .map(|service| {
103 let service_model = ServiceModel {
104 name: service.name,
105 package: parsed.package.clone(),
106 proto_file: proto_file.clone(),
107 relative_path: relative_path.clone(),
108 side,
109 methods: service.methods,
110 actr_type: remote_actr_type.clone(),
111 };
112
113 if side == ProtoSide::Local {
114 local_services.push(service_model.clone());
115 } else {
116 remote_services.push(service_model.clone());
117 }
118
119 service_model
120 })
121 .collect();
122
123 files.push(ProtoFileModel {
124 proto_file: proto_file.clone(),
125 relative_path,
126 package: parsed.package,
127 side,
128 declared_type_names: parsed.declared_type_names,
129 services,
130 });
131 }
132
133 Ok(Self {
134 files,
135 local_services,
136 remote_services,
137 })
138 }
139}
140
141#[derive(Debug)]
142struct ParsedProtoFile {
143 package: String,
144 declared_type_names: Vec<String>,
145 services: Vec<ParsedService>,
146}
147
148#[derive(Debug)]
149struct ParsedService {
150 name: String,
151 methods: Vec<MethodModel>,
152}
153
154fn classify_proto_side(relative_path: &Path) -> ProtoSide {
155 let first_component = relative_path
156 .components()
157 .next()
158 .and_then(|component| component.as_os_str().to_str());
159
160 if first_component == Some("remote") {
161 ProtoSide::Remote
162 } else {
163 ProtoSide::Local
164 }
165}
166
167fn infer_remote_actr_type(
168 relative_path: &Path,
169 dependency_actr_types: &HashMap<String, String>,
170 default_manufacturer: &str,
171 service_name: Option<&str>,
172) -> Option<String> {
173 let alias = relative_path
174 .components()
175 .nth(1)
176 .and_then(|component| component.as_os_str().to_str());
177
178 if let Some(alias) = alias
179 && let Some(actr_type) = dependency_actr_types.get(alias)
180 {
181 return Some(actr_type.clone());
182 }
183
184 service_name.map(|service_name| {
185 ActrType {
186 manufacturer: default_manufacturer.to_string(),
187 name: service_name.to_string(),
188 version: "1.0.0".to_string(),
189 }
190 .to_string_repr()
191 })
192}
193
194fn parse_proto_file(proto_file: &Path) -> Result<ParsedProtoFile> {
195 let content = std::fs::read_to_string(proto_file).map_err(|e| {
196 ActrCliError::config_error(format!(
197 "Failed to read proto file {}: {e}",
198 proto_file.display()
199 ))
200 })?;
201
202 let mut package = String::new();
203 let mut declared_type_names = Vec::new();
204 let mut current_service: Option<ParsedService> = None;
205 let mut services = Vec::new();
206
207 for raw_line in content.lines() {
208 let line = raw_line.trim();
209 if line.is_empty() || line.starts_with("//") {
210 continue;
211 }
212
213 if let Some(rest) = line.strip_prefix("package ") {
214 package = rest
215 .trim_end_matches(';')
216 .split_whitespace()
217 .next()
218 .unwrap_or_default()
219 .to_string();
220 continue;
221 }
222
223 if let Some(rest) = line.strip_prefix("service ") {
224 if let Some(service) = current_service.take() {
225 services.push(service);
226 }
227
228 let name = rest
229 .split(|character: char| character.is_whitespace() || character == '{')
230 .find(|segment| !segment.is_empty())
231 .unwrap_or_default()
232 .to_string();
233
234 if !name.is_empty() {
235 declared_type_names.push(name.clone());
236 current_service = Some(ParsedService {
237 name,
238 methods: Vec::new(),
239 });
240 }
241 continue;
242 }
243
244 if let Some(name) = extract_declared_type_name(line, "message ") {
245 declared_type_names.push(name);
246 continue;
247 }
248
249 if let Some(name) = extract_declared_type_name(line, "enum ") {
250 declared_type_names.push(name);
251 continue;
252 }
253
254 if let Some(rest) = line.strip_prefix("rpc ")
255 && let Some(service) = current_service.as_mut()
256 {
257 if let Some(method) = parse_rpc_method(rest, &package, &service.name) {
258 service.methods.push(method);
259 }
260 continue;
261 }
262
263 if line.starts_with('}')
264 && let Some(service) = current_service.take()
265 {
266 services.push(service);
267 }
268 }
269
270 if let Some(service) = current_service.take() {
271 services.push(service);
272 }
273
274 Ok(ParsedProtoFile {
275 package,
276 declared_type_names,
277 services,
278 })
279}
280
281fn parse_rpc_method(rest: &str, package: &str, service_name: &str) -> Option<MethodModel> {
282 let input_start = rest.find('(')?;
283 let method_name = rest[..input_start]
284 .split_whitespace()
285 .next()
286 .unwrap_or_default()
287 .to_string();
288 if method_name.is_empty() {
289 return None;
290 }
291
292 let after_input_start = &rest[input_start + 1..];
293 let input_end = after_input_start.find(')')?;
294 let input_type = normalize_proto_type(&after_input_start[..input_end]);
295
296 let returns_pos = after_input_start.find("returns")?;
297 let after_returns = &after_input_start[returns_pos + "returns".len()..];
298 let output_start = after_returns.find('(')?;
299 let output_end = after_returns[output_start + 1..].find(')')?;
300 let output_type =
301 normalize_proto_type(&after_returns[output_start + 1..output_start + 1 + output_end]);
302
303 let route_key = if package.is_empty() {
304 format!("{service_name}.{method_name}")
305 } else {
306 format!("{package}.{service_name}.{method_name}")
307 };
308
309 Some(MethodModel {
310 snake_name: to_snake_case(&method_name),
311 name: method_name,
312 input_type,
313 output_type,
314 route_key,
315 })
316}
317
318fn normalize_proto_type(raw_type: &str) -> String {
319 raw_type.trim().trim_start_matches('.').to_string()
320}
321
322fn extract_declared_type_name(line: &str, prefix: &str) -> Option<String> {
323 let rest = line.strip_prefix(prefix)?;
324 let name = rest
325 .split(|character: char| character.is_whitespace() || character == '{')
326 .find(|segment| !segment.is_empty())?;
327 Some(name.to_string())
328}