Skip to main content

aperion_shield/suggest/
render.rs

1//! Render a list of [`Suggestion`]s for human + machine consumption.
2//!
3//! Three formats:
4//!
5//!  * **text** — terminal-friendly, grouped by suggestion kind. Default.
6//!  * **markdown** — copy-paste into a PR review or issue. Same shape
7//!    as the text format but with `##` headings + bullet lists.
8//!  * **yaml-patch** — partial-shieldset YAML snippets the operator can
9//!    splice into their `shieldset.yaml`. Each snippet has a
10//!    `# rationale:` comment line that explains why the change is
11//!    suggested.
12//!
13//! All three formats include the same per-suggestion fields (rule_id,
14//! kind, evidence). What differs is the wrapping.
15
16use crate::suggest::analyze::Suggestion;
17use std::fmt::Write;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum OutputFormat {
21    Text,
22    Markdown,
23    YamlPatch,
24}
25
26impl OutputFormat {
27    pub fn parse(s: &str) -> anyhow::Result<Self> {
28        match s.to_ascii_lowercase().as_str() {
29            "text" => Ok(Self::Text),
30            "markdown" | "md" => Ok(Self::Markdown),
31            "yaml-patch" | "yaml" | "patch" => Ok(Self::YamlPatch),
32            _ => anyhow::bail!(
33                "unknown --format '{}'. Valid: text | markdown | yaml-patch",
34                s
35            ),
36        }
37    }
38}
39
40pub fn render(suggestions: &[Suggestion], format: OutputFormat) -> String {
41    match format {
42        OutputFormat::Text => render_text(suggestions),
43        OutputFormat::Markdown => render_markdown(suggestions),
44        OutputFormat::YamlPatch => render_yaml_patch(suggestions),
45    }
46}
47
48fn render_text(suggestions: &[Suggestion]) -> String {
49    let mut out = String::new();
50    if suggestions.is_empty() {
51        out.push_str("[shield-suggest-rules] No tuning suggestions — your shieldset is well-fit for the audit window.\n");
52        return out;
53    }
54    let _ = writeln!(out, "[shield-suggest-rules] {} suggestion(s):\n", suggestions.len());
55    for s in suggestions {
56        match s {
57            Suggestion::RuleNeverFires { rule_id, window_days } => {
58                let _ = writeln!(
59                    out,
60                    "  [{kind}] {rid}",
61                    kind = s.kind(),
62                    rid = rule_id,
63                );
64                match window_days {
65                    Some(d) => {
66                        let _ = writeln!(out, "    Did not fire over the last {} day(s) of audit log.", d);
67                    }
68                    None => {
69                        let _ = writeln!(out, "    Did not fire over the audit log window.");
70                    }
71                }
72                let _ = writeln!(
73                    out,
74                    "    Suggestion: review whether this rule is still needed for your\n               \
75                     environment. Do NOT remove blindly — \"never fired\" can mean\n               \
76                     \"nobody's tried this destructive thing yet,\" which is exactly\n               \
77                     the case Shield exists for.\n",
78                );
79            }
80            Suggestion::ConsistentlyDemoted {
81                rule_id,
82                observed_fires,
83                raw_severity,
84                observed_final,
85            } => {
86                let _ = writeln!(out, "  [{}] {}", s.kind(), rule_id);
87                let _ = writeln!(
88                    out,
89                    "    Fired {} time(s); the adaptive layer demoted EVERY observation\n    from `{}` down to `{}`.\n    Suggestion: bump the static `severity:` from {} to {} (or remove\n    `severity:` entirely and let the adaptive layer decide).\n",
90                    observed_fires, raw_severity, observed_final, raw_severity, observed_final,
91                );
92            }
93            Suggestion::NoisyWarn { rule_id, observed_fires } => {
94                let _ = writeln!(out, "  [{}] {}", s.kind(), rule_id);
95                let _ = writeln!(
96                    out,
97                    "    Fired {} time(s); every observation resolved to `warn` (never\n    escalated). This rule is eating composite-score headroom for\n    higher-stakes rules without ever blocking the call.\n    Suggestion: consider dropping severity to `Low` so it stops\n    contributing composite points OR add an exclude rule for the\n    specific call shape that's spamming it.\n",
98                    observed_fires,
99                );
100            }
101        }
102    }
103    out
104}
105
106fn render_markdown(suggestions: &[Suggestion]) -> String {
107    let mut out = String::new();
108    if suggestions.is_empty() {
109        out.push_str("## Aperion Shield — rule tuning suggestions\n\nNo tuning suggestions for this audit window. Your shieldset is well-fit.\n");
110        return out;
111    }
112    let _ = writeln!(
113        out,
114        "## Aperion Shield — rule tuning suggestions\n\n{} suggestion(s) from analyzing your audit log.\n",
115        suggestions.len()
116    );
117    for s in suggestions {
118        match s {
119            Suggestion::RuleNeverFires { rule_id, window_days } => {
120                let _ = writeln!(out, "### `{}` — never fires", rule_id);
121                match window_days {
122                    Some(d) => {
123                        let _ = writeln!(out, "\n- **Kind:** `RULE_NEVER_FIRES`");
124                        let _ = writeln!(out, "- **Evidence:** 0 audit rows over the last {} day(s).", d);
125                    }
126                    None => {
127                        let _ = writeln!(out, "\n- **Kind:** `RULE_NEVER_FIRES`");
128                        let _ = writeln!(out, "- **Evidence:** 0 audit rows over the analyzed window.");
129                    }
130                }
131                let _ = writeln!(
132                    out,
133                    "- **Suggestion:** review whether this rule is still needed for your environment. *Do not remove blindly* — \"never fired\" can mean \"nobody's tried this destructive thing yet,\" which is exactly the case Shield exists for.\n"
134                );
135            }
136            Suggestion::ConsistentlyDemoted {
137                rule_id,
138                observed_fires,
139                raw_severity,
140                observed_final,
141            } => {
142                let _ = writeln!(out, "### `{}` — consistently demoted", rule_id);
143                let _ = writeln!(out, "\n- **Kind:** `CONSISTENTLY_DEMOTED`");
144                let _ = writeln!(
145                    out,
146                    "- **Evidence:** {} fires; the adaptive layer demoted every observation from `{}` to `{}`.",
147                    observed_fires, raw_severity, observed_final,
148                );
149                let _ = writeln!(
150                    out,
151                    "- **Suggestion:** bump static `severity:` from `{}` to `{}`, or remove `severity:` entirely and let the adaptive layer continue to do the job it's already doing.\n",
152                    raw_severity, observed_final,
153                );
154            }
155            Suggestion::NoisyWarn { rule_id, observed_fires } => {
156                let _ = writeln!(out, "### `{}` — noisy warn", rule_id);
157                let _ = writeln!(out, "\n- **Kind:** `NOISY_WARN`");
158                let _ = writeln!(
159                    out,
160                    "- **Evidence:** {} fires, all resolving to `warn`. Never escalated.",
161                    observed_fires,
162                );
163                let _ = writeln!(
164                    out,
165                    "- **Suggestion:** drop severity to `Low` so it stops contributing composite-score points, or add an exclude rule for the call shape that's spamming it.\n",
166                );
167            }
168        }
169    }
170    out
171}
172
173fn render_yaml_patch(suggestions: &[Suggestion]) -> String {
174    let mut out = String::new();
175    out.push_str(
176        "# aperion-shield --suggest-rules YAML patch\n\
177         # Apply by hand to your shieldset.yaml. Each block is a partial\n\
178         # rule update — splice the `severity:` / `excludes:` fields into\n\
179         # the matching rule. Do NOT paste the whole block verbatim.\n\
180         #\n",
181    );
182    if suggestions.is_empty() {
183        out.push_str("# (no suggestions)\n");
184        return out;
185    }
186    for s in suggestions {
187        match s {
188            Suggestion::RuleNeverFires { rule_id, window_days } => {
189                let _ = writeln!(out);
190                let _ = writeln!(out, "# RULE_NEVER_FIRES: {}", rule_id);
191                match window_days {
192                    Some(d) => {
193                        let _ = writeln!(out, "#   rationale: 0 audit rows in the last {} day(s).", d);
194                    }
195                    None => {
196                        let _ = writeln!(out, "#   rationale: 0 audit rows in the analyzed window.");
197                    }
198                }
199                let _ = writeln!(out, "#   action: REVIEW. We do not auto-suggest removal.");
200                let _ = writeln!(out, "# - id: {}\n#   # (left intact — review only)", rule_id);
201            }
202            Suggestion::ConsistentlyDemoted {
203                rule_id,
204                observed_fires,
205                raw_severity,
206                observed_final,
207            } => {
208                let _ = writeln!(out);
209                let _ = writeln!(out, "# CONSISTENTLY_DEMOTED: {}", rule_id);
210                let _ = writeln!(
211                    out,
212                    "#   rationale: {} fires; every one demoted from {} to {}.",
213                    observed_fires, raw_severity, observed_final,
214                );
215                let _ = writeln!(out, "- id: {}", rule_id);
216                let _ = writeln!(out, "  severity: {}", observed_final);
217            }
218            Suggestion::NoisyWarn { rule_id, observed_fires } => {
219                let _ = writeln!(out);
220                let _ = writeln!(out, "# NOISY_WARN: {}", rule_id);
221                let _ = writeln!(
222                    out,
223                    "#   rationale: {} fires, all resolving to `warn`. Never escalated.",
224                    observed_fires,
225                );
226                let _ = writeln!(out, "- id: {}", rule_id);
227                let _ = writeln!(out, "  severity: Low");
228            }
229        }
230    }
231    out
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237    use crate::suggest::analyze::Suggestion;
238
239    #[test]
240    fn parse_format_accepts_aliases() {
241        assert_eq!(OutputFormat::parse("text").unwrap(), OutputFormat::Text);
242        assert_eq!(OutputFormat::parse("markdown").unwrap(), OutputFormat::Markdown);
243        assert_eq!(OutputFormat::parse("md").unwrap(), OutputFormat::Markdown);
244        assert_eq!(OutputFormat::parse("yaml-patch").unwrap(), OutputFormat::YamlPatch);
245        assert_eq!(OutputFormat::parse("patch").unwrap(), OutputFormat::YamlPatch);
246        assert!(OutputFormat::parse("bogus").is_err());
247    }
248
249    #[test]
250    fn empty_suggestion_list_renders_a_clean_message_in_each_format() {
251        for fmt in [OutputFormat::Text, OutputFormat::Markdown, OutputFormat::YamlPatch] {
252            let s = render(&[], fmt);
253            assert!(!s.is_empty(), "format {:?} should always render something", fmt);
254        }
255    }
256
257    #[test]
258    fn yaml_patch_emits_severity_block_for_demoted() {
259        let suggestions = vec![Suggestion::ConsistentlyDemoted {
260            rule_id: "sql.foo".into(),
261            observed_fires: 6,
262            raw_severity: "Critical".into(),
263            observed_final: "Low".into(),
264        }];
265        let out = render(&suggestions, OutputFormat::YamlPatch);
266        assert!(out.contains("- id: sql.foo"));
267        assert!(out.contains("severity: Low"));
268        assert!(out.contains("CONSISTENTLY_DEMOTED"));
269    }
270
271    #[test]
272    fn text_format_does_not_recommend_blind_removal() {
273        let suggestions = vec![Suggestion::RuleNeverFires {
274            rule_id: "sql.unused".into(),
275            window_days: Some(30),
276        }];
277        let out = render(&suggestions, OutputFormat::Text);
278        // Must surface the cautionary message.
279        assert!(out.contains("Do NOT remove blindly") || out.contains("Do not remove"));
280        assert!(out.contains("sql.unused"));
281    }
282}