rulewright 0.1.2

Executable engineering standards for Rust workspaces
Documentation
use ra_ap_syntax::ast::{self, HasAttrs, HasName, HasVisibility, VisibilityKind};

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

#[rustfmt::skip]
const EXAMPLES: &[Example] = &[
    Example {
        label: "public enum without non_exhaustive",
        code: "pub enum Color { Red, Green, Blue }",
        pass: false,
    },
    Example {
        label: "public enum with non_exhaustive",
        code: "#[non_exhaustive]\npub enum Color { Red, Green, Blue }",
        pass: true,
    },
    Example {
        label: "private enum without non_exhaustive",
        code: "enum Color { Red, Green, Blue }",
        pass: true,
    },
    Example {
        label: "pub(crate) enum without non_exhaustive",
        code: "pub(crate) enum Color { Red, Green, Blue }",
        pass: true,
    },
    Example {
        label: "public enum in test",
        code: "#[cfg(test)]\nmod tests {\n    pub enum Color { Red, Green, Blue }\n}",
        pass: true,
    },
];

crate::ast_rule!(
    non_exhaustive_on_public,
    "Flag `pub` enums without `#[non_exhaustive]` when downstream variants should remain extensible.",
    "Adding a variant can break downstream exhaustive matches. Use `#[non_exhaustive]` for externally reachable enums in publishable packages whose variant set may grow; packages that explicitly set `publish = false` are skipped. Keep the rule disabled or scoped away for private-module enums and deliberately closed protocol, state-machine, or schema vocabularies. Rulewright recognizes syntax-level `pub` but does not resolve re-export visibility.",
    Medium,
);

fn check_non_exhaustive_on_public(ctx: &AstCtx<'_>) -> Vec<Violation> {
    if ctx.file.is_explicitly_non_publishable() {
        return Vec::new();
    }

    let public_enums = ctx
        .nodes::<ast::Enum>()
        .filter(|item| !ctx.is_in_test(item))
        .filter(|item| {
            item.visibility()
                .is_some_and(|vis| matches!(vis.kind(), VisibilityKind::Pub))
        });

    public_enums
        .filter(|item| {
            !item
                .attrs()
                .any(|attr| attr.simple_name().as_deref() == Some("non_exhaustive"))
        })
        .filter_map(|item| {
            let name = item.name()?;

            Some(ctx.violation(
                &name,
                format!(
                    "`pub` enum `{name}` is exhaustively matchable downstream — add `#[non_exhaustive]` only when this externally reachable vocabulary should grow without a breaking release"
                ),
            ))
        })
        .collect()
}

crate::rulewright_ast_test!(check_non_exhaustive_on_public, {
    crate::example_tests!(EXAMPLES, check_non_exhaustive_on_public);

    #[test]
    fn package_publishability_controls_downstream_enum_stability() {
        let source = "pub enum Status { Ready, Waiting }";

        assert_eq!(
            crate::test_support::check_source_ast_at(
                "fixture.rs",
                source,
                check_non_exhaustive_on_public,
            )
            .len(),
            1
        );
        assert!(
            crate::test_support::check_source_ast_publishability(
                source,
                false,
                check_non_exhaustive_on_public,
            )
            .is_empty()
        );
        assert_eq!(
            crate::test_support::check_source_ast_publishability(
                source,
                true,
                check_non_exhaustive_on_public,
            )
            .len(),
            1
        );
    }
});