1use std::collections::BTreeMap;
15use std::fmt::Write as _;
16
17use serde_json::json;
18
19use super::{loosening_count, FlipCounter, RuleDelta, DECISIONS};
20
21pub(crate) fn short_input(input: &serde_json::Value, maxlen: usize) -> String {
29 let s = if let Some(tool) = input.get("tool").and_then(|v| v.as_str()) {
30 let empty = json!({});
31 let params = input.get("params").unwrap_or(&empty);
32 let key_hit = ["query", "command", "cmd", "sql", "path", "url"]
33 .iter()
34 .find_map(|k| params.get(k));
35 match key_hit {
36 Some(v) => {
37 let val_str = v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string());
38 format!("{}: {}", tool, val_str)
39 }
40 None => {
41 let mut blob = params.to_string();
42 if blob.len() > 80 {
43 blob.truncate(80);
44 }
45 format!("{}: {}", tool, blob)
46 }
47 }
48 } else if let Some(text) = input.get("text").and_then(|v| v.as_str()) {
49 format!("text: {}", text)
50 } else {
51 input.to_string()
52 };
53 let s = s.replace('\n', " ").replace('\t', " ");
54 if s.chars().count() <= maxlen {
55 s
56 } else {
57 let head = maxlen.saturating_sub(3);
60 let mut truncated: String = s.chars().take(head).collect();
61 truncated.push_str("...");
62 truncated
63 }
64}
65
66fn delta_pct(before: i64, after: i64) -> String {
67 let delta = after - before;
68 if before == 0 {
69 format!("({:+})", delta)
70 } else {
71 let pct = (delta as f64) / (before as f64) * 100.0;
72 format!("({:+}, {:+.1}%)", delta, pct)
73 }
74}
75
76#[allow(clippy::too_many_arguments)]
81pub fn render_text(
82 before_path: &str,
83 after_path: &str,
84 corpus_lines: usize,
85 decision_before: &BTreeMap<String, usize>,
86 decision_after: &BTreeMap<String, usize>,
87 deltas: &BTreeMap<String, RuleDelta>,
88 flips: &FlipCounter,
89 max_samples: usize,
90) -> String {
91 let mut buf = String::new();
92 let _ = writeln!(buf, "shield-diff: {} -> {}", before_path, after_path);
93 let _ = writeln!(buf, "corpus: {} commands\n", fmt_n(corpus_lines));
94
95 let _ = writeln!(buf, "DECISION DISTRIBUTION");
96 let _ = writeln!(buf, "{:<12}{:>10}{:>10}{:>14}", "", "before", "after", "delta");
97 for d in DECISIONS {
98 let b = *decision_before.get(d).unwrap_or(&0);
99 let a = *decision_after.get(d).unwrap_or(&0);
100 let pct = delta_pct(b as i64, a as i64);
101 let _ = writeln!(
102 buf,
103 " {:<10}{:>10}{:>10} {:<14}",
104 d,
105 fmt_n(b),
106 fmt_n(a),
107 pct
108 );
109 }
110 buf.push('\n');
111
112 let added: Vec<&RuleDelta> = deltas.values().filter(|d| d.status == "added").collect();
113 let removed: Vec<&RuleDelta> = deltas.values().filter(|d| d.status == "removed").collect();
114 let modified: Vec<&RuleDelta> = deltas.values().filter(|d| d.status == "modified").collect();
115 let unchanged_n = deltas.values().filter(|d| d.status == "unchanged").count();
116
117 let _ = writeln!(buf, "RULESET CHANGES");
118 if !added.is_empty() {
119 let _ = writeln!(
120 buf,
121 " added ({}): {}",
122 added.len(),
123 added.iter().map(|d| d.rule_id.as_str()).collect::<Vec<_>>().join(", "),
124 );
125 }
126 if !removed.is_empty() {
127 let _ = writeln!(
128 buf,
129 " removed ({}): {}",
130 removed.len(),
131 removed.iter().map(|d| d.rule_id.as_str()).collect::<Vec<_>>().join(", "),
132 );
133 }
134 if !modified.is_empty() {
135 let _ = writeln!(
136 buf,
137 " modified ({}): {}",
138 modified.len(),
139 modified.iter().map(|d| d.rule_id.as_str()).collect::<Vec<_>>().join(", "),
140 );
141 }
142 let _ = writeln!(buf, " unchanged: {} rules\n", unchanged_n);
143
144 for d in added.iter().chain(removed.iter()).chain(modified.iter()) {
145 let _ = writeln!(buf, " --- {} ({}) ---", d.rule_id, d.status);
146 for line in d.yaml_diff.lines() {
147 let _ = writeln!(buf, " {}", line);
148 }
149 buf.push('\n');
150 }
151
152 let _ = writeln!(buf, "BEHAVIORAL IMPACT BY RULE");
153 let mut behavioral: Vec<&RuleDelta> = deltas
154 .values()
155 .filter(|d| d.fires_before != d.fires_after || !d.flipped_lines_caused.is_empty())
156 .collect();
157 behavioral.sort_by_key(|d| {
158 -((d.fires_after as i64 - d.fires_before as i64).abs())
160 });
161 if behavioral.is_empty() {
162 let _ = writeln!(buf, " (no rules changed their fire counts in this corpus)\n");
163 } else {
164 for d in &behavioral {
165 let delta = d.fires_after as i64 - d.fires_before as i64;
166 let _ = writeln!(buf, " {}:", d.rule_id);
167 let _ = writeln!(buf, " fired before: {} lines", d.fires_before);
168 let _ = writeln!(
169 buf,
170 " fired after: {} lines ({:+})",
171 d.fires_after, delta
172 );
173 if !d.flipped_lines_caused.is_empty() {
174 let take_n = d.flipped_lines_caused.len().min(max_samples);
175 let _ = writeln!(
176 buf,
177 " sample of {} of {} flipped lines:",
178 take_n,
179 d.flipped_lines_caused.len()
180 );
181 for (db, da, inp) in d.flipped_lines_caused.iter().take(take_n) {
182 let _ = writeln!(buf, " [{} -> {}] {}", db, da, short_input(inp, 110));
183 }
184 }
185 buf.push('\n');
186 }
187 }
188
189 let flipped_total: usize = flips.values().sum();
190 let _ = writeln!(buf, "SUMMARY");
191 if corpus_lines > 0 {
192 let pct = (flipped_total as f64) / (corpus_lines as f64) * 100.0;
193 let _ = writeln!(
194 buf,
195 " flipped lines: {} of {} ({:.2}% of corpus)",
196 fmt_n(flipped_total),
197 fmt_n(corpus_lines),
198 pct
199 );
200 } else {
201 let _ = writeln!(buf, " flipped lines: 0");
202 }
203 if !flips.is_empty() {
204 let mut sorted: Vec<(&(String, String), &usize)> = flips.iter().collect();
205 sorted.sort_by_key(|&(_, c)| -(*c as i64));
206 for ((b, a), c) in sorted {
207 let arrow = format!("{} -> {}", b, a);
208 let _ = writeln!(buf, " {:<24}{:>6}", arrow, c);
209 }
210 let loosened = loosening_count(flips);
211 if loosened > 0 {
212 let _ = writeln!(
213 buf,
214 "\n loosened decisions: {} \
215 (this proposed change makes the engine MORE permissive on \
216 {} previously-flagged calls -- review each by hand)",
217 loosened, loosened
218 );
219 } else {
220 let _ = writeln!(
221 buf,
222 "\n no loosening detected (no line moved toward a more permissive decision)"
223 );
224 }
225 } else {
226 let _ = writeln!(buf, " no behavioral change in this corpus.");
227 }
228 buf.push('\n');
229
230 if flipped_total == 0 {
232 let _ = writeln!(
233 buf,
234 "GUIDANCE: this ruleset change has no observable effect on the supplied\n\
235 corpus. Either it only affects patterns your team hasn't seen yet, or\n\
236 it's a no-op. Add more representative cases to the corpus before merging."
237 );
238 } else {
239 let n_appr: usize = flips
240 .iter()
241 .filter(|((_, a), _)| a == "approval")
242 .map(|(_, c)| *c)
243 .sum();
244 let n_block: usize = flips
245 .iter()
246 .filter(|((_, a), _)| a == "block")
247 .map(|(_, c)| *c)
248 .sum();
249 let mut parts = Vec::new();
250 if n_appr > 0 {
251 parts.push(format!("~{} more daily approval prompts", n_appr));
252 }
253 if n_block > 0 {
254 parts.push(format!("~{} more daily hard blocks", n_block));
255 }
256 if !parts.is_empty() {
257 let _ = writeln!(
258 buf,
259 "GUIDANCE: based on this corpus, expect {}.\n\
260 Review the flipped-line samples above to confirm these are the\n\
261 prompts/blocks the change intends to add.",
262 parts.join(" and ")
263 );
264 }
265 }
266 buf
267}
268
269#[allow(clippy::too_many_arguments)]
274pub fn render_markdown(
275 before_path: &str,
276 after_path: &str,
277 corpus_lines: usize,
278 decision_before: &BTreeMap<String, usize>,
279 decision_after: &BTreeMap<String, usize>,
280 deltas: &BTreeMap<String, RuleDelta>,
281 flips: &FlipCounter,
282 max_samples: usize,
283) -> String {
284 let mut buf = String::new();
285 let _ = writeln!(
286 buf,
287 "### shieldset behavior diff -- `{}` -> `{}`",
288 before_path, after_path
289 );
290 let _ = writeln!(buf, "_corpus: {} commands_\n", fmt_n(corpus_lines));
291
292 let _ = writeln!(buf, "| decision | before | after | delta |");
293 let _ = writeln!(buf, "|---|---:|---:|---:|");
294 for d in DECISIONS {
295 let b = *decision_before.get(d).unwrap_or(&0);
296 let a = *decision_after.get(d).unwrap_or(&0);
297 let delta = a as i64 - b as i64;
298 let pct = if b > 0 {
299 format!(" ({:+.1}%)", (delta as f64) / (b as f64) * 100.0)
300 } else {
301 String::new()
302 };
303 let _ = writeln!(
304 buf,
305 "| `{}` | {} | {} | {:+}{} |",
306 d,
307 fmt_n(b),
308 fmt_n(a),
309 delta,
310 pct
311 );
312 }
313 buf.push('\n');
314
315 let added = deltas.values().filter(|d| d.status == "added").count();
316 let removed = deltas.values().filter(|d| d.status == "removed").count();
317 let modified = deltas.values().filter(|d| d.status == "modified").count();
318 let mut parts = Vec::new();
319 if added > 0 {
320 parts.push(format!("{} added", added));
321 }
322 if removed > 0 {
323 parts.push(format!("{} removed", removed));
324 }
325 if modified > 0 {
326 parts.push(format!("{} modified", modified));
327 }
328 if parts.is_empty() {
329 parts.push("none".into());
330 }
331 let _ = writeln!(buf, "**Ruleset changes:** {}\n", parts.join(", "));
332
333 let behavioral: Vec<&RuleDelta> = deltas
334 .values()
335 .filter(|d| d.fires_before != d.fires_after || !d.flipped_lines_caused.is_empty())
336 .collect();
337 if !behavioral.is_empty() {
338 let _ = writeln!(buf, "<details><summary>Rules with changed behavior on this corpus</summary>\n");
339 for d in &behavioral {
340 let delta = d.fires_after as i64 - d.fires_before as i64;
341 let _ = writeln!(
342 buf,
343 "**`{}`** ({}) -- fires `{}` -> `{}` ({:+})\n",
344 d.rule_id, d.status, d.fires_before, d.fires_after, delta
345 );
346 if !d.flipped_lines_caused.is_empty() {
347 let take_n = d.flipped_lines_caused.len().min(max_samples);
348 let _ = writeln!(
349 buf,
350 "_Sample of {} of {} flipped lines:_\n",
351 take_n,
352 d.flipped_lines_caused.len()
353 );
354 for (db, da, inp) in d.flipped_lines_caused.iter().take(take_n) {
355 let _ = writeln!(
356 buf,
357 "- `{} -> {}`: `{}`",
358 db,
359 da,
360 short_input(inp, 110)
361 );
362 }
363 buf.push('\n');
364 }
365 }
366 let _ = writeln!(buf, "</details>\n");
367 }
368
369 let flipped_total: usize = flips.values().sum();
370 if flipped_total == 0 {
371 let _ = writeln!(buf, "**Behavioral impact:** no flipped decisions on this corpus.");
372 } else {
373 let pct = if corpus_lines > 0 {
374 (flipped_total as f64) / (corpus_lines as f64) * 100.0
375 } else {
376 0.0
377 };
378 let _ = writeln!(
379 buf,
380 "**Behavioral impact:** {} of {} lines flipped ({:.2}%).\n",
381 fmt_n(flipped_total),
382 fmt_n(corpus_lines),
383 pct
384 );
385 let _ = writeln!(buf, "| direction | count |");
386 let _ = writeln!(buf, "|---|---:|");
387 let mut sorted: Vec<(&(String, String), &usize)> = flips.iter().collect();
388 sorted.sort_by_key(|&(_, c)| -(*c as i64));
389 for ((b, a), c) in sorted {
390 let _ = writeln!(buf, "| `{} -> {}` | {} |", b, a, c);
391 }
392 let loosened = loosening_count(flips);
393 if loosened > 0 {
394 let _ = writeln!(
395 buf,
396 "\n> **{} lines loosened** (moved toward a more permissive decision). Review each by hand.",
397 loosened
398 );
399 }
400 }
401 buf
402}
403
404#[allow(clippy::too_many_arguments)]
410pub fn render_json(
411 before_path: &str,
412 after_path: &str,
413 corpus_lines: usize,
414 decision_before: &BTreeMap<String, usize>,
415 decision_after: &BTreeMap<String, usize>,
416 deltas: &BTreeMap<String, RuleDelta>,
417 flips: &FlipCounter,
418) -> String {
419 let mut dbf = serde_json::Map::new();
420 let mut daf = serde_json::Map::new();
421 for d in DECISIONS {
422 dbf.insert(d.to_string(), json!(*decision_before.get(d).unwrap_or(&0)));
423 daf.insert(d.to_string(), json!(*decision_after.get(d).unwrap_or(&0)));
424 }
425 let rules: Vec<_> = deltas
426 .values()
427 .map(|d| {
428 json!({
429 "id": d.rule_id,
430 "status": d.status,
431 "fires_before": d.fires_before,
432 "fires_after": d.fires_after,
433 "flipped_caused": d.flipped_lines_caused.len(),
434 })
435 })
436 .collect();
437 let flips_arr: Vec<_> = flips
438 .iter()
439 .map(|((b, a), c)| json!({"from": b, "to": a, "count": c}))
440 .collect();
441 let payload = json!({
442 "before": before_path,
443 "after": after_path,
444 "corpus_lines": corpus_lines,
445 "decision_before": dbf,
446 "decision_after": daf,
447 "rules": rules,
448 "flips": flips_arr,
449 "loosened_count": loosening_count(flips),
450 });
451 serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string())
452}
453
454fn fmt_n(n: usize) -> String {
462 let s = n.to_string();
463 let bytes = s.as_bytes();
464 if bytes.len() <= 3 {
465 return s;
466 }
467 let mut out = Vec::with_capacity(bytes.len() + bytes.len() / 3);
468 for (i, b) in bytes.iter().rev().enumerate() {
469 if i != 0 && i % 3 == 0 {
470 out.push(b',');
471 }
472 out.push(*b);
473 }
474 out.reverse();
475 String::from_utf8(out).expect("ASCII digits + commas are always valid UTF-8")
476}
477
478#[cfg(test)]
479mod tests {
480 use super::*;
481
482 #[test]
483 fn fmt_n_thousands() {
484 assert_eq!(fmt_n(0), "0");
485 assert_eq!(fmt_n(123), "123");
486 assert_eq!(fmt_n(1_000), "1,000");
487 assert_eq!(fmt_n(12_345), "12,345");
488 assert_eq!(fmt_n(1_000_000), "1,000,000");
489 assert_eq!(fmt_n(13_456_789), "13,456,789");
490 }
491
492 #[test]
493 fn short_input_tool_query() {
494 let v = json!({"tool": "execute_sql", "params": {"query": "DROP DATABASE x"}});
495 assert_eq!(short_input(&v, 80), "execute_sql: DROP DATABASE x");
496 }
497
498 #[test]
499 fn short_input_text() {
500 let v = json!({"text": "I will rm -rf /"});
501 assert_eq!(short_input(&v, 80), "text: I will rm -rf /");
502 }
503
504 #[test]
505 fn short_input_truncates() {
506 let long = "a".repeat(200);
507 let v = json!({"tool": "shell", "params": {"command": long}});
508 let s = short_input(&v, 50);
509 assert!(s.len() <= 50);
510 assert!(s.ends_with("..."));
511 }
512}