Modum enforces consistent module naming, import style, and public API paths across a Rust workspace.
It is a lint tool. It reports diagnostics. It does not rewrite code.
modum exists to catch two common Rust API-shape problems:
- flattened imports that hide useful context at call sites
- redundant leaf names that repeat context the module path already provides
These call-site shapes are legal Rust, but they make signatures and public paths harder to scan:
pub fn handle(repo: UserRepository) -> Result<StatusCode, Error> {
todo!()
}These usually read more clearly:
pub fn handle(repo: user::Repository) -> Result<http::StatusCode, partials::Error> {
todo!()
}The same pattern shows up in public API paths:
user::Repository
user::error::InvalidEmail
partials::Errorinstead of:
user::UserRepository
user::error::InvalidEmailError
partials::error::ErrorThe central comparison is often this:
user::Repository
UserRepositoryIn the abstract, user::Repository is usually better than UserRepository.
Why:
- The domain lives in the path, which is where Rust already gives you structure.
- The leaf can stay generic and composable:
user::Repository,user::Service,user::Id. - It scales better across a crate than baking the domain into every type name.
That is also why user::Repository is usually better than user::UserRepository: once the path is doing the domain work, the leaf does not need to repeat it.
The main caveat is that this only holds when user is a real semantic module. If the parent path is weak or technical, then a longer leaf can still be better. UserRepository is often clearer than storage::Repository.
So the rule is:
- strong semantic parent: prefer
user::Repository - weak or technical parent: prefer the more descriptive leaf
modum enforces that style across an entire workspace.
modum follows four rules:
- Keep namespace context visible at call sites.
- Prefer a strong semantic parent with a short leaf:
user::RepositoryoverUserRepository. - Keep a more descriptive leaf when the parent path is weak or technical.
- Use modules for domain boundaries, not file organization.
cargo install modum
cargo modum check --root .
cargo modum check --root . --mode warn
cargo modum check --root . --show advisory
cargo modum check --root . --exclude examples/high-coverage
cargo modum check --root . --format jsoncargo install modum installs both modum and the Cargo subcommand cargo-modum, so either of these is valid:
modum check --root .
cargo modum check --root .modum works well with nvim-lint. Use --mode warn so diagnostics do not fail the editor job, and use --format json for stable parsing.
local lint = require("lint")
lint.linters.modum = {
cmd = "modum",
stdin = false,
stream = "stdout",
args = { "check", "--root", vim.fn.getcwd(), "--mode", "warn", "--format", "json" },
parser = function(output, bufnr)
if output == "" then
return {}
end
local decoded = vim.json.decode(output)
local current_file = vim.api.nvim_buf_get_name(bufnr)
local diagnostics = {}
for _, item in ipairs(((decoded or {}).report or {}).diagnostics or {}) do
if item.file == current_file then
diagnostics[#diagnostics + 1] = {
bufnr = bufnr,
lnum = math.max((item.line or 1) - 1, 0),
col = 0,
severity = item.level == "Error"
and vim.diagnostic.severity.ERROR
or vim.diagnostic.severity.WARN,
source = "modum",
code = item.code,
message = item.message,
}
end
end
return diagnostics
end,
}
lint.linters_by_ft.rust = { "modum" }If you edit multiple crates from one Neovim session, replace vim.fn.getcwd() with your workspace root resolver. modum is workspace-oriented, so it is usually better to run it on save than on every InsertLeave.
If you are developing modum itself:
cargo run -p modum -- check --root .Environment:
MODUM=off|warn|denyDefault mode is deny.
Text output groups diagnostics into Errors, Policy Diagnostics, and Advisory Diagnostics.
Use --show policy or --show advisory when you want to focus one side of the report without changing exit behavior. The exit code still reflects the full report.
JSON output keeps the full diagnostic list and includes:
policy: whether the diagnostic counts as a policy violationfix: optional autofix metadata when the rewrite is a direct path replacement, such asresponse::ResponsetoResponse
Use modum the same way you would use clippy or cargo-deny: run it as a normal command in CI, not from build.rs.
- run: cargo install modum
- run: cargo modum check --root .0: clean, or warnings allowed via--mode warn2: warning-level policy violations found indenymode1: hard errors, including parse/configuration failures and error-level policy violations such asapi_organizational_submodule_flatten
Configure the lints in any workspace with Cargo metadata:
[workspace.metadata.modum]
include = ["src", "crates/*/src"]
exclude = ["examples/high-coverage"]
generic_nouns = ["Id", "Repository", "Service", "Error", "Command", "Request", "Response", "Outcome"]
weak_modules = ["storage", "transport", "infra", "common", "misc", "helpers", "helper", "types", "util", "utils"]
catch_all_modules = ["common", "misc", "helpers", "helper", "types", "util", "utils"]
organizational_modules = ["error", "errors", "request", "response"]
namespace_preserving_modules = ["auth", "command", "components", "email", "error", "http", "page", "partials", "policy", "query", "repo", "store", "storage", "transport", "infra"]Use [package.metadata.modum] inside a member crate to override workspace defaults for that package.
include and exclude are optional scan defaults. CLI --include overrides metadata include, and CLI --exclude adds to metadata exclude.
Tuning guide:
generic_nouns: generic leaves likeRepository,Error, orRequestnamespace_preserving_modules: modules that should stay visible at call sites, such ashttp,email,partials, orcomponentsorganizational_modules: modules that should not leak into the public API surface, such aserror,request, orresponse
These warn when imports or re-exports flatten a namespace that should stay visible.
namespace_flat_useWarning for flattened imports of generic nouns when there is an actionable namespace-visible call-site form that adds net context, such asstorage::Repositoryorhttp::StatusCode. It skips cases where the only preserved form would still be redundant, such aserror::Errororresponse::Response.namespace_flat_use_preserve_moduleWarning for flattened imports from configured namespace-preserving modules when the preserved call-site form still adds net context.namespace_flat_use_redundant_leaf_contextWarning for flattened imports or actionable rename-heavy aliases whose leaf repeats parent context. For plain imports, this only fires when the shorter leaf would be an actionable generic noun such asRepository,Error, orId. For rename aliases, this only fires when the qualified form would still preserve real context, such ashttp::StatusCodeorpage::Event.namespace_redundant_qualified_genericWarning for qualified call-site paths whose module only repeats a generic category already named by the leaf, such asresponse::Responseorerror::Error.namespace_parent_surfacenamespace_flat_pub_usenamespace_flat_pub_use_preserve_modulenamespace_flat_pub_use_redundant_leaf_context
Examples:
use storage::Repository;use http::Client;use user::UserRepository;response::Responseuse crate::error::Error;inside a crate whose root surface already exposesErrorpub use auth::{login, logout};
Canonical parent-surface re-exports are allowed. pub use error::{Error, Result}; is valid when that is how a module intentionally exposes module::Error and module::Result. The same applies to broader UI surfaces such as exposing both components::Button and partials::Button.
A semantic child module namespace can also stay flat when it is already doing the call-site naming work. For example, use components::tab_set; with call sites like tab_set::ContentProps should not be forced into components::tab_set::ContentProps.
These warn when public leaves are too generic for a weak parent, when the path repeats context it already has, or when a flat family suggests a semantic module surface.
api_missing_parent_surface_exportWarning for public child modules that should also surface a readable parent alias, such ascomponents::Buttonovercomponents::button::Button, oroutcome::Toxicityoveroutcome::toxicity::Outcome.api_weak_module_generic_leafapi_redundant_leaf_contextWarning for public leaves that repeat semantic module context already carried by the path, such asuser::UserRepository, or that bake a sibling semantic module into a flat public leaf whenuser::Repositoryalready exists.api_candidate_semantic_moduleAdvisory warning for public item families that suggest a semantic module surface, either through a shared head across at least three siblings likeUserRepository,UserService, andUserId, or through a shared generic tail likeCompletedOutcome,RejectedOutcome, andtoxicity::Outcome.api_manual_enum_string_helperAdvisory warning for public enum string surfaces that are spelled manually, including bespoke non-const methods such aslabel()oras_str(), free helpers such asscenario_label(&Scenario), and manualDisplayimpls that only map variants to string literals.api_ad_hoc_parse_helperAdvisory warning for public enum parse helpers, including freeparse_*functions and inherent methods such asMode::parse(&str) -> Result<Self, _>, whenFromStrorTryFrom<&str>would be a better standard boundary.api_parallel_enum_metadata_helperAdvisory warning for public enums that expose several parallel metadata helpers likelabel(),code(), andsource_term()over repeatedmatch selfblocks, when a typed descriptor surface would model that metadata more cleanly.api_strum_serialize_all_candidateWarning for per-variantstrumstring attributes that could be replaced by one enum-levelserialize_allrule without changing the external strings.api_builder_candidateWarning for public constructors or workflow entrypoints that take several positional weak parameters and would read better as a builder or typed options struct. It skips functions already marked with a builder surface.api_repeated_parameter_clusterWarning for repeated public constructor or workflow signatures that reuse the same ordered named parameter cluster across entrypoints, when a shared options type orbonbuilder would avoid duplicating the call shape.api_optional_parameter_builderWarning for builder-shaped public entrypoints that take positionalOption<_>parameters and would read better as abonbuilder, so callers can omit unset values instead of passingNone.api_defaulted_optional_parameterWarning for builder-shaped public entrypoints that immediately default positionalOption<_>parameters, when abonbuilder would let callers omit those values entirely.api_standalone_builder_surfaceAdvisory warning for families of publicwith_*orset_*free functions that collectively behave like a builder surface for one type.api_boolean_protocol_decisionWarning for publicboolparameters or fields that encode a domain or protocol decision rather than a runtime toggle.api_forwarding_compat_wrapperWarning for explicit conversion helpers such asto_*orinto_*methods that only forward to an existingFromconversion already present in the crate.api_stringly_protocol_collectionAdvisory warning for public const or static collections that enumerate protocol, state, transition, artifact, gate, or step values as raw strings instead of typed enums or descriptor maps.api_stringly_model_scaffoldAdvisory warning for public structs that carry several semantic descriptor fields as raw strings, such asstate_path,kind_label, andnext_machine, when those concepts would read better as typed enums, newtypes, or a focused descriptor type.api_redundant_category_suffix
Examples:
UserRepository,UserService,UserIdCompletedOutcome,RejectedOutcome,toxicity::OutcomeUserRepositorywhenuser::Repositoryalready existspartials::button::Buttonwhen the intended surface should also exposepartials::Buttonoutcome::toxicity::Outcomewhen the intended surface should also exposeoutcome::Toxicitystorage::Repositoryuser::UserRepositoryuser::error::InvalidEmailError
Private organizational child modules are allowed to flatten their family items back to the parent surface. For example, mod auth_shell; pub use auth_shell::{AuthShell, AuthShellVariant}; is treated as a valid parent-surface export shape.
These catch weak or redundant public module structure.
api_catch_all_moduleapi_repeated_module_segment
Examples:
helperserror::error
This rule is an error, not a warning.
api_organizational_submodule_flatten
Example:
partials::error::Errorshould usually bepartials::Errorresponse::Responseshould usually beResponse
Some naming-guide rules stay advisory because they are too semantic to lint reliably without compiler-grade context.
Examples:
- choosing the best public path among several plausible domain decompositions
- deciding when an internal long name plus
pub use ... as ...is the right tradeoff - deciding whether a new module level adds real meaning or only mirrors the file tree in edge cases
Default discovery:
- package root: scans
<root>/src - workspace root: scans each member crate's
src
Override discovery with --include:
modum check --root . --include crates/api/src --include crates/domain/srcThe broader import-style lints only inspect module-scope use items. They do not scan local block imports inside functions or tight test scopes, because those scopes often benefit from flatter imports.
To reduce false negatives:
- extend
namespace_preserving_modulesfor domain modules likeuser,billing, ortenant - keep
generic_nounsaligned with the generic leaves your API actually uses - keep
organizational_modulesconfigured sopartials::error::Error-style paths stay blocked