rustcop 0.1.5

A Rust style linter and formatter inspired by C#'s StyleCop
Documentation
use std::{collections::HashSet, path::Path};

use crate::{
    config::{Config, ExportsConfig},
    diagnostic::{Diagnostic, Severity},
    rules::Rule,
};

pub struct ExportsRule {
    enabled: bool,
    severity: Severity,
    allowed_lib_exports: HashSet<String>,
}

impl ExportsRule {
    pub fn from_config(config: &Config) -> Self {
        let exports = config
            .get_config::<ExportsConfig>("exports")
            .unwrap_or_default();

        let enabled = exports.severity != "none";
        let severity = match exports.severity.as_str() {
            "error" => Severity::Error,
            _ => Severity::Warning,
        };

        Self {
            enabled,
            severity,
            allowed_lib_exports: exports.allowed_lib_exports.into_iter().collect(),
        }
    }

    fn is_lib_file(file: &Path) -> bool {
        file.file_name().and_then(|n| n.to_str()) == Some("lib.rs")
    }
}

impl Rule for ExportsRule {
    fn id(&self) -> &str {
        "RC3002"
    }

    fn name(&self) -> &str {
        "ExportRules"
    }

    fn check(&self, content: &str, file: &Path) -> Vec<Diagnostic> {
        if !self.enabled || !Self::is_lib_file(file) {
            return vec![];
        }

        if self.allowed_lib_exports.is_empty() {
            return vec![];
        }

        let lines: Vec<&str> = content.lines().collect();
        let mut diagnostics = Vec::new();
        let mut i = 0usize;

        while i < lines.len() {
            let trimmed = lines[i].trim();

            if trimmed.starts_with("pub mod ") {
                if let Ok(item_mod) = syn::parse_str::<syn::ItemMod>(trimmed) {
                    let name = item_mod.ident.to_string();
                    if !self.allowed_lib_exports.contains(&name) {
                        diagnostics.push(Diagnostic {
                            rule_id: self.id().to_string(),
                            message: format!(
                                "Module `{}` is exported from lib.rs but is not in exports.allowed_lib_exports",
                                name
                            ),
                            file: file.to_path_buf(),
                            line: i + 1,
                            severity: self.severity.clone(),
                            suppressed: false,
                            suppression_justification: None,
                        });
                    }
                }
                i += 1;
                continue;
            }

            if trimmed.starts_with("pub use ") {
                let end = consume_stmt_end(&lines, i);
                let stmt = lines[i..=end].join("\n");

                if let Ok(item_use) = syn::parse_str::<syn::ItemUse>(&stmt) {
                    let mut modules = Vec::new();
                    collect_reexported_modules(&item_use.tree, &mut modules);

                    for module in modules {
                        if !self.allowed_lib_exports.contains(module.as_str()) {
                            diagnostics.push(Diagnostic {
                                rule_id: self.id().to_string(),
                                message: format!(
                                    "Module `{}` is re-exported from lib.rs but is not in exports.allowed_lib_exports",
                                    module
                                ),
                                file: file.to_path_buf(),
                                line: i + 1,
                                severity: self.severity.clone(),
                                suppressed: false,
                                suppression_justification: None,
                            });
                        }
                    }
                }

                i = end + 1;
                continue;
            }

            i += 1;
        }

        diagnostics
    }

    fn fix(&self, content: &str) -> String {
        content.to_string()
    }
}

fn consume_stmt_end(lines: &[&str], start: usize) -> usize {
    let mut i = start;
    let mut brace_depth = 0i32;

    while i < lines.len() {
        for ch in lines[i].chars() {
            match ch {
                '{' => brace_depth += 1,
                '}' => brace_depth -= 1,
                _ => {}
            }
        }

        if brace_depth <= 0 && lines[i].contains(';') {
            return i;
        }

        i += 1;
    }

    lines.len().saturating_sub(1)
}

fn collect_reexported_modules(tree: &syn::UseTree, out: &mut Vec<String>) {
    match tree {
        syn::UseTree::Path(path) => {
            let ident = path.ident.to_string();
            if ident == "crate" || ident == "self" || ident == "super" {
                collect_reexported_modules(&path.tree, out);
            } else {
                out.push(ident);
            }
        }
        syn::UseTree::Name(name) => out.push(name.ident.to_string()),
        syn::UseTree::Rename(rename) => out.push(rename.ident.to_string()),
        syn::UseTree::Group(group) => {
            for item in &group.items {
                collect_reexported_modules(item, out);
            }
        }
        syn::UseTree::Glob(_) => out.push("*".to_string()),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_reports_disallowed_pub_mod_exports() {
        let content = concat!("pub mod config;\n", "pub mod secret;\n",);

        let rule = ExportsRule {
            enabled: true,
            severity: Severity::Error,
            allowed_lib_exports: ["config".to_string()].into_iter().collect(),
        };

        let diags = rule.check(content, Path::new("lib.rs"));
        assert_eq!(diags.len(), 1);
        assert!(diags[0].message.contains("secret"));
    }

    #[test]
    fn test_reports_disallowed_pub_use_reexports() {
        let content = concat!(
            "pub use config::Config;\n",
            "pub use secret::Hidden;\n",
            "pub use crate::{config, secret as alias};\n",
        );

        let rule = ExportsRule {
            enabled: true,
            severity: Severity::Error,
            allowed_lib_exports: ["config".to_string()].into_iter().collect(),
        };

        let diags = rule.check(content, Path::new("lib.rs"));
        assert!(diags.iter().any(|d| d.message.contains("secret")));
    }

    #[test]
    fn test_empty_allowlist_is_unconstrained() {
        let content = concat!("pub mod secret;\n", "pub use secret::Hidden;\n",);

        let rule = ExportsRule {
            enabled: true,
            severity: Severity::Error,
            allowed_lib_exports: HashSet::new(),
        };

        let diags = rule.check(content, Path::new("lib.rs"));
        assert!(diags.is_empty());
    }

    #[test]
    fn test_non_lib_file_is_ignored() {
        let content = "pub mod secret;\n";
        let rule = ExportsRule {
            enabled: true,
            severity: Severity::Error,
            allowed_lib_exports: ["config".to_string()].into_iter().collect(),
        };

        let diags = rule.check(content, Path::new("main.rs"));
        assert!(diags.is_empty());
    }
}