testlint_sdk/runtime_coverage/
ruby.rs

1use crate::platform::{process_exists, signal_process, ProcessSignal};
2use crate::runtime_coverage::{CoverageSummary, RuntimeCoverageResult};
3use std::fs;
4use std::path::Path;
5use std::process::Command;
6use std::thread;
7use std::time::Duration;
8
9pub struct RubyRuntimeCoverage;
10
11impl Default for RubyRuntimeCoverage {
12    fn default() -> Self {
13        Self::new()
14    }
15}
16
17impl RubyRuntimeCoverage {
18    pub fn new() -> Self {
19        RubyRuntimeCoverage
20    }
21
22    /// Attach to a running Ruby process and collect coverage continuously
23    pub fn attach_and_collect(&self, pid: u32) -> Result<RuntimeCoverageResult, String> {
24        println!("šŸ’Ž Attaching coverage to Ruby process PID: {}", pid);
25        println!("šŸ“Š Collecting coverage continuously (press Ctrl+C to stop)...");
26
27        // Check if process exists
28        self.check_process_exists(pid)?;
29
30        // Create a Ruby script that will inject SimpleCov into the target process
31        let inject_script = self.create_injection_script(pid)?;
32
33        // Run the injection script
34        println!("šŸ’‰ Injecting SimpleCov into process...");
35        let output = Command::new("ruby")
36            .arg(&inject_script)
37            .output()
38            .map_err(|e| format!("Failed to run injection script: {}", e))?;
39
40        if !output.status.success() {
41            let stderr = String::from_utf8_lossy(&output.stderr);
42            return Err(format!(
43                "Coverage injection failed: {}\n{}",
44                stderr, "Install debase for Ruby injection: gem install debase"
45            ));
46        }
47
48        println!("āœ“ Coverage injected successfully");
49        println!("ā³ Collecting coverage... Press Ctrl+C to stop");
50
51        // Wait for Ctrl+C
52        use std::sync::atomic::{AtomicBool, Ordering};
53        use std::sync::Arc;
54
55        let running = Arc::new(AtomicBool::new(true));
56        let r = running.clone();
57
58        ctrlc::set_handler(move || {
59            r.store(false, Ordering::SeqCst);
60        })
61        .expect("Error setting Ctrl-C handler");
62
63        let start_time = std::time::Instant::now();
64
65        while running.load(Ordering::SeqCst) {
66            thread::sleep(Duration::from_millis(100));
67        }
68
69        let duration_secs = start_time.elapsed().as_secs();
70
71        // Signal the process to save coverage (send SIGUSR1 on Unix, file trigger on Windows)
72        println!("\nšŸ“ Signaling process to save coverage data...");
73
74        #[cfg(unix)]
75        {
76            signal_process(pid, ProcessSignal::User1)?;
77        }
78
79        #[cfg(windows)]
80        {
81            // On Windows, create a trigger file that the injected code watches for
82            let trigger_file = std::env::temp_dir().join(format!("coverage_trigger_{}.txt", pid));
83            fs::write(&trigger_file, "save")?;
84            println!(
85                "ā„¹ļø  Created trigger file (Windows alternative to SIGUSR1): {}",
86                trigger_file.display()
87            );
88        }
89
90        // Wait a bit for coverage to be saved
91        thread::sleep(Duration::from_secs(2));
92
93        // Find and parse the coverage file
94        let coverage_file = self.find_coverage_file(pid)?;
95        let summary = self.parse_coverage_summary(&coverage_file)?;
96
97        // Clean up injection script
98        let _ = fs::remove_file(&inject_script);
99
100        Ok(RuntimeCoverageResult {
101            language: "Ruby".to_string(),
102            pid,
103            duration_secs,
104            coverage_file,
105            summary,
106        })
107    }
108
109    /// Check if the process exists
110    fn check_process_exists(&self, pid: u32) -> Result<(), String> {
111        if !process_exists(pid)? {
112            return Err(format!("Process {} not found", pid));
113        }
114        Ok(())
115    }
116
117    /// Create a Ruby script that injects SimpleCov into the target process
118    fn create_injection_script(&self, pid: u32) -> Result<String, String> {
119        let temp_dir = std::env::temp_dir();
120        let script_path = temp_dir.join(format!("coverage_inject_{}.rb", pid));
121        let script_path_str = script_path.to_string_lossy().to_string();
122
123        let script_content = format!(
124            r#"#!/usr/bin/env ruby
125# Runtime coverage injection script for Ruby
126# Injects SimpleCov into a running Ruby process
127
128require 'socket'
129
130PID = {}
131
132# Ruby code to inject into the target process
133COVERAGE_CODE = <<~RUBY
134  require 'simplecov'
135  require 'json'
136
137  SimpleCov.start do
138    coverage_dir 'coverage'
139    command_name "runtime-pid-#{{Process.pid}}"
140  end
141
142  # Enable Ruby's built-in Coverage if SimpleCov isn't starting it
143  Coverage.start unless Coverage.running?
144
145  puts "[Coverage] SimpleCov started for PID #{{Process.pid}}"
146
147  # Save coverage on SIGUSR1
148  Signal.trap('USR1') do
149    puts "[Coverage] Saving coverage data..."
150    result = Coverage.result
151
152    # Save to JSON format
153    coverage_file = ".coverage.#{{Process.pid}}.json"
154    File.write(coverage_file, JSON.pretty_generate(result))
155    puts "[Coverage] Coverage saved to #{{coverage_file}}"
156
157    # Try to use SimpleCov formatter too
158    begin
159      SimpleCov.result.format!
160      puts "[Coverage] SimpleCov HTML report generated"
161    rescue => e
162      puts "[Coverage] SimpleCov format error: #{{e.message}}"
163    end
164  end
165
166  # Also save on exit
167  at_exit do
168    result = Coverage.result
169    coverage_file = ".coverage.#{{Process.pid}}.json"
170    File.write(coverage_file, JSON.pretty_generate(result))
171  end
172RUBY
173
174def inject_via_debugger
175  begin
176    require 'debase'
177
178    puts "Injecting coverage into PID #{{PID}} using debase..."
179
180    # Create temp file with coverage code
181    require 'tmpdir'
182    File.write(File.join(Dir.tmpdir, "cov_inject_#{{PID}}.rb"), COVERAGE_CODE)
183
184    # This is a simplified approach - real implementation would use
185    # Ruby debugger protocol to inject code into running process
186    puts "Note: Ruby runtime injection requires manual setup."
187    puts "For production use, consider:"
188    puts "  1. Start app with SimpleCov from beginning"
189    puts "  2. Use pry-remote or debugging gems"
190    puts "  3. Use file-based coverage triggers"
191
192    # Write signal script
193    signal_script = <<~SCRIPT
194      Process.kill('USR1', #{{PID}})
195    SCRIPT
196
197    File.write(File.join(Dir.tmpdir, "signal_#{{PID}}.rb"), signal_script)
198
199    return true
200  rescue LoadError
201    puts "debase not installed"
202    return false
203  end
204end
205
206def inject_via_gdb
207  # For MRI Ruby, we can try using gdb similar to Python
208  puts "Attempting GDB injection for Ruby process " + PID.to_s + "..."
209
210  # Simplified: Just notify user that gdb injection is complex
211  puts "Note: GDB injection for Ruby requires manual steps."
212  puts "For production use, start your Ruby app with SimpleCov enabled."
213
214  return false
215end
216
217# Try debugger approach first, fall back to gdb
218unless inject_via_debugger
219  unless inject_via_gdb
220    puts "ERROR: Failed to inject coverage."
221    puts "Install debase: gem install debase "
222    puts "or start your Ruby app with SimpleCov from the beginning"
223    exit 1
224  end
225end
226
227puts "Coverage collection running continuously..."
228puts "Process will save coverage when receiving SIGUSR1 signal "
229"#,
230            pid
231        );
232
233        fs::write(&script_path, script_content)
234            .map_err(|e| format!("Failed to create injection script: {}", e))?;
235
236        // Make executable
237        #[cfg(unix)]
238        {
239            Command::new("chmod")
240                .args(["+x", &script_path_str])
241                .output()
242                .map_err(|e| format!("Failed to make script executable: {}", e))?;
243        }
244
245        Ok(script_path_str)
246    }
247
248    /// Find the coverage file generated by the process
249    fn find_coverage_file(&self, pid: u32) -> Result<String, String> {
250        let coverage_file = format!(".coverage.{}.json", pid);
251
252        if Path::new(&coverage_file).exists() {
253            return Ok(coverage_file);
254        }
255
256        // Try SimpleCov's default location
257        let simplecov_file = "coverage/.resultset.json".to_string();
258        if Path::new(&simplecov_file).exists() {
259            return Ok(simplecov_file);
260        }
261
262        Err(format!(
263            "Coverage file not found. Expected .coverage.{}.json or coverage/.resultset.json",
264            pid
265        ))
266    }
267
268    /// Parse coverage summary from coverage file
269    fn parse_coverage_summary(&self, coverage_file: &str) -> Result<CoverageSummary, String> {
270        println!("šŸ“Š Parsing coverage report...");
271
272        let content = fs::read_to_string(coverage_file)
273            .map_err(|e| format!("Failed to read coverage file: {}", e))?;
274
275        let data: serde_json::Value =
276            serde_json::from_str(&content).map_err(|e| format!("Failed to parse JSON: {}", e))?;
277
278        let mut total_lines = 0;
279        let mut covered_lines = 0;
280
281        // Parse Ruby Coverage format: {"file.rb": [1, 0, 2, null, ...]}
282        // or SimpleCov format with nested structure
283        if let Some(obj) = data.as_object() {
284            for (_filename, coverage) in obj {
285                if let Some(lines) = coverage.as_array() {
286                    for line_hits in lines {
287                        if let Some(hits) = line_hits.as_u64() {
288                            total_lines += 1;
289                            if hits > 0 {
290                                covered_lines += 1;
291                            }
292                        } else if let Some(hits) = line_hits.as_i64() {
293                            if hits >= 0 {
294                                total_lines += 1;
295                                if hits > 0 {
296                                    covered_lines += 1;
297                                }
298                            }
299                        }
300                    }
301                }
302            }
303        }
304
305        let coverage_percentage = if total_lines > 0 {
306            (covered_lines as f64 / total_lines as f64) * 100.0
307        } else {
308            0.0
309        };
310
311        Ok(CoverageSummary {
312            total_lines,
313            covered_lines,
314            coverage_percentage,
315            total_branches: None,
316            covered_branches: None,
317            branch_percentage: None,
318        })
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    #[test]
327    fn test_runtime_coverage_new() {
328        let coverage = RubyRuntimeCoverage::new();
329        assert_eq!(std::mem::size_of_val(&coverage), 0);
330    }
331
332    #[test]
333    fn test_runtime_coverage_default() {
334        let coverage = RubyRuntimeCoverage;
335        assert_eq!(std::mem::size_of_val(&coverage), 0);
336    }
337}