use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCoverage {
pub total_tools_in_policy: usize,
pub tools_seen_in_traces: usize,
pub coverage_pct: f64,
pub unseen_tools: Vec<String>,
pub unexpected_tools: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PolicyViolation {
pub trace_id: String,
pub tool: String,
pub error_code: String,
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PolicyWarning {
pub trace_id: String,
pub tool: String,
pub warning_code: String,
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CoverageReport {
pub tool_coverage: ToolCoverage,
pub rule_coverage: RuleCoverage,
pub high_risk_gaps: Vec<HighRiskGap>,
#[serde(default)]
pub policy_violations: Vec<PolicyViolation>,
#[serde(default)]
pub policy_warnings: Vec<PolicyWarning>,
pub overall_coverage_pct: f64,
pub meets_threshold: bool,
pub threshold: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RuleCoverage {
pub total_rules: usize,
pub rules_triggered: usize,
pub coverage_pct: f64,
pub untriggered_rules: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HighRiskGap {
pub tool: String,
pub reason: String,
pub severity: String,
}
#[derive(Debug, Clone)]
pub struct TraceRecord {
pub trace_id: String,
pub tools_called: Vec<String>,
pub rules_triggered: HashSet<String>,
}
pub struct CoverageAnalyzer {
policy_tools: HashSet<String>,
high_risk_tools: HashSet<String>,
rule_ids: Vec<String>,
aliases: HashMap<String, Vec<String>>,
}
impl CoverageAnalyzer {
pub fn from_policy(policy: &crate::model::Policy) -> Self {
let mut policy_tools = HashSet::new();
let mut high_risk_tools = HashSet::new();
let mut rule_ids = Vec::new();
if let Some(allow) = &policy.tools.allow {
for tool in allow {
policy_tools.insert(tool.clone());
}
}
if let Some(deny) = &policy.tools.deny {
for tool in deny {
policy_tools.insert(tool.clone());
high_risk_tools.insert(tool.clone()); }
}
if let Some(require_args) = &policy.tools.require_args {
for tool in require_args.keys() {
policy_tools.insert(tool.clone());
}
}
for (idx, rule) in policy.sequences.iter().enumerate() {
let rule_id = Self::rule_id(rule, idx);
rule_ids.push(rule_id);
match rule {
crate::model::SequenceRule::Require { tool } => {
policy_tools.insert(tool.clone());
}
crate::model::SequenceRule::Eventually { tool, .. } => {
policy_tools.insert(tool.clone());
}
crate::model::SequenceRule::MaxCalls { tool, .. } => {
policy_tools.insert(tool.clone());
}
crate::model::SequenceRule::Before { first, then } => {
policy_tools.insert(first.clone());
policy_tools.insert(then.clone());
}
crate::model::SequenceRule::After { trigger, then, .. } => {
policy_tools.insert(trigger.clone());
policy_tools.insert(then.clone());
}
crate::model::SequenceRule::NeverAfter { trigger, forbidden } => {
policy_tools.insert(trigger.clone());
policy_tools.insert(forbidden.clone());
high_risk_tools.insert(forbidden.clone()); }
crate::model::SequenceRule::Sequence { tools, .. } => {
for tool in tools {
policy_tools.insert(tool.clone());
}
}
crate::model::SequenceRule::Blocklist { pattern } => {
high_risk_tools.insert(format!("*{}*", pattern));
}
}
}
for (alias, members) in &policy.aliases {
policy_tools.insert(alias.clone());
for member in members {
policy_tools.insert(member.clone());
}
}
Self {
policy_tools,
high_risk_tools,
rule_ids,
aliases: policy.aliases.clone(),
}
}
fn rule_id(rule: &crate::model::SequenceRule, _idx: usize) -> String {
match rule {
crate::model::SequenceRule::Require { tool } => {
format!("require_{}", tool.to_lowercase())
}
crate::model::SequenceRule::Eventually { tool, within } => {
format!("eventually_{}_{}", tool.to_lowercase(), within)
}
crate::model::SequenceRule::MaxCalls { tool, max } => {
format!("max_calls_{}_{}", tool.to_lowercase(), max)
}
crate::model::SequenceRule::Before { first, then } => {
format!(
"before_{}_then_{}",
first.to_lowercase(),
then.to_lowercase()
)
}
crate::model::SequenceRule::After { trigger, then, .. } => {
format!(
"after_{}_then_{}",
trigger.to_lowercase(),
then.to_lowercase()
)
}
crate::model::SequenceRule::NeverAfter { trigger, forbidden } => {
format!(
"never_after_{}_forbidden_{}",
trigger.to_lowercase(),
forbidden.to_lowercase()
)
}
crate::model::SequenceRule::Sequence { tools, strict } => {
let mode = if *strict { "strict" } else { "seq" };
format!("{}_{}", mode, tools.join("_").to_lowercase())
}
crate::model::SequenceRule::Blocklist { pattern } => {
format!("blocklist_{}", pattern.to_lowercase())
}
}
}
pub fn analyze(&self, traces: &[TraceRecord], threshold: f64) -> CoverageReport {
let mut tools_seen: HashSet<String> = HashSet::new();
let mut rules_triggered: HashSet<String> = HashSet::new();
let mut unexpected_tools: HashSet<String> = HashSet::new();
for trace in traces {
for tool in &trace.tools_called {
tools_seen.insert(tool.clone());
if !self.is_policy_tool(tool) {
unexpected_tools.insert(tool.clone());
}
}
for rule_id in &trace.rules_triggered {
rules_triggered.insert(rule_id.clone());
}
}
let policy_tool_count = self.policy_tools.len();
let seen_policy_tools: HashSet<_> = tools_seen
.iter()
.filter(|t| self.is_policy_tool(t))
.cloned()
.collect();
let tools_seen_count = seen_policy_tools.len();
let unseen_tools: Vec<String> = self
.policy_tools
.iter()
.filter(|t| !self.is_tool_seen(t, &tools_seen))
.cloned()
.collect();
let tool_coverage_pct = if policy_tool_count > 0 {
(tools_seen_count as f64 / policy_tool_count as f64) * 100.0
} else {
100.0
};
let total_rules = self.rule_ids.len();
let triggered_count = rules_triggered.len();
let untriggered_rules: Vec<String> = self
.rule_ids
.iter()
.filter(|r| !rules_triggered.contains(*r))
.cloned()
.collect();
let rule_coverage_pct = if total_rules > 0 {
(triggered_count as f64 / total_rules as f64) * 100.0
} else {
100.0
};
let high_risk_gaps: Vec<HighRiskGap> = self
.high_risk_tools
.iter()
.filter(|t| !t.starts_with('*')) .filter(|t| !self.is_tool_seen(t, &tools_seen))
.map(|t| HighRiskGap {
tool: t.clone(),
reason: "Tool is in deny list but never appeared in test traces".to_string(),
severity: "high".to_string(),
})
.collect();
let overall_coverage_pct = (tool_coverage_pct + rule_coverage_pct) / 2.0;
let meets_threshold = overall_coverage_pct >= threshold;
CoverageReport {
tool_coverage: ToolCoverage {
total_tools_in_policy: policy_tool_count,
tools_seen_in_traces: tools_seen_count,
coverage_pct: tool_coverage_pct,
unseen_tools,
unexpected_tools: unexpected_tools.into_iter().collect(),
},
rule_coverage: RuleCoverage {
total_rules,
rules_triggered: triggered_count,
coverage_pct: rule_coverage_pct,
untriggered_rules,
},
high_risk_gaps,
policy_violations: Vec::new(),
policy_warnings: Vec::new(),
overall_coverage_pct,
meets_threshold,
threshold,
}
}
fn is_policy_tool(&self, tool: &str) -> bool {
if self.policy_tools.contains(tool) {
return true;
}
for members in self.aliases.values() {
if members.contains(&tool.to_string()) {
return true;
}
}
false
}
fn is_tool_seen(&self, tool: &str, seen: &HashSet<String>) -> bool {
if seen.contains(tool) {
return true;
}
if let Some(members) = self.aliases.get(tool) {
return members.iter().any(|m| seen.contains(m));
}
for (alias, members) in &self.aliases {
if members.contains(&tool.to_string()) && seen.contains(alias) {
return true;
}
}
false
}
}
impl CoverageReport {
pub fn to_github_annotation(&self) -> String {
let mut lines = Vec::new();
if !self.meets_threshold {
lines.push(format!(
"::error::Coverage {:.1}% is below threshold {:.1}%",
self.overall_coverage_pct, self.threshold
));
}
for gap in &self.high_risk_gaps {
lines.push(format!(
"::warning::High-risk tool '{}' never tested: {}",
gap.tool, gap.reason
));
}
for tool in &self.tool_coverage.unseen_tools {
lines.push(format!(
"::notice::Tool '{}' in policy but not covered by tests",
tool
));
}
lines.join("\n")
}
pub fn to_markdown(&self) -> String {
let status = if self.meets_threshold { "✅" } else { "❌" };
let mut md = format!(
"## Coverage Report {}\n\n\
| Metric | Value |\n\
|--------|-------|\n\
| Overall Coverage | {:.1}% |\n\
| Tool Coverage | {:.1}% ({}/{}) |\n\
| Rule Coverage | {:.1}% ({}/{}) |\n\
| Threshold | {:.1}% |\n\n",
status,
self.overall_coverage_pct,
self.tool_coverage.coverage_pct,
self.tool_coverage.tools_seen_in_traces,
self.tool_coverage.total_tools_in_policy,
self.rule_coverage.coverage_pct,
self.rule_coverage.rules_triggered,
self.rule_coverage.total_rules,
self.threshold,
);
if !self.high_risk_gaps.is_empty() {
md.push_str("### ⚠️ High-Risk Gaps\n\n");
for gap in &self.high_risk_gaps {
md.push_str(&format!("- **{}**: {}\n", gap.tool, gap.reason));
}
md.push('\n');
}
if !self.tool_coverage.unseen_tools.is_empty() {
md.push_str("### Uncovered Tools\n\n");
for tool in &self.tool_coverage.unseen_tools {
md.push_str(&format!("- `{}`\n", tool));
}
md.push('\n');
}
if !self.rule_coverage.untriggered_rules.is_empty() {
md.push_str("### Untriggered Rules\n\n");
for rule in &self.rule_coverage.untriggered_rules {
md.push_str(&format!("- `{}`\n", rule));
}
md.push('\n');
}
md
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{Policy, SequenceRule, ToolsPolicy};
use crate::on_error::ErrorPolicy;
fn make_policy() -> Policy {
Policy {
version: "1.1".to_string(),
name: "test".to_string(),
metadata: None,
tools: ToolsPolicy {
allow: Some(vec![
"SearchKnowledgeBase".to_string(),
"GetCustomerInfo".to_string(),
"CreateTicket".to_string(),
]),
deny: Some(vec!["DeleteAccount".to_string()]),
require_args: None,
arg_constraints: None,
},
sequences: vec![
SequenceRule::Before {
first: "SearchKnowledgeBase".to_string(),
then: "CreateTicket".to_string(),
},
SequenceRule::MaxCalls {
tool: "GetCustomerInfo".to_string(),
max: 3,
},
],
aliases: HashMap::new(),
on_error: ErrorPolicy::default(),
}
}
#[test]
fn test_full_coverage() {
let policy = make_policy();
let analyzer = CoverageAnalyzer::from_policy(&policy);
let traces = vec![TraceRecord {
trace_id: "t1".to_string(),
tools_called: vec![
"SearchKnowledgeBase".to_string(),
"GetCustomerInfo".to_string(),
"CreateTicket".to_string(),
"DeleteAccount".to_string(), ],
rules_triggered: HashSet::from([
"before_searchknowledgebase_then_createticket".to_string(),
"max_calls_getcustomerinfo_3".to_string(),
]),
}];
let report = analyzer.analyze(&traces, 80.0);
assert_eq!(report.tool_coverage.tools_seen_in_traces, 4);
assert!(report.tool_coverage.unseen_tools.is_empty());
assert!(report.high_risk_gaps.is_empty()); assert!(report.meets_threshold);
}
#[test]
fn test_partial_coverage() {
let policy = make_policy();
let analyzer = CoverageAnalyzer::from_policy(&policy);
let traces = vec![TraceRecord {
trace_id: "t1".to_string(),
tools_called: vec!["SearchKnowledgeBase".to_string()],
rules_triggered: HashSet::new(),
}];
let report = analyzer.analyze(&traces, 80.0);
assert_eq!(report.tool_coverage.tools_seen_in_traces, 1);
assert!(report
.tool_coverage
.unseen_tools
.contains(&"CreateTicket".to_string()));
assert!(report
.tool_coverage
.unseen_tools
.contains(&"GetCustomerInfo".to_string()));
assert!(!report.high_risk_gaps.is_empty()); assert!(!report.meets_threshold);
}
#[test]
fn test_unexpected_tools() {
let policy = make_policy();
let analyzer = CoverageAnalyzer::from_policy(&policy);
let traces = vec![TraceRecord {
trace_id: "t1".to_string(),
tools_called: vec![
"SearchKnowledgeBase".to_string(),
"UnknownTool".to_string(), ],
rules_triggered: HashSet::new(),
}];
let report = analyzer.analyze(&traces, 50.0);
assert!(report
.tool_coverage
.unexpected_tools
.contains(&"UnknownTool".to_string()));
}
#[test]
fn test_github_annotation_format() {
let report = CoverageReport {
tool_coverage: ToolCoverage {
total_tools_in_policy: 4,
tools_seen_in_traces: 2,
coverage_pct: 50.0,
unseen_tools: vec!["CreateTicket".to_string()],
unexpected_tools: vec![],
},
rule_coverage: RuleCoverage {
total_rules: 2,
rules_triggered: 1,
coverage_pct: 50.0,
untriggered_rules: vec!["max_calls_api_3".to_string()],
},
high_risk_gaps: vec![HighRiskGap {
tool: "DeleteAccount".to_string(),
reason: "Never tested".to_string(),
severity: "high".to_string(),
}],
policy_violations: vec![],
policy_warnings: vec![],
overall_coverage_pct: 50.0,
meets_threshold: false,
threshold: 80.0,
};
let annotation = report.to_github_annotation();
assert!(annotation.contains("::error::Coverage 50.0% is below threshold 80.0%"));
assert!(annotation.contains("::warning::High-risk tool 'DeleteAccount'"));
assert!(annotation.contains("::notice::Tool 'CreateTicket'"));
}
}