1use std::collections::{BTreeMap, BTreeSet};
20
21use rmcp::model::Tool;
22use serde::Serialize;
23
24use crate::{
25 property::dsl::{Assertion, InvariantFile, ToolMatch},
26 run::{DestructiveDetector, ToolClassification},
27};
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
31#[serde(rename_all = "snake_case")]
32pub enum CoverageCell {
33 Covered,
36 Blocked,
39 Uncovered,
43}
44
45#[derive(Debug, Clone, Serialize)]
47pub struct CoverageMatrix {
48 pub tools: Vec<String>,
50 pub packs: Vec<String>,
52 pub cells: BTreeMap<String, BTreeMap<String, CoverageCell>>,
55 pub uncovered_tools: Vec<String>,
59}
60
61impl CoverageMatrix {
62 pub fn build(
67 packs: &[(String, InvariantFile)],
68 tools: &[Tool],
69 detector: &DestructiveDetector,
70 ) -> Self {
71 let pack_names: Vec<String> = packs.iter().map(|(n, _)| n.clone()).collect();
72 let tool_names: Vec<String> = tools.iter().map(|t| t.name.to_string()).collect();
73 let tool_index: BTreeMap<String, &Tool> =
74 tools.iter().map(|t| (t.name.to_string(), t)).collect();
75
76 let mut cells: BTreeMap<String, BTreeMap<String, CoverageCell>> = BTreeMap::new();
77 for tool_name in &tool_names {
78 cells.insert(tool_name.clone(), BTreeMap::new());
79 }
80
81 for (pack_name, file) in packs {
82 let exercised = collect_exercised_tools(file, tools);
86
87 for tool_name in &tool_names {
88 let cell = if !exercised.contains(tool_name) {
89 CoverageCell::Uncovered
90 } else if let Some(tool) = tool_index.get(tool_name) {
91 if matches!(
92 detector.classify(tool),
93 ToolClassification::Destructive { .. }
94 ) {
95 CoverageCell::Blocked
96 } else {
97 CoverageCell::Covered
98 }
99 } else {
100 CoverageCell::Uncovered
101 };
102 if let Some(row) = cells.get_mut(tool_name) {
103 row.insert(pack_name.clone(), cell);
104 }
105 }
106 }
107
108 let uncovered_tools = tool_names
109 .iter()
110 .filter(|tool| {
111 cells
112 .get(*tool)
113 .map(|row| row.values().all(|c| matches!(c, CoverageCell::Uncovered)))
114 .unwrap_or(true)
115 })
116 .cloned()
117 .collect();
118
119 Self {
120 tools: tool_names,
121 packs: pack_names,
122 cells,
123 uncovered_tools,
124 }
125 }
126
127 pub fn covered_cells(&self) -> usize {
129 self.cells
130 .values()
131 .map(|row| {
132 row.values()
133 .filter(|c| matches!(c, CoverageCell::Covered))
134 .count()
135 })
136 .sum()
137 }
138
139 pub fn total_cells(&self) -> usize {
141 self.cells.values().map(|row| row.len()).sum()
142 }
143}
144
145fn collect_exercised_tools(file: &InvariantFile, tools: &[Tool]) -> BTreeSet<String> {
149 let live_names: BTreeSet<String> = tools.iter().map(|t| t.name.to_string()).collect();
150 let mut out: BTreeSet<String> = BTreeSet::new();
151
152 for invariant in &file.invariants {
153 if live_names.contains(&invariant.tool) {
157 out.insert(invariant.tool.clone());
158 }
159 }
160
161 for block in &file.for_each_tool {
162 let name_re = block
164 .matches
165 .name_matches
166 .as_deref()
167 .and_then(|p| regex::Regex::new(p).ok());
168 let description_re = block
169 .matches
170 .description_matches
171 .as_deref()
172 .and_then(|p| regex::Regex::new(p).ok());
173 for tool in tools {
174 if matches_filter(
175 &block.matches,
176 tool,
177 name_re.as_ref(),
178 description_re.as_ref(),
179 ) {
180 out.insert(tool.name.to_string());
181 }
182 }
183 }
184
185 for sequence in &file.sequences {
186 let all_steps_present = sequence
193 .steps
194 .iter()
195 .all(|step| live_names.contains(&step.call));
196 if !all_steps_present {
197 continue;
198 }
199 for step in &sequence.steps {
200 out.insert(step.call.clone());
201 }
202 }
203
204 let _: &dyn Fn(&[Assertion]) = &|_| {};
209
210 out
211}
212
213fn matches_filter(
214 filter: &ToolMatch,
215 tool: &Tool,
216 name_re: Option<®ex::Regex>,
217 description_re: Option<®ex::Regex>,
218) -> bool {
219 filter.matches(tool, name_re, description_re)
220}
221
222#[cfg(test)]
223#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
224mod tests {
225 use super::*;
226 use crate::{
227 property::dsl::parse,
228 target::{AllowDestructiveConfig, DestructiveConfig},
229 };
230 use rmcp::model::Tool;
231 use std::sync::Arc;
232
233 fn tool(name: &str) -> Tool {
234 Tool::new(name.to_string(), "", Arc::new(serde_json::Map::new()))
235 }
236
237 fn pack(name: &str, source: &str) -> (String, InvariantFile) {
238 (name.to_string(), parse(source).expect("parse"))
239 }
240
241 fn detector() -> DestructiveDetector {
242 DestructiveDetector::from_config(
243 &DestructiveConfig::default(),
244 &AllowDestructiveConfig::default(),
245 )
246 .expect("detector")
247 }
248
249 #[test]
250 fn invariant_targeting_a_live_tool_marks_cell_covered() {
251 let p = pack(
252 "tiny",
253 r#"
254version: 2
255invariants:
256 - name: foo
257 tool: alpha
258 fixed: {}
259 assert:
260 - kind: equals
261 lhs: { path: "$.response.isError" }
262 rhs: { value: false }
263"#,
264 );
265 let tools = vec![tool("alpha"), tool("beta")];
266 let m = CoverageMatrix::build(&[p], &tools, &detector());
267 assert_eq!(m.cells["alpha"]["tiny"], CoverageCell::Covered);
268 assert_eq!(m.cells["beta"]["tiny"], CoverageCell::Uncovered);
269 assert_eq!(m.uncovered_tools, vec!["beta".to_string()]);
270 }
271
272 #[test]
273 fn for_each_tool_block_with_no_filter_covers_every_tool() {
274 let p = pack(
275 "all-tools",
276 r#"
277version: 3
278metadata:
279 name: all-tools
280invariants: []
281for_each_tool:
282 - name: "all.{{tool_name}}"
283 apply:
284 input: schema_valid
285 assert:
286 - kind: equals
287 lhs: { path: "$.response.isError" }
288 rhs: { value: false }
289"#,
290 );
291 let tools = vec![tool("alpha"), tool("beta")];
292 let m = CoverageMatrix::build(&[p], &tools, &detector());
293 assert_eq!(m.cells["alpha"]["all-tools"], CoverageCell::Covered);
294 assert_eq!(m.cells["beta"]["all-tools"], CoverageCell::Covered);
295 assert!(m.uncovered_tools.is_empty());
296 }
297
298 #[test]
299 fn sequence_with_missing_step_does_not_count_as_coverage() {
300 let p = pack(
301 "stateful-like",
302 r#"
303version: 3
304metadata:
305 name: stateful-like
306invariants: []
307sequences:
308 - name: "test.create_then_read"
309 steps:
310 - call: create_thing
311 - call: read_thing
312"#,
313 );
314 let only_create = vec![tool("create_thing")];
315 let m = CoverageMatrix::build(&[p], &only_create, &detector());
316 assert_eq!(
319 m.cells["create_thing"]["stateful-like"],
320 CoverageCell::Uncovered
321 );
322 }
323
324 #[test]
325 fn coverage_summary_counts_covered_cells() {
326 let p = pack(
327 "single",
328 r#"
329version: 2
330invariants:
331 - name: foo
332 tool: alpha
333 fixed: {}
334 assert:
335 - kind: equals
336 lhs: { path: "$.response.isError" }
337 rhs: { value: false }
338"#,
339 );
340 let tools = vec![tool("alpha"), tool("beta")];
341 let m = CoverageMatrix::build(&[p], &tools, &detector());
342 assert_eq!(m.covered_cells(), 1);
343 assert_eq!(m.total_cells(), 2);
344 }
345}