1use std::collections::{BTreeMap, BTreeSet};
4
5#[derive(Debug, Clone, Default)]
7pub struct Coverage {
8 prev: BTreeMap<String, u64>,
9 toggled: BTreeSet<String>,
10 seen: BTreeSet<String>,
11 branch_hits: BTreeSet<String>,
13 branch_seen: BTreeSet<String>,
15 state_hits: BTreeSet<String>,
17 state_seen: BTreeSet<String>,
19}
20
21impl Coverage {
22 pub fn sample(&mut self, name: impl Into<String>, value: u64) {
23 let name = name.into();
24 self.seen.insert(name.clone());
25 if let Some(old) = self.prev.get(&name)
26 && *old != value
27 {
28 self.toggled.insert(name.clone());
29 }
30 self.prev.insert(name, value);
31 }
32
33 pub fn sample_mux_branch(&mut self, sel: &str, took_true: bool) {
35 let t = format!("mux:{sel}:t");
36 let f = format!("mux:{sel}:f");
37 self.branch_seen.insert(t.clone());
38 self.branch_seen.insert(f.clone());
39 if took_true {
40 self.branch_hits.insert(t);
41 } else {
42 self.branch_hits.insert(f);
43 }
44 }
45
46 pub fn register_fsm_states<I, S>(&mut self, fsm: &str, states: I)
48 where
49 I: IntoIterator<Item = S>,
50 S: Into<String>,
51 {
52 for st in states {
53 let id = format!("fsm:{fsm}:{}", st.into());
54 self.state_seen.insert(id);
55 }
56 }
57
58 pub fn sample_state_visit(&mut self, fsm: &str, state: &str) {
60 let id = format!("fsm:{fsm}:{state}");
61 self.state_seen.insert(id.clone());
62 self.state_hits.insert(id);
63 }
64
65 pub fn hits(&self) -> impl Iterator<Item = &str> {
66 self.toggled.iter().map(|s| s.as_str())
67 }
68
69 pub fn misses(&self) -> impl Iterator<Item = &str> {
70 self.seen.difference(&self.toggled).map(|s| s.as_str())
71 }
72
73 pub fn branch_hits(&self) -> impl Iterator<Item = &str> {
74 self.branch_hits.iter().map(|s| s.as_str())
75 }
76
77 pub fn branch_misses(&self) -> impl Iterator<Item = &str> {
78 self.branch_seen
79 .difference(&self.branch_hits)
80 .map(|s| s.as_str())
81 }
82
83 pub fn state_hits(&self) -> impl Iterator<Item = &str> {
84 self.state_hits.iter().map(|s| s.as_str())
85 }
86
87 pub fn state_misses(&self) -> impl Iterator<Item = &str> {
88 self.state_seen
89 .difference(&self.state_hits)
90 .map(|s| s.as_str())
91 }
92
93 pub fn report(&self) -> String {
99 let has_fsm = !self.state_seen.is_empty();
100 let mut out = if has_fsm {
101 String::from("# bitloom-sim coverage v3\n# FR109 C3 FSM/state-visit\n")
102 } else {
103 String::from("# bitloom-sim coverage v2\n")
104 };
105 for n in self.hits() {
106 out.push_str(&format!("hit {n}\n"));
107 }
108 for n in self.misses() {
109 out.push_str(&format!("miss {n}\n"));
110 }
111 for n in self.branch_hits() {
112 out.push_str(&format!("branch_hit {n}\n"));
113 }
114 for n in self.branch_misses() {
115 out.push_str(&format!("branch_miss {n}\n"));
116 }
117 for n in self.state_hits() {
118 out.push_str(&format!("state_hit {n}\n"));
119 }
120 for n in self.state_misses() {
121 out.push_str(&format!("state_miss {n}\n"));
122 }
123 out
124 }
125
126 pub fn is_empty(&self) -> bool {
128 self.seen.is_empty() && self.branch_seen.is_empty() && self.state_seen.is_empty()
129 }
130
131 pub fn to_lcov(&self, source_file: &str) -> String {
136 let mut points: Vec<(String, u64)> = Vec::new();
137 for n in self.hits() {
138 points.push((format!("toggle:{n}"), 1));
139 }
140 for n in self.misses() {
141 points.push((format!("toggle:{n}"), 0));
142 }
143 for n in self.branch_hits() {
144 points.push((n.to_string(), 1));
145 }
146 for n in self.branch_misses() {
147 points.push((n.to_string(), 0));
148 }
149 for n in self.state_hits() {
150 points.push((n.to_string(), 1));
151 }
152 for n in self.state_misses() {
153 points.push((n.to_string(), 0));
154 }
155 points.sort_by(|a, b| a.0.cmp(&b.0));
156
157 let mut out = String::from("TN:bitloom-sim\n");
158 out.push_str(&format!("SF:{source_file}\n"));
159 let mut lh = 0usize;
160 let lf = points.len();
161 for (i, (name, hits)) in points.iter().enumerate() {
162 let line = i + 1;
163 out.push_str(&format!("# {name}\n"));
164 out.push_str(&format!("DA:{line},{hits}\n"));
165 if *hits > 0 {
166 lh += 1;
167 }
168 }
169 out.push_str(&format!("LH:{lh}\n"));
170 out.push_str(&format!("LF:{lf}\n"));
171 out.push_str("end_of_record\n");
172 out
173 }
174
175 pub fn coverage_gui_html(&self, title: &str) -> String {
177 let mut rows = String::new();
178 let mut push_row = |kind: &str, name: &str, hit: bool| {
179 let status = if hit { "hit" } else { "miss" };
180 rows.push_str(&format!(
181 "<tr data-kind=\"{kind}\" data-name=\"{name}\" data-status=\"{status}\">\
182 <td>{kind}</td><td>{name}</td><td class=\"{status}\">{status}</td></tr>\n"
183 ));
184 };
185 for n in self.hits() {
186 push_row("toggle", n, true);
187 }
188 for n in self.misses() {
189 push_row("toggle", n, false);
190 }
191 for n in self.branch_hits() {
192 push_row("branch", n, true);
193 }
194 for n in self.branch_misses() {
195 push_row("branch", n, false);
196 }
197 for n in self.state_hits() {
198 push_row("state", n, true);
199 }
200 for n in self.state_misses() {
201 push_row("state", n, false);
202 }
203
204 format!(
205 r#"<!DOCTYPE html>
206<html lang="en">
207<head>
208<meta charset="utf-8"/>
209<title>Bitloom coverage — {title}</title>
210<style>
211body {{ font-family: ui-sans-serif, system-ui, sans-serif; margin: 1.5rem; background: #f7f4ef; color: #1a1a1a; }}
212h1 {{ font-size: 1.4rem; margin: 0 0 0.25rem; }}
213.brand {{ letter-spacing: 0.04em; text-transform: uppercase; font-size: 0.75rem; color: #5c5346; }}
214.hit {{ color: #0b6b3a; font-weight: 600; }}
215.miss {{ color: #8a1f1f; font-weight: 600; }}
216input {{ margin: 0.75rem 0; padding: 0.4rem 0.6rem; width: min(28rem, 100%); }}
217table {{ border-collapse: collapse; width: min(48rem, 100%); background: #fff; }}
218th, td {{ border: 1px solid #d9d2c5; padding: 0.35rem 0.55rem; text-align: left; }}
219th {{ background: #ebe4d8; }}
220</style>
221</head>
222<body data-bitloom-coverage-gui="fr114">
223<p class="brand">Bitloom</p>
224<h1>Coverage GUI — {title}</h1>
225<p>FR114 LCOV companion view (≠ FR104 interactive wave; ≠ Tywaves). Filter by name:</p>
226<input id="cov-search" type="search" placeholder="search coverage points…" />
227<table>
228<thead><tr><th>Kind</th><th>Point</th><th>Status</th></tr></thead>
229<tbody id="cov-body">
230{rows}</tbody>
231</table>
232<script>
233const q = document.getElementById('cov-search');
234const body = document.getElementById('cov-body');
235q.addEventListener('input', () => {{
236 const needle = q.value.toLowerCase();
237 for (const tr of body.querySelectorAll('tr')) {{
238 const name = (tr.getAttribute('data-name') || '').toLowerCase();
239 tr.style.display = !needle || name.includes(needle) ? '' : 'none';
240 }}
241}});
242</script>
243</body>
244</html>
245"#
246 )
247 }
248}
249
250pub fn write_coverage_artifacts(
252 cov: &Coverage,
253 out_dir: &std::path::Path,
254) -> std::io::Result<(std::path::PathBuf, std::path::PathBuf)> {
255 use std::fs;
256 if cov.is_empty() {
257 return Err(std::io::Error::new(
258 std::io::ErrorKind::InvalidData,
259 "bitloom-sim.coverage-empty: no coverage points — cannot claim FR114 (LCOV+GUI)",
260 ));
261 }
262 fs::create_dir_all(out_dir)?;
263 let lcov_path = out_dir.join("coverage.lcov");
264 let html_path = out_dir.join("coverage.html");
265 fs::write(&lcov_path, cov.to_lcov("bitloom://coverage/points"))?;
266 fs::write(&html_path, cov.coverage_gui_html("sim"))?;
267 Ok((lcov_path, html_path))
268}
269
270pub fn parse_report(text: &str) -> (Vec<String>, Vec<String>) {
272 let mut hits = Vec::new();
273 let mut misses = Vec::new();
274 for line in text.lines() {
275 if let Some(n) = line.strip_prefix("hit ") {
276 hits.push(n.to_string());
277 } else if let Some(n) = line.strip_prefix("miss ") {
278 misses.push(n.to_string());
279 }
280 }
281 (hits, misses)
282}
283
284pub fn parse_branch_report(text: &str) -> (Vec<String>, Vec<String>) {
286 let mut hits = Vec::new();
287 let mut misses = Vec::new();
288 for line in text.lines() {
289 if let Some(n) = line.strip_prefix("branch_hit ") {
290 hits.push(n.to_string());
291 } else if let Some(n) = line.strip_prefix("branch_miss ") {
292 misses.push(n.to_string());
293 }
294 }
295 (hits, misses)
296}
297
298pub fn parse_state_report(text: &str) -> (Vec<String>, Vec<String>) {
300 let mut hits = Vec::new();
301 let mut misses = Vec::new();
302 for line in text.lines() {
303 if let Some(n) = line.strip_prefix("state_hit ") {
304 hits.push(n.to_string());
305 } else if let Some(n) = line.strip_prefix("state_miss ") {
306 misses.push(n.to_string());
307 }
308 }
309 (hits, misses)
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315
316 #[test]
317 fn state_visit_hit_and_miss() {
318 let mut c = Coverage::default();
319 c.register_fsm_states("demo", ["Idle", "Busy", "Done"]);
320 c.sample_state_visit("demo", "Idle");
321 c.sample_state_visit("demo", "Busy");
322 let r = c.report();
323 assert!(r.contains("# bitloom-sim coverage v3"));
324 assert!(r.contains("# FR109 C3 FSM/state-visit"));
325 assert!(r.contains("state_hit fsm:demo:Idle"));
326 assert!(r.contains("state_hit fsm:demo:Busy"));
327 assert!(r.contains("state_miss fsm:demo:Done"));
328 let (h, m) = parse_state_report(&r);
329 assert!(h.contains(&"fsm:demo:Idle".into()));
330 assert!(m.contains(&"fsm:demo:Done".into()));
331 }
332
333 #[test]
334 fn lcov_and_gui_nonempty() {
335 let mut c = Coverage::default();
336 c.sample("y", 0);
337 c.sample("y", 1);
338 c.sample_mux_branch("sel", false);
339 let lcov = c.to_lcov("bitloom://coverage/points");
340 assert!(lcov.contains("end_of_record") && lcov.contains("DA:"));
341 let html = c.coverage_gui_html("t");
342 assert!(html.contains("data-bitloom-coverage-gui"));
343 }
344}