testlint_sdk/runtime_coverage/
php.rs1use 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 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 self.check_process_exists(pid)?;
42
43 let process_type = self.detect_php_process_type(pid)?;
45 println!("š¦ Detected PHP process type: {}", process_type);
46
47 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 return Err(self.get_xdebug_instructions(&process_type));
55 }
56
57 println!("ā Xdebug appears to be loaded");
58 println!();
59
60 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 let start_time = std::time::Instant::now();
73
74 println!("ā³ Monitoring PHP coverage... Press Ctrl+C to collect");
75
76 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 let coverage_file = self.find_coverage_file()?;
98
99 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 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 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 fn check_xdebug_loaded(&self, _pid: u32) -> Result<bool, String> {
136 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 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 fn find_coverage_file(&self) -> Result<String, String> {
209 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 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 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 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}