rscheck-cli 0.1.0-alpha.3

CLI frontend for the rscheck policy engine.
Documentation
use crate::fix::line_col_to_byte_offset;
use crate::report::{
    Finding, FindingLabel, FindingLabelKind, FindingNoteKind, FixSafety, Report, Severity,
};
use annotate_snippets::{AnnotationKind, Group, Level, Renderer, Snippet};
use std::collections::BTreeMap;
use std::env::current_dir;
use std::fs;
use std::ops::Range;
use std::path::{Path, PathBuf};

pub fn render_text_report(report: &Report) -> String {
    TextReportRenderer::new().render(report)
}

struct TextReportRenderer {
    renderer: Renderer,
    cwd: PathBuf,
    source_cache: BTreeMap<String, Option<String>>,
}

impl TextReportRenderer {
    fn new() -> Self {
        Self {
            renderer: Renderer::plain(),
            cwd: current_dir().unwrap_or_else(|_| PathBuf::from(".")),
            source_cache: BTreeMap::new(),
        }
    }

    fn render(&mut self, report: &Report) -> String {
        let mut out = String::new();
        for finding in &report.findings {
            out.push_str(&self.render_finding(finding));
            out.push('\n');
        }

        if let Some(toolchain) = &report.summary.toolchain {
            out.push_str(&format!(
                "toolchain: requested={}, resolved={}, semantic={}, nightly_available={}\n",
                toolchain.requested,
                toolchain.resolved,
                toolchain.semantic,
                toolchain.nightly_available
            ));
            if let Some(reason) = &toolchain.reason {
                out.push_str(&format!("note: {reason}\n"));
            }
        }

        if !report.summary.skipped_rules.is_empty() {
            out.push_str("\nskipped semantic rules:\n");
            for rule in &report.summary.skipped_rules {
                out.push_str(&format!("- {rule}\n"));
            }
        }

        out
    }

    fn render_finding(&mut self, finding: &Finding) -> String {
        let labels = normalize_labels(finding);
        let snippet_groups = self.build_snippet_groups(&labels);
        if snippet_groups.is_empty() {
            return fallback_line(finding);
        }

        let mut message = Group::with_title(
            level_for(finding.severity)
                .primary_title(format!("[{}] {}", finding.rule_id, finding.message)),
        );
        for group in &snippet_groups {
            let mut snippet = Snippet::source(&group.source)
                .line_start(1)
                .path(group.display_path.as_str());
            for label in &group.labels {
                let annotation = match label.kind {
                    FindingLabelKind::Primary => AnnotationKind::Primary,
                    FindingLabelKind::Secondary => AnnotationKind::Context,
                };
                snippet = snippet.annotation(
                    annotation
                        .span(label.range.clone())
                        .label(label.message.as_str()),
                );
            }
            message = message.element(snippet);
        }

        let mut out = self.renderer.render(&[message]).to_string();
        append_text_notes(&mut out, finding);
        out
    }

    fn build_snippet_groups(&mut self, labels: &[FindingLabel]) -> Vec<RenderedSnippetGroup> {
        let mut grouped: BTreeMap<String, Vec<FindingLabel>> = BTreeMap::new();
        for label in labels {
            grouped
                .entry(label.span.file.clone())
                .or_default()
                .push(label.clone());
        }

        let mut snippets = Vec::new();
        for (file, file_labels) in grouped {
            let Some(source) = self.read_source(&file).clone() else {
                continue;
            };
            let display_path = display_path(&self.cwd, Path::new(&file));
            let mut rendered_labels = Vec::new();
            for label in file_labels {
                let Ok(byte_start) = line_col_to_byte_offset(
                    &source,
                    label.span.start.line,
                    label.span.start.column,
                ) else {
                    continue;
                };
                let Ok(byte_end) =
                    line_col_to_byte_offset(&source, label.span.end.line, label.span.end.column)
                else {
                    continue;
                };
                rendered_labels.push(RenderedLabel {
                    kind: label.kind,
                    range: byte_start..byte_end.max(byte_start.saturating_add(1)),
                    message: label.message.unwrap_or_default(),
                });
            }

            if rendered_labels.is_empty() {
                continue;
            }

            snippets.push(RenderedSnippetGroup {
                source,
                display_path,
                labels: rendered_labels,
            });
        }

        snippets
    }

    fn read_source(&mut self, file: &str) -> &Option<String> {
        self.source_cache
            .entry(file.to_string())
            .or_insert_with(|| {
                let path = Path::new(file);
                fs::read_to_string(path).ok()
            })
    }
}

