testlint_sdk/runtime_coverage/
php.rs

1use crate::platform::{get_process_name, process_exists};
2use crate::runtime_coverage::{CoverageSummary, RuntimeCoverageResult};
3use quick_xml::events::Event;
4use quick_xml::Reader;
5use std::fs;
6use std::path::Path;
7use std::process::Command;
8use std::thread;
9use std::time::Duration;
10
11pub struct PhpRuntimeCoverage;
12
13impl Default for PhpRuntimeCoverage {
14    fn default() -> Self {
15        Self::new()
16    }
17}
18
19impl PhpRuntimeCoverage {
20    pub fn new() -> Self {
21        PhpRuntimeCoverage
22    }
23
24    /// Attach to a running PHP process and collect coverage
25    /// Note: PHP requires Xdebug/PCOV loaded at startup, so this uses graceful restart
26    pub fn attach_and_collect(&self, pid: u32) -> Result<RuntimeCoverageResult, String> {
27        println!(
28            "🐘 Attempting to collect coverage from PHP process PID: {}",
29            pid
30        );
31        println!();
32        println!("āš ļø  IMPORTANT: PHP coverage requires Xdebug or PCOV extension");
33        println!("   loaded at process startup. This tool will:");
34        println!("   1. Check if PHP-FPM/Apache is running");
35        println!("   2. Enable Xdebug in php.ini if not already");
36        println!("   3. Trigger graceful reload");
37        println!("   4. Monitor coverage output");
38        println!();
39
40        // Check if process exists
41        self.check_process_exists(pid)?;
42
43        // Detect PHP process type (FPM, Apache, CLI)
44        let process_type = self.detect_php_process_type(pid)?;
45        println!("šŸ“¦ Detected PHP process type: {}", process_type);
46
47        // Check if Xdebug is already loaded
48        if !self.check_xdebug_loaded(pid)? {
49            println!("āš ļø  Xdebug not detected in running process");
50            println!("   This typically requires process restart with Xdebug enabled");
51            println!();
52
53            // Provide instructions
54            return Err(self.get_xdebug_instructions(&process_type));
55        }
56
57        println!("āœ“ Xdebug appears to be loaded");
58        println!();
59
60        // For CLI scripts, we can't really attach mid-execution
61        if process_type == "cli" {
62            return Err("Cannot attach to PHP CLI scripts mid-execution.\n\
63                \n\
64                PHP CLI coverage must be enabled from the start:\n\
65                php -d xdebug.mode=coverage -d xdebug.start_with_request=yes script.php\n\
66                \n\
67                Or use the Coverage Orchestrator to wrap script execution."
68                .to_string());
69        }
70
71        // For FPM/Apache, monitor existing coverage
72        let start_time = std::time::Instant::now();
73
74        println!("ā³ Monitoring PHP coverage... Press Ctrl+C to collect");
75
76        // Wait for Ctrl+C
77        use std::sync::atomic::{AtomicBool, Ordering};
78        use std::sync::Arc;
79
80        let running = Arc::new(AtomicBool::new(true));
81        let r = running.clone();
82
83        ctrlc::set_handler(move || {
84            r.store(false, Ordering::SeqCst);
85        })
86        .expect("Error setting Ctrl-C handler");
87
88        while running.load(Ordering::SeqCst) {
89            thread::sleep(Duration::from_millis(100));
90        }
91
92        let duration_secs = start_time.elapsed().as_secs();
93
94        println!("\nšŸ“ Looking for PHP coverage files...");
95
96        // Find coverage file
97        let coverage_file = self.find_coverage_file()?;
98
99        // Parse the coverage file
100        let summary = self.parse_coverage_file(&coverage_file)?;
101
102        Ok(RuntimeCoverageResult {
103            language: "PHP".to_string(),
104            pid,
105            duration_secs,
106            coverage_file,
107            summary,
108        })
109    }
110
111    /// Check if the process exists
112    fn check_process_exists(&self, pid: u32) -> Result<(), String> {
113        if !process_exists(pid)? {
114            return Err(format!("Process {} not found", pid));
115        }
116        Ok(())
117    }
118
119    /// Detect type of PHP process (FPM, Apache mod_php, CLI)
120    fn detect_php_process_type(&self, pid: u32) -> Result<String, String> {
121        let process_name = get_process_name(pid)?.to_lowercase();
122
123        if process_name.contains("php-fpm") {
124            return Ok("fpm".to_string());
125        } else if process_name.contains("apache") || process_name.contains("httpd") {
126            return Ok("apache".to_string());
127        } else if process_name.contains("php") {
128            return Ok("cli".to_string());
129        }
130
131        Ok("unknown".to_string())
132    }
133
134    /// Check if Xdebug is loaded in the process
135    fn check_xdebug_loaded(&self, _pid: u32) -> Result<bool, String> {
136        // Try to check if Xdebug is loaded via phpinfo or php -m
137        let output = Command::new("php")
138            .args(["-m"])
139            .output()
140            .map_err(|_| "Failed to run php -m")?;
141
142        let modules = String::from_utf8_lossy(&output.stdout);
143
144        Ok(modules.contains("Xdebug") || modules.contains("PCOV"))
145    }
146
147    /// Get instructions for enabling Xdebug based on process type
148    fn get_xdebug_instructions(&self, process_type: &str) -> String {
149        let temp_coverage = std::env::temp_dir().join("php-coverage");
150        let temp_coverage_str = temp_coverage.display();
151
152        match process_type {
153            "fpm" => format!(
154                "PHP-FPM process does not have Xdebug loaded.\n\
155                \n\
156                To enable coverage:\n\
157                \n\
158                1. Edit php.ini (usually /etc/php/fpm/php.ini):\n\
159                   zend_extension=xdebug.so\n\
160                   xdebug.mode=coverage\n\
161                   xdebug.output_dir={}\n\
162                \n\
163                2. Restart PHP-FPM:\n\
164                   sudo systemctl restart php-fpm\n\
165                   # or\n\
166                   sudo service php8.1-fpm restart\n\
167                \n\
168                3. Re-run this tool to monitor coverage\n\
169                \n\
170                Note: Xdebug significantly impacts performance in production!",
171                temp_coverage_str
172            ),
173            "apache" => format!(
174                "Apache/mod_php process does not have Xdebug loaded.\n\
175                \n\
176                To enable coverage:\n\
177                \n\
178                1. Edit php.ini (usually /etc/php/apache2/php.ini):\n\
179                   zend_extension=xdebug.so\n\
180                   xdebug.mode=coverage\n\
181                   xdebug.output_dir={}\n\
182                \n\
183                2. Restart Apache:\n\
184                   sudo systemctl restart apache2\n\
185                   # or\n\
186                   sudo apachectl restart\n\
187                \n\
188                3. Re-run this tool to monitor coverage\n\
189                \n\
190                Warning: Xdebug slows down PHP significantly!",
191                temp_coverage_str
192            ),
193            _ => "PHP process does not have Xdebug loaded.\n\
194                \n\
195                For CLI scripts:\n\
196                   php -d zend_extension=xdebug.so -d xdebug.mode=coverage script.php\n\
197                \n\
198                For servers:\n\
199                   Install Xdebug: pecl install xdebug\n\
200                   Enable in php.ini and restart server\n\
201                \n\
202                Or use Coverage Orchestrator to wrap execution automatically."
203                .to_string(),
204        }
205    }
206
207    /// Find PHP coverage file (Clover XML or serialized format)
208    fn find_coverage_file(&self) -> Result<String, String> {
209        // Common locations for PHP coverage
210        let temp_coverage = std::env::temp_dir().join("php-coverage");
211        let temp_coverage_str = temp_coverage.to_string_lossy().to_string();
212        let possible_paths = vec![
213            temp_coverage_str.as_str(),
214            "./coverage",
215            "./build/logs",
216            ".",
217        ];
218
219        let possible_files = vec![
220            "clover.xml",
221            "coverage.xml",
222            "coverage.clover",
223            ".coverage.dat",
224        ];
225
226        for dir in &possible_paths {
227            for file in &possible_files {
228                let full_path = format!("{}/{}", dir, file);
229                if Path::new(&full_path).exists() {
230                    return Ok(full_path);
231                }
232            }
233        }
234
235        let temp_coverage = std::env::temp_dir().join("php-coverage");
236        Err(format!(
237            "PHP coverage file not found.\n\
238            \n\
239            Coverage files are typically generated by:\n\
240            - PHPUnit with --coverage-clover flag\n\
241            - Xdebug with xdebug.coverage_output_dir set\n\
242            - PCOV with pcov.directory configured\n\
243            \n\
244            Expected locations:\n\
245            - {}/\n\
246            - ./coverage/\n\
247            - ./build/logs/\n\
248            \n\
249            Tip: Use Coverage Orchestrator instead:\n\
250               ./target/release/run_with_coverage php vendor/bin/phpunit",
251            temp_coverage.display()
252        ))
253    }
254
255    /// Parse PHP coverage file (Clover XML format)
256    fn parse_coverage_file(&self, coverage_file: &str) -> Result<CoverageSummary, String> {
257        println!("šŸ“Š Parsing PHP coverage file...");
258
259        let content = fs::read_to_string(coverage_file)
260            .map_err(|e| format!("Failed to read coverage file: {}", e))?;
261
262        let mut reader = Reader::from_str(&content);
263        reader.config_mut().trim_text(true);
264
265        let mut buf = Vec::new();
266        let mut total_lines = 0;
267        let mut covered_lines = 0;
268        let mut total_statements = 0;
269        let mut covered_statements = 0;
270
271        loop {
272            match reader.read_event_into(&mut buf) {
273                Ok(Event::Eof) => break,
274
275                Ok(Event::Start(e)) | Ok(Event::Empty(e)) => {
276                    if e.name().as_ref() == b"metrics" {
277                        // Parse attributes from <metrics> element
278                        for attr in e.attributes().flatten() {
279                            let key = String::from_utf8_lossy(attr.key.as_ref());
280                            let value = String::from_utf8_lossy(&attr.value);
281
282                            match key.as_ref() {
283                                "statements" => {
284                                    total_statements = value.parse().unwrap_or(0);
285                                }
286                                "coveredstatements" => {
287                                    covered_statements = value.parse().unwrap_or(0);
288                                }
289                                "elements" => {
290                                    let elements: usize = value.parse().unwrap_or(0);
291                                    if elements > total_lines {
292                                        total_lines = elements;
293                                    }
294                                }
295                                "coveredelements" => {
296                                    let covered: usize = value.parse().unwrap_or(0);
297                                    if covered > covered_lines {
298                                        covered_lines = covered;
299                                    }
300                                }
301                                _ => {}
302                            }
303                        }
304                    }
305                }
306
307                Ok(_) => {}
308
309                Err(e) => {
310                    return Err(format!(
311                        "Error parsing XML at position {}: {:?}",
312                        reader.buffer_position(),
313                        e
314                    ));
315                }
316            }
317
318            buf.clear();
319        }
320
321        // Use statements if available, otherwise lines
322        if total_statements > 0 {
323            total_lines = total_statements;
324            covered_lines = covered_statements;
325        }
326
327        if total_lines == 0 {
328            return Err("No coverage data found in file".to_string());
329        }
330
331        let coverage_percentage = (covered_lines as f64 / total_lines as f64) * 100.0;
332
333        Ok(CoverageSummary {
334            total_lines,
335            covered_lines,
336            coverage_percentage,
337            total_branches: None,
338            covered_branches: None,
339            branch_percentage: None,
340        })
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347
348    #[test]
349    fn test_runtime_coverage_new() {
350        let coverage = PhpRuntimeCoverage::new();
351        assert_eq!(std::mem::size_of_val(&coverage), 0);
352    }
353
354    #[test]
355    fn test_runtime_coverage_default() {
356        let coverage = PhpRuntimeCoverage;
357        assert_eq!(std::mem::size_of_val(&coverage), 0);
358    }
359}