rscheck-cli 0.1.0-alpha.3

CLI frontend for the rscheck policy engine.
Documentation
use crate::fix::apply_text_edits;
use crate::report::{FixSafety, Report, TextEdit};
use similar::TextDiff;
use std::collections::BTreeMap;
use std::path::Path;
use std::{fs, io};

#[derive(Debug, thiserror::Error)]
pub enum ApplyError {
    #[error("failed to read file: {path}")]
    Read { path: String, source: io::Error },
    #[error("failed to write file: {path}")]
    Write { path: String, source: io::Error },
    #[error("failed to apply edits for file: {path}")]
    Apply {
        path: String,
        source: crate::fix::FixError,
    },
}

#[derive(Debug, Clone)]
pub struct PlannedEdits {
    pub by_file: BTreeMap<String, Vec<TextEdit>>,
}

impl PlannedEdits {
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.by_file.values().all(Vec::is_empty)
    }
}

pub fn plan_edits(report: &Report, include_unsafe: bool) -> PlannedEdits {
    #[derive(Clone)]
    struct EditWithMeta {
        safety: FixSafety,
        fix_id: String,
        edit: TextEdit,
    }

    let mut by_file: BTreeMap<String, Vec<EditWithMeta>> = BTreeMap::new();
    for finding in &report.findings {
        for fix in &finding.fixes {
            if fix.safety == FixSafety::Unsafe && !include_unsafe {
                continue;
            }
            for edit in &fix.edits {
                by_file
                    .entry(edit.file.clone())
                    .or_default()
                    .push(EditWithMeta {
                        safety: fix.safety,
                        fix_id: fix.id.clone(),
                        edit: edit.clone(),
                    });
            }
        }
    }

    let mut planned: BTreeMap<String, Vec<TextEdit>> = BTreeMap::new();
    for (file, mut edits) in by_file {
        edits.sort_by(|a, b| {
            let sa = safety_rank(a.safety);
            let sb = safety_rank(b.safety);
            sb.cmp(&sa)
                .then(a.edit.byte_start.cmp(&b.edit.byte_start))
                .then(a.edit.byte_end.cmp(&b.edit.byte_end))
                .then(a.fix_id.cmp(&b.fix_id))
        });

        let mut chosen: Vec<EditWithMeta> = Vec::new();
        'next: for e in edits {
            for c in &chosen {
                if overlaps(&e.edit, &c.edit) {
                    continue 'next;
                }
            }
            chosen.push(e);
        }

        planned.insert(file, chosen.into_iter().map(|e| e.edit).collect());
    }

    PlannedEdits { by_file: planned }
}

fn safety_rank(s: FixSafety) -> u8 {
    match s {
        FixSafety::Safe => 2,
        FixSafety::Unsafe => 1,
    }
}

fn overlaps(a: &TextEdit, b: &TextEdit) -> bool {
    let a0 = a.byte_start;
    let a1 = a.byte_end;
    let b0 = b.byte_start;
    let b1 = b.byte_end;
    !(a1 <= b0 || b1 <= a0)
}

pub fn apply_planned_edits(planned: &PlannedEdits) -> Result<bool, ApplyError> {
    let mut changed = false;
    for (file, edits) in &planned.by_file {
        if edits.is_empty() {
            continue;
        }
        let old = fs::read_to_string(file).map_err(|source| ApplyError::Read {
            path: file.clone(),
            source,
        })?;
        let new = apply_text_edits(&old, edits).map_err(|source| ApplyError::Apply {
            path: file.clone(),
            source,
        })?;
        if new != old {
            fs::write(file, new).map_err(|source| ApplyError::Write {
                path: file.clone(),
                source,
            })?;
            changed = true;
        }
    }
    Ok(changed)
}

pub fn print_dry_run(planned: &PlannedEdits) -> Result<bool, ApplyError> {
    let mut would_change = false;
    for (file, edits) in &planned.by_file {
        if edits.is_empty() {
            continue;
        }
        let old = fs::read_to_string(file).map_err(|source| ApplyError::Read {
            path: file.clone(),
            source,
        })?;
        let new = apply_text_edits(&old, edits).map_err(|source| ApplyError::Apply {
            path: file.clone(),
            source,
        })?;
        if new == old {
            continue;
        }
        would_change = true;
        let diff = TextDiff::from_lines(&old, &new)
            .unified_diff()
            .header(
                &format!("a/{}", display_path(file)),
                &format!("b/{}", display_path(file)),
            )
            .to_string();
        print!("{diff}");
    }
    Ok(would_change)
}

fn display_path(path: &str) -> String {
    Path::new(path).to_string_lossy().to_string()
}