#[derive(Clone)]
struct RenderedSnippetGroup {
    source: String,
    display_path: String,
    labels: Vec<RenderedLabel>,
}

#[derive(Clone)]
struct RenderedLabel {
    kind: FindingLabelKind,
    range: Range<usize>,
    message: String,
}

fn normalize_labels(finding: &Finding) -> Vec<FindingLabel> {
    if !finding.labels.is_empty() {
        return finding.labels.clone();
    }

    let mut labels = Vec::new();
    if let Some(primary) = &finding.primary {
        labels.push(FindingLabel {
            kind: FindingLabelKind::Primary,
            span: primary.clone(),
            message: Some(finding.message.clone()),
        });
    }
    for secondary in &finding.secondary {
        labels.push(FindingLabel {
            kind: FindingLabelKind::Secondary,
            span: secondary.clone(),
            message: None,
        });
    }
    labels
}

fn append_text_notes(out: &mut String, finding: &Finding) {
    if let Some(help) = &finding.help {
        out.push_str(&format!("help: {help}\n"));
    }
    if let Some(evidence) = &finding.evidence {
        out.push_str(&format!("note: {evidence}\n"));
    }
    if let Some(confidence) = &finding.confidence {
        out.push_str(&format!("info: confidence={confidence}\n"));
    }
    for note in &finding.notes {
        let prefix = match note.kind {
            FindingNoteKind::Help => "help",
            FindingNoteKind::Note => "note",
            FindingNoteKind::Info => "info",
        };
        out.push_str(&format!("{prefix}: {}\n", note.message));
    }
    for fix in &finding.fixes {
        let safety = match fix.safety {
            FixSafety::Safe => "safe",
            FixSafety::Unsafe => "unsafe",
        };
        out.push_str(&format!("suggestion[{safety}]: {}\n", fix.message));
    }
}

fn fallback_line(finding: &Finding) -> String {
    let severity = match finding.severity {
        Severity::Info => "info",
        Severity::Warn => "warning",
        Severity::Deny => "error",
    };
    match &finding.primary {
        Some(span) => format!(
            "{severity}[{}]: {} at {}:{}:{}\n",
            finding.rule_id, finding.message, span.file, span.start.line, span.start.column
        ),
        None => format!("{severity}[{}]: {}\n", finding.rule_id, finding.message),
    }
}

fn level_for(severity: Severity) -> Level<'static> {
    match severity {
        Severity::Info => Level::INFO,
        Severity::Warn => Level::WARNING,
        Severity::Deny => Level::ERROR,
    }
}

fn display_path(cwd: &Path, path: &Path) -> String {
    path.strip_prefix(cwd)
        .unwrap_or(path)
        .to_string_lossy()
        .to_string()
}

#[cfg(test)]
mod tests {
    use super::render_text_report;
    use crate::report::{Finding, FindingLabel, FindingLabelKind, Report, Severity};
    use crate::span::{Location, Span};

    #[test]
    fn renders_fallback_when_source_missing() {
        let report = Report {
            findings: vec![Finding {
                rule_id: "demo.rule".to_string(),
                family: None,
                engine: None,
                severity: Severity::Warn,
                message: "warning text".to_string(),
                primary: Some(Span {
                    file: "missing.rs".to_string(),
                    start: Location { line: 1, column: 1 },
                    end: Location { line: 1, column: 3 },
                }),
                secondary: Vec::new(),
                help: None,
                evidence: None,
                confidence: None,
                tags: Vec::new(),
                labels: Vec::new(),
                notes: Vec::new(),
                fixes: Vec::new(),
            }],
            ..Report::default()
        };

        let text = render_text_report(&report);
        assert!(text.contains("warning[demo.rule]: warning text"));
    }

    #[test]
    fn prefers_structured_labels() {
        let report = Report {
            findings: vec![Finding {
                rule_id: "demo.rule".to_string(),
                family: None,
                engine: None,
                severity: Severity::Warn,
                message: "warning text".to_string(),
                primary: None,
                secondary: Vec::new(),
                help: None,
                evidence: None,
                confidence: None,
                tags: Vec::new(),
                labels: vec![FindingLabel {
                    kind: FindingLabelKind::Primary,
                    span: Span {
                        file: "missing.rs".to_string(),
                        start: Location { line: 1, column: 1 },
                        end: Location { line: 1, column: 3 },
                    },
                    message: Some("label".to_string()),
                }],
                notes: Vec::new(),
                fixes: Vec::new(),
            }],
            ..Report::default()
        };

        let text = render_text_report(&report);
        assert!(text.contains("warning[demo.rule]: warning text"));
    }
}