use std::path::{Path, PathBuf};
use anyhow::{anyhow, Context};
use serde::Serialize;
use serde_json::{json, Value};
use crate::engine::{decide, Adjustments, Decision, Engine};
use crate::WorkspaceContext;
#[derive(Debug, Clone, Default)]
pub struct EvalOptions {
pub workspace: Option<PathBuf>,
}
#[derive(Debug, Clone, Serialize)]
pub struct DecisionLine {
pub decision: String,
pub primary_rule_id: Option<String>,
pub matched_rules: Vec<String>,
pub raw_severity: String,
pub composite_severity: String,
pub composite_points: u32,
pub reason: String,
pub input: Value,
}
pub fn evaluate_corpus(
rules_path: &Path,
corpus: &str,
opts: &EvalOptions,
) -> anyhow::Result<Vec<DecisionLine>> {
let raw = std::fs::read_to_string(rules_path).with_context(|| {
format!("reading shieldset for evaluation from {}", rules_path.display())
})?;
let engine = Engine::from_yaml(&raw)
.with_context(|| format!("loading shieldset from {}", rules_path.display()))?;
let workspace = {
let mut policy = engine.policy.clone();
policy.workspace_probe.enabled = true;
match &opts.workspace {
Some(p) => WorkspaceContext::probe_at(&policy, p),
None => WorkspaceContext::probe(&policy),
}
};
let mut out = Vec::new();
for raw_line in corpus.lines() {
let trimmed = raw_line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with("//") {
continue;
}
let input: Value = match serde_json::from_str::<Value>(trimmed) {
Ok(v) => v,
Err(_) => {
out.push(DecisionLine {
decision: "allow".into(),
primary_rule_id: None,
matched_rules: Vec::new(),
raw_severity: "allow".into(),
composite_severity: "allow".into(),
composite_points: 0,
reason: "invalid JSON in corpus line".into(),
input: json!({"_raw": trimmed}),
});
continue;
}
};
let adj = Adjustments {
workspace_is_prod: workspace.is_prod,
..Default::default()
};
let eval = if let Some(text) = input.get("text").and_then(|v| v.as_str()) {
engine.evaluate_text(text, adj)
} else {
let tool = input.get("tool").and_then(|v| v.as_str()).unwrap_or("");
let params = input.get("params").cloned().unwrap_or(Value::Null);
let canonical = if params.get("name").is_some() || params.get("arguments").is_some()
{
params.clone()
} else {
json!({ "name": tool, "arguments": params })
};
engine.evaluate(tool, &canonical, adj)
};
let decision = decide(&eval);
let label = decision.label().to_string();
let (primary_rule_id, reason) = match &decision {
Decision::Block { rule_id, reason, .. }
| Decision::Approval { rule_id, reason, .. }
| Decision::IdentityVerification { rule_id, reason, .. } => {
(Some(rule_id.clone()), reason.clone())
}
Decision::Warn { rule_id, banner, .. } => (Some(rule_id.clone()), banner.clone()),
Decision::Allow => (None, String::new()),
};
out.push(DecisionLine {
decision: label,
primary_rule_id,
matched_rules: eval.matches.iter().map(|m| m.rule_id.clone()).collect(),
raw_severity: eval.raw_severity.as_str().into(),
composite_severity: eval.composite_severity.as_str().into(),
composite_points: eval.composite_points,
reason,
input,
});
}
Ok(out)
}
#[allow(dead_code)]
pub fn ensure_rules_exists(p: &Path) -> anyhow::Result<()> {
if !p.is_file() {
return Err(anyhow!(
"shieldset not found at {} -- check the --rules-before / --rules-after paths",
p.display()
));
}
Ok(())
}