use assay_core::explain::{StepVerdict, ToolCall, TraceExplainer};
use assay_core::model::{Policy, SequenceRule, ToolsPolicy};
use assay_core::on_error::ErrorPolicy;
use std::collections::HashMap;
fn make_policy(rules: Vec<SequenceRule>) -> Policy {
Policy {
version: "1.1".to_string(),
name: "test-policy".to_string(),
metadata: None,
tools: ToolsPolicy::default(),
sequences: rules,
aliases: HashMap::new(),
on_error: ErrorPolicy::default(),
}
}
fn make_policy_with_tools(
rules: Vec<SequenceRule>,
allow: Option<Vec<&str>>,
deny: Option<Vec<&str>>,
) -> Policy {
Policy {
version: "1.1".to_string(),
name: "test-policy".to_string(),
metadata: None,
tools: ToolsPolicy {
allow: allow.map(|v| v.into_iter().map(String::from).collect()),
deny: deny.map(|v| v.into_iter().map(String::from).collect()),
require_args: None,
arg_constraints: None,
},
sequences: rules,
aliases: HashMap::new(),
on_error: ErrorPolicy::default(),
}
}
fn trace(tools: &[&str]) -> Vec<ToolCall> {
tools
.iter()
.map(|t| ToolCall {
tool: t.to_string(),
args: None,
})
.collect()
}
#[test]
fn test_explain_empty_trace() {
let policy = make_policy(vec![]);
let explainer = TraceExplainer::new(policy);
let explanation = explainer.explain(&[]);
assert_eq!(explanation.total_steps, 0);
assert_eq!(explanation.allowed_steps, 0);
assert_eq!(explanation.blocked_steps, 0);
}
#[test]
fn test_explain_all_allowed() {
let policy = make_policy(vec![]);
let explainer = TraceExplainer::new(policy);
let explanation = explainer.explain(&trace(&["Search", "Create", "Update"]));
assert_eq!(explanation.total_steps, 3);
assert_eq!(explanation.allowed_steps, 3);
assert_eq!(explanation.blocked_steps, 0);
assert!(explanation.first_block_index.is_none());
}
#[test]
fn test_explain_before_pass() {
let policy = make_policy(vec![SequenceRule::Before {
first: "Auth".to_string(),
then: "Access".to_string(),
}]);
let explainer = TraceExplainer::new(policy);
let explanation = explainer.explain(&trace(&["Auth", "Access"]));
assert_eq!(explanation.blocked_steps, 0);
let access_step = &explanation.steps[1];
assert_eq!(access_step.verdict, StepVerdict::Allowed);
let before_rule = access_step
.rules_evaluated
.iter()
.find(|r| r.rule_type == "before")
.expect("before rule should be evaluated");
assert!(before_rule.passed);
assert!(before_rule.explanation.contains("was called first"));
}
#[test]
fn test_explain_before_fail() {
let policy = make_policy(vec![SequenceRule::Before {
first: "Auth".to_string(),
then: "Access".to_string(),
}]);
let explainer = TraceExplainer::new(policy);
let explanation = explainer.explain(&trace(&["Access"]));
assert_eq!(explanation.blocked_steps, 1);
assert_eq!(explanation.first_block_index, Some(0));
let access_step = &explanation.steps[0];
assert_eq!(access_step.verdict, StepVerdict::Blocked);
let before_rule = access_step
.rules_evaluated
.iter()
.find(|r| r.rule_type == "before")
.expect("before rule should be evaluated");
assert!(!before_rule.passed);
assert!(before_rule.explanation.contains("requires"));
}
#[test]
fn test_explain_max_calls_counting() {
let policy = make_policy(vec![SequenceRule::MaxCalls {
tool: "API".to_string(),
max: 3,
}]);
let explainer = TraceExplainer::new(policy);
let explanation = explainer.explain(&trace(&["API", "API", "API", "API"]));
assert_eq!(explanation.allowed_steps, 3);
assert_eq!(explanation.blocked_steps, 1);
assert_eq!(explanation.first_block_index, Some(3));
for (i, step) in explanation.steps.iter().take(3).enumerate() {
let max_rule = step
.rules_evaluated
.iter()
.find(|r| r.rule_type == "max_calls")
.expect("max_calls rule should be evaluated");
assert!(max_rule.passed);
assert!(max_rule.explanation.contains(&format!("{}/3", i + 1)));
}
let blocked_step = &explanation.steps[3];
let max_rule = blocked_step
.rules_evaluated
.iter()
.find(|r| r.rule_type == "max_calls")
.expect("max_calls rule should be evaluated");
assert!(!max_rule.passed);
assert!(max_rule.explanation.contains("exceeded"));
}
#[test]
fn test_explain_eventually_progress() {
let policy = make_policy(vec![SequenceRule::Eventually {
tool: "Validate".to_string(),
within: 3,
}]);
let explainer = TraceExplainer::new(policy);
let explanation = explainer.explain(&trace(&["Search", "Create", "Validate"]));
assert_eq!(explanation.blocked_steps, 0);
let validate_step = &explanation.steps[2];
let ev_rule = validate_step
.rules_evaluated
.iter()
.find(|r| r.rule_type == "eventually")
.expect("eventually rule should be evaluated");
assert!(ev_rule.passed);
assert!(ev_rule.explanation.contains("already seen"));
}
#[test]
fn test_explain_eventually_fail() {
let policy = make_policy(vec![SequenceRule::Eventually {
tool: "Validate".to_string(),
within: 2,
}]);
let explainer = TraceExplainer::new(policy);
let explanation = explainer.explain(&trace(&["Search", "Create", "Update"]));
assert!(explanation.blocked_steps > 0);
}
#[test]
fn test_explain_never_after_triggered() {
let policy = make_policy(vec![SequenceRule::NeverAfter {
trigger: "Archive".to_string(),
forbidden: "Delete".to_string(),
}]);
let explainer = TraceExplainer::new(policy);
let explanation = explainer.explain(&trace(&["Archive", "Delete"]));
assert_eq!(explanation.steps[0].verdict, StepVerdict::Allowed);
assert_eq!(explanation.steps[1].verdict, StepVerdict::Blocked);
let never_rule = explanation.steps[1]
.rules_evaluated
.iter()
.find(|r| r.rule_type == "never_after")
.expect("never_after rule should be evaluated");
assert!(!never_rule.passed);
assert!(never_rule.explanation.contains("forbidden"));
}
#[test]
fn test_explain_never_after_before_trigger() {
let policy = make_policy(vec![SequenceRule::NeverAfter {
trigger: "Archive".to_string(),
forbidden: "Delete".to_string(),
}]);
let explainer = TraceExplainer::new(policy);
let explanation = explainer.explain(&trace(&["Delete", "Archive"]));
assert_eq!(explanation.blocked_steps, 0);
}
#[test]
fn test_explain_sequence_progress() {
let policy = make_policy(vec![SequenceRule::Sequence {
tools: vec!["A".to_string(), "B".to_string(), "C".to_string()],
strict: false,
}]);
let explainer = TraceExplainer::new(policy);
let explanation = explainer.explain(&trace(&["A", "X", "B", "Y", "C"]));
assert_eq!(explanation.blocked_steps, 0);
let step_a = &explanation.steps[0];
let seq_rule_a = step_a
.rules_evaluated
.iter()
.find(|r| r.rule_type == "sequence")
.unwrap();
assert!(seq_rule_a.explanation.contains("1/3"));
}
#[test]
fn test_explain_sequence_strict_violation() {
let policy = make_policy(vec![SequenceRule::Sequence {
tools: vec!["A".to_string(), "B".to_string(), "C".to_string()],
strict: true,
}]);
let explainer = TraceExplainer::new(policy);
let explanation = explainer.explain(&trace(&["A", "X", "B", "C"]));
assert!(explanation.blocked_steps > 0);
let x_step = &explanation.steps[1];
assert_eq!(x_step.verdict, StepVerdict::Blocked);
}
#[test]
fn test_explain_deny_list() {
let policy = make_policy_with_tools(vec![], None, Some(vec!["DeleteAccount", "DropDatabase"]));
let explainer = TraceExplainer::new(policy);
let explanation = explainer.explain(&trace(&["Search", "DeleteAccount"]));
assert_eq!(explanation.steps[0].verdict, StepVerdict::Allowed);
assert_eq!(explanation.steps[1].verdict, StepVerdict::Blocked);
let deny_rule = explanation.steps[1]
.rules_evaluated
.iter()
.find(|r| r.rule_type == "deny")
.expect("deny rule should be evaluated");
assert!(!deny_rule.passed);
assert!(deny_rule.explanation.contains("deny list"));
}
#[test]
fn test_terminal_output_format() {
let policy = make_policy(vec![SequenceRule::MaxCalls {
tool: "API".to_string(),
max: 2,
}]);
let explainer = TraceExplainer::new(policy);
let explanation = explainer.explain(&trace(&["API", "API", "API"]));
let output = explanation.to_terminal();
assert!(output.contains("Timeline:"));
assert!(output.contains("[0]"));
assert!(output.contains("[1]"));
assert!(output.contains("[2]"));
assert!(output.contains("✅"));
assert!(output.contains("❌"));
assert!(output.contains("BLOCKED"));
}
#[test]
fn test_markdown_output_format() {
let policy = make_policy(vec![]);
let explainer = TraceExplainer::new(policy);
let explanation = explainer.explain(&trace(&["Search"]));
let output = explanation.to_markdown();
assert!(output.contains("## Trace Explanation"));
assert!(output.contains("| # | Tool | Verdict |"));
assert!(output.contains("| 0 | `Search` |"));
}
#[test]
fn test_html_output_format() {
let policy = make_policy(vec![]);
let explainer = TraceExplainer::new(policy);
let explanation = explainer.explain(&trace(&["Search"]));
let output = explanation.to_html();
assert!(output.contains("<!DOCTYPE html>"));
assert!(output.contains("<title>Trace Explanation</title>"));
assert!(output.contains("Search"));
}
#[test]
fn test_explain_multiple_rules() {
let policy = make_policy(vec![
SequenceRule::Before {
first: "Auth".to_string(),
then: "Access".to_string(),
},
SequenceRule::MaxCalls {
tool: "Access".to_string(),
max: 2,
},
SequenceRule::Eventually {
tool: "Logout".to_string(),
within: 5,
},
]);
let explainer = TraceExplainer::new(policy);
let explanation = explainer.explain(&trace(&["Auth", "Access", "Access", "Logout"]));
assert_eq!(explanation.blocked_steps, 0);
for step in &explanation.steps {
assert!(step.rules_evaluated.len() >= 2);
}
}
#[test]
fn test_explain_first_failure_stops_not_evaluation() {
let policy = make_policy(vec![
SequenceRule::Before {
first: "Auth".to_string(),
then: "Access".to_string(),
},
SequenceRule::MaxCalls {
tool: "Access".to_string(),
max: 2,
},
]);
let explainer = TraceExplainer::new(policy);
let explanation = explainer.explain(&trace(&["Access"]));
assert_eq!(explanation.blocked_steps, 1);
let step = &explanation.steps[0];
assert!(step.rules_evaluated.iter().any(|r| r.rule_type == "before"));
assert!(step
.rules_evaluated
.iter()
.any(|r| r.rule_type == "max_calls"));
}
#[test]
fn test_require_end_of_trace_violation() {
let policy = make_policy(vec![SequenceRule::Require {
tool: "Audit".to_string(),
}]);
let explainer = TraceExplainer::new(policy);
let explanation = explainer.explain(&trace(&["Search", "Create"]));
assert!(explanation
.blocking_rules
.iter()
.any(|r| r.contains("require_audit")));
let last_step = explanation.steps.last().unwrap();
let req_rule = last_step
.rules_evaluated
.iter()
.find(|r| r.rule_type == "require" && !r.passed)
.expect("require rule failure should be reported at end");
assert!(req_rule.explanation.contains("never called"));
}
#[test]
fn test_after_end_of_trace_violation() {
let policy = make_policy(vec![SequenceRule::After {
trigger: "Create".to_string(),
then: "Notify".to_string(),
within: 2,
}]);
let explainer = TraceExplainer::new(policy);
let explanation = explainer.explain(&trace(&["Create", "Update"]));
assert!(explanation
.blocking_rules
.iter()
.any(|r| r.contains("after_create")));
let last_step = explanation.steps.last().unwrap();
let after_rule = last_step
.rules_evaluated
.iter()
.find(|r| r.rule_type == "after" && !r.passed)
.expect("after rule failure should be reported at end");
assert!(after_rule.explanation.contains("trace ended"));
}