assay_core/explain_next/
render.rs1use super::model::{StepVerdict, TraceExplanation};
2
3impl TraceExplanation {
4 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 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 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 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}