1use 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 assert!(out.contains("Do NOT remove blindly") || out.contains("Do not remove"));
280 assert!(out.contains("sql.unused"));
281 }
282}