Skip to main content

assay_core/
explain.rs

1//! Trace explanation and visualization
2//!
3//! Evaluates a trace against a policy and produces a step-by-step
4//! explanation of what happened at each tool call.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9/// A single step in the explained trace
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct ExplainedStep {
12    pub index: usize,
13    pub tool: String,
14
15    /// Tool arguments (if available)
16    #[serde(default, skip_serializing_if = "Option::is_none")]
17    pub args: Option<serde_json::Value>,
18
19    /// Verdict for this step
20    pub verdict: StepVerdict,
21
22    /// Rules that were evaluated
23    pub rules_evaluated: Vec<RuleEvaluation>,
24
25    /// Current state of stateful rules after this step
26    #[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    /// Tool call allowed
34    Allowed,
35    /// Tool call blocked by a rule
36    Blocked,
37    /// Tool call allowed but triggered a warning
38    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/// Complete explanation of a trace
53#[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    /// Detailed step-by-step explanation
65    pub steps: Vec<ExplainedStep>,
66
67    /// Summary of rules that blocked
68    pub blocking_rules: Vec<String>,
69}
70
71/// Tool call input for explanation
72#[derive(Debug, Clone, Deserialize)]
73pub struct ToolCall {
74    /// Tool name
75    #[serde(alias = "name", alias = "tool_name")]
76    pub tool: String,
77
78    /// Tool arguments
79    #[serde(default)]
80    pub args: Option<serde_json::Value>,
81}
82
83/// Trace explainer
84pub 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    /// Explain a trace step by step
94    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        // Check end-of-trace constraints
117        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        // Check static constraints (allow/deny lists)
160        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        // Check each sequence rule
169        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        // Update state after evaluation
181        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        // Check deny list first
197        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        // Check allow list
210        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
235/// Internal state tracking for stateful rules
236struct ExplainerState {
237    /// Tools seen so far
238    tools_seen: Vec<String>,
239
240    /// Call counts per tool
241    call_counts: HashMap<String, u32>,
242
243    /// Whether specific tools have been seen (for before/after)
244    tool_seen_flags: HashMap<String, bool>,
245
246    /// Triggered state for never_after rules
247    never_after_triggered: HashMap<usize, usize>, // rule_idx -> trigger_idx
248
249    /// Pending "after" constraints: rule_idx -> (trigger_idx, deadline)
250    pending_after: HashMap<usize, (usize, usize)>,
251
252    /// Sequence progress: rule_idx -> current position in sequence
253    sequence_progress: HashMap<usize, usize>,
254
255    /// Aliases for resolution
256    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                // Require is checked at end of trace, always passes during
295                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                // Check if we're past deadline
423                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                        // In strict mode, if sequence started, next must be expected
530                        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                        // Non-strict: check for out-of-order
548                        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        // Update call counts
614        *self.call_counts.entry(tool.to_string()).or_insert(0) += 1;
615
616        // Update seen flags
617        self.tool_seen_flags.insert(tool.to_string(), true);
618
619        // Update rule-specific state
620        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                        // Start/restart the deadline timer on trigger
634                        // Note: If triggered multiple times, this implementation updates to the LATEST trigger.
635                        // This matches "within N calls after [any] trigger".
636                        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        // Add to tools seen
651        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 we have a pending deadline that wasn't satisfied
679                    if let Some((trigger_idx, deadline)) = self.pending_after.get(&rule_idx) {
680                        // Check if we saw 'then' AFTER the trigger
681                        // Note: self.tools_seen contains all calls.
682                        // We need to see if 'then' appeared between trigger_idx+1 and end (or deadline).
683                        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    /// Format as terminal output with colors
727    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            // Show blocking rule details
766            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    /// Format as markdown
788    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    /// Format as HTML
848    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            }, // Blocked - no Search first
1002        ];
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            }, // Blocked
1032        ];
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}