#![deny(missing_docs)]
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::process::Command;
use serde_json::Value;
use syn::parse::Parser;
use syn::visit::Visit;
mod resolve;
use resolve::{
BareFallback, DynCollector, ImplTraitCollector, PathCollector, ReexportMap, ShapeExposure,
UseMap, canonical_path_str, canonical_self_owner, canonicalize_through_reexports,
collect_reexports, collect_uses, resolve_path, stamp_seam, strip_raw, type_to_string,
};
pub use xuanji::{
Baseline, BoundaryKind, Outcome, Report, Severity, Violation, ViolationId, apply_baseline,
};
pub const SIGNATURE_RULE: &str = "must not expose";
pub const DYN_TRAIT_RULE: &str = "must not expose dyn";
pub const IMPL_TRAIT_RULE: &str = "must not expose impl trait";
pub const ASYNC_EXPOSURE_RULE: &str = "must not expose async fn";
pub const TRAIT_IMPL_RULE: &str = "must only be implemented in the declared location(s)";
pub const VISIBILITY_RULE: &str = "must not declare pub items";
pub const FORBIDDEN_MARKER_RULE: &str = "must not acquire trait";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SemanticBoundary {
pub(crate) crate_package: String,
pub(crate) module: String,
pub(crate) forbidden: Vec<String>,
pub(crate) reason: String,
pub(crate) severity: Severity,
}
impl SemanticBoundary {
pub fn in_crate(package: &str) -> SemanticCrateDraft {
SemanticCrateDraft {
crate_package: package.to_string(),
}
}
pub fn crate_package(&self) -> &str {
&self.crate_package
}
pub fn module(&self) -> &str {
&self.module
}
pub fn forbidden(&self) -> &[String] {
&self.forbidden
}
pub fn reason(&self) -> &str {
&self.reason
}
pub fn severity(&self) -> Severity {
self.severity
}
}
pub struct SemanticCrateDraft {
crate_package: String,
}
impl SemanticCrateDraft {
pub fn module(self, module: &str) -> SemanticModuleDraft {
SemanticModuleDraft {
crate_package: self.crate_package,
module: module.to_string(),
}
}
}
pub struct SemanticModuleDraft {
crate_package: String,
module: String,
}
impl SemanticModuleDraft {
pub fn must_not_expose(self, path: &str) -> SemanticBoundaryDraft {
SemanticBoundaryDraft {
crate_package: self.crate_package,
module: self.module,
forbidden: vec![path.to_string()],
severity: Severity::Enforce,
}
}
}
pub struct SemanticBoundaryDraft {
crate_package: String,
module: String,
forbidden: Vec<String>,
severity: Severity,
}
impl SemanticBoundaryDraft {
pub fn and_not_expose(mut self, path: &str) -> Self {
self.forbidden.push(path.to_string());
self
}
pub fn warn(mut self) -> Self {
self.severity = Severity::Warn;
self
}
pub fn because(self, reason: &str) -> SemanticBoundary {
SemanticBoundary {
crate_package: self.crate_package,
module: self.module,
forbidden: self.forbidden,
reason: reason.to_string(),
severity: self.severity,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TraitImplBoundary {
pub(crate) crate_package: String,
pub(crate) trait_path: String,
pub(crate) allowed_locations: Vec<String>,
pub(crate) reason: String,
pub(crate) severity: Severity,
}
impl TraitImplBoundary {
pub fn in_crate(package: &str) -> TraitImplCrateDraft {
TraitImplCrateDraft {
crate_package: package.to_string(),
}
}
pub fn crate_package(&self) -> &str {
&self.crate_package
}
pub fn trait_(&self) -> &str {
&self.trait_path
}
pub fn allowed_locations(&self) -> &[String] {
&self.allowed_locations
}
pub fn reason(&self) -> &str {
&self.reason
}
pub fn severity(&self) -> Severity {
self.severity
}
}
pub struct TraitImplCrateDraft {
crate_package: String,
}
impl TraitImplCrateDraft {
pub fn trait_(self, trait_path: &str) -> TraitImplTraitDraft {
TraitImplTraitDraft {
crate_package: self.crate_package,
trait_path: trait_path.to_string(),
}
}
}
pub struct TraitImplTraitDraft {
crate_package: String,
trait_path: String,
}
impl TraitImplTraitDraft {
pub fn only_implemented_in(self, location: &str) -> TraitImplBoundaryDraft {
TraitImplBoundaryDraft {
crate_package: self.crate_package,
trait_path: self.trait_path,
allowed_locations: vec![location.to_string()],
severity: Severity::Enforce,
}
}
}
pub struct TraitImplBoundaryDraft {
crate_package: String,
trait_path: String,
allowed_locations: Vec<String>,
severity: Severity,
}
impl TraitImplBoundaryDraft {
pub fn and_in(mut self, location: &str) -> Self {
self.allowed_locations.push(location.to_string());
self
}
pub fn warn(mut self) -> Self {
self.severity = Severity::Warn;
self
}
pub fn because(self, reason: &str) -> TraitImplBoundary {
TraitImplBoundary {
crate_package: self.crate_package,
trait_path: self.trait_path,
allowed_locations: self.allowed_locations,
reason: reason.to_string(),
severity: self.severity,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VisibilityBoundary {
pub(crate) crate_package: String,
pub(crate) module: String,
pub(crate) reason: String,
pub(crate) severity: Severity,
}
impl VisibilityBoundary {
pub fn in_crate(package: &str) -> VisibilityCrateDraft {
VisibilityCrateDraft {
crate_package: package.to_string(),
}
}
pub fn crate_package(&self) -> &str {
&self.crate_package
}
pub fn module(&self) -> &str {
&self.module
}
pub fn reason(&self) -> &str {
&self.reason
}
pub fn severity(&self) -> Severity {
self.severity
}
}
pub struct VisibilityCrateDraft {
crate_package: String,
}
impl VisibilityCrateDraft {
pub fn module(self, module: &str) -> VisibilityModuleDraft {
VisibilityModuleDraft {
crate_package: self.crate_package,
module: module.to_string(),
}
}
}
pub struct VisibilityModuleDraft {
crate_package: String,
module: String,
}
impl VisibilityModuleDraft {
pub fn must_not_declare_pub(self) -> VisibilityBoundaryDraft {
VisibilityBoundaryDraft {
crate_package: self.crate_package,
module: self.module,
severity: Severity::Enforce,
}
}
}
pub struct VisibilityBoundaryDraft {
crate_package: String,
module: String,
severity: Severity,
}
impl VisibilityBoundaryDraft {
pub fn warn(mut self) -> Self {
self.severity = Severity::Warn;
self
}
pub fn because(self, reason: &str) -> VisibilityBoundary {
VisibilityBoundary {
crate_package: self.crate_package,
module: self.module,
reason: reason.to_string(),
severity: self.severity,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ForbiddenMarkerBoundary {
pub(crate) crate_package: String,
pub(crate) module: String,
pub(crate) forbidden: Vec<String>,
pub(crate) reason: String,
pub(crate) severity: Severity,
}
impl ForbiddenMarkerBoundary {
pub fn in_crate(package: &str) -> ForbiddenMarkerCrateDraft {
ForbiddenMarkerCrateDraft {
crate_package: package.to_string(),
}
}
pub fn crate_package(&self) -> &str {
&self.crate_package
}
pub fn module(&self) -> &str {
&self.module
}
pub fn forbidden(&self) -> &[String] {
&self.forbidden
}
pub fn reason(&self) -> &str {
&self.reason
}
pub fn severity(&self) -> Severity {
self.severity
}
}
pub struct ForbiddenMarkerCrateDraft {
crate_package: String,
}
impl ForbiddenMarkerCrateDraft {
pub fn module(self, module: &str) -> ForbiddenMarkerModuleDraft {
ForbiddenMarkerModuleDraft {
crate_package: self.crate_package,
module: module.to_string(),
}
}
}
pub struct ForbiddenMarkerModuleDraft {
crate_package: String,
module: String,
}
impl ForbiddenMarkerModuleDraft {
pub fn must_not_acquire(self, trait_path: &str) -> ForbiddenMarkerBoundaryDraft {
ForbiddenMarkerBoundaryDraft {
crate_package: self.crate_package,
module: self.module,
forbidden: vec![trait_path.to_string()],
severity: Severity::Enforce,
}
}
}
pub struct ForbiddenMarkerBoundaryDraft {
crate_package: String,
module: String,
forbidden: Vec<String>,
severity: Severity,
}
impl ForbiddenMarkerBoundaryDraft {
pub fn and_not_acquire(mut self, trait_path: &str) -> Self {
self.forbidden.push(trait_path.to_string());
self
}
pub fn warn(mut self) -> Self {
self.severity = Severity::Warn;
self
}
pub fn because(self, reason: &str) -> ForbiddenMarkerBoundary {
ForbiddenMarkerBoundary {
crate_package: self.crate_package,
module: self.module,
forbidden: self.forbidden,
reason: reason.to_string(),
severity: self.severity,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DynTraitBoundary {
pub(crate) crate_package: String,
pub(crate) module: String,
pub(crate) forbidden_operands: Vec<String>,
pub(crate) reason: String,
pub(crate) severity: Severity,
}
impl DynTraitBoundary {
pub fn in_crate(package: &str) -> DynTraitCrateDraft {
DynTraitCrateDraft {
crate_package: package.to_string(),
}
}
pub fn crate_package(&self) -> &str {
&self.crate_package
}
pub fn module(&self) -> &str {
&self.module
}
pub fn forbidden_operands(&self) -> &[String] {
&self.forbidden_operands
}
pub fn reason(&self) -> &str {
&self.reason
}
pub fn severity(&self) -> Severity {
self.severity
}
}
pub struct DynTraitCrateDraft {
crate_package: String,
}
impl DynTraitCrateDraft {
pub fn module(self, module: &str) -> DynTraitModuleDraft {
DynTraitModuleDraft {
crate_package: self.crate_package,
module: module.to_string(),
}
}
}
pub struct DynTraitModuleDraft {
crate_package: String,
module: String,
}
impl DynTraitModuleDraft {
pub fn must_not_expose_dyn(self) -> DynTraitBoundaryDraft {
DynTraitBoundaryDraft {
crate_package: self.crate_package,
module: self.module,
forbidden_operands: Vec::new(),
severity: Severity::Enforce,
}
}
pub fn must_not_expose_dyn_of<I, S>(self, operands: I) -> DynTraitBoundaryDraft
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
DynTraitBoundaryDraft {
crate_package: self.crate_package,
module: self.module,
forbidden_operands: operands.into_iter().map(Into::into).collect(),
severity: Severity::Enforce,
}
}
}
pub struct DynTraitBoundaryDraft {
crate_package: String,
module: String,
forbidden_operands: Vec<String>,
severity: Severity,
}
impl DynTraitBoundaryDraft {
pub fn warn(mut self) -> Self {
self.severity = Severity::Warn;
self
}
pub fn because(self, reason: &str) -> DynTraitBoundary {
DynTraitBoundary {
crate_package: self.crate_package,
module: self.module,
forbidden_operands: self.forbidden_operands,
reason: reason.to_string(),
severity: self.severity,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImplTraitBoundary {
pub(crate) crate_package: String,
pub(crate) module: String,
pub(crate) forbidden_operands: Vec<String>,
pub(crate) reason: String,
pub(crate) severity: Severity,
}
impl ImplTraitBoundary {
pub fn in_crate(package: &str) -> ImplTraitCrateDraft {
ImplTraitCrateDraft {
crate_package: package.to_string(),
}
}
pub fn crate_package(&self) -> &str {
&self.crate_package
}
pub fn module(&self) -> &str {
&self.module
}
pub fn forbidden_operands(&self) -> &[String] {
&self.forbidden_operands
}
pub fn reason(&self) -> &str {
&self.reason
}
pub fn severity(&self) -> Severity {
self.severity
}
}
pub struct ImplTraitCrateDraft {
crate_package: String,
}
impl ImplTraitCrateDraft {
pub fn module(self, module: &str) -> ImplTraitModuleDraft {
ImplTraitModuleDraft {
crate_package: self.crate_package,
module: module.to_string(),
}
}
}
pub struct ImplTraitModuleDraft {
crate_package: String,
module: String,
}
impl ImplTraitModuleDraft {
pub fn must_not_expose_impl_trait(self) -> ImplTraitBoundaryDraft {
ImplTraitBoundaryDraft {
crate_package: self.crate_package,
module: self.module,
forbidden_operands: Vec::new(),
severity: Severity::Enforce,
}
}
pub fn must_not_expose_impl_trait_of<I, S>(self, operands: I) -> ImplTraitBoundaryDraft
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
ImplTraitBoundaryDraft {
crate_package: self.crate_package,
module: self.module,
forbidden_operands: operands.into_iter().map(Into::into).collect(),
severity: Severity::Enforce,
}
}
}
pub struct ImplTraitBoundaryDraft {
crate_package: String,
module: String,
forbidden_operands: Vec<String>,
severity: Severity,
}
impl ImplTraitBoundaryDraft {
pub fn warn(mut self) -> Self {
self.severity = Severity::Warn;
self
}
pub fn because(self, reason: &str) -> ImplTraitBoundary {
ImplTraitBoundary {
crate_package: self.crate_package,
module: self.module,
forbidden_operands: self.forbidden_operands,
reason: reason.to_string(),
severity: self.severity,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AsyncExposureBoundary {
pub(crate) crate_package: String,
pub(crate) module: String,
pub(crate) reason: String,
pub(crate) severity: Severity,
}
impl AsyncExposureBoundary {
pub fn in_crate(package: &str) -> AsyncExposureCrateDraft {
AsyncExposureCrateDraft {
crate_package: package.to_string(),
}
}
pub fn crate_package(&self) -> &str {
&self.crate_package
}
pub fn module(&self) -> &str {
&self.module
}
pub fn reason(&self) -> &str {
&self.reason
}
pub fn severity(&self) -> Severity {
self.severity
}
}
pub struct AsyncExposureCrateDraft {
crate_package: String,
}
impl AsyncExposureCrateDraft {
pub fn module(self, module: &str) -> AsyncExposureModuleDraft {
AsyncExposureModuleDraft {
crate_package: self.crate_package,
module: module.to_string(),
}
}
}
pub struct AsyncExposureModuleDraft {
crate_package: String,
module: String,
}
impl AsyncExposureModuleDraft {
pub fn must_not_expose_async_fn(self) -> AsyncExposureBoundaryDraft {
AsyncExposureBoundaryDraft {
crate_package: self.crate_package,
module: self.module,
severity: Severity::Enforce,
}
}
}
pub struct AsyncExposureBoundaryDraft {
crate_package: String,
module: String,
severity: Severity,
}
impl AsyncExposureBoundaryDraft {
pub fn warn(mut self) -> Self {
self.severity = Severity::Warn;
self
}
pub fn because(self, reason: &str) -> AsyncExposureBoundary {
AsyncExposureBoundary {
crate_package: self.crate_package,
module: self.module,
reason: reason.to_string(),
severity: self.severity,
}
}
}
fn unreadable_workspace_error(manifest_path: &Path, err: &str) -> String {
format!(
"a boundary is observed against a real workspace, so an unreadable one cannot be judged \
and its verdict would be a false pass: cannot read target workspace at {} ({err}); check \
the manifest path and that `cargo metadata` succeeds",
manifest_path.display()
)
}
fn crate_not_found_error(crate_package: &str) -> String {
format!(
"a boundary must govern a real crate or it silently never reacts: target crate \
'{crate_package}' is not a member of the target workspace — check the name or --manifest-path"
)
}
fn missing_src_error(crate_package: &str) -> String {
format!(
"a semantic boundary is observed from source, so with no src it could never react: cannot \
locate the crate root source for '{crate_package}'"
)
}
fn unknown_module_error(module: &str, crate_package: &str) -> String {
format!(
"a boundary must anchor to a real module or it silently never reacts: module '{module}' is \
not found among the modules of crate '{crate_package}' (declared via `mod`) — check the path"
)
}
fn unknown_trait_error(trait_path: &str, crate_package: &str) -> String {
format!(
"a trait-impl-locality boundary must anchor to a real local trait or it silently never \
reacts: trait '{trait_path}' is not found as a `trait` item (directly or via a local \
`pub use`) in crate '{crate_package}' — check the path"
)
}
fn missing_module_file_error(module: &str, crate_package: &str) -> String {
format!(
"module '{module}' of crate '{crate_package}' is declared (`mod …;`) but its source file \
could not be located (expected `<name>.rs` or `<name>/mod.rs`)"
)
}
fn unreadable_source_error(file: &Path, err: &str) -> String {
format!("cannot read source file '{}': {err}", file.display())
}
fn unparseable_source_error(file: &Path, err: &str) -> String {
format!("cannot parse source file '{}': {err}", file.display())
}
#[derive(Debug, Clone, Default)]
pub struct SemanticBoundaries {
pub signature: Vec<SemanticBoundary>,
pub trait_impl: Vec<TraitImplBoundary>,
pub visibility: Vec<VisibilityBoundary>,
pub forbidden_marker: Vec<ForbiddenMarkerBoundary>,
pub dyn_trait: Vec<DynTraitBoundary>,
pub impl_trait: Vec<ImplTraitBoundary>,
pub async_exposure: Vec<AsyncExposureBoundary>,
}
impl SemanticBoundaries {
pub fn is_empty(&self) -> bool {
self.signature.is_empty()
&& self.trait_impl.is_empty()
&& self.visibility.is_empty()
&& self.forbidden_marker.is_empty()
&& self.dyn_trait.is_empty()
&& self.impl_trait.is_empty()
&& self.async_exposure.is_empty()
}
}
pub fn check_all(boundaries: &SemanticBoundaries, manifest_path: &Path) -> Outcome {
let metadata = match cargo_metadata(manifest_path) {
Ok(metadata) => metadata,
Err(err) => {
return Outcome::ConstitutionError(unreadable_workspace_error(manifest_path, &err));
}
};
let mut violations = Vec::new();
for boundary in &boundaries.signature {
if let Err(error) = check_boundary(&metadata, boundary, &mut violations) {
return Outcome::ConstitutionError(error);
}
}
for boundary in &boundaries.trait_impl {
if let Err(error) = check_trait_impl_boundary(&metadata, boundary, &mut violations) {
return Outcome::ConstitutionError(error);
}
}
for boundary in &boundaries.visibility {
if let Err(error) = check_visibility_boundary(&metadata, boundary, &mut violations) {
return Outcome::ConstitutionError(error);
}
}
for boundary in &boundaries.forbidden_marker {
if let Err(error) = check_forbidden_marker_boundary(&metadata, boundary, &mut violations) {
return Outcome::ConstitutionError(error);
}
}
for boundary in &boundaries.dyn_trait {
if let Err(error) = check_dyn_trait_boundary(&metadata, boundary, &mut violations) {
return Outcome::ConstitutionError(error);
}
}
for boundary in &boundaries.impl_trait {
if let Err(error) = check_impl_trait_boundary(&metadata, boundary, &mut violations) {
return Outcome::ConstitutionError(error);
}
}
for boundary in &boundaries.async_exposure {
if let Err(error) = check_async_exposure_boundary(&metadata, boundary, &mut violations) {
return Outcome::ConstitutionError(error);
}
}
if violations.is_empty() {
Outcome::Clean
} else {
Outcome::Violations(Report::new(violations))
}
}
pub fn check(boundaries: &[SemanticBoundary], manifest_path: &Path) -> Outcome {
let metadata = match cargo_metadata(manifest_path) {
Ok(metadata) => metadata,
Err(err) => {
return Outcome::ConstitutionError(unreadable_workspace_error(manifest_path, &err));
}
};
let mut violations = Vec::new();
for boundary in boundaries {
if let Err(error) = check_boundary(&metadata, boundary, &mut violations) {
return Outcome::ConstitutionError(error);
}
}
if violations.is_empty() {
Outcome::Clean
} else {
Outcome::Violations(Report::new(violations))
}
}
fn check_boundary(
metadata: &Value,
boundary: &SemanticBoundary,
violations: &mut Vec<Violation>,
) -> Result<(), String> {
let package = find_package(metadata, &boundary.crate_package)
.ok_or_else(|| crate_not_found_error(&boundary.crate_package))?;
let root_file =
crate_root_file(package).ok_or_else(|| missing_src_error(&boundary.crate_package))?;
let src_dir = root_file
.parent()
.ok_or_else(|| missing_src_error(&boundary.crate_package))?;
let findings = module_findings(
src_dir,
&root_file,
&boundary.module,
&boundary.forbidden,
&boundary.crate_package,
)?;
for finding in findings {
violations.push(Violation::new(
BoundaryKind::Semantic,
boundary.module.clone(),
SIGNATURE_RULE.to_string(),
finding,
boundary.reason.clone(),
boundary.severity,
));
}
Ok(())
}
pub(crate) fn module_findings(
src_dir: &Path,
root_file: &Path,
module: &str,
forbidden: &[String],
crate_package: &str,
) -> Result<Vec<String>, String> {
let items = resolve_module_items(src_dir, root_file, module, crate_package)?;
let uses = collect_uses(&items);
let reexports = scan_crate(src_dir, root_file, crate_package)?.reexports;
let forbidden: Vec<String> = forbidden.iter().map(|f| canonical_path_str(f)).collect();
let mut exposed = Vec::new();
for (ordinal, item) in items.iter().enumerate() {
collect_item_exposures(item, module, &uses, ordinal, &mut exposed);
}
let mut findings: Vec<String> = exposed
.iter()
.filter_map(|exposure| {
resolve_path(&exposure.path, &uses, module, BareFallback::Ignore)
.map(|canonical| canonicalize_through_reexports(&canonical, &reexports))
.filter(|canonical| matches_forbidden(canonical, &forbidden))
.map(|canonical| format!("{canonical} exposed by {}", exposure.seam))
})
.collect();
findings.sort();
findings.dedup();
Ok(findings)
}
pub fn check_dyn_trait(boundaries: &[DynTraitBoundary], manifest_path: &Path) -> Outcome {
let metadata = match cargo_metadata(manifest_path) {
Ok(metadata) => metadata,
Err(err) => {
return Outcome::ConstitutionError(unreadable_workspace_error(manifest_path, &err));
}
};
let mut violations = Vec::new();
for boundary in boundaries {
if let Err(error) = check_dyn_trait_boundary(&metadata, boundary, &mut violations) {
return Outcome::ConstitutionError(error);
}
}
if violations.is_empty() {
Outcome::Clean
} else {
Outcome::Violations(Report::new(violations))
}
}
fn check_dyn_trait_boundary(
metadata: &Value,
boundary: &DynTraitBoundary,
violations: &mut Vec<Violation>,
) -> Result<(), String> {
let package = find_package(metadata, &boundary.crate_package)
.ok_or_else(|| crate_not_found_error(&boundary.crate_package))?;
let root_file =
crate_root_file(package).ok_or_else(|| missing_src_error(&boundary.crate_package))?;
let src_dir = root_file
.parent()
.ok_or_else(|| missing_src_error(&boundary.crate_package))?;
let findings = if boundary.forbidden_operands.is_empty() {
dyn_module_findings(
src_dir,
&root_file,
&boundary.module,
&boundary.crate_package,
)?
} else {
dyn_operand_module_findings(
src_dir,
&root_file,
&boundary.module,
&boundary.forbidden_operands,
&boundary.crate_package,
)?
};
for finding in findings {
violations.push(Violation::new(
BoundaryKind::Semantic,
boundary.module.clone(),
DYN_TRAIT_RULE.to_string(),
finding,
boundary.reason.clone(),
boundary.severity,
));
}
Ok(())
}
pub(crate) fn dyn_module_findings(
src_dir: &Path,
root_file: &Path,
module: &str,
crate_package: &str,
) -> Result<Vec<String>, String> {
let items = resolve_module_items(src_dir, root_file, module, crate_package)?;
let uses = collect_uses(&items);
let mut exposures = Vec::new();
for (ordinal, item) in items.iter().enumerate() {
collect_item_dyn_exposures(item, module, &uses, ordinal, &mut exposures);
}
let mut findings: Vec<String> = exposures.into_iter().map(shape_finding).collect();
findings.sort();
findings.dedup();
Ok(findings)
}
pub(crate) fn dyn_operand_module_findings(
src_dir: &Path,
root_file: &Path,
module: &str,
forbidden: &[String],
crate_package: &str,
) -> Result<Vec<String>, String> {
let items = resolve_module_items(src_dir, root_file, module, crate_package)?;
let uses = collect_uses(&items);
let reexports = scan_crate(src_dir, root_file, crate_package)?.reexports;
let forbidden: Vec<String> = forbidden.iter().map(|f| canonical_path_str(f)).collect();
let mut exposures = Vec::new();
for (ordinal, item) in items.iter().enumerate() {
collect_item_dyn_exposures(item, module, &uses, ordinal, &mut exposures);
}
let mut findings: Vec<String> = exposures
.into_iter()
.filter(|exposure| {
forbidden.is_empty()
|| exposure
.principal
.as_ref()
.and_then(|path| resolve_path(path, &uses, module, BareFallback::Ignore))
.map(|canonical| canonicalize_through_reexports(&canonical, &reexports))
.is_some_and(|canonical| matches_forbidden(&canonical, &forbidden))
})
.map(shape_finding)
.collect();
findings.sort();
findings.dedup();
Ok(findings)
}
pub fn check_impl_trait(boundaries: &[ImplTraitBoundary], manifest_path: &Path) -> Outcome {
let metadata = match cargo_metadata(manifest_path) {
Ok(metadata) => metadata,
Err(err) => {
return Outcome::ConstitutionError(unreadable_workspace_error(manifest_path, &err));
}
};
let mut violations = Vec::new();
for boundary in boundaries {
if let Err(error) = check_impl_trait_boundary(&metadata, boundary, &mut violations) {
return Outcome::ConstitutionError(error);
}
}
if violations.is_empty() {
Outcome::Clean
} else {
Outcome::Violations(Report::new(violations))
}
}
fn check_impl_trait_boundary(
metadata: &Value,
boundary: &ImplTraitBoundary,
violations: &mut Vec<Violation>,
) -> Result<(), String> {
let package = find_package(metadata, &boundary.crate_package)
.ok_or_else(|| crate_not_found_error(&boundary.crate_package))?;
let root_file =
crate_root_file(package).ok_or_else(|| missing_src_error(&boundary.crate_package))?;
let src_dir = root_file
.parent()
.ok_or_else(|| missing_src_error(&boundary.crate_package))?;
let findings = if boundary.forbidden_operands.is_empty() {
impl_trait_module_findings(
src_dir,
&root_file,
&boundary.module,
&boundary.crate_package,
)?
} else {
impl_trait_operand_module_findings(
src_dir,
&root_file,
&boundary.module,
&boundary.forbidden_operands,
&boundary.crate_package,
)?
};
for finding in findings {
violations.push(Violation::new(
BoundaryKind::Semantic,
boundary.module.clone(),
IMPL_TRAIT_RULE.to_string(),
finding,
boundary.reason.clone(),
boundary.severity,
));
}
Ok(())
}
pub(crate) fn impl_trait_module_findings(
src_dir: &Path,
root_file: &Path,
module: &str,
crate_package: &str,
) -> Result<Vec<String>, String> {
let items = resolve_module_items(src_dir, root_file, module, crate_package)?;
let uses = collect_uses(&items);
let mut exposures = Vec::new();
for (ordinal, item) in items.iter().enumerate() {
collect_item_return_impl_traits(item, module, &uses, ordinal, &mut exposures);
}
let mut findings: Vec<String> = exposures.into_iter().map(shape_finding).collect();
findings.sort();
findings.dedup();
Ok(findings)
}
pub(crate) fn impl_trait_operand_module_findings(
src_dir: &Path,
root_file: &Path,
module: &str,
forbidden: &[String],
crate_package: &str,
) -> Result<Vec<String>, String> {
let items = resolve_module_items(src_dir, root_file, module, crate_package)?;
let uses = collect_uses(&items);
let reexports = scan_crate(src_dir, root_file, crate_package)?.reexports;
let forbidden: Vec<String> = forbidden.iter().map(|f| canonical_path_str(f)).collect();
let mut exposures = Vec::new();
for (ordinal, item) in items.iter().enumerate() {
collect_item_return_impl_traits(item, module, &uses, ordinal, &mut exposures);
}
let mut findings: Vec<String> = exposures
.into_iter()
.filter(|exposure| {
forbidden.is_empty()
|| exposure
.principal
.as_ref()
.and_then(|path| resolve_path(path, &uses, module, BareFallback::Ignore))
.map(|canonical| canonicalize_through_reexports(&canonical, &reexports))
.is_some_and(|canonical| matches_forbidden(&canonical, &forbidden))
})
.map(shape_finding)
.collect();
findings.sort();
findings.dedup();
Ok(findings)
}
fn collect_item_return_impl_traits(
item: &syn::Item,
module: &str,
uses: &UseMap,
ordinal: usize,
out: &mut Vec<ShapeExposure>,
) {
match item {
syn::Item::Fn(item) if is_public(&item.vis) => {
let seam = fn_seam(module, &item.sig.ident);
out.extend(stamp_seam(impl_traits_in_return(&item.sig), &seam));
}
syn::Item::Trait(item) if is_public(&item.vis) => {
let trait_name = strip_raw(&item.ident.to_string());
for trait_item in &item.items {
if let syn::TraitItem::Fn(method) = trait_item {
let seam = trait_method_seam(module, &trait_name, &method.sig.ident);
out.extend(stamp_seam(impl_traits_in_return(&method.sig), &seam));
}
}
}
syn::Item::Impl(item) if item.trait_.is_none() => {
let owner = canonical_self_owner(&item.self_ty, uses, module, ordinal);
for impl_item in &item.items {
if let syn::ImplItem::Fn(method) = impl_item {
if is_public(&method.vis) {
let seam = inherent_method_seam(&owner, &method.sig.ident);
out.extend(stamp_seam(impl_traits_in_return(&method.sig), &seam));
}
}
}
}
_ => {}
}
}
fn impl_traits_in_return(sig: &syn::Signature) -> Vec<ShapeExposure> {
let mut collector = ImplTraitCollector::default();
if let syn::ReturnType::Type(_, ty) = &sig.output {
collector.visit_type(ty);
}
collector.exposures
}
pub fn check_async_exposure(boundaries: &[AsyncExposureBoundary], manifest_path: &Path) -> Outcome {
let metadata = match cargo_metadata(manifest_path) {
Ok(metadata) => metadata,
Err(err) => {
return Outcome::ConstitutionError(unreadable_workspace_error(manifest_path, &err));
}
};
let mut violations = Vec::new();
for boundary in boundaries {
if let Err(error) = check_async_exposure_boundary(&metadata, boundary, &mut violations) {
return Outcome::ConstitutionError(error);
}
}
if violations.is_empty() {
Outcome::Clean
} else {
Outcome::Violations(Report::new(violations))
}
}
fn check_async_exposure_boundary(
metadata: &Value,
boundary: &AsyncExposureBoundary,
violations: &mut Vec<Violation>,
) -> Result<(), String> {
let package = find_package(metadata, &boundary.crate_package)
.ok_or_else(|| crate_not_found_error(&boundary.crate_package))?;
let root_file =
crate_root_file(package).ok_or_else(|| missing_src_error(&boundary.crate_package))?;
let src_dir = root_file
.parent()
.ok_or_else(|| missing_src_error(&boundary.crate_package))?;
let findings = async_exposure_module_findings(
src_dir,
&root_file,
&boundary.module,
&boundary.crate_package,
)?;
for finding in findings {
violations.push(Violation::new(
BoundaryKind::Semantic,
boundary.module.clone(),
ASYNC_EXPOSURE_RULE.to_string(),
finding,
boundary.reason.clone(),
boundary.severity,
));
}
Ok(())
}
pub(crate) fn async_exposure_module_findings(
src_dir: &Path,
root_file: &Path,
module: &str,
crate_package: &str,
) -> Result<Vec<String>, String> {
let items = resolve_module_items(src_dir, root_file, module, crate_package)?;
let uses = collect_uses(&items);
let mut found = Vec::new();
for (ordinal, item) in items.iter().enumerate() {
collect_item_async_exposures(item, module, &uses, ordinal, &mut found);
}
found.sort();
found.dedup();
Ok(found)
}
fn collect_item_async_exposures(
item: &syn::Item,
module: &str,
uses: &UseMap,
ordinal: usize,
out: &mut Vec<String>,
) {
match item {
syn::Item::Fn(item) if is_public(&item.vis) => {
if item.sig.asyncness.is_some() {
out.push(format!(
"async fn {module}::{}{}",
strip_raw(&item.sig.ident.to_string()),
render_sig_tail(&item.sig),
));
}
}
syn::Item::Trait(item) if is_public(&item.vis) => {
let trait_name = strip_raw(&item.ident.to_string());
for trait_item in &item.items {
if let syn::TraitItem::Fn(method) = trait_item {
if method.sig.asyncness.is_some() {
out.push(format!(
"async fn trait {module}::{trait_name}::{}{}",
strip_raw(&method.sig.ident.to_string()),
render_sig_tail(&method.sig),
));
}
}
}
}
syn::Item::Impl(item) if item.trait_.is_none() => {
let owner = canonical_self_owner(&item.self_ty, uses, module, ordinal);
for impl_item in &item.items {
if let syn::ImplItem::Fn(method) = impl_item {
if is_public(&method.vis) && method.sig.asyncness.is_some() {
out.push(format!(
"async fn <{owner}>::{}{}",
strip_raw(&method.sig.ident.to_string()),
render_sig_tail(&method.sig),
));
}
}
}
}
_ => {}
}
}
fn render_sig_tail(sig: &syn::Signature) -> String {
let params: Vec<String> = sig
.inputs
.iter()
.map(|arg| match arg {
syn::FnArg::Receiver(receiver) => {
let reference = if receiver.reference.is_some() {
"&"
} else {
""
};
let mutability = if receiver.mutability.is_some() {
"mut "
} else {
""
};
format!("{reference}{mutability}self")
}
syn::FnArg::Typed(pat_type) => {
type_to_string(&pat_type.ty).unwrap_or_else(|| "_".to_string())
}
})
.collect();
let ret = match &sig.output {
syn::ReturnType::Type(_, ty) => {
format!(
" -> {}",
type_to_string(ty).unwrap_or_else(|| "_".to_string())
)
}
syn::ReturnType::Default => String::new(),
};
format!("({}){ret}", params.join(", "))
}
pub fn check_trait_impl_locality(
boundaries: &[TraitImplBoundary],
manifest_path: &Path,
) -> Outcome {
let metadata = match cargo_metadata(manifest_path) {
Ok(metadata) => metadata,
Err(err) => {
return Outcome::ConstitutionError(unreadable_workspace_error(manifest_path, &err));
}
};
let mut violations = Vec::new();
for boundary in boundaries {
if let Err(error) = check_trait_impl_boundary(&metadata, boundary, &mut violations) {
return Outcome::ConstitutionError(error);
}
}
if violations.is_empty() {
Outcome::Clean
} else {
Outcome::Violations(Report::new(violations))
}
}
fn check_trait_impl_boundary(
metadata: &Value,
boundary: &TraitImplBoundary,
violations: &mut Vec<Violation>,
) -> Result<(), String> {
let package = find_package(metadata, &boundary.crate_package)
.ok_or_else(|| crate_not_found_error(&boundary.crate_package))?;
let root_file =
crate_root_file(package).ok_or_else(|| missing_src_error(&boundary.crate_package))?;
let src_dir = root_file
.parent()
.ok_or_else(|| missing_src_error(&boundary.crate_package))?;
let findings = trait_impl_findings(
src_dir,
&root_file,
&boundary.trait_path,
&boundary.allowed_locations,
&boundary.crate_package,
)?;
let rule = TRAIT_IMPL_RULE.to_string();
for finding in findings {
violations.push(Violation::new(
BoundaryKind::Semantic,
canonical_path_str(&boundary.trait_path),
rule.clone(),
finding,
boundary.reason.clone(),
boundary.severity,
));
}
Ok(())
}
pub(crate) fn trait_impl_findings(
src_dir: &Path,
root_file: &Path,
trait_path: &str,
allowed: &[String],
crate_package: &str,
) -> Result<Vec<String>, String> {
let scan = scan_crate(src_dir, root_file, crate_package)?;
let given = canonical_path_str(trait_path);
let true_anchor = canonicalize_through_reexports(&given, &scan.reexports);
if !scan.trait_defs.contains(&true_anchor) {
return Err(unknown_trait_error(trait_path, crate_package));
}
let allowed: Vec<String> = allowed.iter().map(|a| canonical_path_str(a)).collect();
let mut findings = Vec::new();
for (ordinal, site) in scan.impls.iter().enumerate() {
let Some(resolved) = resolve_path(
&site.trait_path,
&site.uses,
&site.module,
BareFallback::CurrentModule,
) else {
continue;
};
let canonical = canonicalize_through_reexports(&resolved, &scan.reexports);
if canonical != true_anchor {
continue;
}
if matches_allowed(&site.module, &allowed) {
continue;
}
let owner = canonical_self_owner(&site.self_ty, &site.uses, &site.module, ordinal);
findings.push(format!("{} (impl for {owner})", site.module));
}
findings.sort();
findings.dedup();
Ok(findings)
}
struct ImplSite {
module: String,
trait_path: syn::Path,
self_ty: syn::Type,
uses: UseMap,
}
struct TypeDef {
canonical: String,
derives: Vec<syn::Path>,
}
struct CrateScan {
reexports: ReexportMap,
trait_defs: HashSet<String>,
impls: Vec<ImplSite>,
type_defs: Vec<TypeDef>,
}
fn scan_crate(src_dir: &Path, root_file: &Path, crate_package: &str) -> Result<CrateScan, String> {
let root = read_parse(root_file)?;
let mut scan = CrateScan {
reexports: ReexportMap::new(),
trait_defs: HashSet::new(),
impls: Vec::new(),
type_defs: Vec::new(),
};
walk_module(
root.items,
"crate".to_string(),
src_dir.to_path_buf(),
crate_package,
&mut scan,
)?;
Ok(scan)
}
fn walk_module(
items: Vec<syn::Item>,
module: String,
child_dir: PathBuf,
crate_package: &str,
scan: &mut CrateScan,
) -> Result<(), String> {
let uses = collect_uses(&items);
collect_reexports(&items, &module, &mut scan.reexports);
for item in &items {
match item {
syn::Item::Trait(trait_item) => {
scan.trait_defs.insert(format!(
"{module}::{}",
strip_raw(&trait_item.ident.to_string())
));
}
syn::Item::Impl(impl_item) if impl_item.trait_.is_some() => {
let (_, trait_path, _) = impl_item.trait_.as_ref().expect("trait_ is Some");
scan.impls.push(ImplSite {
module: module.clone(),
trait_path: trait_path.clone(),
self_ty: (*impl_item.self_ty).clone(),
uses: uses.clone(),
});
}
syn::Item::Struct(i) => {
push_type_def(&i.attrs, &i.ident, &module, scan)?;
}
syn::Item::Enum(i) => {
push_type_def(&i.attrs, &i.ident, &module, scan)?;
}
syn::Item::Union(i) => {
push_type_def(&i.attrs, &i.ident, &module, scan)?;
}
_ => {}
}
}
for item in items {
if let syn::Item::Mod(module_item) = item {
if has_path_attr(&module_item.attrs) {
continue;
}
let name = strip_raw(&module_item.ident.to_string());
let child_module = format!("{module}::{name}");
match module_item.content {
Some((_, inner)) => {
walk_module(
inner,
child_module,
child_dir.join(&name),
crate_package,
scan,
)?;
}
None => match locate_module_file(&child_dir, &name) {
Some(file) => {
let parsed = read_parse(&file)?;
walk_module(
parsed.items,
child_module,
child_dir.join(&name),
crate_package,
scan,
)?;
}
None => {
if !has_cfg_attr(&module_item.attrs) {
return Err(missing_module_file_error(&child_module, crate_package));
}
}
},
}
}
}
Ok(())
}
fn has_path_attr(attrs: &[syn::Attribute]) -> bool {
attrs.iter().any(|attr| attr.path().is_ident("path"))
}
fn push_type_def(
attrs: &[syn::Attribute],
ident: &syn::Ident,
module: &str,
scan: &mut CrateScan,
) -> Result<(), String> {
let name = strip_raw(&ident.to_string());
let derives = extract_derives(attrs)?;
scan.type_defs.push(TypeDef {
canonical: format!("{module}::{name}"),
derives,
});
Ok(())
}
fn extract_derives(attrs: &[syn::Attribute]) -> Result<Vec<syn::Path>, String> {
let mut out = Vec::new();
for attr in attrs {
if attr.path().is_ident("derive") {
out.extend(parse_derive_paths(&attr.meta)?);
} else if attr.path().is_ident("cfg_attr") {
let metas = attr
.parse_args_with(meta_list_parser())
.map_err(|e| format!("cannot parse #[cfg_attr(...)]: {e}"))?;
extract_derives_from_cfg_metas(&metas, &mut out)?;
}
}
Ok(out)
}
fn meta_list_parser() -> impl Parser<Output = syn::punctuated::Punctuated<syn::Meta, syn::Token![,]>>
{
syn::punctuated::Punctuated::<syn::Meta, syn::Token![,]>::parse_terminated
}
fn parse_derive_paths(meta: &syn::Meta) -> Result<Vec<syn::Path>, String> {
let parser = syn::punctuated::Punctuated::<syn::Path, syn::Token![,]>::parse_terminated;
match meta {
syn::Meta::List(list) => Ok(list
.parse_args_with(parser)
.map_err(|e| format!("cannot parse derive(...): {e}"))?
.into_iter()
.collect()),
_ => Ok(Vec::new()),
}
}
fn extract_derives_from_cfg_metas(
metas: &syn::punctuated::Punctuated<syn::Meta, syn::Token![,]>,
out: &mut Vec<syn::Path>,
) -> Result<(), String> {
for meta in metas.iter().skip(1) {
if let syn::Meta::List(list) = meta {
if list.path.is_ident("derive") {
out.extend(parse_derive_paths(meta)?);
} else if list.path.is_ident("cfg_attr") {
let inner = list
.parse_args_with(meta_list_parser())
.map_err(|e| format!("cannot parse nested #[cfg_attr(...)]: {e}"))?;
extract_derives_from_cfg_metas(&inner, out)?;
}
}
}
Ok(())
}
fn has_cfg_attr(attrs: &[syn::Attribute]) -> bool {
attrs.iter().any(|attr| attr.path().is_ident("cfg"))
}
pub fn check_visibility(boundaries: &[VisibilityBoundary], manifest_path: &Path) -> Outcome {
let metadata = match cargo_metadata(manifest_path) {
Ok(metadata) => metadata,
Err(err) => {
return Outcome::ConstitutionError(unreadable_workspace_error(manifest_path, &err));
}
};
let mut violations = Vec::new();
for boundary in boundaries {
if let Err(error) = check_visibility_boundary(&metadata, boundary, &mut violations) {
return Outcome::ConstitutionError(error);
}
}
if violations.is_empty() {
Outcome::Clean
} else {
Outcome::Violations(Report::new(violations))
}
}
fn check_visibility_boundary(
metadata: &Value,
boundary: &VisibilityBoundary,
violations: &mut Vec<Violation>,
) -> Result<(), String> {
let package = find_package(metadata, &boundary.crate_package)
.ok_or_else(|| crate_not_found_error(&boundary.crate_package))?;
let root_file =
crate_root_file(package).ok_or_else(|| missing_src_error(&boundary.crate_package))?;
let src_dir = root_file
.parent()
.ok_or_else(|| missing_src_error(&boundary.crate_package))?;
let findings = visibility_findings(
src_dir,
&root_file,
&boundary.module,
&boundary.crate_package,
)?;
for finding in findings {
violations.push(Violation::new(
BoundaryKind::Semantic,
boundary.module.clone(),
VISIBILITY_RULE.to_string(),
finding,
boundary.reason.clone(),
boundary.severity,
));
}
Ok(())
}
pub(crate) fn visibility_findings(
src_dir: &Path,
root_file: &Path,
module: &str,
crate_package: &str,
) -> Result<Vec<String>, String> {
let items = resolve_module_items(src_dir, root_file, module, crate_package)?;
let mut findings: Vec<String> = items.iter().filter_map(pub_item_description).collect();
findings.sort();
findings.dedup();
Ok(findings)
}
fn pub_item_description(item: &syn::Item) -> Option<String> {
match item {
syn::Item::Fn(i) if is_public(&i.vis) => Some(format!("pub fn {}", i.sig.ident)),
syn::Item::Struct(i) if is_public(&i.vis) => Some(format!("pub struct {}", i.ident)),
syn::Item::Enum(i) if is_public(&i.vis) => Some(format!("pub enum {}", i.ident)),
syn::Item::Union(i) if is_public(&i.vis) => Some(format!("pub union {}", i.ident)),
syn::Item::Type(i) if is_public(&i.vis) => Some(format!("pub type {}", i.ident)),
syn::Item::Const(i) if is_public(&i.vis) => Some(format!("pub const {}", i.ident)),
syn::Item::Static(i) if is_public(&i.vis) => Some(format!("pub static {}", i.ident)),
syn::Item::Trait(i) if is_public(&i.vis) => Some(format!("pub trait {}", i.ident)),
syn::Item::TraitAlias(i) if is_public(&i.vis) => {
Some(format!("pub trait {} (alias)", i.ident))
}
syn::Item::Mod(i) if is_public(&i.vis) => Some(format!("pub mod {}", i.ident)),
syn::Item::ExternCrate(i) if is_public(&i.vis) => {
Some(format!("pub extern crate {}", i.ident))
}
syn::Item::Use(i) if is_public(&i.vis) => Some(format!(
"pub use {}{}",
if i.leading_colon.is_some() { "::" } else { "" },
use_tree_desc(&i.tree)
)),
_ => None,
}
}
fn use_tree_desc(tree: &syn::UseTree) -> String {
match tree {
syn::UseTree::Path(p) => {
format!(
"{}::{}",
strip_raw(&p.ident.to_string()),
use_tree_desc(&p.tree)
)
}
syn::UseTree::Name(n) => strip_raw(&n.ident.to_string()),
syn::UseTree::Rename(r) => format!(
"{} as {}",
strip_raw(&r.ident.to_string()),
strip_raw(&r.rename.to_string())
),
syn::UseTree::Glob(_) => "*".to_string(),
syn::UseTree::Group(g) => {
let inner: Vec<String> = g.items.iter().map(use_tree_desc).collect();
format!("{{{}}}", inner.join(", "))
}
}
}
pub fn check_forbidden_marker(
boundaries: &[ForbiddenMarkerBoundary],
manifest_path: &Path,
) -> Outcome {
let metadata = match cargo_metadata(manifest_path) {
Ok(metadata) => metadata,
Err(err) => {
return Outcome::ConstitutionError(unreadable_workspace_error(manifest_path, &err));
}
};
let mut violations = Vec::new();
for boundary in boundaries {
if let Err(error) = check_forbidden_marker_boundary(&metadata, boundary, &mut violations) {
return Outcome::ConstitutionError(error);
}
}
if violations.is_empty() {
Outcome::Clean
} else {
Outcome::Violations(Report::new(violations))
}
}
fn check_forbidden_marker_boundary(
metadata: &Value,
boundary: &ForbiddenMarkerBoundary,
violations: &mut Vec<Violation>,
) -> Result<(), String> {
let package = find_package(metadata, &boundary.crate_package)
.ok_or_else(|| crate_not_found_error(&boundary.crate_package))?;
let root_file =
crate_root_file(package).ok_or_else(|| missing_src_error(&boundary.crate_package))?;
let src_dir = root_file
.parent()
.ok_or_else(|| missing_src_error(&boundary.crate_package))?;
let findings = forbidden_marker_findings(
src_dir,
&root_file,
&boundary.module,
&boundary.forbidden,
&boundary.crate_package,
)?;
for finding in findings {
violations.push(Violation::new(
BoundaryKind::Semantic,
boundary.module.clone(),
FORBIDDEN_MARKER_RULE.to_string(),
finding,
boundary.reason.clone(),
boundary.severity,
));
}
Ok(())
}
pub(crate) fn forbidden_marker_findings(
src_dir: &Path,
root_file: &Path,
subtree: &str,
forbidden: &[String],
crate_package: &str,
) -> Result<Vec<String>, String> {
let scan = scan_crate(src_dir, root_file, crate_package)?;
let subtree = canonical_path_str(subtree);
let mut findings = Vec::new();
for entry in forbidden {
let entry_leaf = leaf_of(entry);
for td in &scan.type_defs {
if !under_subtree(&td.canonical, &subtree) {
continue;
}
for derived in &td.derives {
if path_leaf(derived) == entry_leaf {
findings.push(format!("derive {entry} on {}", td.canonical));
}
}
}
for site in &scan.impls {
if path_leaf(&site.trait_path) != entry_leaf {
continue;
}
let Some(self_canonical) = resolve_self_type(&site.self_ty, &site.uses, &site.module)
else {
continue; };
if under_subtree(&self_canonical, &subtree) {
findings.push(format!("impl {entry} for {self_canonical}"));
}
}
}
findings.sort();
findings.dedup();
Ok(findings)
}
fn under_subtree(canonical: &str, subtree: &str) -> bool {
canonical == subtree || canonical.starts_with(&format!("{subtree}::"))
}
fn leaf_of(path: &str) -> &str {
path.rsplit("::").next().unwrap_or(path)
}
fn path_leaf(path: &syn::Path) -> String {
path.segments
.last()
.map(|s| strip_raw(&s.ident.to_string()))
.unwrap_or_default()
}
fn resolve_self_type(self_ty: &syn::Type, uses: &UseMap, module: &str) -> Option<String> {
match self_ty {
syn::Type::Path(tp) => resolve_path(&tp.path, uses, module, BareFallback::CurrentModule),
_ => None,
}
}
fn crate_root_file(package: &Value) -> Option<PathBuf> {
let targets = package["targets"].as_array()?;
let has_kind = |target: &Value, wanted: &str| {
target["kind"]
.as_array()
.map(|kinds| kinds.iter().any(|k| k.as_str() == Some(wanted)))
.unwrap_or(false)
};
let pick = targets
.iter()
.find(|t| has_kind(t, "lib"))
.or_else(|| targets.iter().find(|t| has_kind(t, "bin")))?;
pick["src_path"].as_str().map(PathBuf::from)
}
fn module_segments(module: &str) -> Vec<String> {
module
.split("::")
.map(strip_raw)
.enumerate()
.filter(|(i, seg)| !(*i == 0 && seg == "crate"))
.map(|(_, seg)| seg)
.filter(|seg| !seg.is_empty())
.collect()
}
fn resolve_module_items(
src_dir: &Path,
root_file: &Path,
module: &str,
crate_package: &str,
) -> Result<Vec<syn::Item>, String> {
let root = read_parse(root_file)?;
let segments = module_segments(module);
descend(
root.items,
src_dir.to_path_buf(),
&segments,
module,
crate_package,
)
}
fn descend(
items: Vec<syn::Item>,
child_dir: PathBuf,
segments: &[String],
module: &str,
crate_package: &str,
) -> Result<Vec<syn::Item>, String> {
let Some(seg) = segments.first() else {
return Ok(items);
};
for item in &items {
if let syn::Item::Mod(module_item) = item {
if strip_raw(&module_item.ident.to_string()) != *seg {
continue;
}
match &module_item.content {
Some((_, inner)) => {
return descend(
inner.clone(),
child_dir.join(seg),
&segments[1..],
module,
crate_package,
);
}
None => {
let file = locate_module_file(&child_dir, seg)
.ok_or_else(|| missing_module_file_error(module, crate_package))?;
let parsed = read_parse(&file)?;
return descend(
parsed.items,
child_dir.join(seg),
&segments[1..],
module,
crate_package,
);
}
}
}
}
Err(unknown_module_error(module, crate_package))
}
fn locate_module_file(child_dir: &Path, seg: &str) -> Option<PathBuf> {
let flat = child_dir.join(format!("{seg}.rs"));
if flat.is_file() {
return Some(flat);
}
let nested = child_dir.join(seg).join("mod.rs");
if nested.is_file() {
return Some(nested);
}
None
}
fn read_parse(file: &Path) -> Result<syn::File, String> {
let text = std::fs::read_to_string(file)
.map_err(|err| unreadable_source_error(file, &err.to_string()))?;
syn::parse_file(&text).map_err(|err| unparseable_source_error(file, &err.to_string()))
}
fn matches_forbidden(canonical: &str, forbidden: &[String]) -> bool {
forbidden
.iter()
.any(|entry| canonical == entry || canonical.starts_with(&format!("{entry}::")))
}
fn matches_allowed(location: &str, allowed: &[String]) -> bool {
allowed
.iter()
.any(|entry| location == entry || location.starts_with(&format!("{entry}::")))
}
fn is_public(vis: &syn::Visibility) -> bool {
matches!(vis, syn::Visibility::Public(_))
}
fn paths_in_signature(sig: &syn::Signature) -> Vec<syn::Path> {
let mut c = PathCollector::default();
c.visit_signature(sig);
c.paths
}
fn paths_in_type(ty: &syn::Type) -> Vec<syn::Path> {
let mut c = PathCollector::default();
c.visit_type(ty);
c.paths
}
fn paths_in_generics(generics: &syn::Generics) -> Vec<syn::Path> {
let mut c = PathCollector::default();
c.visit_generics(generics);
c.paths
}
fn dyns_in_signature(sig: &syn::Signature) -> Vec<ShapeExposure> {
let mut c = DynCollector::default();
c.visit_signature(sig);
c.exposures
}
fn dyns_in_type(ty: &syn::Type) -> Vec<ShapeExposure> {
let mut c = DynCollector::default();
c.visit_type(ty);
c.exposures
}
fn dyns_in_generics(generics: &syn::Generics) -> Vec<ShapeExposure> {
let mut c = DynCollector::default();
c.visit_generics(generics);
c.exposures
}
struct PathExposure {
seam: String,
path: syn::Path,
}
fn shape_finding(exposure: ShapeExposure) -> String {
format!("{} exposed by {}", exposure.shape, exposure.seam)
}
fn tag_paths(paths: Vec<syn::Path>, seam: &str) -> Vec<PathExposure> {
paths
.into_iter()
.map(|path| PathExposure {
seam: seam.to_string(),
path,
})
.collect()
}
fn fn_seam(module: &str, name: &syn::Ident) -> String {
format!("fn {module}::{}", strip_raw(&name.to_string()))
}
fn inherent_method_seam(owner: &str, name: &syn::Ident) -> String {
format!("fn <{owner}>::{}", strip_raw(&name.to_string()))
}
fn trait_method_seam(module: &str, trait_name: &str, name: &syn::Ident) -> String {
format!(
"fn trait {module}::{trait_name}::{}",
strip_raw(&name.to_string())
)
}
fn item_seam(kind: &str, module: &str, name: &syn::Ident) -> String {
format!("{kind} {module}::{}", strip_raw(&name.to_string()))
}
fn field_seam(kind: &str, module: &str, owner: &str, member: &str) -> String {
format!("{kind} {module}::{owner}::{member}")
}
fn trait_assoc_seam(kind: &str, module: &str, trait_name: &str, name: &syn::Ident) -> String {
format!(
"{kind} trait {module}::{trait_name}::{}",
strip_raw(&name.to_string())
)
}
fn member_label(index: usize, field: &syn::Field) -> String {
match &field.ident {
Some(ident) => strip_raw(&ident.to_string()),
None => index.to_string(),
}
}
fn collect_item_exposures(
item: &syn::Item,
module: &str,
uses: &UseMap,
ordinal: usize,
out: &mut Vec<PathExposure>,
) {
match item {
syn::Item::Fn(item) if is_public(&item.vis) => {
let seam = fn_seam(module, &item.sig.ident);
out.extend(tag_paths(paths_in_signature(&item.sig), &seam));
}
syn::Item::Struct(item) if is_public(&item.vis) => {
let name = strip_raw(&item.ident.to_string());
out.extend(tag_paths(
paths_in_generics(&item.generics),
&item_seam("struct", module, &item.ident),
));
for (index, field) in item.fields.iter().enumerate() {
if is_public(&field.vis) {
let seam = field_seam("field", module, &name, &member_label(index, field));
out.extend(tag_paths(paths_in_type(&field.ty), &seam));
}
}
}
syn::Item::Enum(item) if is_public(&item.vis) => {
let name = strip_raw(&item.ident.to_string());
out.extend(tag_paths(
paths_in_generics(&item.generics),
&item_seam("enum", module, &item.ident),
));
for variant in &item.variants {
let seam = field_seam(
"variant",
module,
&name,
&strip_raw(&variant.ident.to_string()),
);
for field in &variant.fields {
out.extend(tag_paths(paths_in_type(&field.ty), &seam));
}
}
}
syn::Item::Union(item) if is_public(&item.vis) => {
let name = strip_raw(&item.ident.to_string());
out.extend(tag_paths(
paths_in_generics(&item.generics),
&item_seam("union", module, &item.ident),
));
for (index, field) in item.fields.named.iter().enumerate() {
if is_public(&field.vis) {
let seam = field_seam("field", module, &name, &member_label(index, field));
out.extend(tag_paths(paths_in_type(&field.ty), &seam));
}
}
}
syn::Item::Type(item) if is_public(&item.vis) => {
let seam = item_seam("type", module, &item.ident);
out.extend(tag_paths(paths_in_generics(&item.generics), &seam));
out.extend(tag_paths(paths_in_type(&item.ty), &seam));
}
syn::Item::Const(item) if is_public(&item.vis) => {
out.extend(tag_paths(
paths_in_type(&item.ty),
&item_seam("const", module, &item.ident),
));
}
syn::Item::Static(item) if is_public(&item.vis) => {
out.extend(tag_paths(
paths_in_type(&item.ty),
&item_seam("static", module, &item.ident),
));
}
syn::Item::Trait(item) if is_public(&item.vis) => {
let trait_name = strip_raw(&item.ident.to_string());
let trait_seam = item_seam("trait", module, &item.ident);
out.extend(tag_paths(paths_in_generics(&item.generics), &trait_seam));
for bound in &item.supertraits {
if let syn::TypeParamBound::Trait(trait_bound) = bound {
out.push(PathExposure {
seam: trait_seam.clone(),
path: trait_bound.path.clone(),
});
}
}
for trait_item in &item.items {
match trait_item {
syn::TraitItem::Fn(method) => {
let seam = trait_method_seam(module, &trait_name, &method.sig.ident);
out.extend(tag_paths(paths_in_signature(&method.sig), &seam));
}
syn::TraitItem::Type(assoc) => {
let seam = trait_assoc_seam("type", module, &trait_name, &assoc.ident);
for bound in &assoc.bounds {
if let syn::TypeParamBound::Trait(trait_bound) = bound {
out.push(PathExposure {
seam: seam.clone(),
path: trait_bound.path.clone(),
});
}
}
}
syn::TraitItem::Const(assoc) => {
let seam = trait_assoc_seam("const", module, &trait_name, &assoc.ident);
out.extend(tag_paths(paths_in_type(&assoc.ty), &seam));
}
_ => {}
}
}
}
syn::Item::Impl(item) if item.trait_.is_none() => {
let owner = canonical_self_owner(&item.self_ty, uses, module, ordinal);
for impl_item in &item.items {
if let syn::ImplItem::Fn(method) = impl_item {
if is_public(&method.vis) {
let seam = inherent_method_seam(&owner, &method.sig.ident);
out.extend(tag_paths(paths_in_signature(&method.sig), &seam));
}
}
}
}
_ => {}
}
}
fn collect_item_dyn_exposures(
item: &syn::Item,
module: &str,
uses: &UseMap,
ordinal: usize,
out: &mut Vec<ShapeExposure>,
) {
match item {
syn::Item::Fn(item) if is_public(&item.vis) => {
let seam = fn_seam(module, &item.sig.ident);
out.extend(stamp_seam(dyns_in_signature(&item.sig), &seam));
}
syn::Item::Struct(item) if is_public(&item.vis) => {
let name = strip_raw(&item.ident.to_string());
out.extend(stamp_seam(
dyns_in_generics(&item.generics),
&item_seam("struct", module, &item.ident),
));
for (index, field) in item.fields.iter().enumerate() {
if is_public(&field.vis) {
let seam = field_seam("field", module, &name, &member_label(index, field));
out.extend(stamp_seam(dyns_in_type(&field.ty), &seam));
}
}
}
syn::Item::Enum(item) if is_public(&item.vis) => {
let name = strip_raw(&item.ident.to_string());
out.extend(stamp_seam(
dyns_in_generics(&item.generics),
&item_seam("enum", module, &item.ident),
));
for variant in &item.variants {
let seam = field_seam(
"variant",
module,
&name,
&strip_raw(&variant.ident.to_string()),
);
for field in &variant.fields {
out.extend(stamp_seam(dyns_in_type(&field.ty), &seam));
}
}
}
syn::Item::Union(item) if is_public(&item.vis) => {
let name = strip_raw(&item.ident.to_string());
out.extend(stamp_seam(
dyns_in_generics(&item.generics),
&item_seam("union", module, &item.ident),
));
for (index, field) in item.fields.named.iter().enumerate() {
if is_public(&field.vis) {
let seam = field_seam("field", module, &name, &member_label(index, field));
out.extend(stamp_seam(dyns_in_type(&field.ty), &seam));
}
}
}
syn::Item::Type(item) if is_public(&item.vis) => {
let seam = item_seam("type", module, &item.ident);
out.extend(stamp_seam(dyns_in_generics(&item.generics), &seam));
out.extend(stamp_seam(dyns_in_type(&item.ty), &seam));
}
syn::Item::Const(item) if is_public(&item.vis) => {
out.extend(stamp_seam(
dyns_in_type(&item.ty),
&item_seam("const", module, &item.ident),
));
}
syn::Item::Static(item) if is_public(&item.vis) => {
out.extend(stamp_seam(
dyns_in_type(&item.ty),
&item_seam("static", module, &item.ident),
));
}
syn::Item::Trait(item) if is_public(&item.vis) => {
let trait_name = strip_raw(&item.ident.to_string());
out.extend(stamp_seam(
dyns_in_generics(&item.generics),
&item_seam("trait", module, &item.ident),
));
for trait_item in &item.items {
match trait_item {
syn::TraitItem::Fn(method) => {
let seam = trait_method_seam(module, &trait_name, &method.sig.ident);
out.extend(stamp_seam(dyns_in_signature(&method.sig), &seam));
}
syn::TraitItem::Type(assoc) => {
if let Some((_, default)) = &assoc.default {
let seam = trait_assoc_seam("type", module, &trait_name, &assoc.ident);
out.extend(stamp_seam(dyns_in_type(default), &seam));
}
}
syn::TraitItem::Const(assoc) => {
let seam = trait_assoc_seam("const", module, &trait_name, &assoc.ident);
out.extend(stamp_seam(dyns_in_type(&assoc.ty), &seam));
}
_ => {}
}
}
}
syn::Item::Impl(item) if item.trait_.is_none() => {
let owner = canonical_self_owner(&item.self_ty, uses, module, ordinal);
for impl_item in &item.items {
if let syn::ImplItem::Fn(method) = impl_item {
if is_public(&method.vis) {
let seam = inherent_method_seam(&owner, &method.sig.ident);
out.extend(stamp_seam(dyns_in_signature(&method.sig), &seam));
}
}
}
}
_ => {}
}
}
fn cargo_metadata(manifest_path: &Path) -> Result<Value, String> {
let output = Command::new("cargo")
.args([
"metadata",
"--no-deps",
"--format-version",
"1",
"--manifest-path",
])
.arg(manifest_path)
.output()
.map_err(|err| err.to_string())?;
if !output.status.success() {
return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
}
serde_json::from_slice(&output.stdout).map_err(|err| err.to_string())
}
fn find_package<'a>(metadata: &'a Value, package: &str) -> Option<&'a Value> {
metadata["packages"]
.as_array()?
.iter()
.find(|candidate| candidate["name"].as_str() == Some(package))
}
#[cfg(test)]
mod tests {
use super::*;
fn findings(
name: &str,
files: &[(&str, &str)],
module: &str,
forbidden: &[&str],
) -> Result<Vec<String>, String> {
let dir = std::env::temp_dir().join(format!("hunyi-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let src = dir.join("src");
for (rel, contents) in files {
let path = src.join(rel);
std::fs::create_dir_all(path.parent().expect("file has a parent")).expect("mkdir");
std::fs::write(&path, contents).expect("write source");
}
let forbidden: Vec<String> = forbidden.iter().map(|s| s.to_string()).collect();
let root = src.join("lib.rs");
let result = module_findings(&src, &root, module, &forbidden, "x");
let _ = std::fs::remove_dir_all(&dir);
result
}
#[test]
fn forbidden_type_in_a_public_return_is_a_finding() {
let out = findings(
"return",
&[
("lib.rs", "pub mod domain;\n"),
(
"domain.rs",
"pub fn pool() -> crate::infra::DbPool { todo!() }\n",
),
],
"crate::domain",
&["crate::infra"],
)
.unwrap();
assert_eq!(
out,
["crate::infra::DbPool exposed by fn crate::domain::pool"]
);
}
#[test]
fn a_type_used_only_internally_is_not_a_finding() {
let out = findings(
"internal-only",
&[
("lib.rs", "pub mod domain;\n"),
(
"domain.rs",
"use crate::infra::DbPool;\nfn helper() -> DbPool { todo!() }\nstruct Private { p: DbPool }\n",
),
],
"crate::domain",
&["crate::infra"],
)
.unwrap();
assert!(out.is_empty(), "internal use is not exposure: {out:?}");
}
#[test]
fn forbidden_type_in_a_public_field_is_a_finding() {
let out = findings(
"field",
&[
("lib.rs", "pub mod domain;\n"),
(
"domain.rs",
"pub struct Service { pub pool: crate::infra::DbPool, secret: u8 }\n",
),
],
"crate::domain",
&["crate::infra"],
)
.unwrap();
assert_eq!(
out,
["crate::infra::DbPool exposed by field crate::domain::Service::pool"]
);
}
#[test]
fn a_private_field_does_not_expose() {
let out = findings(
"private-field",
&[
("lib.rs", "pub mod domain;\n"),
(
"domain.rs",
"pub struct Service { pool: crate::infra::DbPool }\n",
),
],
"crate::domain",
&["crate::infra"],
)
.unwrap();
assert!(out.is_empty(), "a private field is not public API: {out:?}");
}
#[test]
fn inherent_impl_public_method_exposes() {
let out = findings(
"inherent-impl",
&[
("lib.rs", "pub mod domain;\n"),
(
"domain.rs",
"pub struct S;\nimpl S { pub fn pool(&self) -> crate::infra::DbPool { todo!() } }\n",
),
],
"crate::domain",
&["crate::infra"],
)
.unwrap();
assert_eq!(
out,
["crate::infra::DbPool exposed by fn <crate::domain::S>::pool"]
);
}
#[test]
fn trait_impl_is_out_of_scope() {
let out = findings(
"trait-impl",
&[
("lib.rs", "pub mod domain;\n"),
(
"domain.rs",
"pub struct S;\nimpl From<crate::infra::DbPool> for S { fn from(_: crate::infra::DbPool) -> S { S } }\n",
),
],
"crate::domain",
&["crate::infra"],
)
.unwrap();
assert!(
out.is_empty(),
"trait impls are a documented bound: {out:?}"
);
}
#[test]
fn a_renamed_import_resolves_and_reacts() {
let out = findings(
"renamed",
&[
("lib.rs", "pub mod domain;\n"),
(
"domain.rs",
"use crate::infra::DbPool as Pool;\npub fn pool() -> Pool { todo!() }\n",
),
],
"crate::domain",
&["crate::infra"],
)
.unwrap();
assert_eq!(
out,
["crate::infra::DbPool exposed by fn crate::domain::pool"]
);
}
#[test]
fn a_use_imported_type_resolves_via_its_head() {
let out = findings(
"use-head",
&[
("lib.rs", "pub mod domain;\n"),
(
"domain.rs",
"use crate::infra;\npub fn pool() -> infra::DbPool { todo!() }\n",
),
],
"crate::domain",
&["crate::infra"],
)
.unwrap();
assert_eq!(
out,
["crate::infra::DbPool exposed by fn crate::domain::pool"]
);
}
#[test]
fn a_glob_import_is_a_documented_bound() {
let out = findings(
"glob",
&[
("lib.rs", "pub mod domain;\n"),
(
"domain.rs",
"use crate::infra::*;\npub fn pool() -> DbPool { todo!() }\n",
),
],
"crate::domain",
&["crate::infra"],
)
.unwrap();
assert!(
out.is_empty(),
"glob is out of scope, not silently matched: {out:?}"
);
}
#[test]
fn a_forbidden_trait_in_a_generic_bound_is_a_finding() {
let out = findings(
"bound",
&[
("lib.rs", "pub mod domain;\n"),
(
"domain.rs",
"pub fn run<T: crate::infra::Pooled>(_: T) {}\n",
),
],
"crate::domain",
&["crate::infra"],
)
.unwrap();
assert_eq!(
out,
["crate::infra::Pooled exposed by fn crate::domain::run"]
);
}
#[test]
fn a_module_prefix_matches_beneath_but_not_a_sibling() {
let out = findings(
"prefix",
&[
("lib.rs", "pub mod domain;\n"),
(
"domain.rs",
"pub fn a() -> crate::infra::db::Pool { todo!() }\npub fn b() -> crate::infrastructure::Helper { todo!() }\n",
),
],
"crate::domain",
&["crate::infra"],
)
.unwrap();
assert_eq!(
out,
["crate::infra::db::Pool exposed by fn crate::domain::a"],
"sibling must not match: {out:?}"
);
}
#[test]
fn a_nested_generic_argument_is_observed() {
let out = findings(
"nested",
&[
("lib.rs", "pub mod domain;\n"),
(
"domain.rs",
"pub fn pools() -> Vec<crate::infra::DbPool> { todo!() }\n",
),
],
"crate::domain",
&["crate::infra"],
)
.unwrap();
assert_eq!(
out,
["crate::infra::DbPool exposed by fn crate::domain::pools"]
);
}
#[test]
fn an_unknown_module_is_a_constitution_error() {
let err = findings(
"unknown",
&[
("lib.rs", "pub mod domain;\n"),
("domain.rs", "// nothing\n"),
],
"crate::ghost",
&["crate::infra"],
)
.unwrap_err();
assert_eq!(err, unknown_module_error("crate::ghost", "x"));
}
#[test]
fn a_mod_rs_backed_module_resolves() {
let out = findings(
"modrs",
&[
("lib.rs", "pub mod domain;\n"),
(
"domain/mod.rs",
"pub fn pool() -> crate::infra::DbPool { todo!() }\n",
),
],
"crate::domain",
&["crate::infra"],
)
.unwrap();
assert_eq!(
out,
["crate::infra::DbPool exposed by fn crate::domain::pool"]
);
}
#[test]
fn an_inline_module_resolves() {
let out = findings(
"inline",
&[(
"lib.rs",
"pub mod domain { pub fn pool() -> crate::infra::DbPool { todo!() } }\n",
)],
"crate::domain",
&["crate::infra"],
)
.unwrap();
assert_eq!(
out,
["crate::infra::DbPool exposed by fn crate::domain::pool"]
);
}
#[test]
fn a_forbidden_type_via_a_pub_use_facade_resolves_and_reacts() {
let out = findings(
"reexport-exposure",
&[
("lib.rs", "pub mod domain;\npub mod facade;\n"),
("facade.rs", "pub use crate::infra::DbPool;\n"),
(
"domain.rs",
"use crate::facade::DbPool;\npub fn pool() -> DbPool { todo!() }\n",
),
],
"crate::domain",
&["crate::infra"],
)
.unwrap();
assert_eq!(
out,
["crate::infra::DbPool exposed by fn crate::domain::pool"],
"a forbidden type reached through a pub use facade must react"
);
}
#[test]
fn a_forbidden_type_via_a_super_relative_use_resolves_and_reacts() {
let out = findings(
"super-exposure",
&[
("lib.rs", "pub mod domain;\npub mod infra;\n"),
("infra.rs", "pub struct DbPool;\n"),
(
"domain.rs",
"use super::infra::DbPool;\npub fn pool() -> DbPool { todo!() }\n",
),
],
"crate::domain",
&["crate::infra"],
)
.unwrap();
assert_eq!(
out,
["crate::infra::DbPool exposed by fn crate::domain::pool"]
);
}
fn locality_findings(
name: &str,
files: &[(&str, &str)],
trait_path: &str,
allowed: &[&str],
) -> Result<Vec<String>, String> {
let dir = std::env::temp_dir().join(format!("hunyi-loc-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let src = dir.join("src");
for (rel, contents) in files {
let path = src.join(rel);
std::fs::create_dir_all(path.parent().expect("file has a parent")).expect("mkdir");
std::fs::write(&path, contents).expect("write source");
}
let allowed: Vec<String> = allowed.iter().map(|s| s.to_string()).collect();
let root = src.join("lib.rs");
let result = trait_impl_findings(&src, &root, trait_path, &allowed, "x");
let _ = std::fs::remove_dir_all(&dir);
result
}
#[test]
fn an_impl_outside_the_allowed_location_is_a_finding() {
let out = locality_findings(
"outside",
&[
("lib.rs", "pub mod command;\npub mod domain;\n"),
("command.rs", "pub trait Command {}\n"),
(
"domain.rs",
"use crate::command::Command;\npub struct Foo;\nimpl Command for Foo {}\n",
),
],
"crate::command::Command",
&["crate::commands"],
)
.unwrap();
assert_eq!(out, ["crate::domain (impl for crate::domain::Foo)"]);
}
#[test]
fn an_impl_inside_the_allowed_location_is_clean() {
let out = locality_findings(
"inside",
&[
("lib.rs", "pub mod command;\npub mod commands;\n"),
("command.rs", "pub trait Command {}\n"),
(
"commands.rs",
"use crate::command::Command;\npub struct Foo;\nimpl Command for Foo {}\n",
),
],
"crate::command::Command",
&["crate::commands"],
)
.unwrap();
assert!(
out.is_empty(),
"an impl in the allowed location is clean: {out:?}"
);
}
#[test]
fn a_nested_module_beneath_the_allowed_prefix_is_clean() {
let out = locality_findings(
"nested-allowed",
&[
("lib.rs", "pub mod command;\npub mod commands;\n"),
("command.rs", "pub trait Command {}\n"),
("commands.rs", "pub mod greet;\n"),
(
"commands/greet.rs",
"use crate::command::Command;\npub struct Foo;\nimpl Command for Foo {}\n",
),
],
"crate::command::Command",
&["crate::commands"],
)
.unwrap();
assert!(
out.is_empty(),
"beneath an allowed prefix is clean: {out:?}"
);
}
#[test]
fn a_prefix_colliding_sibling_location_is_not_allowed() {
let out = locality_findings(
"sibling",
&[
("lib.rs", "pub mod command;\npub mod commandeer;\n"),
("command.rs", "pub trait Command {}\n"),
(
"commandeer.rs",
"use crate::command::Command;\npub struct Foo;\nimpl Command for Foo {}\n",
),
],
"crate::command::Command",
&["crate::commands"],
)
.unwrap();
assert_eq!(
out,
["crate::commandeer (impl for crate::commandeer::Foo)"],
"a sibling of the allowed prefix is not allowed"
);
}
#[test]
fn an_impl_in_any_of_several_allowed_locations_is_clean() {
let out = locality_findings(
"multi-allowed",
&[
("lib.rs", "pub mod command;\npub mod builtins;\n"),
("command.rs", "pub trait Command {}\n"),
(
"builtins.rs",
"use crate::command::Command;\npub struct Foo;\nimpl Command for Foo {}\n",
),
],
"crate::command::Command",
&["crate::commands", "crate::builtins"],
)
.unwrap();
assert!(out.is_empty(), "any one allowed location suffices: {out:?}");
}
#[test]
fn a_bare_same_module_trait_name_reacts() {
let out = locality_findings(
"bare-same-module",
&[
("lib.rs", "pub mod command;\n"),
(
"command.rs",
"pub trait Command {}\npub struct Foo;\nimpl Command for Foo {}\n",
),
],
"crate::command::Command",
&["crate::commands"],
)
.unwrap();
assert_eq!(out, ["crate::command (impl for crate::command::Foo)"]);
}
#[test]
fn a_renamed_trait_import_reacts() {
let out = locality_findings(
"renamed-trait",
&[
("lib.rs", "pub mod command;\npub mod domain;\n"),
("command.rs", "pub trait Command {}\n"),
(
"domain.rs",
"use crate::command::Command as Cmd;\npub struct Foo;\nimpl Cmd for Foo {}\n",
),
],
"crate::command::Command",
&["crate::commands"],
)
.unwrap();
assert_eq!(out, ["crate::domain (impl for crate::domain::Foo)"]);
}
#[test]
fn a_super_relative_trait_import_reacts() {
let out = locality_findings(
"super-trait",
&[
("lib.rs", "pub mod command;\npub mod domain;\n"),
("command.rs", "pub trait Command {}\n"),
(
"domain.rs",
"use super::command::Command;\npub struct Foo;\nimpl Command for Foo {}\n",
),
],
"crate::command::Command",
&["crate::commands"],
)
.unwrap();
assert_eq!(out, ["crate::domain (impl for crate::domain::Foo)"]);
}
#[test]
fn a_cfg_gated_module_with_no_file_is_skipped_not_errored() {
let out = locality_findings(
"cfg-absent-mod",
&[
(
"lib.rs",
"pub mod command;\n#[cfg(feature = \"never\")]\npub mod optional;\n",
),
("command.rs", "pub trait Command {}\n"),
],
"crate::command::Command",
&["crate::commands"],
)
.unwrap();
assert!(
out.is_empty(),
"a cfg-gated absent module is skipped: {out:?}"
);
}
#[test]
fn a_reexported_trait_path_reacts() {
let out = locality_findings(
"reexport-impl",
&[
(
"lib.rs",
"pub mod command;\npub mod facade;\npub mod domain;\n",
),
("command.rs", "pub trait Command {}\n"),
("facade.rs", "pub use crate::command::Command;\n"),
(
"domain.rs",
"use crate::facade::Command;\npub struct Foo;\nimpl Command for Foo {}\n",
),
],
"crate::command::Command",
&["crate::commands"],
)
.unwrap();
assert_eq!(out, ["crate::domain (impl for crate::domain::Foo)"]);
}
#[test]
fn an_anchor_named_at_a_reexport_path_resolves_not_a_constitution_error() {
let out = locality_findings(
"reexport-anchor",
&[
(
"lib.rs",
"pub mod command;\npub mod facade;\npub mod domain;\n",
),
("command.rs", "pub trait Command {}\n"),
("facade.rs", "pub use crate::command::Command;\n"),
(
"domain.rs",
"use crate::command::Command;\npub struct Foo;\nimpl Command for Foo {}\n",
),
],
"crate::facade::Command",
&["crate::commands"],
)
.unwrap();
assert_eq!(out, ["crate::domain (impl for crate::domain::Foo)"]);
}
#[test]
fn an_unresolvable_trait_anchor_is_a_constitution_error() {
let err = locality_findings(
"ghost-trait",
&[
("lib.rs", "pub mod command;\n"),
("command.rs", "pub trait Command {}\n"),
],
"crate::command::Ghost",
&["crate::commands"],
)
.unwrap_err();
assert_eq!(err, unknown_trait_error("crate::command::Ghost", "x"));
}
#[test]
fn a_non_anchored_traits_impl_is_ignored() {
let out = locality_findings(
"other-trait",
&[
("lib.rs", "pub mod command;\npub mod domain;\n"),
("command.rs", "pub trait Command {}\npub trait Other {}\n"),
(
"domain.rs",
"use crate::command::Other;\npub struct Foo;\nimpl Other for Foo {}\n",
),
],
"crate::command::Command",
&["crate::commands"],
)
.unwrap();
assert!(out.is_empty(), "only the anchored trait reacts: {out:?}");
}
#[test]
fn an_inline_module_impl_is_located() {
let out = locality_findings(
"inline-impl",
&[
(
"lib.rs",
"pub mod command;\npub mod domain { use crate::command::Command; pub struct Foo; impl Command for Foo {} }\n",
),
("command.rs", "pub trait Command {}\n"),
],
"crate::command::Command",
&["crate::commands"],
)
.unwrap();
assert_eq!(out, ["crate::domain (impl for crate::domain::Foo)"]);
}
#[test]
fn a_glob_imported_trait_is_a_documented_bound() {
let out = locality_findings(
"glob-trait",
&[
("lib.rs", "pub mod command;\npub mod domain;\n"),
("command.rs", "pub trait Command {}\n"),
(
"domain.rs",
"use crate::command::*;\npub struct Foo;\nimpl Command for Foo {}\n",
),
],
"crate::command::Command",
&["crate::commands"],
)
.unwrap();
assert!(
out.is_empty(),
"a glob-imported trait is out of scope, not silently matched: {out:?}"
);
}
#[test]
fn a_path_remapped_module_is_a_documented_bound() {
let out = locality_findings(
"path-remapped",
&[
(
"lib.rs",
"pub mod command;\n#[path = \"weird.rs\"]\npub mod domain;\n",
),
("command.rs", "pub trait Command {}\n"),
(
"weird.rs",
"use crate::command::Command;\npub struct Foo;\nimpl Command for Foo {}\n",
),
],
"crate::command::Command",
&["crate::commands"],
)
.unwrap();
assert!(
out.is_empty(),
"a #[path]-remapped module is out of scope, not silently matched: {out:?}"
);
}
#[test]
fn two_impls_in_one_module_are_distinct_findings_by_self_type() {
let out = locality_findings(
"distinct-self",
&[
("lib.rs", "pub mod command;\npub mod domain;\n"),
("command.rs", "pub trait Command {}\n"),
(
"domain.rs",
"use crate::command::Command;\npub struct A;\npub struct B;\nimpl Command for A {}\nimpl Command for B {}\n",
),
],
"crate::command::Command",
&["crate::commands"],
)
.unwrap();
assert_eq!(
out,
[
"crate::domain (impl for crate::domain::A)",
"crate::domain (impl for crate::domain::B)"
]
);
}
#[test]
fn const_generic_expr_self_types_stay_distinct_owners() {
let out = findings(
"const-generic-expr",
&[
("lib.rs", "pub mod domain;\n"),
(
"domain.rs",
"pub struct Arr<const N: usize>(u8);\n\
impl Arr<{ 1 + 1 }> { pub fn a(&self) -> crate::infra::T { todo!() } }\n\
impl Arr<{ 2 + 2 }> { pub fn a(&self) -> crate::infra::T { todo!() } }\n",
),
],
"crate::domain",
&["crate::infra"],
)
.unwrap();
assert_eq!(
out,
[
"crate::infra::T exposed by fn <crate::domain::Arr<_#1>>::a",
"crate::infra::T exposed by fn <crate::domain::Arr<_#2>>::a",
],
"two const-generic-expr self types yield two distinct positional owners, not one",
);
}
#[test]
fn owner_is_canonical_across_written_forms() {
let out = findings(
"canonical-forms",
&[
("lib.rs", "pub mod m;\n"),
(
"m.rs",
"pub struct Foo;\n\
impl Foo { pub fn a(&self) -> crate::infra::T { todo!() } }\n\
impl crate::m::Foo { pub fn b(&self) -> crate::infra::T { todo!() } }\n",
),
],
"crate::m",
&["crate::infra"],
)
.unwrap();
assert_eq!(
out,
[
"crate::infra::T exposed by fn <crate::m::Foo>::a",
"crate::infra::T exposed by fn <crate::m::Foo>::b",
],
"both written forms of the same self type render the identical canonical owner",
);
}
#[test]
fn a_cfg_gated_impl_is_observed_as_written() {
let out = locality_findings(
"cfg-gated",
&[
("lib.rs", "pub mod command;\npub mod domain;\n"),
("command.rs", "pub trait Command {}\n"),
(
"domain.rs",
"use crate::command::Command;\npub struct Foo;\n#[cfg(feature = \"never\")]\nimpl Command for Foo {}\n",
),
],
"crate::command::Command",
&["crate::commands"],
)
.unwrap();
assert_eq!(out, ["crate::domain (impl for crate::domain::Foo)"]);
}
#[test]
fn a_macro_generated_impl_is_a_documented_bound() {
let out = locality_findings(
"macro-impl",
&[
("lib.rs", "pub mod command;\npub mod domain;\n"),
("command.rs", "pub trait Command {}\n"),
("domain.rs", "make_impl!(Foo);\n"),
],
"crate::command::Command",
&["crate::commands"],
)
.unwrap();
assert!(
out.is_empty(),
"a macro-generated impl is out of scope, not silently matched: {out:?}"
);
}
#[test]
fn the_builder_carries_severity() {
let warn = TraitImplBoundary::in_crate("app")
.trait_("crate::command::Command")
.only_implemented_in("crate::commands")
.warn()
.because("advisory first");
assert_eq!(warn.severity(), Severity::Warn);
let enforce = TraitImplBoundary::in_crate("app")
.trait_("crate::command::Command")
.only_implemented_in("crate::commands")
.because("enforced");
assert_eq!(enforce.severity(), Severity::Enforce);
}
fn vis_findings(
name: &str,
files: &[(&str, &str)],
module: &str,
) -> Result<Vec<String>, String> {
let dir = std::env::temp_dir().join(format!("hunyi-vis-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let src = dir.join("src");
for (rel, contents) in files {
let path = src.join(rel);
std::fs::create_dir_all(path.parent().expect("file has a parent")).expect("mkdir");
std::fs::write(&path, contents).expect("write source");
}
let root = src.join("lib.rs");
let result = visibility_findings(&src, &root, module, "x");
let _ = std::fs::remove_dir_all(&dir);
result
}
#[test]
fn pub_items_react_and_non_pub_items_are_clean() {
let out = vis_findings(
"pub-mix",
&[
("lib.rs", "pub mod internal;\n"),
(
"internal.rs",
"pub fn a() {}\npub struct B;\npub trait C {}\npub(crate) fn d() {}\npub(super) fn e() {}\nfn f() {}\n",
),
],
"crate::internal",
)
.unwrap();
assert_eq!(
out,
["pub fn a", "pub struct B", "pub trait C"],
"only bare-pub items react: {out:?}"
);
}
#[test]
fn a_pub_use_and_glob_react() {
let out = vis_findings(
"pub-use",
&[
("lib.rs", "pub mod internal;\n"),
(
"internal.rs",
"pub use crate::db::Handle;\npub use crate::db::*;\npub(crate) use crate::db::Hidden;\n",
),
],
"crate::internal",
)
.unwrap();
assert_eq!(out, ["pub use crate::db::*", "pub use crate::db::Handle"]);
}
#[test]
fn a_pub_submodule_reacts() {
let out = vis_findings(
"pub-mod",
&[
("lib.rs", "pub mod internal;\n"),
("internal.rs", "pub mod sub;\nmod hidden;\n"),
("internal/sub.rs", "\n"),
("internal/hidden.rs", "\n"),
],
"crate::internal",
)
.unwrap();
assert_eq!(out, ["pub mod sub"]);
}
#[test]
fn a_bare_pub_item_in_a_non_pub_module_still_reacts() {
let out = vis_findings(
"pub-in-crate-mod",
&[
("lib.rs", "pub(crate) mod internal;\n"),
("internal.rs", "pub fn helper() {}\n"),
],
"crate::internal",
)
.unwrap();
assert_eq!(
out,
["pub fn helper"],
"the rule governs the declared pub keyword, not crate-reachability"
);
}
#[test]
fn a_pub_extern_crate_and_pub_trait_alias_react() {
let out = vis_findings(
"extern-and-alias",
&[
("lib.rs", "pub mod internal;\n"),
(
"internal.rs",
"pub extern crate serde;\npub trait Alias = Clone;\n",
),
],
"crate::internal",
)
.unwrap();
assert_eq!(out, ["pub extern crate serde", "pub trait Alias (alias)"]);
}
#[test]
fn a_leading_colon_pub_use_is_rendered_and_distinct() {
let out = vis_findings(
"leading-colon",
&[
("lib.rs", "pub mod internal;\n"),
(
"internal.rs",
"pub use ::external::X;\npub use external::X;\n",
),
],
"crate::internal",
)
.unwrap();
assert_eq!(out, ["pub use ::external::X", "pub use external::X"]);
}
#[test]
fn a_macro_export_macro_is_out_of_scope() {
let out = vis_findings(
"macro-export",
&[
("lib.rs", "pub mod internal;\n"),
(
"internal.rs",
"#[macro_export]\nmacro_rules! m { () => {} }\npub(crate) fn helper() {}\n",
),
],
"crate::internal",
)
.unwrap();
assert!(
out.is_empty(),
"a #[macro_export] macro carries no pub keyword — out of declared scope: {out:?}"
);
}
#[test]
fn a_macro_invocation_pub_item_is_a_documented_bound() {
let out = vis_findings(
"macro-gen",
&[
("lib.rs", "pub mod internal;\n"),
("internal.rs", "make_public!();\n"),
],
"crate::internal",
)
.unwrap();
assert!(
out.is_empty(),
"a macro-generated item is out of scope, not silently claimed: {out:?}"
);
}
#[test]
fn a_cfg_gated_pub_item_is_observed_as_written() {
let out = vis_findings(
"cfg-pub",
&[
("lib.rs", "pub mod internal;\n"),
(
"internal.rs",
"#[cfg(feature = \"never\")]\npub fn gated() {}\n",
),
],
"crate::internal",
)
.unwrap();
assert_eq!(out, ["pub fn gated"], "cfg is observed as-written");
}
#[test]
fn an_unknown_visibility_module_is_a_constitution_error() {
let err = vis_findings(
"vis-unknown",
&[("lib.rs", "pub mod internal;\n"), ("internal.rs", "\n")],
"crate::ghost",
)
.unwrap_err();
assert_eq!(err, unknown_module_error("crate::ghost", "x"));
}
#[test]
fn an_inline_visibility_module_is_scanned() {
let out = vis_findings(
"vis-inline",
&[("lib.rs", "pub mod internal { pub fn a() {} fn b() {} }\n")],
"crate::internal",
)
.unwrap();
assert_eq!(out, ["pub fn a"]);
}
#[test]
fn the_visibility_builder_carries_severity() {
let warn = VisibilityBoundary::in_crate("app")
.module("crate::internal")
.must_not_declare_pub()
.warn()
.because("advisory first");
assert_eq!(warn.severity(), Severity::Warn);
let enforce = VisibilityBoundary::in_crate("app")
.module("crate::internal")
.must_not_declare_pub()
.because("enforced");
assert_eq!(enforce.severity(), Severity::Enforce);
}
#[test]
fn a_generic_self_type_is_rendered_distinctly() {
let out = locality_findings(
"generic-self",
&[
("lib.rs", "pub mod command;\npub mod domain;\n"),
("command.rs", "pub trait Command {}\n"),
(
"domain.rs",
"use crate::command::Command;\npub struct W<T>(T);\nimpl Command for W<u8> {}\nimpl Command for W<u16> {}\n",
),
],
"crate::command::Command",
&["crate::commands"],
)
.unwrap();
assert_eq!(
out,
[
"crate::domain (impl for crate::domain::W<u16>)",
"crate::domain (impl for crate::domain::W<u8>)"
]
);
}
fn marker_findings(
name: &str,
files: &[(&str, &str)],
subtree: &str,
forbidden: &[&str],
) -> Result<Vec<String>, String> {
let dir = std::env::temp_dir().join(format!("hunyi-mark-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let src = dir.join("src");
for (rel, contents) in files {
let path = src.join(rel);
std::fs::create_dir_all(path.parent().expect("file has a parent")).expect("mkdir");
std::fs::write(&path, contents).expect("write source");
}
let forbidden: Vec<String> = forbidden.iter().map(|s| s.to_string()).collect();
let root = src.join("lib.rs");
let result = forbidden_marker_findings(&src, &root, subtree, &forbidden, "x");
let _ = std::fs::remove_dir_all(&dir);
result
}
#[test]
fn a_forbidden_derive_on_a_subtree_type_reacts_and_a_clean_type_does_not() {
let out = marker_findings(
"derive",
&[
("lib.rs", "pub mod domain;\n"),
(
"domain.rs",
"#[derive(serde::Serialize)]\npub struct Order;\n#[derive(Clone, Debug)]\npub struct Plain;\n",
),
],
"crate::domain",
&["serde::Serialize"],
)
.unwrap();
assert_eq!(out, ["derive serde::Serialize on crate::domain::Order"]);
}
#[test]
fn a_serde_derive_path_and_cfg_attr_derive_react_by_leaf() {
let out = marker_findings(
"leaf-and-cfgattr",
&[
("lib.rs", "pub mod domain;\n"),
(
"domain.rs",
"#[derive(serde_derive::Serialize)]\npub struct A;\n#[cfg_attr(feature = \"serde\", derive(serde::Serialize))]\npub struct B;\n",
),
],
"crate::domain",
&["serde::Serialize"],
)
.unwrap();
assert_eq!(
out,
[
"derive serde::Serialize on crate::domain::A",
"derive serde::Serialize on crate::domain::B"
],
"serde_derive path (leaf) and cfg_attr-wrapped derive both react: {out:?}"
);
}
#[test]
fn a_hand_impl_outside_the_subtree_reacts_via_the_self_type() {
let out = marker_findings(
"hand-impl",
&[
("lib.rs", "pub mod domain;\npub mod wire;\n"),
("domain.rs", "pub struct Order;\n"),
(
"wire.rs",
"impl serde::Serialize for crate::domain::Order {}\n",
),
],
"crate::domain",
&["serde::Serialize"],
)
.unwrap();
assert_eq!(
out,
["impl serde::Serialize for crate::domain::Order"],
"a hand impl written outside the subtree, for a subtree type, reacts: {out:?}"
);
}
#[test]
fn a_submodule_type_is_governed_and_a_sibling_is_not() {
let out = marker_findings(
"subtree",
&[
("lib.rs", "pub mod domain;\npub mod domainx;\n"),
("domain.rs", "pub mod order;\n"),
(
"domain/order.rs",
"#[derive(serde::Serialize)]\npub struct Order;\n",
),
(
"domainx.rs",
"#[derive(serde::Serialize)]\npub struct Other;\n",
),
],
"crate::domain",
&["serde::Serialize"],
)
.unwrap();
assert_eq!(
out,
["derive serde::Serialize on crate::domain::order::Order"],
"a submodule type is governed; the prefix-colliding sibling crate::domainx is not: {out:?}"
);
}
#[test]
fn a_same_leaf_different_trait_is_a_documented_false_positive() {
let out = marker_findings(
"leaf-fp",
&[
("lib.rs", "pub mod domain;\n"),
(
"domain.rs",
"#[derive(rkyv::Serialize)]\npub struct Order;\n",
),
],
"crate::domain",
&["Serialize"],
)
.unwrap();
assert_eq!(
out,
["derive Serialize on crate::domain::Order"],
"leaf-match reacts (accepted false positive; path-qualify to document intent)"
);
}
#[test]
fn an_unresolvable_glob_self_type_is_a_documented_bound() {
let out = marker_findings(
"glob-self",
&[
("lib.rs", "pub mod domain;\npub mod wire;\n"),
("domain.rs", "pub struct Order;\n"),
(
"wire.rs",
"use crate::domain::*;\nimpl serde::Serialize for Order {}\n",
),
],
"crate::domain",
&["serde::Serialize"],
)
.unwrap();
assert!(
out.is_empty(),
"a glob-imported self-type cannot be placed in the subtree — a stated bound: {out:?}"
);
}
#[test]
fn a_nested_cfg_attr_derive_reacts() {
let out = marker_findings(
"nested-cfgattr",
&[
("lib.rs", "pub mod domain;\n"),
(
"domain.rs",
"#[cfg_attr(all(), cfg_attr(all(), derive(serde::Serialize)))]\npub struct Order;\n",
),
],
"crate::domain",
&["serde::Serialize"],
)
.unwrap();
assert_eq!(out, ["derive serde::Serialize on crate::domain::Order"]);
}
#[test]
fn two_same_named_types_in_different_submodules_stay_distinct() {
let out = marker_findings(
"same-name",
&[
("lib.rs", "pub mod domain;\n"),
("domain.rs", "pub mod a;\npub mod b;\n"),
(
"domain/a.rs",
"#[derive(serde::Serialize)]\npub struct Order;\n",
),
(
"domain/b.rs",
"#[derive(serde::Serialize)]\npub struct Order;\n",
),
],
"crate::domain",
&["serde::Serialize"],
)
.unwrap();
assert_eq!(
out,
[
"derive serde::Serialize on crate::domain::a::Order",
"derive serde::Serialize on crate::domain::b::Order"
],
"two same-named types must stay distinct findings: {out:?}"
);
}
#[test]
fn the_forbidden_marker_builder_carries_severity() {
let b = ForbiddenMarkerBoundary::in_crate("app")
.module("crate::domain")
.must_not_acquire("serde::Serialize")
.and_not_acquire("serde::Deserialize")
.warn()
.because("r");
assert_eq!(b.forbidden(), &["serde::Serialize", "serde::Deserialize"]);
assert_eq!(b.severity(), Severity::Warn);
}
fn dyn_findings(
name: &str,
files: &[(&str, &str)],
module: &str,
) -> Result<Vec<String>, String> {
let dir = std::env::temp_dir().join(format!("hunyi-dyn-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let src = dir.join("src");
for (rel, contents) in files {
let path = src.join(rel);
std::fs::create_dir_all(path.parent().expect("file has a parent")).expect("mkdir");
std::fs::write(&path, contents).expect("write source");
}
let root = src.join("lib.rs");
let result = dyn_module_findings(&src, &root, module, "x");
let _ = std::fs::remove_dir_all(&dir);
result
}
fn dyn_mod(name: &str, body: &str) -> Result<Vec<String>, String> {
dyn_findings(
name,
&[("lib.rs", "pub mod m;\n"), ("m.rs", body)],
"crate::m",
)
}
fn dyn_operand_findings(
name: &str,
files: &[(&str, &str)],
module: &str,
forbidden: &[&str],
) -> Result<Vec<String>, String> {
let dir = std::env::temp_dir().join(format!("hunyi-dynop-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let src = dir.join("src");
for (rel, contents) in files {
let path = src.join(rel);
std::fs::create_dir_all(path.parent().expect("file has a parent")).expect("mkdir");
std::fs::write(&path, contents).expect("write source");
}
let root = src.join("lib.rs");
let forbidden: Vec<String> = forbidden.iter().map(|f| f.to_string()).collect();
let result = dyn_operand_module_findings(&src, &root, module, &forbidden, "x");
let _ = std::fs::remove_dir_all(&dir);
result
}
fn dyn_operand_mod(name: &str, body: &str, forbidden: &[&str]) -> Result<Vec<String>, String> {
dyn_operand_findings(
name,
&[("lib.rs", "pub mod m;\n"), ("m.rs", body)],
"crate::m",
forbidden,
)
}
#[test]
fn dyn_operand_flags_a_named_trait_and_passes_others() {
assert_eq!(
dyn_operand_mod(
"named",
"pub fn c() -> Box<dyn crate::ports::Port> { todo!() }\n",
&["crate::ports::Port"],
)
.unwrap(),
["dyn crate::ports::Port exposed by fn crate::m::c"],
);
assert!(
dyn_operand_mod(
"other",
"pub fn e() -> Box<dyn std::error::Error> { todo!() }\n",
&["crate::ports::Port"],
)
.unwrap()
.is_empty(),
"a dyn of an unlisted trait passes",
);
}
#[test]
fn dyn_operand_honors_a_module_prefix() {
assert_eq!(
dyn_operand_mod(
"prefix",
"pub fn c() -> Box<dyn crate::ports::Port> { todo!() }\n",
&["crate::ports"],
)
.unwrap(),
["dyn crate::ports::Port exposed by fn crate::m::c"],
);
}
#[test]
fn dyn_operand_matches_a_reexported_trait_by_its_defining_path() {
let files = &[
(
"lib.rs",
"pub mod ports;\npub use crate::ports::Port;\npub mod m;\n",
),
("ports.rs", "pub trait Port {}\n"),
("m.rs", "pub fn c() -> Box<dyn crate::Port> { todo!() }\n"),
];
assert_eq!(
dyn_operand_findings(
"reexport-defining",
files,
"crate::m",
&["crate::ports::Port"]
)
.unwrap(),
["dyn crate::Port exposed by fn crate::m::c"],
"a dyn written through a re-export facade matches the forbidden defining path",
);
}
#[test]
fn dyn_operand_ignores_auto_trait_markers() {
assert_eq!(
dyn_operand_mod(
"marker-port",
"pub fn c() -> Box<dyn crate::ports::Port + Send> { todo!() }\n",
&["crate::ports::Port"],
)
.unwrap(),
["dyn crate::ports::Port + Send exposed by fn crate::m::c"],
);
assert!(
dyn_operand_mod(
"marker-send",
"pub fn c() -> Box<dyn crate::ports::Port + Send> { todo!() }\n",
&["Send"],
)
.unwrap()
.is_empty(),
"the trailing Send marker is not the operand",
);
}
#[test]
fn dyn_operand_matches_a_dyn_nested_deep() {
assert_eq!(
dyn_operand_mod(
"nested",
"pub fn c() -> Vec<Box<dyn crate::ports::Port>> { todo!() }\n",
&["crate::ports::Port"],
)
.unwrap(),
["dyn crate::ports::Port exposed by fn crate::m::c"],
);
}
#[test]
fn dyn_operand_empty_set_degenerates_to_any() {
let body = "pub fn c() -> Box<dyn crate::ports::Port> { todo!() }\n";
assert_eq!(
dyn_operand_mod("empty", body, &[]).unwrap(),
dyn_mod("empty-shape", body).unwrap(),
"must_not_expose_dyn_of([]) matches exactly what shape-only must_not_expose_dyn does",
);
assert_eq!(
dyn_operand_mod("empty2", body, &[]).unwrap(),
["dyn crate::ports::Port exposed by fn crate::m::c"],
);
}
#[test]
fn dyn_operand_boundary_carries_its_operands_and_severity() {
let b = DynTraitBoundary::in_crate("core")
.module("crate::core")
.must_not_expose_dyn_of(["crate::ports::Port"])
.warn()
.because("the core seam must not leak a dyn Port");
assert_eq!(b.forbidden_operands(), ["crate::ports::Port"]);
assert_eq!(b.severity(), Severity::Warn);
let shape = DynTraitBoundary::in_crate("core")
.module("crate::core")
.must_not_expose_dyn()
.because("no dyn at all");
assert!(shape.forbidden_operands().is_empty());
}
fn impl_trait_findings(
name: &str,
files: &[(&str, &str)],
module: &str,
) -> Result<Vec<String>, String> {
let dir = std::env::temp_dir().join(format!("hunyi-impl-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let src = dir.join("src");
for (rel, contents) in files {
let path = src.join(rel);
std::fs::create_dir_all(path.parent().expect("file has a parent")).expect("mkdir");
std::fs::write(&path, contents).expect("write source");
}
let root = src.join("lib.rs");
let result = impl_trait_module_findings(&src, &root, module, "x");
let _ = std::fs::remove_dir_all(&dir);
result
}
fn impl_trait_mod(name: &str, body: &str) -> Result<Vec<String>, String> {
impl_trait_findings(
name,
&[("lib.rs", "pub mod m;\n"), ("m.rs", body)],
"crate::m",
)
}
#[test]
fn impl_trait_flags_a_returned_impl_trait() {
assert_eq!(
impl_trait_mod("ret", "pub fn make() -> impl crate::Port { todo!() }\n").unwrap(),
["impl crate::Port exposed by fn crate::m::make"],
);
}
#[test]
fn impl_trait_flags_a_nested_returned_impl_trait() {
assert_eq!(
impl_trait_mod(
"nested",
"pub fn maybe() -> Option<impl crate::Port> { todo!() }\n"
)
.unwrap(),
["impl crate::Port exposed by fn crate::m::maybe"],
"an impl Trait at depth in the return type is existential and reacts",
);
}
#[test]
fn impl_trait_flags_a_trait_method_rpit() {
assert_eq!(
impl_trait_mod(
"rpitit",
"pub trait T { fn make(&self) -> impl crate::Port; }\n"
)
.unwrap(),
["impl crate::Port exposed by fn trait crate::m::T::make"],
"a trait method's declared RPIT is the existential, governed at the declaration",
);
}
#[test]
fn impl_trait_does_not_flag_an_argument_position() {
assert!(
impl_trait_mod("apit", "pub fn drive(p: impl crate::Port) { let _ = p; }\n")
.unwrap()
.is_empty(),
"argument-position impl Trait is not governed",
);
}
#[test]
fn impl_trait_does_not_flag_an_async_fn() {
assert!(
impl_trait_mod("async", "pub async fn connect() -> u8 { 0 }\n")
.unwrap()
.is_empty(),
"async fn's implicit impl Future is out of scope",
);
}
#[test]
fn impl_trait_does_not_flag_a_private_fn_or_a_trait_impl_method() {
assert!(
impl_trait_mod("priv", "fn make() -> impl crate::Port { todo!() }\n")
.unwrap()
.is_empty(),
"a private fn's RPIT is not public API",
);
assert!(
impl_trait_mod(
"traitimpl",
"pub struct S; impl crate::T for S { fn make(&self) -> impl crate::Port { todo!() } }\n"
)
.unwrap()
.is_empty(),
"a trait-impl method's return is not double-counted",
);
}
#[test]
fn impl_trait_renders_iterator_and_fn_shapes_distinctly() {
assert_eq!(
impl_trait_mod(
"iter",
"pub fn it() -> impl Iterator<Item = u8> { todo!() }\n"
)
.unwrap(),
["impl Iterator<Item = u8> exposed by fn crate::m::it"],
);
assert_eq!(
impl_trait_mod("clo", "pub fn f() -> impl Fn(i32) -> i32 { todo!() }\n").unwrap(),
["impl Fn(i32) -> i32 exposed by fn crate::m::f"],
);
}
#[test]
fn impl_trait_boundary_carries_anchor_and_severity() {
let b = ImplTraitBoundary::in_crate("core")
.module("crate::core")
.must_not_expose_impl_trait()
.warn()
.because("the core seam must return named types");
assert_eq!(b.crate_package(), "core");
assert_eq!(b.module(), "crate::core");
assert_eq!(b.severity(), Severity::Warn);
}
fn impl_trait_operand_findings(
name: &str,
files: &[(&str, &str)],
module: &str,
forbidden: &[&str],
) -> Result<Vec<String>, String> {
let dir = std::env::temp_dir().join(format!("hunyi-implop-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let src = dir.join("src");
for (rel, contents) in files {
let path = src.join(rel);
std::fs::create_dir_all(path.parent().expect("file has a parent")).expect("mkdir");
std::fs::write(&path, contents).expect("write source");
}
let root = src.join("lib.rs");
let forbidden: Vec<String> = forbidden.iter().map(|f| f.to_string()).collect();
let result = impl_trait_operand_module_findings(&src, &root, module, &forbidden, "x");
let _ = std::fs::remove_dir_all(&dir);
result
}
fn impl_trait_operand_mod(
name: &str,
body: &str,
forbidden: &[&str],
) -> Result<Vec<String>, String> {
impl_trait_operand_findings(
name,
&[("lib.rs", "pub mod m;\n"), ("m.rs", body)],
"crate::m",
forbidden,
)
}
#[test]
fn impl_trait_operand_flags_a_named_trait_and_passes_others() {
assert_eq!(
impl_trait_operand_mod(
"named",
"pub fn make() -> impl crate::ports::Port { todo!() }\n",
&["crate::ports::Port"],
)
.unwrap(),
["impl crate::ports::Port exposed by fn crate::m::make"],
);
assert!(
impl_trait_operand_mod(
"iter",
"pub fn it() -> impl Iterator<Item = u8> { todo!() }\n",
&["crate::ports::Port"],
)
.unwrap()
.is_empty(),
"a returned impl of an unlisted (and bare-std) trait passes",
);
}
#[test]
fn impl_trait_operand_honors_a_module_prefix() {
assert_eq!(
impl_trait_operand_mod(
"prefix",
"pub fn make() -> impl crate::ports::Port { todo!() }\n",
&["crate::ports"],
)
.unwrap(),
["impl crate::ports::Port exposed by fn crate::m::make"],
);
}
#[test]
fn impl_trait_operand_matches_a_reexported_trait_by_its_defining_path() {
let files = &[
(
"lib.rs",
"pub mod ports;\npub use crate::ports::Port;\npub mod m;\n",
),
("ports.rs", "pub trait Port {}\n"),
("m.rs", "pub fn make() -> impl crate::Port { todo!() }\n"),
];
assert_eq!(
impl_trait_operand_findings("reexport", files, "crate::m", &["crate::ports::Port"])
.unwrap(),
["impl crate::Port exposed by fn crate::m::make"],
);
}
#[test]
fn impl_trait_operand_ignores_auto_trait_markers() {
assert_eq!(
impl_trait_operand_mod(
"marker-port",
"pub fn make() -> impl crate::ports::Port + Send { todo!() }\n",
&["crate::ports::Port"],
)
.unwrap(),
["impl crate::ports::Port + Send exposed by fn crate::m::make"],
);
assert!(
impl_trait_operand_mod(
"marker-send",
"pub fn make() -> impl crate::ports::Port + Send { todo!() }\n",
&["Send"],
)
.unwrap()
.is_empty(),
"the trailing Send marker is not the operand",
);
}
#[test]
fn impl_trait_operand_matches_a_nested_returned_impl() {
assert_eq!(
impl_trait_operand_mod(
"nested",
"pub fn maybe() -> Option<impl crate::ports::Port> { todo!() }\n",
&["crate::ports::Port"],
)
.unwrap(),
["impl crate::ports::Port exposed by fn crate::m::maybe"],
);
}
#[test]
fn impl_trait_operand_empty_set_degenerates_to_any() {
let body = "pub fn make() -> impl crate::ports::Port { todo!() }\n";
assert_eq!(
impl_trait_operand_mod("empty", body, &[]).unwrap(),
impl_trait_mod("empty-shape", body).unwrap(),
"must_not_expose_impl_trait_of([]) matches exactly what shape-only does",
);
}
#[test]
fn impl_trait_operand_inherits_return_position_scoping() {
assert!(
impl_trait_operand_mod(
"apit",
"pub fn drive(p: impl crate::ports::Port) { let _ = p; }\n",
&["crate::ports::Port"],
)
.unwrap()
.is_empty(),
"argument-position impl Trait is not governed even with a matching operand",
);
assert!(
impl_trait_operand_mod(
"async",
"pub async fn c() -> u8 { 0 }\n",
&["crate::ports::Port"]
)
.unwrap()
.is_empty(),
);
}
#[test]
fn impl_trait_operand_boundary_carries_operands_and_severity() {
let b = ImplTraitBoundary::in_crate("core")
.module("crate::core")
.must_not_expose_impl_trait_of(["crate::ports::Port"])
.warn()
.because("the core seam must not return an existential Port");
assert_eq!(b.forbidden_operands(), ["crate::ports::Port"]);
assert_eq!(b.severity(), Severity::Warn);
let shape = ImplTraitBoundary::in_crate("core")
.module("crate::core")
.must_not_expose_impl_trait()
.because("no existential at all");
assert!(shape.forbidden_operands().is_empty());
}
fn async_findings(
name: &str,
files: &[(&str, &str)],
module: &str,
) -> Result<Vec<String>, String> {
let dir = std::env::temp_dir().join(format!("hunyi-async-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let src = dir.join("src");
for (rel, contents) in files {
let path = src.join(rel);
std::fs::create_dir_all(path.parent().expect("file has a parent")).expect("mkdir");
std::fs::write(&path, contents).expect("write source");
}
let root = src.join("lib.rs");
let result = async_exposure_module_findings(&src, &root, module, "x");
let _ = std::fs::remove_dir_all(&dir);
result
}
fn async_mod(name: &str, body: &str) -> Result<Vec<String>, String> {
async_findings(
name,
&[("lib.rs", "pub mod m;\n"), ("m.rs", body)],
"crate::m",
)
}
#[test]
fn async_exposure_flags_a_public_async_free_fn() {
assert_eq!(
async_mod("free", "pub async fn connect() -> u8 { 0 }\n").unwrap(),
["async fn crate::m::connect() -> u8"],
);
}
#[test]
fn async_exposure_flags_a_public_inherent_async_method() {
assert_eq!(
async_mod(
"inherent",
"pub struct Service; impl Service { pub async fn run(&self) {} }\n"
)
.unwrap(),
["async fn <crate::m::Service>::run(&self)"],
);
}
#[test]
fn async_exposure_flags_a_public_trait_async_method_declaration() {
assert_eq!(
async_mod("trait", "pub trait Port { async fn fetch(&self) -> u8; }\n").unwrap(),
["async fn trait crate::m::Port::fetch(&self) -> u8"],
);
}
#[test]
fn async_exposure_does_not_flag_trait_impl_private_or_nonasync() {
assert!(
async_mod(
"traitimpl",
"pub struct S; impl crate::T for S { async fn run(&self) {} }\n"
)
.unwrap()
.is_empty(),
);
assert!(
async_mod("priv", "async fn helper() {}\n")
.unwrap()
.is_empty(),
);
assert!(
async_mod("sync", "pub fn ready() -> u8 { 0 }\n")
.unwrap()
.is_empty(),
);
}
#[test]
fn async_exposure_finding_is_injective_across_same_named_owners() {
let two_impls = async_mod(
"two-impls",
"pub struct A; pub struct B;\n\
impl A { pub async fn run(&self) {} }\n\
impl B { pub async fn run(&self) {} }\n",
)
.unwrap();
assert_eq!(
two_impls,
[
"async fn <crate::m::A>::run(&self)".to_string(),
"async fn <crate::m::B>::run(&self)".to_string(),
],
"same-named async methods across two impls yield two distinct owner-qualified findings",
);
let two_traits = async_mod(
"two-traits",
"pub trait T { async fn run(&self); }\npub trait U { async fn run(&self); }\n",
)
.unwrap();
assert_eq!(
two_traits,
[
"async fn trait crate::m::T::run(&self)".to_string(),
"async fn trait crate::m::U::run(&self)".to_string(),
],
);
}
#[test]
fn async_exposure_boundary_carries_anchor_and_severity() {
let b = AsyncExposureBoundary::in_crate("core")
.module("crate::core")
.must_not_expose_async_fn()
.warn()
.because("the core seam is synchronous");
assert_eq!(b.crate_package(), "core");
assert_eq!(b.module(), "crate::core");
assert_eq!(b.severity(), Severity::Warn);
}
#[test]
fn dyn_in_public_return_param_and_field_react() {
assert_eq!(
dyn_mod(
"ret",
"pub fn connect() -> Box<dyn crate::Port> { todo!() }\n"
)
.unwrap(),
["dyn crate::Port exposed by fn crate::m::connect"]
);
assert_eq!(
dyn_mod(
"param",
"pub fn drive(x: &dyn crate::Port) { let _ = x; }\n"
)
.unwrap(),
["dyn crate::Port exposed by fn crate::m::drive"]
);
assert_eq!(
dyn_mod("field", "pub struct S { pub p: Box<dyn crate::Port> }\n").unwrap(),
["dyn crate::Port exposed by field crate::m::S::p"]
);
}
#[test]
fn dyn_reacts_at_any_nesting_depth() {
assert_eq!(
dyn_mod(
"vec",
"pub fn all() -> Vec<Box<dyn crate::Port>> { todo!() }\n"
)
.unwrap(),
["dyn crate::Port exposed by fn crate::m::all"]
);
assert_eq!(
dyn_mod(
"opt",
"pub fn maybe(x: Option<&dyn crate::Port>) { let _ = x; }\n"
)
.unwrap(),
["dyn crate::Port exposed by fn crate::m::maybe"]
);
assert_eq!(
dyn_mod(
"impl-iter",
"pub fn ports() -> impl Iterator<Item = Box<dyn crate::Port>> { std::iter::empty() }\n"
)
.unwrap(),
["dyn crate::Port exposed by fn crate::m::ports"]
);
}
#[test]
fn impl_trait_with_no_dyn_node_is_clean() {
let out = dyn_mod(
"impl-trait",
"pub fn port() -> impl crate::Port { todo!() }\n",
)
.unwrap();
assert!(out.is_empty(), "impl Trait carries no dyn node: {out:?}");
}
#[test]
fn dyn_in_const_static_trait_method_assoc_default_and_where_react() {
assert_eq!(
dyn_mod("const", "pub const C: &dyn crate::Port = todo!();\n").unwrap(),
["dyn crate::Port exposed by const crate::m::C"]
);
assert_eq!(
dyn_mod("static", "pub static S: &dyn crate::Port = todo!();\n").unwrap(),
["dyn crate::Port exposed by static crate::m::S"]
);
assert_eq!(
dyn_mod(
"trait-method",
"pub trait Service { fn port(&self) -> Box<dyn crate::Port>; }\n"
)
.unwrap(),
["dyn crate::Port exposed by fn trait crate::m::Service::port"]
);
assert_eq!(
dyn_mod(
"assoc-default",
"pub trait Service { type Out = Box<dyn crate::Port>; }\n"
)
.unwrap(),
["dyn crate::Port exposed by type trait crate::m::Service::Out"]
);
assert_eq!(
dyn_mod(
"where",
"pub fn run<T>() where Box<dyn crate::Port>: Into<T> { todo!() }\n"
)
.unwrap(),
["dyn crate::Port exposed by fn crate::m::run"]
);
}
#[test]
fn public_alias_target_reacts_but_named_alias_is_not_expanded() {
assert_eq!(
dyn_mod("alias-item", "pub type Handler = Box<dyn crate::Port>;\n").unwrap(),
["dyn crate::Port exposed by type crate::m::Handler"]
);
let out = dyn_mod(
"alias-named",
"type Handler = Box<dyn crate::Port>;\npub fn make() -> Handler { todo!() }\n",
)
.unwrap();
assert!(
out.is_empty(),
"named private alias is not expanded: {out:?}"
);
}
#[test]
fn internal_dyn_is_structurally_clean() {
let out = dyn_mod(
"internal",
"fn helper() -> Box<dyn crate::Port> { todo!() }\nstruct Private { p: Box<dyn crate::Port> }\n",
)
.unwrap();
assert!(out.is_empty(), "internal dyn is never exposed: {out:?}");
}
#[test]
fn dyn_with_multiple_bounds_renders_stably() {
assert_eq!(
dyn_mod(
"bounds",
"pub fn f() -> Box<dyn crate::Port + Send> { todo!() }\n"
)
.unwrap(),
["dyn crate::Port + Send exposed by fn crate::m::f"]
);
}
#[test]
fn distinct_closures_and_nested_dyns_do_not_collide_into_one_finding() {
let out = dyn_mod(
"closures",
"pub fn a(cb: Box<dyn Fn(i32) -> i32>) { let _ = cb; }\n\
pub fn b(cb: Box<dyn FnMut(String) -> bool>) { let _ = cb; }\n",
)
.unwrap();
assert_eq!(
out,
[
"dyn Fn(i32) -> i32 exposed by fn crate::m::a",
"dyn FnMut(String) -> bool exposed by fn crate::m::b"
]
);
assert_eq!(
dyn_mod(
"nested",
"pub fn f() -> Box<dyn crate::Foo<Box<dyn crate::Bar>>> { todo!() }\n"
)
.unwrap(),
[
"dyn crate::Bar exposed by fn crate::m::f",
"dyn crate::Foo<Box<dyn crate::Bar>> exposed by fn crate::m::f"
]
);
let out = dyn_mod(
"assoc",
"pub fn a(x: Box<dyn Iterator<Item = u8>>) { let _ = x; }\n\
pub fn b(x: Box<dyn Iterator<Item = u16>>) { let _ = x; }\n",
)
.unwrap();
assert_eq!(
out,
[
"dyn Iterator<Item = u16> exposed by fn crate::m::b",
"dyn Iterator<Item = u8> exposed by fn crate::m::a"
]
);
let out = dyn_mod(
"macro-fnptr",
"pub fn a(x: Box<dyn crate::Foo<fn(i32)>>) { let _ = x; }\n\
pub fn b(x: Box<dyn crate::Foo<fn(u8)>>) { let _ = x; }\n",
)
.unwrap();
assert_eq!(
out,
[
"dyn crate::Foo<fn(i32)> exposed by fn crate::m::a",
"dyn crate::Foo<fn(u8)> exposed by fn crate::m::b"
]
);
}
#[test]
fn same_shape_at_two_seams_stays_two_findings() {
let out = dyn_mod(
"two-seams",
"pub fn a() -> Box<dyn crate::infra::Port> { todo!() }\n\
pub fn b() -> Box<dyn crate::infra::Port> { todo!() }\n",
)
.unwrap();
assert_eq!(
out,
[
"dyn crate::infra::Port exposed by fn crate::m::a",
"dyn crate::infra::Port exposed by fn crate::m::b"
],
"the same dyn shape at two seams must not collapse to one finding",
);
let out = findings(
"two-seams-sig",
&[
("lib.rs", "pub mod domain;\n"),
(
"domain.rs",
"pub fn a() -> crate::infra::DbPool { todo!() }\n\
pub fn b() -> crate::infra::DbPool { todo!() }\n",
),
],
"crate::domain",
&["crate::infra"],
)
.unwrap();
assert_eq!(
out,
[
"crate::infra::DbPool exposed by fn crate::domain::a",
"crate::infra::DbPool exposed by fn crate::domain::b"
],
"the same forbidden type at two seams must not collapse to one finding",
);
}
#[test]
fn the_dyn_trait_builder_carries_anchor_and_severity() {
let b = DynTraitBoundary::in_crate("app")
.module("crate::core")
.must_not_expose_dyn()
.warn()
.because("the core seam is statically dispatched");
assert_eq!(b.crate_package(), "app");
assert_eq!(b.module(), "crate::core");
assert_eq!(b.severity(), Severity::Warn);
assert_eq!(b.reason(), "the core seam is statically dispatched");
}
#[test]
fn dyn_unknown_module_is_a_constitution_error() {
let err = dyn_findings(
"unknown",
&[("lib.rs", "pub mod m;\n"), ("m.rs", "// nothing\n")],
"crate::ghost",
)
.unwrap_err();
assert_eq!(err, unknown_module_error("crate::ghost", "x"));
}
}