1use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct ExplainedStep {
12 pub index: usize,
13 pub tool: String,
14
15 #[serde(default, skip_serializing_if = "Option::is_none")]
17 pub args: Option<serde_json::Value>,
18
19 pub verdict: StepVerdict,
21
22 pub rules_evaluated: Vec<RuleEvaluation>,
24
25 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
27 pub state_snapshot: HashMap<String, String>,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
31#[serde(rename_all = "lowercase")]
32pub enum StepVerdict {
33 Allowed,
35 Blocked,
37 Warning,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct RuleEvaluation {
43 pub rule_id: String,
44 pub rule_type: String,
45 pub passed: bool,
46 pub explanation: String,
47
48 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub context: Option<serde_json::Value>,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct TraceExplanation {
55 pub policy_name: String,
56 pub policy_version: String,
57 pub total_steps: usize,
58 pub allowed_steps: usize,
59 pub blocked_steps: usize,
60
61 #[serde(skip_serializing_if = "Option::is_none")]
62 pub first_block_index: Option<usize>,
63
64 pub steps: Vec<ExplainedStep>,
66
67 pub blocking_rules: Vec<String>,
69}
70
71#[derive(Debug, Clone, Deserialize)]
73pub struct ToolCall {
74 #[serde(alias = "name", alias = "tool_name")]
76 pub tool: String,
77
78 #[serde(default)]
80 pub args: Option<serde_json::Value>,
81}
82
83pub struct TraceExplainer {
85 policy: crate::model::Policy,
86}
87
88impl TraceExplainer {
89 pub fn new(policy: crate::model::Policy) -> Self {
90 Self { policy }
91 }
92
93 pub fn explain(&self, trace: &[ToolCall]) -> TraceExplanation {
95 let mut steps = Vec::new();
96 let mut state = ExplainerState::new(&self.policy);
97 let mut first_block_index = None;
98 let mut blocking_rules = Vec::new();
99
100 for (idx, call) in trace.iter().enumerate() {
101 let (step, blocked_by) = self.explain_step(idx, call, &mut state);
102
103 if step.verdict == StepVerdict::Blocked && first_block_index.is_none() {
104 first_block_index = Some(idx);
105 }
106
107 if let Some(rule) = blocked_by {
108 if !blocking_rules.contains(&rule) {
109 blocking_rules.push(rule);
110 }
111 }
112
113 steps.push(step);
114 }
115
116 let end_violations = state.check_end_of_trace(&self.policy);
118 if !end_violations.is_empty() && !steps.is_empty() {
119 let last_idx = steps.len() - 1;
120 for violation in end_violations {
121 steps[last_idx].rules_evaluated.push(violation.clone());
122 if !blocking_rules.contains(&violation.rule_id) {
123 blocking_rules.push(violation.rule_id);
124 }
125 }
126 }
127
128 let allowed_steps = steps
129 .iter()
130 .filter(|s| s.verdict == StepVerdict::Allowed)
131 .count();
132 let blocked_steps = steps
133 .iter()
134 .filter(|s| s.verdict == StepVerdict::Blocked)
135 .count();
136
137 TraceExplanation {
138 policy_name: self.policy.name.clone(),
139 policy_version: self.policy.version.clone(),
140 total_steps: steps.len(),
141 allowed_steps,
142 blocked_steps,
143 first_block_index,
144 steps,
145 blocking_rules,
146 }
147 }
148
149 fn explain_step(
150 &self,
151 idx: usize,
152 call: &ToolCall,
153 state: &mut ExplainerState,
154 ) -> (ExplainedStep, Option<String>) {
155 let mut rules_evaluated = Vec::new();
156 let mut verdict = StepVerdict::Allowed;
157 let mut blocked_by = None;
158
159 if let Some(eval) = self.check_static_constraints(&call.tool) {
161 if !eval.passed {
162 verdict = StepVerdict::Blocked;
163 blocked_by = Some(eval.rule_id.clone());
164 }
165 rules_evaluated.push(eval);
166 }
167
168 for (rule_idx, rule) in self.policy.sequences.iter().enumerate() {
170 let eval = state.evaluate_rule(rule_idx, rule, &call.tool, idx);
171
172 if !eval.passed && verdict != StepVerdict::Blocked {
173 verdict = StepVerdict::Blocked;
174 blocked_by = Some(eval.rule_id.clone());
175 }
176
177 rules_evaluated.push(eval);
178 }
179
180 state.update(&call.tool, idx, &self.policy);
182
183 let step = ExplainedStep {
184 index: idx,
185 tool: call.tool.clone(),
186 args: call.args.clone(),
187 verdict,
188 rules_evaluated,
189 state_snapshot: state.snapshot(),
190 };
191
192 (step, blocked_by)
193 }
194
195 fn check_static_constraints(&self, tool: &str) -> Option<RuleEvaluation> {
196 if let Some(deny) = &self.policy.tools.deny {
198 if deny.contains(&tool.to_string()) {
199 return Some(RuleEvaluation {
200 rule_id: "deny_list".to_string(),
201 rule_type: "deny".to_string(),
202 passed: false,
203 explanation: format!("Tool '{}' is in deny list", tool),
204 context: None,
205 });
206 }
207 }
208
209 if let Some(allow) = &self.policy.tools.allow {
211 if !allow.contains(&tool.to_string()) && !self.is_alias_member(tool) {
212 return Some(RuleEvaluation {
213 rule_id: "allow_list".to_string(),
214 rule_type: "allow".to_string(),
215 passed: false,
216 explanation: format!("Tool '{}' is not in allow list", tool),
217 context: None,
218 });
219 }
220 }
221
222 None
223 }
224
225 fn is_alias_member(&self, tool: &str) -> bool {
226 for members in self.policy.aliases.values() {
227 if members.contains(&tool.to_string()) {
228 return true;
229 }
230 }
231 false
232 }
233}
234
235struct ExplainerState {
237 tools_seen: Vec<String>,
239
240 call_counts: HashMap<String, u32>,
242
243 tool_seen_flags: HashMap<String, bool>,
245
246 never_after_triggered: HashMap<usize, usize>, pending_after: HashMap<usize, (usize, usize)>,
251
252 sequence_progress: HashMap<usize, usize>,
254
255 aliases: HashMap<String, Vec<String>>,
257}
258
259impl ExplainerState {
260 fn new(policy: &crate::model::Policy) -> Self {
261 Self {
262 tools_seen: Vec::new(),
263 call_counts: HashMap::new(),
264 tool_seen_flags: HashMap::new(),
265 never_after_triggered: HashMap::new(),
266 pending_after: HashMap::new(),
267 sequence_progress: HashMap::new(),
268 aliases: policy.aliases.clone(),
269 }
270 }
271
272 fn resolve_alias(&self, tool: &str) -> Vec<String> {
273 if let Some(members) = self.aliases.get(tool) {
274 members.clone()
275 } else {
276 vec![tool.to_string()]
277 }
278 }
279
280 fn matches(&self, tool: &str, target: &str) -> bool {
281 let targets = self.resolve_alias(target);
282 targets.contains(&tool.to_string())
283 }
284
285 fn evaluate_rule(
286 &mut self,
287 rule_idx: usize,
288 rule: &crate::model::SequenceRule,
289 tool: &str,
290 idx: usize,
291 ) -> RuleEvaluation {
292 match rule {
293 crate::model::SequenceRule::Require { tool: req_tool } => {
294 RuleEvaluation {
296 rule_id: format!("require_{}", req_tool.to_lowercase()),
297 rule_type: "require".to_string(),
298 passed: true,
299 explanation: format!("Require '{}' (checked at end)", req_tool),
300 context: None,
301 }
302 }
303
304 crate::model::SequenceRule::Eventually {
305 tool: ev_tool,
306 within,
307 } => {
308 let targets = self.resolve_alias(ev_tool);
309 let seen = self.tools_seen.iter().any(|t| targets.contains(t))
310 || targets.contains(&tool.to_string());
311
312 let current_idx = idx as u32;
313 let passed = seen || current_idx < *within;
314
315 let explanation = if seen {
316 format!("'{}' already seen ✓", ev_tool)
317 } else if current_idx < *within {
318 format!(
319 "'{}' required within {} calls (at {}/{})",
320 ev_tool,
321 within,
322 idx + 1,
323 within
324 )
325 } else {
326 format!("'{}' not seen within first {} calls", ev_tool, within)
327 };
328
329 RuleEvaluation {
330 rule_id: format!("eventually_{}_{}", ev_tool.to_lowercase(), within),
331 rule_type: "eventually".to_string(),
332 passed,
333 explanation,
334 context: Some(serde_json::json!({
335 "required_tool": ev_tool,
336 "within": within,
337 "current_index": idx,
338 "seen": seen
339 })),
340 }
341 }
342
343 crate::model::SequenceRule::MaxCalls {
344 tool: max_tool,
345 max,
346 } => {
347 let targets = self.resolve_alias(max_tool);
348 let current_count = if targets.contains(&tool.to_string()) {
349 self.call_counts.get(tool).copied().unwrap_or(0) + 1
350 } else {
351 targets
352 .iter()
353 .map(|t| self.call_counts.get(t).copied().unwrap_or(0))
354 .sum()
355 };
356
357 let passed = current_count <= *max;
358
359 let explanation = if passed {
360 format!("'{}' call {}/{}", max_tool, current_count, max)
361 } else {
362 format!(
363 "'{}' exceeded max calls ({} > {})",
364 max_tool, current_count, max
365 )
366 };
367
368 RuleEvaluation {
369 rule_id: format!("max_calls_{}_{}", max_tool.to_lowercase(), max),
370 rule_type: "max_calls".to_string(),
371 passed,
372 explanation,
373 context: Some(serde_json::json!({
374 "tool": max_tool,
375 "max": max,
376 "current_count": current_count
377 })),
378 }
379 }
380
381 crate::model::SequenceRule::Before { first, then } => {
382 let is_then = self.matches(tool, then);
383 let first_seen = self.tool_seen_flags.get(first).copied().unwrap_or(false)
384 || self.tools_seen.iter().any(|t| self.matches(t, first));
385
386 let passed = !is_then || first_seen;
387
388 let explanation = if !is_then {
389 format!("Not '{}', rule not applicable", then)
390 } else if first_seen {
391 format!("'{}' was called first ✓", first)
392 } else {
393 format!("'{}' requires '{}' first", then, first)
394 };
395
396 RuleEvaluation {
397 rule_id: format!(
398 "before_{}_then_{}",
399 first.to_lowercase(),
400 then.to_lowercase()
401 ),
402 rule_type: "before".to_string(),
403 passed,
404 explanation,
405 context: Some(serde_json::json!({
406 "first": first,
407 "then": then,
408 "first_seen": first_seen,
409 "is_then_call": is_then
410 })),
411 }
412 }
413
414 crate::model::SequenceRule::After {
415 trigger,
416 then,
417 within,
418 } => {
419 let is_trigger = self.matches(tool, trigger);
420 let is_then = self.matches(tool, then);
421
422 let mut passed = true;
424 let explanation;
425
426 if let Some((trigger_idx, deadline)) = self.pending_after.get(&rule_idx) {
427 if is_then {
428 if idx <= *deadline {
429 explanation = format!("'{}' satisfies after '{}' ✓", then, trigger);
430 } else {
431 passed = false;
432 explanation = format!(
433 "'{}' called too late after '{}' (at {}, deadline {})",
434 then, trigger, idx, deadline
435 );
436 }
437 } else if idx > *deadline {
438 passed = false;
439 explanation = format!(
440 "'{}' required within {} calls after '{}' (triggered at {})",
441 then, within, trigger, trigger_idx
442 );
443 } else {
444 explanation = format!(
445 "Pending: '{}' needed within {} more calls",
446 then,
447 deadline - idx
448 );
449 }
450 } else if is_trigger {
451 explanation = format!(
452 "'{}' triggered, '{}' required within {}",
453 trigger, then, within
454 );
455 } else {
456 explanation = format!("After rule: waiting for '{}'", trigger);
457 }
458
459 RuleEvaluation {
460 rule_id: format!(
461 "after_{}_then_{}",
462 trigger.to_lowercase(),
463 then.to_lowercase()
464 ),
465 rule_type: "after".to_string(),
466 passed,
467 explanation,
468 context: Some(serde_json::json!({
469 "trigger": trigger,
470 "then": then,
471 "within": within
472 })),
473 }
474 }
475
476 crate::model::SequenceRule::NeverAfter { trigger, forbidden } => {
477 let is_trigger = self.matches(tool, trigger);
478 let is_forbidden = self.matches(tool, forbidden);
479 let triggered = self.never_after_triggered.contains_key(&rule_idx);
480
481 let passed = !(triggered && is_forbidden);
482
483 let explanation = if !triggered && is_trigger {
484 format!("'{}' triggered, '{}' now forbidden", trigger, forbidden)
485 } else if triggered && is_forbidden {
486 let trigger_idx = self.never_after_triggered.get(&rule_idx).unwrap();
487 format!(
488 "'{}' forbidden after '{}' (triggered at index {})",
489 forbidden, trigger, trigger_idx
490 )
491 } else if triggered {
492 format!(
493 "'{}' forbidden (trigger at {})",
494 forbidden,
495 self.never_after_triggered.get(&rule_idx).unwrap()
496 )
497 } else {
498 format!("Waiting for trigger '{}'", trigger)
499 };
500
501 RuleEvaluation {
502 rule_id: format!(
503 "never_after_{}_forbidden_{}",
504 trigger.to_lowercase(),
505 forbidden.to_lowercase()
506 ),
507 rule_type: "never_after".to_string(),
508 passed,
509 explanation,
510 context: Some(serde_json::json!({
511 "trigger": trigger,
512 "forbidden": forbidden,
513 "triggered": triggered || is_trigger
514 })),
515 }
516 }
517
518 crate::model::SequenceRule::Sequence { tools, strict } => {
519 let seq_idx = self.sequence_progress.get(&rule_idx).copied().unwrap_or(0);
520
521 let mut passed = true;
522 let explanation;
523
524 if seq_idx < tools.len() {
525 let expected = &tools[seq_idx];
526 let is_expected = self.matches(tool, expected);
527
528 if *strict {
529 if seq_idx > 0 && !is_expected {
531 passed = false;
532 explanation = format!(
533 "Strict sequence: expected '{}' but got '{}'",
534 expected, tool
535 );
536 } else if is_expected {
537 explanation = format!(
538 "Sequence step {}/{}: '{}' ✓",
539 seq_idx + 1,
540 tools.len(),
541 tool
542 );
543 } else {
544 explanation = format!("Waiting for sequence start: '{}'", tools[0]);
545 }
546 } else {
547 let future_match = tools
549 .iter()
550 .skip(seq_idx + 1)
551 .position(|t| self.matches(tool, t));
552
553 if future_match.is_some() {
554 passed = false;
555 explanation = format!(
556 "Sequence order violated: '{}' before '{}'",
557 tool, expected
558 );
559 } else if is_expected {
560 explanation = format!(
561 "Sequence step {}/{}: '{}' ✓",
562 seq_idx + 1,
563 tools.len(),
564 tool
565 );
566 } else {
567 explanation = format!(
568 "Sequence: waiting for '{}' ({}/{})",
569 expected,
570 seq_idx,
571 tools.len()
572 );
573 }
574 }
575 } else {
576 explanation = "Sequence complete ✓".to_string();
577 }
578
579 RuleEvaluation {
580 rule_id: format!("sequence_{}", tools.join("_").to_lowercase()),
581 rule_type: "sequence".to_string(),
582 passed,
583 explanation,
584 context: Some(serde_json::json!({
585 "tools": tools,
586 "strict": strict,
587 "progress": seq_idx
588 })),
589 }
590 }
591
592 crate::model::SequenceRule::Blocklist { pattern } => {
593 let passed = !tool.contains(pattern);
594
595 let explanation = if passed {
596 format!("'{}' does not match blocklist '{}'", tool, pattern)
597 } else {
598 format!("'{}' matches blocklist pattern '{}'", tool, pattern)
599 };
600
601 RuleEvaluation {
602 rule_id: format!("blocklist_{}", pattern.to_lowercase()),
603 rule_type: "blocklist".to_string(),
604 passed,
605 explanation,
606 context: None,
607 }
608 }
609 }
610 }
611
612 fn update(&mut self, tool: &str, idx: usize, policy: &crate::model::Policy) {
613 *self.call_counts.entry(tool.to_string()).or_insert(0) += 1;
615
616 self.tool_seen_flags.insert(tool.to_string(), true);
618
619 for (rule_idx, rule) in policy.sequences.iter().enumerate() {
621 match rule {
622 crate::model::SequenceRule::NeverAfter { trigger, .. } => {
623 if self.matches(tool, trigger)
624 && !self.never_after_triggered.contains_key(&rule_idx)
625 {
626 self.never_after_triggered.insert(rule_idx, idx);
627 }
628 }
629 crate::model::SequenceRule::After {
630 trigger, within, ..
631 } => {
632 if self.matches(tool, trigger) {
633 self.pending_after
637 .insert(rule_idx, (idx, idx + *within as usize));
638 }
639 }
640 crate::model::SequenceRule::Sequence { tools, .. } => {
641 let seq_idx = self.sequence_progress.get(&rule_idx).copied().unwrap_or(0);
642 if seq_idx < tools.len() && self.matches(tool, &tools[seq_idx]) {
643 self.sequence_progress.insert(rule_idx, seq_idx + 1);
644 }
645 }
646 _ => {}
647 }
648 }
649
650 self.tools_seen.push(tool.to_string());
652 }
653
654 fn check_end_of_trace(&self, policy: &crate::model::Policy) -> Vec<RuleEvaluation> {
655 let mut violations = Vec::new();
656
657 for (rule_idx, rule) in policy.sequences.iter().enumerate() {
658 match rule {
659 crate::model::SequenceRule::Require { tool } => {
660 let requirements = self.resolve_alias(tool);
661 let ok = self.tools_seen.iter().any(|t| requirements.contains(t));
662
663 if !ok {
664 violations.push(RuleEvaluation {
665 rule_id: format!("require_{}", tool.to_lowercase()),
666 rule_type: "require".to_string(),
667 passed: false,
668 explanation: format!("Required tool '{}' never called", tool),
669 context: None,
670 });
671 }
672 }
673 crate::model::SequenceRule::After {
674 trigger,
675 then,
676 within,
677 } => {
678 if let Some((trigger_idx, deadline)) = self.pending_after.get(&rule_idx) {
680 let then_targets = self.resolve_alias(then);
684 let seen_after = self
685 .tools_seen
686 .iter()
687 .skip(*trigger_idx + 1)
688 .any(|t| then_targets.contains(t));
689
690 if !seen_after {
691 violations.push(RuleEvaluation {
692 rule_id: format!("after_{}_then_{}", trigger.to_lowercase(), then.to_lowercase()),
693 rule_type: "after".to_string(),
694 passed: false,
695 explanation: format!("'{}' triggered at {}, but '{}' never called within {} steps (trace ended)", trigger, trigger_idx, then, within),
696 context: Some(serde_json::json!({
697 "trigger": trigger,
698 "deadline": deadline,
699 "trace_len": self.tools_seen.len()
700 })),
701 });
702 }
703 }
704 }
705 _ => {}
706 }
707 }
708
709 violations
710 }
711
712 fn snapshot(&self) -> HashMap<String, String> {
713 let mut snap = HashMap::new();
714
715 for (tool, count) in &self.call_counts {
716 if *count > 0 {
717 snap.insert(format!("calls:{}", tool), count.to_string());
718 }
719 }
720
721 snap
722 }
723}
724
725impl TraceExplanation {
726 pub fn to_terminal(&self) -> String {
728 let mut lines = Vec::new();
729
730 lines.push(format!(
731 "Policy: {} (v{})",
732 self.policy_name, self.policy_version
733 ));
734 lines.push(format!(
735 "Trace: {} steps ({} allowed, {} blocked)\n",
736 self.total_steps, self.allowed_steps, self.blocked_steps
737 ));
738
739 lines.push("Timeline:".to_string());
740
741 for step in &self.steps {
742 let icon = match step.verdict {
743 StepVerdict::Allowed => "✅",
744 StepVerdict::Blocked => "❌",
745 StepVerdict::Warning => "⚠️",
746 };
747
748 let args_str = step
749 .args
750 .as_ref()
751 .map(|a| format!("({})", summarize_args(a)))
752 .unwrap_or_default();
753
754 let status = match step.verdict {
755 StepVerdict::Allowed => "allowed".to_string(),
756 StepVerdict::Blocked => "BLOCKED".to_string(),
757 StepVerdict::Warning => "warning".to_string(),
758 };
759
760 lines.push(format!(
761 " [{}] {}{:<40} {} {}",
762 step.index, step.tool, args_str, icon, status
763 ));
764
765 if step.verdict == StepVerdict::Blocked {
767 for eval in &step.rules_evaluated {
768 if !eval.passed {
769 lines.push(format!(" └── Rule: {}", eval.rule_id));
770 lines.push(format!(" └── Reason: {}", eval.explanation));
771 }
772 }
773 }
774 }
775
776 if !self.blocking_rules.is_empty() {
777 lines.push(String::new());
778 lines.push("Blocking Rules:".to_string());
779 for rule in &self.blocking_rules {
780 lines.push(format!(" - {}", rule));
781 }
782 }
783
784 lines.join("\n")
785 }
786
787 pub fn to_markdown(&self) -> String {
789 let mut md = String::new();
790
791 let status = if self.blocked_steps == 0 {
792 "✅ PASS"
793 } else {
794 "❌ BLOCKED"
795 };
796
797 md.push_str(&format!("## Trace Explanation {}\n\n", status));
798 md.push_str(&format!(
799 "**Policy:** {} (v{})\n\n",
800 self.policy_name, self.policy_version
801 ));
802 md.push_str("| Steps | Allowed | Blocked |\n");
803 md.push_str("|-------|---------|----------|\n");
804 md.push_str(&format!(
805 "| {} | {} | {} |\n\n",
806 self.total_steps, self.allowed_steps, self.blocked_steps
807 ));
808
809 md.push_str("### Timeline\n\n");
810 md.push_str("| # | Tool | Verdict | Details |\n");
811 md.push_str("|---|------|---------|----------|\n");
812
813 for step in &self.steps {
814 let icon = match step.verdict {
815 StepVerdict::Allowed => "✅",
816 StepVerdict::Blocked => "❌",
817 StepVerdict::Warning => "⚠️",
818 };
819
820 let details = if step.verdict == StepVerdict::Blocked {
821 step.rules_evaluated
822 .iter()
823 .filter(|e| !e.passed)
824 .map(|e| e.explanation.clone())
825 .collect::<Vec<_>>()
826 .join("; ")
827 } else {
828 String::new()
829 };
830
831 md.push_str(&format!(
832 "| {} | `{}` | {} | {} |\n",
833 step.index, step.tool, icon, details
834 ));
835 }
836
837 if !self.blocking_rules.is_empty() {
838 md.push_str("\n### Blocking Rules\n\n");
839 for rule in &self.blocking_rules {
840 md.push_str(&format!("- `{}`\n", rule));
841 }
842 }
843
844 md
845 }
846
847 pub fn to_html(&self) -> String {
849 let mut html = String::new();
850
851 html.push_str("<!DOCTYPE html>\n<html><head>\n");
852 html.push_str("<meta charset=\"utf-8\">\n");
853 html.push_str("<title>Trace Explanation</title>\n");
854 html.push_str("<style>\n");
855 html.push_str("body { font-family: system-ui, sans-serif; max-width: 900px; margin: 2rem auto; padding: 0 1rem; }\n");
856 html.push_str(".step { padding: 0.5rem; margin: 0.25rem 0; border-radius: 4px; }\n");
857 html.push_str(".allowed { background: #d4edda; }\n");
858 html.push_str(".blocked { background: #f8d7da; }\n");
859 html.push_str(".warning { background: #fff3cd; }\n");
860 html.push_str(".rule-detail { margin-left: 2rem; color: #666; font-size: 0.9em; }\n");
861 html.push_str(
862 "code { background: #f4f4f4; padding: 0.2rem 0.4rem; border-radius: 3px; }\n",
863 );
864 html.push_str("</style>\n</head><body>\n");
865
866 let status = if self.blocked_steps == 0 {
867 "✅ PASS"
868 } else {
869 "❌ BLOCKED"
870 };
871 html.push_str(&format!("<h1>Trace Explanation {}</h1>\n", status));
872 html.push_str(&format!(
873 "<p><strong>Policy:</strong> {} (v{})</p>\n",
874 self.policy_name, self.policy_version
875 ));
876 html.push_str(&format!(
877 "<p><strong>Summary:</strong> {} steps ({} allowed, {} blocked)</p>\n",
878 self.total_steps, self.allowed_steps, self.blocked_steps
879 ));
880
881 html.push_str("<h2>Timeline</h2>\n");
882
883 for step in &self.steps {
884 let class = match step.verdict {
885 StepVerdict::Allowed => "allowed",
886 StepVerdict::Blocked => "blocked",
887 StepVerdict::Warning => "warning",
888 };
889
890 let icon = match step.verdict {
891 StepVerdict::Allowed => "✅",
892 StepVerdict::Blocked => "❌",
893 StepVerdict::Warning => "⚠️",
894 };
895
896 html.push_str(&format!("<div class=\"step {}\">\n", class));
897 html.push_str(&format!(
898 " <strong>[{}]</strong> <code>{}</code> {}\n",
899 step.index, step.tool, icon
900 ));
901
902 if step.verdict == StepVerdict::Blocked {
903 for eval in &step.rules_evaluated {
904 if !eval.passed {
905 html.push_str(&format!(
906 " <div class=\"rule-detail\">Rule: <code>{}</code> — {}</div>\n",
907 eval.rule_id, eval.explanation
908 ));
909 }
910 }
911 }
912
913 html.push_str("</div>\n");
914 }
915
916 html.push_str("</body></html>");
917 html
918 }
919}
920
921fn summarize_args(args: &serde_json::Value) -> String {
922 match args {
923 serde_json::Value::Object(map) => map
924 .iter()
925 .take(2)
926 .map(|(k, v)| {
927 let v_str = match v {
928 serde_json::Value::String(s) => {
929 if s.len() > 20 {
930 format!("\"{}...\"", &s[..20])
931 } else {
932 format!("\"{}\"", s)
933 }
934 }
935 _ => v.to_string(),
936 };
937 format!("{}: {}", k, v_str)
938 })
939 .collect::<Vec<_>>()
940 .join(", "),
941 _ => args.to_string(),
942 }
943}
944
945#[cfg(test)]
946mod tests {
947 use super::*;
948 use crate::model::{Policy, SequenceRule, ToolsPolicy};
949 use crate::on_error::ErrorPolicy;
950
951 fn make_policy(rules: Vec<SequenceRule>) -> Policy {
952 Policy {
953 version: "1.1".to_string(),
954 name: "test".to_string(),
955 metadata: None,
956 tools: ToolsPolicy::default(),
957 sequences: rules,
958 aliases: std::collections::HashMap::new(),
959 on_error: ErrorPolicy::default(),
960 }
961 }
962
963 #[test]
964 fn test_explain_simple_trace() {
965 let policy = make_policy(vec![SequenceRule::Before {
966 first: "Search".to_string(),
967 then: "Create".to_string(),
968 }]);
969
970 let explainer = TraceExplainer::new(policy);
971 let trace = vec![
972 ToolCall {
973 tool: "Search".to_string(),
974 args: None,
975 },
976 ToolCall {
977 tool: "Create".to_string(),
978 args: None,
979 },
980 ];
981
982 let explanation = explainer.explain(&trace);
983
984 assert_eq!(explanation.total_steps, 2);
985 assert_eq!(explanation.allowed_steps, 2);
986 assert_eq!(explanation.blocked_steps, 0);
987 }
988
989 #[test]
990 fn test_explain_blocked_trace() {
991 let policy = make_policy(vec![SequenceRule::Before {
992 first: "Search".to_string(),
993 then: "Create".to_string(),
994 }]);
995
996 let explainer = TraceExplainer::new(policy);
997 let trace = vec![
998 ToolCall {
999 tool: "Create".to_string(),
1000 args: None,
1001 }, ];
1003
1004 let explanation = explainer.explain(&trace);
1005
1006 assert_eq!(explanation.blocked_steps, 1);
1007 assert_eq!(explanation.first_block_index, Some(0));
1008 assert!(!explanation.blocking_rules.is_empty());
1009 }
1010
1011 #[test]
1012 fn test_explain_max_calls() {
1013 let policy = make_policy(vec![SequenceRule::MaxCalls {
1014 tool: "API".to_string(),
1015 max: 2,
1016 }]);
1017
1018 let explainer = TraceExplainer::new(policy);
1019 let trace = vec![
1020 ToolCall {
1021 tool: "API".to_string(),
1022 args: None,
1023 },
1024 ToolCall {
1025 tool: "API".to_string(),
1026 args: None,
1027 },
1028 ToolCall {
1029 tool: "API".to_string(),
1030 args: None,
1031 }, ];
1033
1034 let explanation = explainer.explain(&trace);
1035
1036 assert_eq!(explanation.allowed_steps, 2);
1037 assert_eq!(explanation.blocked_steps, 1);
1038 assert_eq!(explanation.first_block_index, Some(2));
1039 }
1040
1041 #[test]
1042 fn test_terminal_output() {
1043 let policy = make_policy(vec![]);
1044 let explainer = TraceExplainer::new(policy);
1045 let trace = vec![ToolCall {
1046 tool: "Search".to_string(),
1047 args: None,
1048 }];
1049
1050 let explanation = explainer.explain(&trace);
1051 let output = explanation.to_terminal();
1052
1053 assert!(output.contains("Timeline:"));
1054 assert!(output.contains("[0]"));
1055 assert!(output.contains("Search"));
1056 }
1057}