actr_cli/commands/
generate.rs1use crate::commands::SupportedLanguage;
7use crate::commands::codegen::{GenContext, ProtoModel, execute_codegen};
8use crate::core::{Command, CommandContext, CommandResult, ComponentType};
9use crate::error::{ActrCliError, Result};
10use crate::project_language::DetectedProjectLanguage;
11use crate::utils::to_pascal_case;
12use actr_config::ConfigParser;
13use async_trait::async_trait;
14use clap::Args;
15use std::path::{Path, PathBuf};
16use tracing::{info, warn};
17
18#[derive(Args, Debug, Clone)]
19#[command(
20 about = "Generate code from proto files",
21 after_help = "Default output paths by language:
22 - rust: src/generated
23 - swift: {PascalName}/Generated (e.g., EchoApp/Generated)
24 - kotlin: app/src/main/java/{package}/generated
25 - python: generated
26 - typescript: src/generated"
27)]
28pub struct GenCommand {
29 #[arg(short, long, default_value = "protos")]
31 pub input: PathBuf,
32
33 #[arg(short, long)]
35 pub output: Option<PathBuf>,
36
37 #[arg(short, long, default_value = "manifest.toml")]
39 pub config: PathBuf,
40
41 #[arg(long = "clean")]
43 pub clean: bool,
44
45 #[arg(long = "no-scaffold")]
47 pub no_scaffold: bool,
48 #[arg(long)]
50 pub overwrite_user_code: bool,
51
52 #[arg(long = "no-format")]
54 pub no_format: bool,
55
56 #[arg(long)]
58 pub debug: bool,
59
60 #[arg(long)]
62 pub skip_validation: bool,
63
64 #[arg(short, long, default_value = "rust")]
66 pub language: SupportedLanguage,
67}
68
69#[async_trait]
70impl Command for GenCommand {
71 async fn execute(&self, _ctx: &CommandContext) -> anyhow::Result<CommandResult> {
72 self.execute_inner().await.map_err(anyhow::Error::from)?;
73 Ok(CommandResult::Success("Generation completed".to_string()))
74 }
75
76 fn required_components(&self) -> Vec<ComponentType> {
77 vec![]
78 }
79
80 fn name(&self) -> &str {
81 "gen"
82 }
83
84 fn description(&self) -> &str {
85 "Generate code from proto files"
86 }
87}
88
89impl GenCommand {
90 async fn execute_inner(&self) -> Result<()> {
91 self.check_lock_file()?;
92 self.validate_project_language_compatibility()?;
93
94 let output = self.determine_output_path()?;
95
96 info!(
97 "๐ Start code generation (language: {:?})...",
98 self.language
99 );
100 let config = ConfigParser::from_manifest_file(&self.config).map_err(|e| {
101 ActrCliError::config_error(format!("Failed to parse manifest.toml: {e}"))
102 })?;
103
104 let proto_files = self.preprocess()?;
105 let proto_model = ProtoModel::parse(&proto_files, &self.input, &config)?;
106 let context = GenContext {
107 proto_files,
108 proto_model,
109 input_path: self.input.clone(),
110 output,
111 config_path: self.config.clone(),
112 config: config.clone(),
113 no_scaffold: self.no_scaffold,
114 overwrite_user_code: self.overwrite_user_code,
115 no_format: self.no_format,
116 debug: self.debug,
117 skip_validation: self.skip_validation,
118 };
119 execute_codegen(self.language, &context).await?;
120 Ok(())
121 }
122}
123
124impl GenCommand {
125 fn validate_project_language_compatibility(&self) -> Result<()> {
126 let project_root = self.config.parent().unwrap_or_else(|| Path::new("."));
127 let detected = DetectedProjectLanguage::detect(project_root);
128
129 if detected == DetectedProjectLanguage::Unknown {
130 eprintln!(
131 "Warning: Could not detect project language from '{}'; skipping language compatibility check.",
132 project_root.display()
133 );
134 return Ok(());
135 }
136
137 if detected == DetectedProjectLanguage::Ambiguous {
138 eprintln!(
139 "Warning: Detected multiple project language markers in '{}'; skipping language compatibility check.",
140 project_root.display()
141 );
142 return Ok(());
143 }
144
145 let requested = self.requested_project_language();
146 if detected == requested {
147 return Ok(());
148 }
149
150 Err(ActrCliError::config_error(format!(
151 "Refusing to generate '{requested}' code in a '{detected}' project.\n\n\
152 Run:\n actr gen -l {detected}"
153 )))
154 }
155
156 fn requested_project_language(&self) -> DetectedProjectLanguage {
157 match self.language {
158 SupportedLanguage::Rust => DetectedProjectLanguage::Rust,
159 SupportedLanguage::Python => DetectedProjectLanguage::Python,
160 SupportedLanguage::Swift => DetectedProjectLanguage::Swift,
161 SupportedLanguage::Kotlin => DetectedProjectLanguage::Kotlin,
162 SupportedLanguage::TypeScript => DetectedProjectLanguage::TypeScript,
163 }
164 }
165
166 fn check_lock_file(&self) -> Result<()> {
167 let config_dir = self
168 .config
169 .parent()
170 .unwrap_or_else(|| std::path::Path::new("."));
171 let lock_file_path = config_dir.join("manifest.lock.toml");
172
173 if !lock_file_path.exists() {
174 return Err(ActrCliError::config_error(
175 "manifest.lock.toml not found\n\n\
176 The lock file is required for code generation. Please run:\n\n\
177 \x20\x20\x20\x20actr deps install\n\n\
178 This will generate manifest.lock.toml based on your manifest.toml configuration.",
179 ));
180 }
181
182 Ok(())
183 }
184
185 fn determine_output_path(&self) -> Result<PathBuf> {
186 if let Some(ref output) = self.output {
187 return Ok(output.clone());
188 }
189
190 match self.language {
191 SupportedLanguage::Swift => {
192 let config = ConfigParser::from_manifest_file(&self.config).map_err(|e| {
193 ActrCliError::config_error(format!("Failed to parse manifest.toml: {e}"))
194 })?;
195 let project_name = &config.package.name;
196 let pascal_name = to_pascal_case(project_name);
197 Ok(PathBuf::from(format!("{}/Generated", pascal_name)))
198 }
199 SupportedLanguage::Kotlin => {
200 let config = ConfigParser::from_manifest_file(&self.config).map_err(|e| {
201 ActrCliError::config_error(format!("Failed to parse manifest.toml: {e}"))
202 })?;
203 let clean_name: String = config
204 .package
205 .name
206 .chars()
207 .filter(|c| c.is_alphanumeric())
208 .collect::<String>()
209 .to_lowercase();
210 let package_path = format!("io/actr/{}", clean_name);
211 Ok(PathBuf::from(format!(
212 "app/src/main/java/{}/generated",
213 package_path
214 )))
215 }
216 SupportedLanguage::Python => Ok(PathBuf::from("generated")),
217 SupportedLanguage::TypeScript => Ok(PathBuf::from("src/generated")),
218 SupportedLanguage::Rust => Ok(PathBuf::from("src/generated")),
219 }
220 }
221
222 fn preprocess(&self) -> Result<Vec<PathBuf>> {
223 self.validate_inputs()?;
224 self.clean_generated_outputs()?;
225 self.prepare_output_dirs()?;
226
227 let proto_files = self.discover_proto_files()?;
228 info!("๐ Found {} proto files", proto_files.len());
229
230 Ok(proto_files)
231 }
232
233 fn clean_generated_outputs(&self) -> Result<()> {
234 use std::fs;
235
236 if !self.clean {
237 return Ok(());
238 }
239
240 let output = self.determine_output_path()?;
241 if !output.exists() {
242 return Ok(());
243 }
244
245 info!("๐งน Cleaning old generation results: {:?}", output);
246
247 self.make_writable_recursive(&output)?;
248 fs::remove_dir_all(&output).map_err(|e| {
249 ActrCliError::config_error(format!("Failed to delete generation directory: {e}"))
250 })?;
251
252 Ok(())
253 }
254
255 #[allow(clippy::only_used_in_recursion)]
256 fn make_writable_recursive(&self, path: &Path) -> Result<()> {
257 use std::fs;
258
259 if path.is_file() {
260 let metadata = fs::metadata(path).map_err(|e| {
261 ActrCliError::config_error(format!("Failed to read file metadata: {e}"))
262 })?;
263 let mut permissions = metadata.permissions();
264
265 #[cfg(unix)]
266 {
267 use std::os::unix::fs::PermissionsExt;
268 let mode = permissions.mode();
269 permissions.set_mode(mode | 0o222);
270 }
271
272 #[cfg(not(unix))]
273 {
274 permissions.set_readonly(false);
275 }
276
277 fs::set_permissions(path, permissions).map_err(|e| {
278 ActrCliError::config_error(format!("Failed to reset file permissions: {e}"))
279 })?;
280 } else if path.is_dir() {
281 for entry in fs::read_dir(path)
282 .map_err(|e| ActrCliError::config_error(format!("Failed to read directory: {e}")))?
283 {
284 let entry = entry.map_err(|e| ActrCliError::config_error(e.to_string()))?;
285 self.make_writable_recursive(&entry.path())?;
286 }
287 }
288
289 Ok(())
290 }
291
292 fn validate_inputs(&self) -> Result<()> {
293 if !self.input.exists() {
294 return Err(ActrCliError::config_error(format!(
295 "Input path does not exist: {:?}",
296 self.input
297 )));
298 }
299
300 if self.input.is_file() && self.input.extension().unwrap_or_default() != "proto" {
301 warn!("Input file is not a .proto file: {:?}", self.input);
302 }
303
304 Ok(())
305 }
306
307 fn prepare_output_dirs(&self) -> Result<()> {
308 let output = self.determine_output_path()?;
309 std::fs::create_dir_all(&output).map_err(|e| {
310 ActrCliError::config_error(format!("Failed to create output directory: {e}"))
311 })?;
312
313 if !self.no_scaffold {
314 let user_code_dir = output.join("../");
315 std::fs::create_dir_all(&user_code_dir).map_err(|e| {
316 ActrCliError::config_error(format!("Failed to create user code directory: {e}"))
317 })?;
318 }
319
320 Ok(())
321 }
322
323 fn discover_proto_files(&self) -> Result<Vec<PathBuf>> {
324 let mut proto_files = Vec::new();
325
326 if self.input.is_file() {
327 proto_files.push(self.input.clone());
328 } else {
329 self.collect_proto_files(&self.input, &mut proto_files)?;
330 }
331
332 if proto_files.is_empty() {
333 return Err(ActrCliError::config_error("No proto files found"));
334 }
335
336 Ok(proto_files)
337 }
338
339 #[allow(clippy::only_used_in_recursion)]
340 fn collect_proto_files(&self, dir: &PathBuf, proto_files: &mut Vec<PathBuf>) -> Result<()> {
341 for entry in std::fs::read_dir(dir)
342 .map_err(|e| ActrCliError::config_error(format!("Failed to read directory: {e}")))?
343 {
344 let entry = entry.map_err(|e| ActrCliError::config_error(e.to_string()))?;
345 let path = entry.path();
346
347 if path.is_file() && path.extension().unwrap_or_default() == "proto" {
348 proto_files.push(path);
349 } else if path.is_dir() {
350 self.collect_proto_files(&path, proto_files)?;
351 }
352 }
353 Ok(())
354 }
355}