use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, Default)]
pub struct Coverage {
prev: BTreeMap<String, u64>,
toggled: BTreeSet<String>,
seen: BTreeSet<String>,
branch_hits: BTreeSet<String>,
branch_seen: BTreeSet<String>,
state_hits: BTreeSet<String>,
state_seen: BTreeSet<String>,
}
impl Coverage {
pub fn sample(&mut self, name: impl Into<String>, value: u64) {
let name = name.into();
self.seen.insert(name.clone());
if let Some(old) = self.prev.get(&name)
&& *old != value
{
self.toggled.insert(name.clone());
}
self.prev.insert(name, value);
}
pub fn sample_mux_branch(&mut self, sel: &str, took_true: bool) {
let t = format!("mux:{sel}:t");
let f = format!("mux:{sel}:f");
self.branch_seen.insert(t.clone());
self.branch_seen.insert(f.clone());
if took_true {
self.branch_hits.insert(t);
} else {
self.branch_hits.insert(f);
}
}
pub fn register_fsm_states<I, S>(&mut self, fsm: &str, states: I)
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
for st in states {
let id = format!("fsm:{fsm}:{}", st.into());
self.state_seen.insert(id);
}
}
pub fn sample_state_visit(&mut self, fsm: &str, state: &str) {
let id = format!("fsm:{fsm}:{state}");
self.state_seen.insert(id.clone());
self.state_hits.insert(id);
}
pub fn hits(&self) -> impl Iterator<Item = &str> {
self.toggled.iter().map(|s| s.as_str())
}
pub fn misses(&self) -> impl Iterator<Item = &str> {
self.seen.difference(&self.toggled).map(|s| s.as_str())
}
pub fn branch_hits(&self) -> impl Iterator<Item = &str> {
self.branch_hits.iter().map(|s| s.as_str())
}
pub fn branch_misses(&self) -> impl Iterator<Item = &str> {
self.branch_seen
.difference(&self.branch_hits)
.map(|s| s.as_str())
}
pub fn state_hits(&self) -> impl Iterator<Item = &str> {
self.state_hits.iter().map(|s| s.as_str())
}
pub fn state_misses(&self) -> impl Iterator<Item = &str> {
self.state_seen
.difference(&self.state_hits)
.map(|s| s.as_str())
}
pub fn report(&self) -> String {
let has_fsm = !self.state_seen.is_empty();
let mut out = if has_fsm {
String::from("# bitloom-sim coverage v3\n# FR109 C3 FSM/state-visit\n")
} else {
String::from("# bitloom-sim coverage v2\n")
};
for n in self.hits() {
out.push_str(&format!("hit {n}\n"));
}
for n in self.misses() {
out.push_str(&format!("miss {n}\n"));
}
for n in self.branch_hits() {
out.push_str(&format!("branch_hit {n}\n"));
}
for n in self.branch_misses() {
out.push_str(&format!("branch_miss {n}\n"));
}
for n in self.state_hits() {
out.push_str(&format!("state_hit {n}\n"));
}
for n in self.state_misses() {
out.push_str(&format!("state_miss {n}\n"));
}
out
}
pub fn is_empty(&self) -> bool {
self.seen.is_empty() && self.branch_seen.is_empty() && self.state_seen.is_empty()
}
pub fn to_lcov(&self, source_file: &str) -> String {
let mut points: Vec<(String, u64)> = Vec::new();
for n in self.hits() {
points.push((format!("toggle:{n}"), 1));
}
for n in self.misses() {
points.push((format!("toggle:{n}"), 0));
}
for n in self.branch_hits() {
points.push((n.to_string(), 1));
}
for n in self.branch_misses() {
points.push((n.to_string(), 0));
}
for n in self.state_hits() {
points.push((n.to_string(), 1));
}
for n in self.state_misses() {
points.push((n.to_string(), 0));
}
points.sort_by(|a, b| a.0.cmp(&b.0));
let mut out = String::from("TN:bitloom-sim\n");
out.push_str(&format!("SF:{source_file}\n"));
let mut lh = 0usize;
let lf = points.len();
for (i, (name, hits)) in points.iter().enumerate() {
let line = i + 1;
out.push_str(&format!("# {name}\n"));
out.push_str(&format!("DA:{line},{hits}\n"));
if *hits > 0 {
lh += 1;
}
}
out.push_str(&format!("LH:{lh}\n"));
out.push_str(&format!("LF:{lf}\n"));
out.push_str("end_of_record\n");
out
}
pub fn coverage_gui_html(&self, title: &str) -> String {
let mut rows = String::new();
let mut push_row = |kind: &str, name: &str, hit: bool| {
let status = if hit { "hit" } else { "miss" };
rows.push_str(&format!(
"<tr data-kind=\"{kind}\" data-name=\"{name}\" data-status=\"{status}\">\
<td>{kind}</td><td>{name}</td><td class=\"{status}\">{status}</td></tr>\n"
));
};
for n in self.hits() {
push_row("toggle", n, true);
}
for n in self.misses() {
push_row("toggle", n, false);
}
for n in self.branch_hits() {
push_row("branch", n, true);
}
for n in self.branch_misses() {
push_row("branch", n, false);
}
for n in self.state_hits() {
push_row("state", n, true);
}
for n in self.state_misses() {
push_row("state", n, false);
}
format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<title>Bitloom coverage — {title}</title>
<style>
body {{ font-family: ui-sans-serif, system-ui, sans-serif; margin: 1.5rem; background: #f7f4ef; color: #1a1a1a; }}
h1 {{ font-size: 1.4rem; margin: 0 0 0.25rem; }}
.brand {{ letter-spacing: 0.04em; text-transform: uppercase; font-size: 0.75rem; color: #5c5346; }}
.hit {{ color: #0b6b3a; font-weight: 600; }}
.miss {{ color: #8a1f1f; font-weight: 600; }}
input {{ margin: 0.75rem 0; padding: 0.4rem 0.6rem; width: min(28rem, 100%); }}
table {{ border-collapse: collapse; width: min(48rem, 100%); background: #fff; }}
th, td {{ border: 1px solid #d9d2c5; padding: 0.35rem 0.55rem; text-align: left; }}
th {{ background: #ebe4d8; }}
</style>
</head>
<body data-bitloom-coverage-gui="fr114">
<p class="brand">Bitloom</p>
<h1>Coverage GUI — {title}</h1>
<p>FR114 LCOV companion view (≠FR104 interactive wave; ≠Tywaves). Filter by name:</p>
<input id="cov-search" type="search" placeholder="search coverage points…" />
<table>
<thead><tr><th>Kind</th><th>Point</th><th>Status</th></tr></thead>
<tbody id="cov-body">
{rows}</tbody>
</table>
<script>
const q = document.getElementById('cov-search');
const body = document.getElementById('cov-body');
q.addEventListener('input', () => {{
const needle = q.value.toLowerCase();
for (const tr of body.querySelectorAll('tr')) {{
const name = (tr.getAttribute('data-name') || '').toLowerCase();
tr.style.display = !needle || name.includes(needle) ? '' : 'none';
}}
}});
</script>
</body>
</html>
"#
)
}
}
pub fn write_coverage_artifacts(
cov: &Coverage,
out_dir: &std::path::Path,
) -> std::io::Result<(std::path::PathBuf, std::path::PathBuf)> {
use std::fs;
if cov.is_empty() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"bitloom-sim.coverage-empty: no coverage points — cannot claim FR114 (LCOV+GUI)",
));
}
fs::create_dir_all(out_dir)?;
let lcov_path = out_dir.join("coverage.lcov");
let html_path = out_dir.join("coverage.html");
fs::write(&lcov_path, cov.to_lcov("bitloom://coverage/points"))?;
fs::write(&html_path, cov.coverage_gui_html("sim"))?;
Ok((lcov_path, html_path))
}
pub fn parse_report(text: &str) -> (Vec<String>, Vec<String>) {
let mut hits = Vec::new();
let mut misses = Vec::new();
for line in text.lines() {
if let Some(n) = line.strip_prefix("hit ") {
hits.push(n.to_string());
} else if let Some(n) = line.strip_prefix("miss ") {
misses.push(n.to_string());
}
}
(hits, misses)
}
pub fn parse_branch_report(text: &str) -> (Vec<String>, Vec<String>) {
let mut hits = Vec::new();
let mut misses = Vec::new();
for line in text.lines() {
if let Some(n) = line.strip_prefix("branch_hit ") {
hits.push(n.to_string());
} else if let Some(n) = line.strip_prefix("branch_miss ") {
misses.push(n.to_string());
}
}
(hits, misses)
}
pub fn parse_state_report(text: &str) -> (Vec<String>, Vec<String>) {
let mut hits = Vec::new();
let mut misses = Vec::new();
for line in text.lines() {
if let Some(n) = line.strip_prefix("state_hit ") {
hits.push(n.to_string());
} else if let Some(n) = line.strip_prefix("state_miss ") {
misses.push(n.to_string());
}
}
(hits, misses)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn state_visit_hit_and_miss() {
let mut c = Coverage::default();
c.register_fsm_states("demo", ["Idle", "Busy", "Done"]);
c.sample_state_visit("demo", "Idle");
c.sample_state_visit("demo", "Busy");
let r = c.report();
assert!(r.contains("# bitloom-sim coverage v3"));
assert!(r.contains("# FR109 C3 FSM/state-visit"));
assert!(r.contains("state_hit fsm:demo:Idle"));
assert!(r.contains("state_hit fsm:demo:Busy"));
assert!(r.contains("state_miss fsm:demo:Done"));
let (h, m) = parse_state_report(&r);
assert!(h.contains(&"fsm:demo:Idle".into()));
assert!(m.contains(&"fsm:demo:Done".into()));
}
#[test]
fn lcov_and_gui_nonempty() {
let mut c = Coverage::default();
c.sample("y", 0);
c.sample("y", 1);
c.sample_mux_branch("sel", false);
let lcov = c.to_lcov("bitloom://coverage/points");
assert!(lcov.contains("end_of_record") && lcov.contains("DA:"));
let html = c.coverage_gui_html("t");
assert!(html.contains("data-bitloom-coverage-gui"));
}
}