Skip to main content

rust_doctor/
plan.rs

1//! Remediation plan generator — turns scan diagnostics into a structured,
2//! prioritized action plan.
3//!
4//! The plan groups findings by priority (P0–P3), provides effort estimates,
5//! and includes actionable fix descriptions for each item.
6
7use crate::diagnostics::{Diagnostic, ScanResult, Severity};
8use std::collections::HashMap;
9use std::fmt::Write;
10
11/// A single remediation item in the plan.
12#[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/// Priority level for remediation items.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
25pub enum Priority {
26    /// Critical — must fix (security, correctness bugs)
27    P0,
28    /// High — should fix (error handling, reliability)
29    P1,
30    /// Medium — recommended (performance, maintainability)
31    P2,
32    /// Low — nice to have (style, info-level)
33    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
47/// Generate a remediation plan from scan results.
48pub fn generate_plan(result: &ScanResult) -> Vec<RemediationItem> {
49    // Group diagnostics by rule
50    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") // Skip informational pass notices
58        .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
86/// Classify a finding into a priority level.
87const 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
110/// Format the plan as a human-readable markdown string.
111pub 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    // Summary stats
186    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
207/// Format the plan as a concise terminal-friendly string (no markdown).
208pub 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        // P0 (security error) should come first
309        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}