Skip to main content

cyrce_forge_langs/
python.rs

1// =============================================================================
2// 🔥 FORGE — Módulos de Lenguaje: Python
3// =============================================================================
4// Gestión de proyectos Python: entornos virtuales, dependencias, ejecución.
5// =============================================================================
6
7use std::path::Path;
8use std::process::Stdio;
9
10use colored::Colorize;
11
12use cyrce_forge_core::config::ForgeConfig;
13use cyrce_forge_core::error::{ForgeError, ForgeResult};
14
15/// Módulo de gestión Python.
16pub struct PythonModule;
17
18impl PythonModule {
19    /// Prepara el entorno Python (crea venv si no existe, instala deps).
20    pub async fn setup(config: &ForgeConfig, project_dir: &Path) -> ForgeResult<()> {
21        let venv_dir = project_dir.join(".forge").join("venv");
22
23        // Crear entorno virtual si no existe
24        if !venv_dir.exists() {
25            println!("   {}", "🐍 Creando entorno virtual Python...".cyan());
26
27            let python_cmd = Self::find_python().await?;
28
29            let output = tokio::process::Command::new(&python_cmd)
30                .args(["-m", "venv"])
31                .arg(&venv_dir)
32                .current_dir(project_dir)
33                .stdout(Stdio::piped())
34                .stderr(Stdio::piped())
35                .output()
36                .await
37                .map_err(|e| ForgeError::CommandNotFound {
38                    command: format!("{}: {}", python_cmd, e),
39                })?;
40
41            if !output.status.success() {
42                let stderr = String::from_utf8_lossy(&output.stderr);
43                return Err(ForgeError::TaskFailed {
44                    task_name: format!("python venv: {}", stderr),
45                    exit_code: output.status.code().unwrap_or(-1),
46                }
47                .into());
48            }
49
50            println!("   {}", "✅ Entorno virtual creado".green());
51        }
52
53        // Instalar dependencias si hay alguna
54        if !config.dependencies.is_empty() {
55            Self::install_deps(config, project_dir).await?;
56        }
57
58        Ok(())
59    }
60
61    /// Instala dependencias Python con pip.
62    async fn install_deps(config: &ForgeConfig, project_dir: &Path) -> ForgeResult<()> {
63        let pip = Self::pip_path(project_dir);
64
65        println!(
66            "   {}",
67            format!(
68                "📦 Instalando {} dependencias Python...",
69                config.dependencies.len()
70            )
71            .cyan()
72        );
73
74        // Construir lista de dependencias con versiones
75        let deps: Vec<String> = config
76            .dependencies
77            .iter()
78            .map(|(name, version)| {
79                if version == "*" || version.is_empty() {
80                    name.clone()
81                } else {
82                    format!("{}=={}", name, version)
83                }
84            })
85            .collect();
86
87        let mut cmd = tokio::process::Command::new(&pip);
88        cmd.arg("install").args(&deps);
89        cmd.current_dir(project_dir)
90            .stdout(Stdio::piped())
91            .stderr(Stdio::piped());
92
93        let output = cmd.output().await.map_err(|e| ForgeError::CommandNotFound {
94            command: format!("pip: {}", e),
95        })?;
96
97        if !output.status.success() {
98            let stderr = String::from_utf8_lossy(&output.stderr);
99            return Err(ForgeError::TaskFailed {
100                task_name: format!("pip install: {}", stderr),
101                exit_code: output.status.code().unwrap_or(-1),
102            }
103            .into());
104        }
105
106        println!(
107            "   {}",
108            format!("✅ {} dependencias instaladas", deps.len()).green()
109        );
110
111        Ok(())
112    }
113
114    /// "Compila" un proyecto Python (verifica sintaxis).
115    pub async fn compile(config: &ForgeConfig, project_dir: &Path) -> ForgeResult<()> {
116        let python_config = config.python.as_ref();
117        let source_dir = project_dir.join(
118            python_config
119                .map(|p| p.source.as_str())
120                .unwrap_or("src"),
121        );
122
123        if !source_dir.exists() {
124            return Err(ForgeError::IoError {
125                path: source_dir,
126                message: "Directorio fuente no existe. ¿Olvidaste crear tus archivos .py?"
127                    .to_string(),
128            }
129            .into());
130        }
131
132        println!("   {}", "🐍 Verificando sintaxis Python...".cyan());
133
134        let python = Self::python_path(project_dir);
135
136        let output = tokio::process::Command::new(&python)
137            .args(["-m", "py_compile"])
138            .arg(
139                config
140                    .main_entry()
141                    .map(|s| source_dir.join(s).to_string_lossy().to_string())
142                    .unwrap_or_else(|| source_dir.to_string_lossy().to_string()),
143            )
144            .current_dir(project_dir)
145            .stdout(Stdio::piped())
146            .stderr(Stdio::piped())
147            .output()
148            .await;
149
150        match output {
151            Ok(out) if out.status.success() => {
152                println!("   {}", "✅ Sintaxis Python válida".green());
153            }
154            Ok(out) => {
155                let stderr = String::from_utf8_lossy(&out.stderr);
156                println!("   {}", format!("⚠️  Advertencias: {}", stderr).yellow());
157            }
158            Err(_) => {
159                println!(
160                    "   {}",
161                    "⚠️  No se pudo verificar la sintaxis (Python no encontrado en venv)".yellow()
162                );
163            }
164        }
165
166        Ok(())
167    }
168
169    /// Ejecuta el proyecto Python.
170    pub async fn run(config: &ForgeConfig, project_dir: &Path) -> ForgeResult<()> {
171        let main_script = config
172            .main_entry()
173            .ok_or_else(|| ForgeError::ConfigMissingField {
174                field: "python.main-script".to_string(),
175            })?;
176
177        let python_config = config.python.as_ref();
178        let source_dir = project_dir.join(
179            python_config
180                .map(|p| p.source.as_str())
181                .unwrap_or("src"),
182        );
183
184        let script_path = source_dir.join(&main_script);
185
186        if !script_path.exists() {
187            return Err(ForgeError::IoError {
188                path: script_path,
189                message: format!(
190                    "Script principal '{}' no encontrado",
191                    main_script
192                ),
193            }
194            .into());
195        }
196
197        // Preparar entorno si es necesario
198        Self::setup(config, project_dir).await?;
199
200        let python = Self::python_path(project_dir);
201
202        println!(
203            "   {}",
204            format!("🚀 Ejecutando {}...", main_script).cyan()
205        );
206        println!();
207
208        let mut cmd = tokio::process::Command::new(&python);
209        cmd.arg(&script_path)
210            .current_dir(project_dir)
211            .stdout(Stdio::inherit())
212            .stderr(Stdio::inherit());
213
214        let status = cmd.status().await.map_err(|e| ForgeError::CommandNotFound {
215            command: format!("python: {}", e),
216        })?;
217
218        if !status.success() {
219            return Err(ForgeError::TaskFailed {
220                task_name: "python".to_string(),
221                exit_code: status.code().unwrap_or(-1),
222            }
223            .into());
224        }
225
226        Ok(())
227    }
228
229    /// Ejecuta tests Python.
230    pub async fn test(config: &ForgeConfig, project_dir: &Path) -> ForgeResult<()> {
231        Self::setup(config, project_dir).await?;
232
233        let python = Self::python_path(project_dir);
234
235        println!("   {}", "🧪 Ejecutando tests Python...".cyan());
236
237        let mut cmd = tokio::process::Command::new(&python);
238        cmd.args(["-m", "pytest", "tests/"]) // Opcional pero recomendada
239            .current_dir(project_dir)
240            .stdout(Stdio::inherit())
241            .stderr(Stdio::inherit());
242
243        let status = cmd.status().await;
244
245        match status {
246            Ok(s) if s.success() => {
247                println!("   {}", "✅ Todos los tests pasaron exitosamente!".green());
248            }
249            Ok(s) => {
250                // Intentar con unittest si pytest no está instalado
251                println!(
252                    "   {}",
253                    "⚠️  pytest no disponible, intentando con unittest...".yellow()
254                );
255                let mut cmd2 = tokio::process::Command::new(&python);
256                cmd2.args(["-m", "unittest", "discover", "-v"])
257                    .current_dir(project_dir)
258                    .stdout(Stdio::inherit())
259                    .stderr(Stdio::inherit());
260
261                let status2 = cmd2.status().await.map_err(|e| ForgeError::CommandNotFound {
262                    command: format!("python unittest: {}", e),
263                })?;
264
265                if !status2.success() {
266                    return Err(ForgeError::TaskFailed {
267                        task_name: "python test".to_string(),
268                        exit_code: s.code().unwrap_or(-1),
269                    }
270                    .into());
271                }
272            }
273            Err(e) => {
274                return Err(ForgeError::CommandNotFound {
275                    command: format!("python: {}", e),
276                }
277                .into());
278            }
279        }
280
281        Ok(())
282    }
283
284    /// Encuentra el ejecutable de Python en el sistema.
285    async fn find_python() -> ForgeResult<String> {
286        // Intentar python3 primero, luego python
287        for cmd in &["python3", "python", "py"] {
288            let result = tokio::process::Command::new(cmd)
289                .arg("--version")
290                .stdout(Stdio::piped())
291                .stderr(Stdio::piped())
292                .output()
293                .await;
294
295            if let Ok(output) = result {
296                if output.status.success() {
297                    return Ok(cmd.to_string());
298                }
299            }
300        }
301
302        Err(ForgeError::CommandNotFound {
303            command: "python/python3".to_string(),
304        }
305        .into())
306    }
307
308    /// Ruta al Python del entorno virtual.
309    fn python_path(project_dir: &Path) -> String {
310        let venv = project_dir.join(".forge").join("venv");
311        if cfg!(target_os = "windows") {
312            venv.join("Scripts").join("python.exe")
313        } else {
314            venv.join("bin").join("python")
315        }
316        .to_string_lossy()
317        .to_string()
318    }
319
320    /// Ruta al pip del entorno virtual.
321    fn pip_path(project_dir: &Path) -> String {
322        let venv = project_dir.join(".forge").join("venv");
323        if cfg!(target_os = "windows") {
324            venv.join("Scripts").join("pip.exe")
325        } else {
326            venv.join("bin").join("pip")
327        }
328        .to_string_lossy()
329        .to_string()
330    }
331}