1use crate::diagnostics::{Diagnostic, ScanResult, Severity};
8use std::collections::HashMap;
9use std::fmt::Write;
10
11#[derive(Debug, Clone)]
13pub struct RemediationItem {
14 pub priority: Priority,
15 pub rule: String,
16 pub count: usize,
17 pub severity: Severity,
18 pub description: String,
19 pub fix_action: String,
20 pub files: Vec<String>,
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
25pub enum Priority {
26 P0,
28 P1,
30 P2,
32 P3,
34}
35
36impl std::fmt::Display for Priority {
37 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38 match self {
39 Self::P0 => write!(f, "P0 Critical"),
40 Self::P1 => write!(f, "P1 High"),
41 Self::P2 => write!(f, "P2 Medium"),
42 Self::P3 => write!(f, "P3 Low"),
43 }
44 }
45}
46
47pub fn generate_plan(result: &ScanResult) -> Vec<RemediationItem> {
49 let mut by_rule: HashMap<&str, Vec<&Diagnostic>> = HashMap::new();
51 for d in &result.diagnostics {
52 by_rule.entry(d.rule.as_str()).or_default().push(d);
53 }
54
55 let mut items: Vec<RemediationItem> = by_rule
56 .into_iter()
57 .filter(|(rule, _)| *rule != "skipped-pass") .filter_map(|(rule, diags)| {
59 let first = diags.first()?;
60 let priority = classify_priority(first.severity, &first.category);
61 let file_set: std::collections::HashSet<String> = diags
62 .iter()
63 .map(|d| d.file_path.to_string_lossy().into_owned())
64 .collect();
65 let files: Vec<String> = file_set.into_iter().collect();
66
67 Some(RemediationItem {
68 priority,
69 rule: rule.to_string(),
70 count: diags.len(),
71 severity: first.severity,
72 description: first.message.clone(),
73 fix_action: first
74 .help
75 .clone()
76 .unwrap_or_else(|| "Review and fix manually".to_string()),
77 files,
78 })
79 })
80 .collect();
81
82 items.sort_by(|a, b| a.priority.cmp(&b.priority).then(b.count.cmp(&a.count)));
83 items
84}
85
86const fn classify_priority(
88 severity: Severity,
89 category: &crate::diagnostics::Category,
90) -> Priority {
91 use crate::diagnostics::Category;
92
93 match severity {
94 Severity::Error => Priority::P0,
95 Severity::Warning => match category {
96 Category::Security => Priority::P0,
97 Category::Correctness
98 | Category::ErrorHandling
99 | Category::Cargo
100 | Category::Dependencies
101 | Category::Async
102 | Category::Framework => Priority::P1,
103 Category::Performance | Category::Architecture => Priority::P2,
104 Category::Style => Priority::P3,
105 },
106 Severity::Info => Priority::P3,
107 }
108}
109
110pub fn format_plan_markdown(items: &[RemediationItem], result: &ScanResult) -> String {
112 let mut out = String::new();
113
114 let _ = writeln!(out, "# Remediation Plan");
115 let _ = writeln!(out);
116 let _ = writeln!(
117 out,
118 "**Score: {}/100 ({})** | {} errors, {} warnings, {} info | {} files scanned",
119 result.score,
120 result.score_label,
121 result.error_count,
122 result.warning_count,
123 result.info_count,
124 result.source_file_count
125 );
126 let _ = writeln!(
127 out,
128 "**Dimensions:** Security {}, Reliability {}, Maintainability {}, Performance {}, Dependencies {}",
129 result.dimension_scores.security,
130 result.dimension_scores.reliability,
131 result.dimension_scores.maintainability,
132 result.dimension_scores.performance,
133 result.dimension_scores.dependencies,
134 );
135 let _ = writeln!(out);
136
137 if items.is_empty() {
138 let _ = writeln!(out, "No actionable findings. The codebase is clean.");
139 return out;
140 }
141
142 let _ = writeln!(out, "## Action Items ({} total)", items.len());
143 let _ = writeln!(out);
144
145 let mut current_priority = None;
146
147 for (i, item) in items.iter().enumerate() {
148 if current_priority != Some(item.priority) {
149 current_priority = Some(item.priority);
150 let _ = writeln!(out, "### {}", item.priority);
151 let _ = writeln!(out);
152 }
153
154 let severity_icon = match item.severity {
155 Severity::Error => "E",
156 Severity::Warning => "W",
157 Severity::Info => "I",
158 };
159
160 let _ = writeln!(
161 out,
162 "{}. **[{}] `{}`** ({} occurrence{})",
163 i + 1,
164 severity_icon,
165 item.rule,
166 item.count,
167 if item.count > 1 { "s" } else { "" }
168 );
169 let _ = writeln!(out, " {}", item.description);
170 let _ = writeln!(out, " **Fix:** {}", item.fix_action);
171
172 if item.files.len() <= 5 {
173 let _ = writeln!(out, " **Files:** {}", item.files.join(", "));
174 } else if let Some(first_three) = item.files.get(..3) {
175 let _ = writeln!(
176 out,
177 " **Files:** {} (+{} more)",
178 first_three.join(", "),
179 item.files.len() - 3
180 );
181 }
182 let _ = writeln!(out);
183 }
184
185 let p0_count = items.iter().filter(|i| i.priority == Priority::P0).count();
187 let p1_count = items.iter().filter(|i| i.priority == Priority::P1).count();
188 let p2_count = items.iter().filter(|i| i.priority == Priority::P2).count();
189 let p3_count = items.iter().filter(|i| i.priority == Priority::P3).count();
190
191 let _ = writeln!(out, "---");
192 let _ = writeln!(
193 out,
194 "**Summary:** {p0_count} P0, {p1_count} P1, {p2_count} P2, {p3_count} P3"
195 );
196
197 if p0_count > 0 {
198 let _ = writeln!(
199 out,
200 "\nP0 items should be fixed immediately before merging."
201 );
202 }
203
204 out
205}
206
207pub fn format_plan_terminal(items: &[RemediationItem]) -> String {
209 let mut out = String::new();
210
211 if items.is_empty() {
212 let _ = writeln!(out, " No actionable findings.");
213 return out;
214 }
215
216 for (i, item) in items.iter().enumerate() {
217 let icon = match item.priority {
218 Priority::P0 => "!!!",
219 Priority::P1 => " ! ",
220 Priority::P2 => " - ",
221 Priority::P3 => " ",
222 };
223
224 let _ = writeln!(
225 out,
226 " {icon} {}) {} — {} ({}x)",
227 i + 1,
228 item.rule,
229 item.fix_action.chars().take(80).collect::<String>(),
230 item.count,
231 );
232 }
233
234 out
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240 use crate::diagnostics::{Category, DimensionScores, ScoreLabel};
241 use std::path::PathBuf;
242 use std::time::Duration;
243
244 fn make_result(diagnostics: Vec<Diagnostic>) -> ScanResult {
245 let error_count = diagnostics
246 .iter()
247 .filter(|d| d.severity == Severity::Error)
248 .count();
249 let warning_count = diagnostics
250 .iter()
251 .filter(|d| d.severity == Severity::Warning)
252 .count();
253 let info_count = diagnostics
254 .iter()
255 .filter(|d| d.severity == Severity::Info)
256 .count();
257 ScanResult {
258 diagnostics,
259 score: 85,
260 score_label: ScoreLabel::Great,
261 dimension_scores: DimensionScores {
262 security: 100,
263 reliability: 90,
264 maintainability: 95,
265 performance: 85,
266 dependencies: 100,
267 },
268 source_file_count: 10,
269 elapsed: Duration::from_millis(500),
270 skipped_passes: vec![],
271 error_count,
272 warning_count,
273 info_count,
274 pass_timings: vec![],
275 }
276 }
277
278 fn make_diag(rule: &str, severity: Severity, category: Category, file: &str) -> Diagnostic {
279 Diagnostic {
280 file_path: PathBuf::from(file),
281 rule: rule.to_string(),
282 category,
283 severity,
284 message: format!("Issue: {rule}"),
285 help: Some(format!("Fix {rule}")),
286 line: Some(1),
287 column: None,
288 fix: None,
289 }
290 }
291
292 #[test]
293 fn test_empty_scan_empty_plan() {
294 let result = make_result(vec![]);
295 let items = generate_plan(&result);
296 assert!(items.is_empty());
297 }
298
299 #[test]
300 fn test_plan_groups_by_rule() {
301 let result = make_result(vec![
302 make_diag("rule-a", Severity::Warning, Category::Performance, "a.rs"),
303 make_diag("rule-a", Severity::Warning, Category::Performance, "b.rs"),
304 make_diag("rule-b", Severity::Error, Category::Security, "c.rs"),
305 ]);
306 let items = generate_plan(&result);
307 assert_eq!(items.len(), 2);
308 assert_eq!(items[0].rule, "rule-b");
310 assert_eq!(items[0].priority, Priority::P0);
311 assert_eq!(items[1].rule, "rule-a");
312 assert_eq!(items[1].count, 2);
313 }
314
315 #[test]
316 fn test_plan_sorted_by_priority() {
317 let result = make_result(vec![
318 make_diag("info-rule", Severity::Info, Category::Style, "a.rs"),
319 make_diag("error-rule", Severity::Error, Category::Correctness, "b.rs"),
320 make_diag(
321 "warn-rule",
322 Severity::Warning,
323 Category::Architecture,
324 "c.rs",
325 ),
326 ]);
327 let items = generate_plan(&result);
328 assert_eq!(items[0].priority, Priority::P0);
329 assert_eq!(items[1].priority, Priority::P2);
330 assert_eq!(items[2].priority, Priority::P3);
331 }
332
333 #[test]
334 fn test_skipped_pass_excluded_from_plan() {
335 let result = make_result(vec![make_diag(
336 "skipped-pass",
337 Severity::Info,
338 Category::Cargo,
339 "Cargo.toml",
340 )]);
341 let items = generate_plan(&result);
342 assert!(items.is_empty());
343 }
344
345 #[test]
346 fn test_format_markdown_includes_score() {
347 let result = make_result(vec![]);
348 let items = generate_plan(&result);
349 let md = format_plan_markdown(&items, &result);
350 assert!(md.contains("85/100"));
351 assert!(md.contains("No actionable findings"));
352 }
353
354 #[test]
355 fn test_format_markdown_with_items() {
356 let result = make_result(vec![make_diag(
357 "unwrap-in-production",
358 Severity::Warning,
359 Category::ErrorHandling,
360 "src/scanner.rs",
361 )]);
362 let items = generate_plan(&result);
363 let md = format_plan_markdown(&items, &result);
364 assert!(md.contains("unwrap-in-production"));
365 assert!(md.contains("P1 High"));
366 assert!(md.contains("src/scanner.rs"));
367 }
368}