phink_lib/cover/
report.rs

1use crate::cli::ziggy::ZiggyConfig;
2use fs::read_to_string;
3
4use crate::{
5    cli::config::{
6        PFiles::CoverageTracePath,
7        PhinkFiles,
8    },
9    cover::trace::COV_IDENTIFIER,
10    EmptyResult,
11};
12use anyhow::{
13    bail,
14    Context,
15};
16use std::{
17    collections::HashMap,
18    fs::{
19        self,
20        File,
21    },
22    io,
23    io::Read,
24};
25use walkdir::WalkDir;
26
27pub struct CoverageTracker {
28    /// Maps each *.rs file of the contract to a `Vec<bool>`. This `Vec` represents the coverage
29    /// map of the file, with a `len()` equal to the number of lines of the file.
30    coverage: HashMap<String, Vec<bool>>,
31    /// Stores each hit line's unique identifier.
32    hit_lines: Vec<usize>,
33}
34
35impl CoverageTracker {
36    /// Creates a new `CoverageTracker` from a string representing hit lines.
37    pub fn new(coverage_string: &str) -> Self {
38        let hit_lines = coverage_string
39            .split("\n")
40            .filter_map(|s| s.parse().ok())
41            .collect();
42
43        CoverageTracker {
44            coverage: HashMap::new(),
45            hit_lines,
46        }
47    }
48
49    /// Calculates and prints a benchmark of the coverage achieved
50    pub fn benchmark(&self, total_feedback: usize) {
51        let mut hit_lines = self.hit_lines.clone();
52        hit_lines.sort();
53        hit_lines.dedup();
54
55        let total_hit_lines = hit_lines.len();
56        let number_of_files = self.coverage.len(); // should be 140 for dummy
57        let coverage_percentage = if total_feedback > 0 {
58            total_hit_lines * 100 / total_feedback
59        } else {
60            0 // div. by zero
61        };
62
63        println!("📐 Phink Coverage Benchmark:");
64        println!("  - Total contract's files: {number_of_files}");
65        println!("  - Total of unique hit lines: {total_hit_lines}");
66        println!("  - Maximum theoretically reachable coverage: {total_feedback}"); // should be 294 for dummy
67        println!("  - Coverage percentage: {coverage_percentage:.2}%");
68    }
69
70    /// Create the proper coverage map for the final report
71    /// # Returns
72    /// Returns the amount of time there was a coverage feedback, reached or not, for one given file
73    pub fn process_file(&mut self, file_path: &str) -> io::Result<usize> {
74        let content = read_to_string(file_path)?;
75        let lines: Vec<&str> = content.lines().collect();
76        let mut spotted_debugprint = 0;
77        let mut file_coverage = vec![false; lines.len()];
78
79        for (i, line) in lines.iter().enumerate() {
80            let trimmed = line.trim();
81
82            if let Some(cov_num) = trimmed.strip_prefix("ink::env::debug_println!(\"COV={}\", ") {
83                if let Some(cov_num) = cov_num.strip_suffix(");") {
84                    if let Ok(num) = cov_num.parse::<usize>() {
85                        // We increment the number of time we spotted `debug_println`
86                        spotted_debugprint += 1;
87                        if self.hit_lines.contains(&num) {
88                            // Mark the current line and previous non-empty lines as covered
89                            // We +1 to avoid marking the debug_println! as the covered one
90                            file_coverage[i + 1] = true;
91                        }
92                    }
93                }
94            }
95        }
96        self.coverage.insert(file_path.to_string(), file_coverage);
97        Ok(spotted_debugprint)
98    }
99
100    pub fn generate_report(&self, output_dir: &str) -> io::Result<()> {
101        fs::create_dir_all(output_dir)?;
102
103        let mut index_html = String::from(
104            "<!DOCTYPE html>
105                        <html>
106                        <head>
107                            <title>Phink Coverage Report</title>
108                            <style>
109                                body {
110                                    font-family: Arial, sans-serif;
111                                    margin: 40px;
112                                    background-color: #f4f4f9;
113                                }
114                                h1 {
115                                    color: #333;
116                                }
117                                ul {
118                                    list-style-type: none;
119                                    padding: 0;
120                                }
121                                li {
122                                    margin: 10px 0;
123                                }
124                                a {
125                                    text-decoration: none;
126                                    color: #007bff;
127                                }
128                                a:hover {
129                                    text-decoration: underline;
130                                }
131                            </style>
132                        </head>
133                        <body>
134                            <h1>Phink Coverage Report</h1>
135                            <ul>",
136        );
137
138        for (file_path, coverage) in &self.coverage {
139            let sanitized_path = file_path.replace("/", "_").replace("\\", "_");
140            let report_path = format!("{output_dir}/{sanitized_path}.html");
141            self.generate_file_report(file_path, coverage, &report_path)?;
142
143            index_html.push_str(&format!(
144                "<li><a href='{sanitized_path}.html'>- {file_path}</a></li>",
145            ));
146        }
147
148        index_html.push_str("</ul></body></html>");
149        fs::write(format!("{output_dir}/index.html"), index_html)?;
150        println!("📊 Coverage report generated at: {output_dir}");
151
152        Ok(())
153    }
154
155    fn generate_file_report(
156        &self,
157        file_path: &str,
158        coverage: &[bool],
159        output_path: &str,
160    ) -> io::Result<()> {
161        let source_code = read_to_string(file_path)?;
162        let lines: Vec<&str> = source_code.lines().collect();
163
164        let mut html = String::from(
165            "<!DOCTYPE html><html><head><title>Phink File Coverage</title><style>
166            .covered { background-color: #90EE90; }
167            /*.uncovered { background-color: #FFB6C1; }*/
168            </style></head><body>",
169        );
170
171        html.push_str(&format!("<h1>Coverage for {file_path}</h1><pre>"));
172        html.push_str("<h3>This is a beta version of the code visualizer. \
173        <br>You can assume that if a line is green, it has been executed. <br>\
174        If the green line represents a block (e.g., green `if`), it means that the `if` condition was met, and we got inside the condition.<br>\
175        The report doesn't integrate the coverage of the crashing seeds.
176        <br></h3>");
177
178        for (i, line) in lines.iter().enumerate() {
179            let line_class = if coverage[i] { "covered" } else { "uncovered" };
180            html.push_str(&format!(
181                "<span class='{}'>{:4} | {}</span>\n",
182                line_class,
183                i + 1,
184                html_escape(line)
185            ));
186        }
187
188        html.push_str("</pre></body></html>");
189        Self::remove_debug_statement(&mut html);
190        fs::write(output_path, html)?;
191
192        Ok(())
193    }
194
195    pub fn generate(config: ZiggyConfig) -> EmptyResult {
196        let cov_trace_path =
197            PhinkFiles::new(config.to_owned().fuzz_output()).path(CoverageTracePath);
198
199        let mut coverage_trace = match File::open(cov_trace_path) {
200            Ok(file) => file,
201            Err(_) => {
202                bail!("Coverage file not found. Please execute the \"run\" command to create the coverage file.")
203            }
204        };
205
206        let mut contents = String::new();
207        coverage_trace.read_to_string(&mut contents)?;
208        let mut total_feedback = 0;
209        let mut tracker = CoverageTracker::new(&contents);
210        for entry in WalkDir::new(config.contract_path()?)
211            .into_iter()
212            .filter_map(|e| e.ok())
213            .filter(|e| e.path().extension().map_or(false, |ext| ext == "rs"))
214            .filter(|e| !e.path().components().any(|c| c.as_os_str() == "target"))
215        {
216            let entry = entry.path().as_os_str().to_str().unwrap();
217            let one_file_feedback = tracker
218                .process_file(entry)
219                .context(format!("Cannot process {entry:?} file"))?;
220
221            total_feedback += one_file_feedback;
222        }
223
224        tracker
225            .generate_report(
226                config
227                    .config()
228                    .report_path
229                    .clone()
230                    .unwrap()
231                    .to_str()
232                    .unwrap(),
233            )
234            .context("Cannot generate coverage report")?;
235
236        tracker.benchmark(total_feedback);
237
238        Ok(())
239    }
240
241    pub fn remove_debug_statement(html: &mut String) {
242        let lines: Vec<&str> = html.lines().collect();
243
244        let filtered_lines: Vec<&str> = lines
245            .into_iter()
246            .filter(|line| {
247                !(line.contains("ink::env::debug_println!") && line.contains(COV_IDENTIFIER))
248            })
249            .collect();
250
251        *html = filtered_lines.join("\n");
252    }
253}
254
255fn html_escape(s: &str) -> String {
256    s.replace('&', "&amp;")
257        .replace('<', "&lt;")
258        .replace('>', "&gt;")
259        .replace('"', "&quot;")
260        .replace('\'', "&#39;")
261}