Skip to main content

assay_core/explain_next/
render.rs

1use super::model::{StepVerdict, TraceExplanation};
2
3impl TraceExplanation {
4    /// Format as terminal output with colors
5    pub fn to_terminal(&self) -> String {
6        let mut lines = Vec::new();
7
8        lines.push(format!(
9            "Policy: {} (v{})",
10            self.policy_name, self.policy_version
11        ));
12        lines.push(format!(
13            "Trace: {} steps ({} allowed, {} blocked)\n",
14            self.total_steps, self.allowed_steps, self.blocked_steps
15        ));
16
17        lines.push("Timeline:".to_string());
18
19        for step in &self.steps {
20            let icon = match step.verdict {
21                StepVerdict::Allowed => "✅",
22                StepVerdict::Blocked => "❌",
23                StepVerdict::Warning => "⚠️",
24            };
25
26            let args_str = step
27                .args
28                .as_ref()
29                .map(|a| format!("({})", summarize_args(a)))
30                .unwrap_or_default();
31
32            let status = match step.verdict {
33                StepVerdict::Allowed => "allowed".to_string(),
34                StepVerdict::Blocked => "BLOCKED".to_string(),
35                StepVerdict::Warning => "warning".to_string(),
36            };
37
38            lines.push(format!(
39                "  [{}] {}{:<40} {} {}",
40                step.index, step.tool, args_str, icon, status
41            ));
42
43            // Show blocking rule details
44            if step.verdict == StepVerdict::Blocked {
45                for eval in &step.rules_evaluated {
46                    if !eval.passed {
47                        lines.push(format!("      └── Rule: {}", eval.rule_id));
48                        lines.push(format!("      └── Reason: {}", eval.explanation));
49                    }
50                }
51            }
52        }
53
54        if !self.blocking_rules.is_empty() {
55            lines.push(String::new());
56            lines.push("Blocking Rules:".to_string());
57            for rule in &self.blocking_rules {
58                lines.push(format!("  - {}", rule));
59            }
60        }
61
62        lines.join("\n")
63    }
64
65    /// Format as markdown
66    pub fn to_markdown(&self) -> String {
67        let mut md = String::new();
68
69        let status = if self.blocked_steps == 0 {
70            "✅ PASS"
71        } else {
72            "❌ BLOCKED"
73        };
74
75        md.push_str(&format!("## Trace Explanation {}\n\n", status));
76        md.push_str(&format!(
77            "**Policy:** {} (v{})\n\n",
78            self.policy_name, self.policy_version
79        ));
80        md.push_str("| Steps | Allowed | Blocked |\n");
81        md.push_str("|-------|---------|----------|\n");
82        md.push_str(&format!(
83            "| {} | {} | {} |\n\n",
84            self.total_steps, self.allowed_steps, self.blocked_steps
85        ));
86
87        md.push_str("### Timeline\n\n");
88        md.push_str("| # | Tool | Verdict | Details |\n");
89        md.push_str("|---|------|---------|----------|\n");
90
91        for step in &self.steps {
92            let icon = match step.verdict {
93                StepVerdict::Allowed => "✅",
94                StepVerdict::Blocked => "❌",
95                StepVerdict::Warning => "⚠️",
96            };
97
98            let details = if step.verdict == StepVerdict::Blocked {
99                step.rules_evaluated
100                    .iter()
101                    .filter(|e| !e.passed)
102                    .map(|e| e.explanation.clone())
103                    .collect::<Vec<_>>()
104                    .join("; ")
105            } else {
106                String::new()
107            };
108
109            md.push_str(&format!(
110                "| {} | `{}` | {} | {} |\n",
111                step.index, step.tool, icon, details
112            ));
113        }
114
115        if !self.blocking_rules.is_empty() {
116            md.push_str("\n### Blocking Rules\n\n");
117            for rule in &self.blocking_rules {
118                md.push_str(&format!("- `{}`\n", rule));
119            }
120        }
121
122        md
123    }
124
125    /// Format as HTML
126    pub fn to_html(&self) -> String {
127        let mut html = String::new();
128
129        html.push_str("<!DOCTYPE html>\n<html><head>\n");
130        html.push_str("<meta charset=\"utf-8\">\n");
131        html.push_str("<title>Trace Explanation</title>\n");
132        html.push_str("<style>\n");
133        html.push_str("body { font-family: system-ui, sans-serif; max-width: 900px; margin: 2rem auto; padding: 0 1rem; }\n");
134        html.push_str(".step { padding: 0.5rem; margin: 0.25rem 0; border-radius: 4px; }\n");
135        html.push_str(".allowed { background: #d4edda; }\n");
136        html.push_str(".blocked { background: #f8d7da; }\n");
137        html.push_str(".warning { background: #fff3cd; }\n");
138        html.push_str(".rule-detail { margin-left: 2rem; color: #666; font-size: 0.9em; }\n");
139        html.push_str(
140            "code { background: #f4f4f4; padding: 0.2rem 0.4rem; border-radius: 3px; }\n",
141        );
142        html.push_str("</style>\n</head><body>\n");
143
144        let status = if self.blocked_steps == 0 {
145            "✅ PASS"
146        } else {
147            "❌ BLOCKED"
148        };
149        html.push_str(&format!("<h1>Trace Explanation {}</h1>\n", status));
150        html.push_str(&format!(
151            "<p><strong>Policy:</strong> {} (v{})</p>\n",
152            self.policy_name, self.policy_version
153        ));
154        html.push_str(&format!(
155            "<p><strong>Summary:</strong> {} steps ({} allowed, {} blocked)</p>\n",
156            self.total_steps, self.allowed_steps, self.blocked_steps
157        ));
158
159        html.push_str("<h2>Timeline</h2>\n");
160
161        for step in &self.steps {
162            let class = match step.verdict {
163                StepVerdict::Allowed => "allowed",
164                StepVerdict::Blocked => "blocked",
165                StepVerdict::Warning => "warning",
166            };
167
168            let icon = match step.verdict {
169                StepVerdict::Allowed => "✅",
170                StepVerdict::Blocked => "❌",
171                StepVerdict::Warning => "⚠️",
172            };
173
174            html.push_str(&format!("<div class=\"step {}\">\n", class));
175            html.push_str(&format!(
176                "  <strong>[{}]</strong> <code>{}</code> {}\n",
177                step.index, step.tool, icon
178            ));
179
180            if step.verdict == StepVerdict::Blocked {
181                for eval in &step.rules_evaluated {
182                    if !eval.passed {
183                        html.push_str(&format!(
184                            "  <div class=\"rule-detail\">Rule: <code>{}</code> — {}</div>\n",
185                            eval.rule_id, eval.explanation
186                        ));
187                    }
188                }
189            }
190
191            html.push_str("</div>\n");
192        }
193
194        html.push_str("</body></html>");
195        html
196    }
197}
198
199fn summarize_args(args: &serde_json::Value) -> String {
200    match args {
201        serde_json::Value::Object(map) => map
202            .iter()
203            .take(2)
204            .map(|(k, v)| {
205                let v_str = match v {
206                    serde_json::Value::String(s) => {
207                        if s.len() > 20 {
208                            format!("\"{}...\"", &s[..20])
209                        } else {
210                            format!("\"{}\"", s)
211                        }
212                    }
213                    _ => v.to_string(),
214                };
215                format!("{}: {}", k, v_str)
216            })
217            .collect::<Vec<_>>()
218            .join(", "),
219        _ => args.to_string(),
220    }
221}