Skip to main content

actr_cli/commands/
generate.rs

1//! # Code Generation Command
2//!
3//! Shared CLI entry point for `actr gen`. Language-specific logic lives in
4//! `src/commands/codegen/{rust,swift,typescript,...}.rs`.
5
6use 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    /// Input proto file or directory
30    #[arg(short, long, default_value = "protos")]
31    pub input: PathBuf,
32
33    /// Output directory for generated code (use -o to override language defaults)
34    #[arg(short, long)]
35    pub output: Option<PathBuf>,
36
37    /// Path to manifest.toml
38    #[arg(short, long, default_value = "manifest.toml")]
39    pub config: PathBuf,
40
41    /// Clean generated outputs before regenerating
42    #[arg(long = "clean")]
43    pub clean: bool,
44
45    /// Skip user code scaffold generation
46    #[arg(long = "no-scaffold")]
47    pub no_scaffold: bool,
48    /// Whether to overwrite existing user code files
49    #[arg(long)]
50    pub overwrite_user_code: bool,
51
52    /// Skip formatting
53    #[arg(long = "no-format")]
54    pub no_format: bool,
55
56    /// Debug mode: keep intermediate generated files
57    #[arg(long)]
58    pub debug: bool,
59
60    /// Skip code validation after generation
61    #[arg(long)]
62    pub skip_validation: bool,
63
64    /// Target language for generation
65    #[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}