use crate::core::parser::{matches_scope, violating_pairs};
use crate::core::types::{ForjarConfig, PolicyRuleType};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
#[cfg(test)]
#[path = "tests.rs"]
mod tests;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "cli", derive(schemars::JsonSchema))]
pub struct PolicyCoverage {
pub total_resources: usize,
pub covered_resources: usize,
pub coverage_percent: f64,
pub fully_covered: bool,
pub uncovered: Vec<String>,
pub per_resource: BTreeMap<String, usize>,
pub total_rules: usize,
pub rules_triggered: usize,
pub untriggered_rules: Vec<String>,
pub clean_resources: usize,
pub by_type: BTreeMap<String, usize>,
pub by_severity: BTreeMap<String, usize>,
pub by_resource_scope: BTreeMap<String, usize>,
pub compliance_frameworks: BTreeMap<String, usize>,
}
pub fn compute_coverage(config: &ForjarConfig) -> PolicyCoverage {
let pairs = violating_pairs(config);
let per_resource = scoped_rule_counts(config);
let total_resources = config.resources.len();
let covered_resources = per_resource.len();
let mut uncovered: Vec<String> = config
.resources
.keys()
.filter(|id| !per_resource.contains_key(*id))
.cloned()
.collect();
uncovered.sort();
let (rules_triggered, untriggered_rules) = trigger_split(config, &pairs);
PolicyCoverage {
total_resources,
covered_resources,
coverage_percent: percent(covered_resources, total_resources),
fully_covered: uncovered.is_empty(),
uncovered,
per_resource,
total_rules: config.policies.len(),
rules_triggered,
untriggered_rules,
clean_resources: clean_resource_count(config, &pairs),
by_type: tally(config, |r| policy_type_name(&r.rule_type)),
by_severity: tally(config, |r| {
format!("{:?}", r.effective_severity()).to_lowercase()
}),
by_resource_scope: tally(config, |r| {
r.resource_type.as_deref().unwrap_or("*").to_string()
}),
compliance_frameworks: framework_tally(config),
}
}
fn percent(covered: usize, total: usize) -> f64 {
if total == 0 {
return 100.0;
}
(covered as f64 / total as f64) * 100.0
}
fn scoped_rule_counts(config: &ForjarConfig) -> BTreeMap<String, usize> {
let mut counts: BTreeMap<String, usize> = BTreeMap::new();
for rule in &config.policies {
for (id, resource) in &config.resources {
if matches_scope(rule, resource) {
*counts.entry(id.clone()).or_insert(0) += 1;
}
}
}
counts
}
fn trigger_split(config: &ForjarConfig, pairs: &[(usize, String)]) -> (usize, Vec<String>) {
let fired: BTreeSet<usize> = pairs.iter().map(|(index, _)| *index).collect();
let untriggered: Vec<String> = config
.policies
.iter()
.enumerate()
.filter(|(index, _)| !fired.contains(index))
.map(|(index, rule)| rule.display_id_at(index))
.collect();
(fired.len(), untriggered)
}
fn clean_resource_count(config: &ForjarConfig, pairs: &[(usize, String)]) -> usize {
let violating: BTreeSet<&str> = pairs.iter().map(|(_, id)| id.as_str()).collect();
config.resources.len().saturating_sub(violating.len())
}
fn tally<F>(config: &ForjarConfig, key: F) -> BTreeMap<String, usize>
where
F: Fn(&crate::core::types::PolicyRule) -> String,
{
let mut out: BTreeMap<String, usize> = BTreeMap::new();
for rule in &config.policies {
*out.entry(key(rule)).or_insert(0) += 1;
}
out
}
fn framework_tally(config: &ForjarConfig) -> BTreeMap<String, usize> {
let mut out: BTreeMap<String, usize> = BTreeMap::new();
for rule in &config.policies {
let named: BTreeSet<&str> = rule
.compliance
.iter()
.map(|c| c.framework.as_str())
.collect();
for f in named {
*out.entry(f.to_string()).or_insert(0) += 1;
}
}
out
}
fn policy_type_name(rt: &PolicyRuleType) -> String {
match rt {
PolicyRuleType::Require => "require".into(),
PolicyRuleType::Deny => "deny".into(),
PolicyRuleType::Warn => "warn".into(),
PolicyRuleType::Assert => "assert".into(),
PolicyRuleType::Limit => "limit".into(),
}
}
pub fn coverage_to_json(cov: &PolicyCoverage) -> serde_json::Value {
serde_json::to_value(cov).unwrap_or(serde_json::Value::Null)
}
pub fn format_coverage(cov: &PolicyCoverage) -> String {
let mut lines = vec![format!(
"Policy Coverage: {:.1}% ({}/{})",
cov.coverage_percent, cov.covered_resources, cov.total_resources
)];
if !cov.by_type.is_empty() {
lines.push(" Policies by type:".into());
for (t, count) in &cov.by_type {
lines.push(format!(" {t}: {count}"));
}
}
if !cov.compliance_frameworks.is_empty() {
let fws: Vec<&str> = cov
.compliance_frameworks
.keys()
.map(String::as_str)
.collect();
lines.push(format!(" Frameworks: {}", fws.join(", ")));
}
if !cov.uncovered.is_empty() {
lines.push(format!(" Uncovered ({}):", cov.uncovered.len()));
for id in &cov.uncovered {
lines.push(format!(" - {id}"));
}
}
lines.join("\n")
}