testlint_sdk/test_orchestrator/
mod.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3use std::process::{Command, Stdio};
4use std::time::{Duration, SystemTime};
5
6use crate::common::Language;
7
8// Module declarations
9mod cpp;
10mod csharp;
11mod go;
12mod java;
13mod javascript;
14mod php;
15mod python;
16mod ruby;
17mod rust_lang;
18mod utils;
19
20// Re-exports
21
22#[derive(Debug, Clone)]
23pub struct TestConfig {
24    pub language: Language,
25    pub source_paths: Vec<String>,
26    pub output_dir: PathBuf,
27    pub output_format: CoverageFormat,
28    pub include_patterns: Vec<String>,
29    pub exclude_patterns: Vec<String>,
30    pub branch_coverage: bool,
31    pub tool_version: Option<String>, // e.g., "7.4.0" for coverage.py
32    pub command_timeout_secs: Option<u64>, // Timeout for external commands (default: 300s / 5min)
33    pub retry_attempts: u32,          // Number of retry attempts for operations (default: 3)
34}
35
36#[derive(Debug, Clone)]
37pub enum CoverageFormat {
38    Json,
39    Xml,
40    Lcov,
41    Html,
42}
43
44impl Default for TestConfig {
45    fn default() -> Self {
46        Self {
47            language: Language::Python,
48            source_paths: vec![],
49            output_dir: PathBuf::from("coverage"),
50            output_format: CoverageFormat::Json,
51            include_patterns: vec![],
52            exclude_patterns: vec![
53                // Language-agnostic test directories
54                "*/tests/*".to_string(),
55                "*/test/*".to_string(),
56                "*/__tests__/*".to_string(),
57                // Language-agnostic IDE/tool directories
58                "*/.git/*".to_string(),
59                "*/.idea/*".to_string(),
60                "*/.vscode/*".to_string(),
61            ],
62            branch_coverage: true,
63            tool_version: None,              // Use latest version
64            command_timeout_secs: Some(300), // 5 minutes default
65            retry_attempts: 3,               // 3 attempts default
66        }
67    }
68}
69
70pub struct TestOrchestrator {
71    pub(crate) config: TestConfig,
72}
73
74impl TestOrchestrator {
75    pub fn new(config: TestConfig) -> Self {
76        Self { config }
77    }
78
79    /// Run a command with coverage collection
80    pub fn run_with_coverage(
81        &self,
82        command: &str,
83        args: &[&str],
84    ) -> Result<CoverageResult, String> {
85        // Create output directory
86        fs::create_dir_all(&self.config.output_dir)
87            .map_err(|e| format!("Failed to create output directory: {}", e))?;
88
89        let start_time = SystemTime::now();
90
91        let result = match self.config.language {
92            Language::Python => self.run_python_coverage(command, args)?,
93            Language::Java => self.run_java_coverage(command, args)?,
94            Language::JavaScript | Language::TypeScript => {
95                self.run_javascript_coverage(command, args)?
96            }
97            Language::Go => self.run_go_coverage(command, args)?,
98            Language::Rust => self.run_rust_coverage(command, args)?,
99            Language::CSharp => self.run_csharp_coverage(command, args)?,
100            Language::Ruby => self.run_ruby_coverage(command, args)?,
101            Language::Php => self.run_php_coverage(command, args)?,
102            Language::Cpp => self.run_cpp_coverage(command, args)?,
103        };
104
105        let duration = start_time
106            .elapsed()
107            .unwrap_or(Duration::from_secs(0))
108            .as_secs();
109
110        Ok(CoverageResult {
111            language: self.config.language.clone(),
112            coverage_file: result,
113            duration_secs: duration,
114            timestamp: chrono::Utc::now().to_rfc3339(),
115        })
116    }
117
118    /// Display coverage summary
119    pub fn show_summary(&self) -> Result<(), String> {
120        match self.config.language {
121            Language::Python => {
122                println!("\nšŸ“ˆ Coverage Summary:");
123                let output = Command::new("coverage")
124                    .arg("report")
125                    .output()
126                    .map_err(|e| format!("Failed to show coverage report: {}", e))?;
127
128                if output.status.success() {
129                    println!("{}", String::from_utf8_lossy(&output.stdout));
130                }
131            }
132            Language::JavaScript | Language::TypeScript => {
133                println!("\nšŸ“ˆ Coverage Summary:");
134                let output = Command::new("nyc")
135                    .arg("report")
136                    .arg("--reporter=text")
137                    .output()
138                    .map_err(|e| format!("Failed to show coverage report: {}", e))?;
139
140                if output.status.success() {
141                    println!("{}", String::from_utf8_lossy(&output.stdout));
142                } else {
143                    // NYC might not have cached data, read from report directory
144                    println!(
145                        "Coverage data generated in: {}",
146                        self.config.output_dir.display()
147                    );
148                }
149            }
150            Language::Java => {
151                println!("\nšŸ“ˆ Coverage Summary:");
152
153                let cli_path = dirs::home_dir()
154                    .map(|h| h.join(".jacoco/jacococli.jar"))
155                    .ok_or_else(|| "Could not determine JaCoCo CLI path".to_string())?;
156
157                let exec_file = self.config.output_dir.join("jacoco.exec");
158
159                if !exec_file.exists() {
160                    println!("JaCoCo execution data not found: {}", exec_file.display());
161                    return Ok(());
162                }
163
164                let mut cmd = Command::new("java");
165                cmd.arg("-jar").arg(&cli_path).arg("report");
166                cmd.arg(&exec_file);
167
168                // Try to find class files automatically in common locations
169                let class_paths = vec![
170                    PathBuf::from("target/classes"),
171                    PathBuf::from("build/classes"),
172                    PathBuf::from("classes"),
173                    PathBuf::from("target"),
174                    PathBuf::from("build"),
175                    PathBuf::from("out"),
176                    PathBuf::from("bin"),
177                    PathBuf::from("examples"),
178                ];
179
180                for class_path in class_paths {
181                    if class_path.exists() && class_path.is_dir() {
182                        cmd.arg("--classfiles").arg(&class_path);
183                    }
184                }
185
186                let output = cmd
187                    .output()
188                    .map_err(|e| format!("Failed to show coverage report: {}", e))?;
189
190                if output.status.success() {
191                    println!("{}", String::from_utf8_lossy(&output.stdout));
192                } else {
193                    println!(
194                        "Coverage data generated in: {}",
195                        self.config.output_dir.display()
196                    );
197                    println!(
198                        "View HTML report: open {}/jacoco-html/index.html",
199                        self.config.output_dir.display()
200                    );
201                }
202            }
203            Language::Go => {
204                println!("\nšŸ“ˆ Coverage Summary:");
205
206                let coverage_file = self.config.output_dir.join("coverage.out");
207
208                if !coverage_file.exists() {
209                    println!("Go coverage data not found: {}", coverage_file.display());
210                    return Ok(());
211                }
212
213                // Use go tool cover to display summary
214                let output = Command::new("go")
215                    .arg("tool")
216                    .arg("cover")
217                    .arg(format!("-func={}", coverage_file.display()))
218                    .output()
219                    .map_err(|e| format!("Failed to show coverage report: {}", e))?;
220
221                if output.status.success() {
222                    println!("{}", String::from_utf8_lossy(&output.stdout));
223                } else {
224                    println!(
225                        "Coverage data generated in: {}",
226                        self.config.output_dir.display()
227                    );
228                    let html_file = self.config.output_dir.join("coverage.html");
229                    if html_file.exists() {
230                        println!("View HTML report: open {}", html_file.display());
231                    }
232                }
233            }
234            Language::Rust => {
235                println!("\nšŸ“ˆ Coverage Summary:");
236
237                // cargo-llvm-cov generates comprehensive output during execution
238                // The summary is already displayed during the run
239                println!(
240                    "Coverage report generated in: {}",
241                    self.config.output_dir.display()
242                );
243
244                // Show HTML path if applicable
245                let html_dir = self.config.output_dir.join("html");
246                if html_dir.exists() {
247                    println!("View HTML report: open {}/index.html", html_dir.display());
248                }
249
250                // Show LCOV path if applicable
251                let lcov_file = self.config.output_dir.join("lcov.info");
252                if lcov_file.exists() {
253                    println!("LCOV file: {}", lcov_file.display());
254                }
255            }
256            Language::CSharp => {
257                println!("\nšŸ“ˆ Coverage Summary:");
258
259                // Check if ReportGenerator is available for summary
260                let cobertura_file = self.config.output_dir.join("cobertura.xml");
261                if cobertura_file.exists() {
262                    println!("Coverage report generated: {}", cobertura_file.display());
263                }
264
265                // Show HTML path if applicable
266                let html_dir = self.config.output_dir.join("html");
267                if html_dir.exists() {
268                    let index_file = html_dir.join("index.html");
269                    if index_file.exists() {
270                        println!("View HTML report: open {}", index_file.display());
271                    }
272                }
273
274                // Show LCOV path if applicable
275                let lcov_file = self.config.output_dir.join("lcov.info");
276                if lcov_file.exists() {
277                    println!("LCOV file: {}", lcov_file.display());
278                }
279
280                // Show JSON path if applicable
281                let json_file = self.config.output_dir.join("coverage.json");
282                if json_file.exists() {
283                    println!("JSON file: {}", json_file.display());
284                }
285            }
286            Language::Ruby => {
287                println!("\nšŸ“ˆ Coverage Summary:");
288
289                // SimpleCov generates reports in coverage directory
290                println!(
291                    "Coverage report generated in: {}",
292                    self.config.output_dir.display()
293                );
294
295                // Show HTML path if applicable
296                let html_dir = self.config.output_dir.join("html");
297                if html_dir.exists() {
298                    let index_file = html_dir.join("index.html");
299                    if index_file.exists() {
300                        println!("View HTML report: open {}", index_file.display());
301                    }
302                }
303
304                // Show LCOV path if applicable
305                let lcov_file = self.config.output_dir.join("lcov.info");
306                if lcov_file.exists() {
307                    println!("LCOV file: {}", lcov_file.display());
308                }
309
310                // Show JSON path if applicable
311                let json_file = self.config.output_dir.join("coverage.json");
312                if json_file.exists() {
313                    println!("JSON file: {}", json_file.display());
314                }
315
316                // Show XML path if applicable
317                let xml_file = self.config.output_dir.join("cobertura.xml");
318                if xml_file.exists() {
319                    println!("XML file: {}", xml_file.display());
320                }
321            }
322            Language::Php => {
323                println!("\nšŸ“ˆ Coverage Summary:");
324
325                // PHPUnit generates reports in specified directories
326                println!(
327                    "Coverage report generated in: {}",
328                    self.config.output_dir.display()
329                );
330
331                // Show HTML path if applicable
332                let html_dir = self.config.output_dir.join("html");
333                if html_dir.exists() {
334                    let index_file = html_dir.join("index.html");
335                    if index_file.exists() {
336                        println!("View HTML report: open {}", index_file.display());
337                    }
338                }
339
340                // Show XML (Clover) path if applicable
341                let xml_file = self.config.output_dir.join("coverage.xml");
342                if xml_file.exists() {
343                    println!("Clover XML file: {}", xml_file.display());
344                }
345
346                // Show LCOV path if applicable
347                let lcov_file = self.config.output_dir.join("lcov.info");
348                if lcov_file.exists() {
349                    println!("LCOV file: {}", lcov_file.display());
350                }
351
352                // Show JSON path if applicable
353                let json_file = self.config.output_dir.join("coverage.json");
354                if json_file.exists() {
355                    println!("JSON file: {}", json_file.display());
356                }
357            }
358            Language::Cpp => {
359                println!("\nšŸ“ˆ Coverage Summary:");
360
361                // C++ coverage can use gcov/lcov or llvm-cov
362                println!(
363                    "Coverage report generated in: {}",
364                    self.config.output_dir.display()
365                );
366
367                // Show HTML path if applicable
368                let html_dir = self.config.output_dir.join("html");
369                if html_dir.exists() {
370                    let index_file = html_dir.join("index.html");
371                    if index_file.exists() {
372                        println!("View HTML report: open {}", index_file.display());
373                    }
374                }
375
376                // Show LCOV path if applicable
377                let lcov_file = self.config.output_dir.join("lcov.info");
378                if lcov_file.exists() {
379                    println!("LCOV file: {}", lcov_file.display());
380                }
381
382                // Show XML path if applicable
383                let xml_file = self.config.output_dir.join("coverage.xml");
384                if xml_file.exists() {
385                    println!("XML file: {}", xml_file.display());
386                }
387
388                // Show JSON path if applicable
389                let json_file = self.config.output_dir.join("coverage.json");
390                if json_file.exists() {
391                    println!("JSON file: {}", json_file.display());
392                }
393            }
394        }
395
396        Ok(())
397    }
398}
399
400#[derive(Debug)]
401pub struct CoverageResult {
402    pub language: Language,
403    pub coverage_file: PathBuf,
404    pub duration_secs: u64,
405    pub timestamp: String,
406}
407
408// Helper functions for improved error handling
409use std::process::Output;
410
411/// Format a command error with stderr output for better debugging
412pub(crate) fn format_command_error(cmd_name: &str, output: &Output) -> String {
413    let stderr = String::from_utf8_lossy(&output.stderr);
414    let exit_code = output.status.code().unwrap_or(-1);
415
416    if stderr.trim().is_empty() {
417        format!("{} command failed with exit code {}", cmd_name, exit_code)
418    } else {
419        format!(
420            "{} command failed with exit code {}:\n{}",
421            cmd_name,
422            exit_code,
423            stderr.trim()
424        )
425    }
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431
432    #[test]
433    fn test_default_config() {
434        let config = TestConfig::default();
435        assert!(matches!(config.language, Language::Python));
436        assert_eq!(config.output_dir, PathBuf::from("coverage"));
437        assert!(config.branch_coverage);
438    }
439
440    #[test]
441    fn test_orchestrator_creation() {
442        let config = TestConfig::default();
443        let _orchestrator = TestOrchestrator::new(config);
444    }
445}