use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum StructuralRule {
NoModRs,
MaxFiveEntriesPerDir,
NoUseSuper,
}
impl StructuralRule {
#[must_use]
pub const fn name(self) -> &'static str {
match self {
Self::NoModRs => "no_mod_rs",
Self::MaxFiveEntriesPerDir => "max_5_entries_per_dir",
Self::NoUseSuper => "no_use_super",
}
}
#[must_use]
pub const fn fix_hint(self) -> &'static str {
match self {
Self::NoModRs => {
"Fix: rename `foo/mod.rs` to `foo.rs` at the parent level and \
move the submodule declarations into the sibling file. Run \
`cargo run -p vyre-tree-gen -- rewrite foo` to do it \
deterministically."
}
Self::MaxFiveEntriesPerDir => {
"Fix: split the directory into topic subdirectories until \
no parent has more than 5 direct entries. Related items \
group together; unrelated items each get their own leaf."
}
Self::NoUseSuper => {
"Fix: rewrite `use super::X` as the absolute path \
`use crate::<module path>::X`. Relative imports hide \
dependencies and become lies when a file moves."
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StructuralFinding {
pub path: PathBuf,
pub rule: StructuralRule,
pub line: Option<usize>,
pub detail: String,
}
impl std::fmt::Display for StructuralFinding {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if let Some(line) = self.line {
write!(
f,
"{}:{}: {}: {}",
self.path.display(),
line,
self.rule.name(),
self.detail
)
} else {
write!(
f,
"{}: {}: {}",
self.path.display(),
self.rule.name(),
self.detail
)
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Waiver {
pub path: PathBuf,
pub rule: StructuralRule,
}
#[derive(Debug, Clone)]
pub struct StructuralRulesConfig {
pub roots: Vec<PathBuf>,
pub skip_dirs: Vec<String>,
pub waivers: BTreeSet<Waiver>,
}
impl StructuralRulesConfig {
#[must_use]
#[inline]
pub fn with_root(root: impl Into<PathBuf>) -> Self {
Self {
roots: vec![root.into()],
skip_dirs: default_skip_dirs(),
waivers: BTreeSet::new(),
}
}
}
fn default_skip_dirs() -> Vec<String> {
["target", ".git", "coordination", "docs", "mutants.out"]
.into_iter()
.map(String::from)
.collect()
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct StructuralReport {
pub new_findings: Vec<StructuralFinding>,
pub unused_waivers: Vec<Waiver>,
pub all_findings: Vec<StructuralFinding>,
}
impl StructuralReport {
#[must_use]
#[inline]
pub fn is_green(&self) -> bool {
self.new_findings.is_empty() && self.unused_waivers.is_empty()
}
#[must_use]
#[inline]
pub fn messages(&self) -> Vec<String> {
let mut out = Vec::new();
for finding in &self.new_findings {
out.push(format!("NEW: {finding}. {}", finding.rule.fix_hint()));
}
for waiver in &self.unused_waivers {
out.push(format!(
"STALE WAIVER: {} for rule {} — the violation no longer \
exists. Fix: remove this entry from the waiver list so \
the allowlist keeps shrinking.",
waiver.path.display(),
waiver.rule.name()
));
}
out
}
}
#[inline]
pub fn scan(config: &StructuralRulesConfig) -> Result<StructuralReport, String> {
let mut all_findings: Vec<StructuralFinding> = Vec::new();
for root in &config.roots {
scan_root(root, config, &mut all_findings)?;
}
all_findings.sort_by(|a, b| {
a.path
.cmp(&b.path)
.then_with(|| a.rule.cmp(&b.rule))
.then_with(|| a.line.cmp(&b.line))
});
let mut matched_waivers = BTreeSet::new();
let mut new_findings = Vec::new();
for finding in &all_findings {
let key = Waiver {
path: finding.path.clone(),
rule: finding.rule,
};
if config.waivers.contains(&key) {
matched_waivers.insert(key);
} else {
new_findings.push(finding.clone());
}
}
let unused_waivers: Vec<Waiver> = config
.waivers
.iter()
.filter(|waiver| !matched_waivers.contains(*waiver))
.cloned()
.collect();
Ok(StructuralReport {
new_findings,
unused_waivers,
all_findings,
})
}
fn scan_root(
root: &Path,
config: &StructuralRulesConfig,
findings: &mut Vec<StructuralFinding>,
) -> Result<(), String> {
if !root.exists() {
return Err(format!(
"structural rules gate root does not exist: {}. Fix: pass an existing directory.",
root.display()
));
}
let walker = WalkDir::new(root).into_iter().filter_entry(|entry| {
if entry.depth() == 0 {
return true;
}
let name = entry.file_name().to_string_lossy();
!config.skip_dirs.iter().any(|skip| skip == name.as_ref())
});
for entry in walker {
let entry = entry.map_err(|error| {
format!(
"structural rules walker error under {}: {error}. Fix: restore read permissions.",
root.display()
)
})?;
let path = entry.path();
let rel = path.strip_prefix(root).unwrap_or(path).to_path_buf();
if entry.file_type().is_dir() {
scan_directory_rule(root, path, &rel, findings)?;
continue;
}
if entry.file_type().is_file() {
if path.file_name().and_then(|name| name.to_str()) == Some("mod.rs") {
findings.push(StructuralFinding {
path: rel.clone(),
rule: StructuralRule::NoModRs,
line: None,
detail: "mod.rs is forbidden under LAW 7".to_string(),
});
}
if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
scan_file_rules(path, &rel, findings)?;
}
}
}
Ok(())
}
fn scan_directory_rule(
_root: &Path,
path: &Path,
rel: &Path,
findings: &mut Vec<StructuralFinding>,
) -> Result<(), String> {
if !rel
.components()
.any(|component| component.as_os_str() == "src")
{
return Ok(());
}
let mut count = 0usize;
let read_dir = fs::read_dir(path).map_err(|error| {
format!(
"structural rules failed to read directory {}: {error}. Fix: restore read permissions.",
path.display()
)
})?;
for entry in read_dir {
let entry = entry.map_err(|error| {
format!(
"structural rules failed to enumerate {}: {error}",
path.display()
)
})?;
let name = entry.file_name().to_string_lossy().to_string();
if name == "_tree.rs"
|| name == "_deprecated_bridge.rs"
|| name == "rust-project.json"
|| name.starts_with('.')
{
continue;
}
count += 1;
}
if count > 5 {
findings.push(StructuralFinding {
path: rel.to_path_buf(),
rule: StructuralRule::MaxFiveEntriesPerDir,
line: None,
detail: format!("{count} direct entries (max 5)"),
});
}
Ok(())
}
fn scan_file_rules(
path: &Path,
rel: &Path,
findings: &mut Vec<StructuralFinding>,
) -> Result<(), String> {
if !rel
.components()
.any(|component| component.as_os_str() == "src")
{
return Ok(());
}
let source = fs::read_to_string(path).map_err(|error| {
format!(
"structural rules failed to read {}: {error}. Fix: restore read permissions.",
path.display()
)
})?;
for (line_number, line) in source.lines().enumerate() {
let trimmed = line.trim_start();
if trimmed.starts_with("//") {
continue;
}
if trimmed.starts_with("use super::") || trimmed.starts_with("use self::") {
findings.push(StructuralFinding {
path: rel.to_path_buf(),
rule: StructuralRule::NoUseSuper,
line: Some(line_number + 1),
detail: trimmed.to_string(),
});
}
}
Ok(())
}
#[must_use]
#[inline]
pub fn violation_count(report: &StructuralReport) -> usize {
report.all_findings.len()
}
pub struct StructuralRulesEnforcer;
impl crate::enforce::EnforceGate for StructuralRulesEnforcer {
fn id(&self) -> &'static str {
"structural_rules"
}
fn name(&self) -> &'static str {
"structural_rules"
}
fn run(&self, ctx: &crate::enforce::EnforceCtx<'_>) -> Vec<crate::enforce::Finding> {
let config = StructuralRulesConfig::with_root(ctx.workspace_root.to_path_buf());
match scan(&config) {
Ok(report) => crate::enforce::finding_result(self.id(), report.messages()),
Err(error) => vec![crate::enforce::aggregate_finding(self.id(), vec![error])],
}
}
}
pub const REGISTERED: StructuralRulesEnforcer = StructuralRulesEnforcer;
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::TempDir;
fn write_file(dir: &TempDir, rel: &str, content: &str) -> PathBuf {
let path = dir.path().join(rel);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
let mut file = std::fs::File::create(&path).unwrap();
file.write_all(content.as_bytes()).unwrap();
path
}
fn run(dir: &TempDir) -> StructuralReport {
scan(&StructuralRulesConfig::with_root(dir.path())).expect("scan must not fail")
}
#[test]
fn clean_tree_is_green() {
let dir = TempDir::new().unwrap();
write_file(&dir, "src/lib.rs", "pub fn a() {}\n");
write_file(&dir, "src/a.rs", "pub fn a() {}\n");
let report = run(&dir);
assert!(report.is_green(), "{:?}", report);
}
#[test]
fn mod_rs_is_detected() {
let dir = TempDir::new().unwrap();
write_file(&dir, "src/lib.rs", "pub mod foo;\n");
write_file(&dir, "src/foo/mod.rs", "pub fn a() {}\n");
let report = run(&dir);
let kinds: Vec<_> = report
.new_findings
.iter()
.map(|finding| finding.rule)
.collect();
assert!(kinds.contains(&StructuralRule::NoModRs), "{:?}", report);
}
#[test]
fn six_entries_in_src_dir_fails_max_5() {
let dir = TempDir::new().unwrap();
for letter in 'a'..='f' {
write_file(
&dir,
&format!("src/foo/item_{letter}.rs"),
"pub fn a() {}\n",
);
}
let report = run(&dir);
let dir_findings: Vec<_> = report
.new_findings
.iter()
.filter(|finding| finding.rule == StructuralRule::MaxFiveEntriesPerDir)
.collect();
assert_eq!(dir_findings.len(), 1, "{:?}", report);
}
#[test]
fn max_5_ignores_tree_gen_artifacts() {
let dir = TempDir::new().unwrap();
write_file(&dir, "src/a.rs", "pub fn a() {}\n");
write_file(&dir, "src/b.rs", "pub fn a() {}\n");
write_file(&dir, "src/c.rs", "pub fn a() {}\n");
write_file(&dir, "src/d.rs", "pub fn a() {}\n");
write_file(&dir, "src/e.rs", "pub fn a() {}\n");
write_file(&dir, "src/_tree.rs", "pub mod a;\n");
write_file(&dir, "src/_deprecated_bridge.rs", "\n");
let report = run(&dir);
assert!(
!report
.new_findings
.iter()
.any(|finding| finding.rule == StructuralRule::MaxFiveEntriesPerDir),
"tree-gen files must not count toward the 5-entry limit: {:?}",
report
);
}
#[test]
fn use_super_is_detected_and_use_crate_is_not() {
let dir = TempDir::new().unwrap();
write_file(
&dir,
"src/lib.rs",
"use super::parent;\nuse self::sibling;\nuse crate::elsewhere;\nfn f() {}\n",
);
let report = run(&dir);
let count = report
.new_findings
.iter()
.filter(|finding| finding.rule == StructuralRule::NoUseSuper)
.count();
assert_eq!(count, 2, "{:?}", report);
}
#[test]
fn use_super_in_comment_is_ignored() {
let dir = TempDir::new().unwrap();
write_file(
&dir,
"src/lib.rs",
"/// Historical example: `use super::parent;` is banned.\nfn f() {}\n",
);
let report = run(&dir);
assert!(report.is_green(), "{:?}", report);
}
#[test]
fn skip_dirs_default_excludes_target_and_docs() {
let dir = TempDir::new().unwrap();
write_file(&dir, "target/poison/src/mod.rs", "pub fn a() {}\n");
write_file(&dir, "src/good.rs", "pub fn a() {}\n");
let report = run(&dir);
assert!(report.is_green(), "{:?}", report);
}
#[test]
fn waiver_suppresses_exactly_its_rule_on_its_path() {
let dir = TempDir::new().unwrap();
write_file(&dir, "src/a/mod.rs", "pub fn a() {}\n");
write_file(&dir, "src/b.rs", "use super::x;\nfn f() {}\n");
let config = StructuralRulesConfig {
roots: vec![dir.path().to_path_buf()],
skip_dirs: default_skip_dirs(),
waivers: [
Waiver {
path: PathBuf::from("src/a/mod.rs"),
rule: StructuralRule::NoModRs,
},
Waiver {
path: PathBuf::from("src/b.rs"),
rule: StructuralRule::NoUseSuper,
},
]
.into_iter()
.collect(),
};
let report = scan(&config).unwrap();
assert!(
report.is_green(),
"all violations should be waived: {:?}",
report
);
}
#[test]
fn stale_waiver_is_a_regression() {
let dir = TempDir::new().unwrap();
write_file(&dir, "src/ok.rs", "pub fn a() {}\n");
let config = StructuralRulesConfig {
roots: vec![dir.path().to_path_buf()],
skip_dirs: default_skip_dirs(),
waivers: [Waiver {
path: PathBuf::from("src/gone.rs"),
rule: StructuralRule::NoModRs,
}]
.into_iter()
.collect(),
};
let report = scan(&config).unwrap();
assert!(
!report.is_green(),
"unused waivers should fail the gate: {:?}",
report
);
assert_eq!(report.unused_waivers.len(), 1);
}
#[test]
fn waiver_does_not_bleed_across_rules() {
let dir = TempDir::new().unwrap();
write_file(&dir, "src/foo/mod.rs", "use super::parent;\n");
let config = StructuralRulesConfig {
roots: vec![dir.path().to_path_buf()],
skip_dirs: default_skip_dirs(),
waivers: [Waiver {
path: PathBuf::from("src/foo/mod.rs"),
rule: StructuralRule::NoModRs,
}]
.into_iter()
.collect(),
};
let report = scan(&config).unwrap();
assert_eq!(
report
.new_findings
.iter()
.filter(|finding| finding.rule == StructuralRule::NoUseSuper)
.count(),
1,
"{:?}",
report
);
}
#[test]
fn violation_count_counts_all_findings_regardless_of_waivers() {
let dir = TempDir::new().unwrap();
write_file(&dir, "src/a/mod.rs", "use super::x;\n");
let config = StructuralRulesConfig {
roots: vec![dir.path().to_path_buf()],
skip_dirs: default_skip_dirs(),
waivers: [
Waiver {
path: PathBuf::from("src/a/mod.rs"),
rule: StructuralRule::NoModRs,
},
Waiver {
path: PathBuf::from("src/a/mod.rs"),
rule: StructuralRule::NoUseSuper,
},
]
.into_iter()
.collect(),
};
let report = scan(&config).unwrap();
assert_eq!(violation_count(&report), 2);
assert!(report.is_green());
}
#[test]
fn findings_are_deterministically_sorted() {
let dir = TempDir::new().unwrap();
write_file(&dir, "src/z/mod.rs", "\n");
write_file(&dir, "src/a/mod.rs", "\n");
write_file(&dir, "src/m/mod.rs", "\n");
let report = run(&dir);
let paths: Vec<_> = report
.all_findings
.iter()
.map(|finding| finding.path.to_string_lossy().to_string())
.collect();
let mut sorted = paths.clone();
sorted.sort();
assert_eq!(paths, sorted, "{:?}", paths);
}
#[test]
fn missing_root_returns_actionable_error() {
let config = StructuralRulesConfig::with_root("/nope/does/not/exist");
let error = scan(&config).unwrap_err();
assert!(
error.contains("structural rules gate root does not exist"),
"{error}"
);
assert!(error.contains("Fix:"), "{error}");
}
#[test]
fn rule_names_are_unique() {
let mut seen = std::collections::BTreeSet::new();
for rule in [
StructuralRule::NoModRs,
StructuralRule::MaxFiveEntriesPerDir,
StructuralRule::NoUseSuper,
] {
assert!(seen.insert(rule.name()), "duplicate name {}", rule.name());
}
}
#[test]
fn display_finding_includes_rule_name_and_fix() {
let finding = StructuralFinding {
path: PathBuf::from("src/a/mod.rs"),
rule: StructuralRule::NoModRs,
line: None,
detail: "mod.rs is forbidden".to_string(),
};
let rendered = format!("{finding}");
assert!(rendered.contains("src/a/mod.rs"), "{rendered}");
assert!(rendered.contains("no_mod_rs"), "{rendered}");
}
#[test]
fn dir_rule_ignores_non_src_directories() {
let dir = TempDir::new().unwrap();
for letter in 'a'..='f' {
write_file(&dir, &format!("tests/{letter}.rs"), "\n");
}
let report = run(&dir);
assert!(report.is_green(), "{:?}", report);
}
}