rulewright 0.1.1

Executable engineering standards for Rust workspaces
Documentation
use ra_ap_syntax::{AstNode, ast, ast::HasArgList};

use crate::{AstCtx, Example, Violation};

#[rustfmt::skip]
const EXAMPLES: &[Example] = &[
    Example {
        label: "bare variant path",
        code: "fn f(r: Result<u8, IoError>) -> Result<u8, AppError> { r.map_err(AppError::Io) }",
        pass: false,
    },
    Example {
        label: "bare from path",
        code: "fn f(r: Result<u8, IoError>) -> Result<u8, AppError> { r.map_err(AppError::from) }",
        pass: false,
    },
    Example {
        label: "closure wrapping only the error",
        code: "fn f(r: Result<u8, IoError>) -> Result<u8, AppError> { r.map_err(|e| AppError::Io(e)) }",
        pass: false,
    },
    Example {
        label: "closure adding context arguments",
        code: "fn f(r: Result<u8, IoError>) -> Result<u8, AppError> { r.map_err(|e| AppError::io(e, \"config.toml\")) }",
        pass: true,
    },
    Example {
        label: "closure building struct context",
        code: "fn f(r: Result<u8, IoError>) -> Result<u8, AppError> { r.map_err(|e| AppError { source: e }) }",
        pass: true,
    },
    Example {
        label: "closure transforming the error",
        code: "fn f(r: Result<u8, IoError>) -> Result<u8, AppError> { r.map_err(|e| AppError::parse(e.to_string())) }",
        pass: true,
    },
    Example {
        label: "closure discarding the error",
        code: "fn f(r: Result<u8, IoError>) -> Result<u8, AppError> { r.map_err(|_| AppError::Unknown) }",
        pass: true,
    },
    Example {
        label: "free fn argument",
        code: "fn f(r: Result<u8, IoError>) -> Result<u8, AppError> { r.map_err(convert) }",
        pass: true,
    },
    Example {
        label: "no map_err",
        code: "fn f(r: Result<u8, IoError>) -> Result<u8, IoError> { r }",
        pass: true,
    },
    Example {
        label: "pure wrap in test module",
        code: "#[cfg(test)]\nmod tests {\n    fn f(r: Result<u8, IoError>) -> Result<u8, AppError> { r.map_err(AppError::Io) }\n}",
        pass: true,
    },
];

crate::ast_rule!(
    map_err_pure_wrap,
    "Flag `.map_err(...)` that only wraps the error in another type — implement `From` and let `?` convert.",
    "Context-free error wrapping repeated at every call site obscures the happy path; a single From impl gives the conversion to every ? for free.",
    Low,
);

fn check_map_err_pure_wrap(ctx: &AstCtx<'_>) -> Vec<Violation> {
    ctx.nodes::<ast::MethodCallExpr>()
        .filter(|call| !ctx.is_in_test(call))
        .filter_map(|call| {
            let method = call.name_ref()?;

            if method.text() != "map_err" {
                return None;
            }

            let args = call.arg_list()?;
            let mut args = args.args();
            let arg = args.next()?;

            (args.next().is_none() && is_pure_wrap(&arg)).then(|| {
                ctx.violation(
                    &method,
                    ".map_err() wraps the error without adding context — implement `From` and use `?`",
                )
            })
        })
        .collect()
}

fn is_pure_wrap(arg: &ast::Expr) -> bool {
    match arg {
        ast::Expr::PathExpr(path) => path.path().is_some_and(|path| is_ctor_like_path(&path)),
        ast::Expr::ClosureExpr(closure) => closure_pure_wraps(closure),
        _ => false,
    }
}

fn is_ctor_like_path(path: &ast::Path) -> bool {
    if path.qualifier().is_some() {
        return true;
    }

    path.segment()
        .and_then(|seg| seg.name_ref())
        .is_some_and(|name| name.text().chars().next().is_some_and(char::is_uppercase))
}

fn closure_pure_wraps(closure: &ast::ClosureExpr) -> bool {
    let normalized: String = closure
        .syntax()
        .text()
        .to_string()
        .chars()
        .filter(|ch| !ch.is_whitespace())
        .collect();
    let Some(rest) = normalized.strip_prefix('|') else {
        return false;
    };
    let Some((binding, body)) = rest.split_once('|') else {
        return false;
    };

    if binding.is_empty() || binding == "_" || binding.contains(',') {
        return false;
    }

    let body = body
        .strip_prefix('{')
        .and_then(|body| body.strip_suffix('}'))
        .unwrap_or(body);
    let Some(open) = body.find('(') else {
        return false;
    };
    let Some(argument) = body.get(open + 1..body.len().saturating_sub(1)) else {
        return false;
    };

    body.ends_with(')')
        && argument == binding
        && body
            .get(..open)
            .is_some_and(|constructor| !constructor.is_empty())
}

crate::rulewright_ast_test!(check_map_err_pure_wrap, {
    crate::example_tests!(EXAMPLES, check_map_err_pure_wrap);
});