Skip to main content

aperion_shield/diff/
render.rs

1//! Output formatters for `aperion-shield --diff`.
2//!
3//! Three formats: `text` (humans, terminal default), `markdown`
4//! (CI / PR comments), and `json` (machine consumers, schema-stable
5//! with `scripts/shield-diff.py`).
6//!
7//! Each renderer takes the same inputs -- the corpus header, the
8//! decision distributions before/after, the per-rule deltas (already
9//! populated with fires_before/fires_after/flipped_lines_caused),
10//! and the flip counter -- and returns a `String`. None of them
11//! print directly so that callers can capture the output cleanly
12//! (tests, PR-comment posting, etc.).
13
14use std::collections::BTreeMap;
15use std::fmt::Write as _;
16
17use serde_json::json;
18
19use super::{loosening_count, FlipCounter, RuleDelta, DECISIONS};
20
21// ────────────────────────────────────────────────────────────────────
22// Helpers
23// ────────────────────────────────────────────────────────────────────
24
25/// Short, single-line summary of one corpus input record. Kept in
26/// sync with the Python prototype's `short_input` so flipped-line
27/// samples are recognisable across the two implementations.
28pub(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        // Reserve 3 chars for the "..." marker so the final string
58        // always fits in `maxlen`.
59        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// ────────────────────────────────────────────────────────────────────
77// Text renderer (default)
78// ────────────────────────────────────────────────────────────────────
79
80#[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        // largest absolute fire-count delta first
159        -((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    // Guidance line
231    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// ────────────────────────────────────────────────────────────────────
270// Markdown renderer (PR-comment friendly)
271// ────────────────────────────────────────────────────────────────────
272
273#[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// ────────────────────────────────────────────────────────────────────
405// JSON renderer (machine consumers; schema MUST stay stable with the
406// Python prototype's `--format json` output).
407// ────────────────────────────────────────────────────────────────────
408
409#[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
454// ────────────────────────────────────────────────────────────────────
455// Helpers
456// ────────────────────────────────────────────────────────────────────
457
458/// Thousand-separated number formatting (matches the Python `{:,}`
459/// format used in the prototype). Builds the string from the right
460/// so the comma positions are unambiguous for any digit count.
461fn 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}