wowlab_tidy/languages/rust/rules/hygiene/
spec_module_layout.rs1#[cfg(test)]
2use googletest::prelude::*;
3use wowlab_fs::{
4 directory::{self, EntryKind},
5 path::Path,
6};
7
8use crate::{Example, FileCtx, Violation, violation};
9
10const HOOKS_MOD: &str = "crates/engine-content/src/hooks/mod.rs";
11const HOOKS_TREE: &str = "crates/engine-content/src/hooks/";
12const BUG_MACRO: &str = "define_game_bug!";
13
14#[rustfmt::skip]
15const EXAMPLES: &[Example] = &[
16 Example {
17 label: "canonical spec directory",
18 code: "mod.rs\nitems/\nshared/\narms_warrior/mod.rs\narms_warrior/hooks.rs\narms_warrior/config.rs\narcane_mage/mod.rs\narcane_mage/hooks.rs",
19 pass: true,
20 },
21 Example {
22 label: "top-level spec file",
23 code: "mod.rs\narms_warrior.rs",
24 pass: false,
25 },
26 Example {
27 label: "spec directory missing mod.rs",
28 code: "mod.rs\narms_warrior/hooks.rs",
29 pass: false,
30 },
31 Example {
32 label: "spec directory missing hook implementation",
33 code: "mod.rs\narms_warrior/mod.rs",
34 pass: false,
35 },
36];
37
38crate::line_rule!(
39 spec_module_layout,
40 "Enforce the minimum directory layout for engine-content spec hook modules, including `define_game_bug!` placement in `bugs.rs`.",
41 "A uniform spec layout keeps hook implementations discoverable, prevents new single-file specs from bypassing the module structure, and keeps every modeled live-game bug findable in one file per spec.",
42 Medium,
43);
44
45fn check_spec_module_layout(ctx: &FileCtx<'_>) -> Vec<Violation> {
46 if ctx.rel == HOOKS_MOD {
47 return check_hooks_directory(ctx.path.parent().unwrap_or(ctx.path), ctx.rel);
48 }
49
50 check_bug_declaration_placement(ctx)
51}
52
53fn check_bug_declaration_placement(ctx: &FileCtx<'_>) -> Vec<Violation> {
54 if !ctx.rel.starts_with(HOOKS_TREE) || ctx.rel.ends_with("/bugs.rs") {
55 return Vec::new();
56 }
57
58 ctx.lines
59 .iter()
60 .enumerate()
61 .filter(|(_, line)| line.contains(BUG_MACRO))
62 .map(|(index, _)| {
63 violation(
64 ctx.rel,
65 index + 1,
66 "`define_game_bug!` declarations belong in the spec's `bugs.rs`",
67 )
68 })
69 .collect()
70}
71
72fn check_hooks_directory(hooks: &Path, anchor_rel: &str) -> Vec<Violation> {
73 let Ok(entries) = directory::entries(hooks) else {
74 return vec![violation(
75 anchor_rel,
76 1,
77 "unable to inspect the engine-content hooks directory",
78 )];
79 };
80
81 let mut violations = Vec::new();
82
83 for entry in entries {
84 let Some(name) = entry.path().file_name() else {
85 continue;
86 };
87 let name = name.to_string_lossy();
88
89 if name == "mod.rs" || name == "items" || name == "shared" {
90 continue;
91 }
92
93 let path = entry.path();
94
95 if entry.kind() != EntryKind::Directory {
96 violations.push(violation(
97 anchor_rel,
98 1,
99 format!("top-level hooks child `{name}` must be a spec directory"),
100 ));
101 continue;
102 }
103
104 if !directory::inspect(&path.join("mod.rs"))
105 .ok()
106 .flatten()
107 .is_some_and(|entry| entry.kind() == EntryKind::File)
108 {
109 violations.push(violation(
110 anchor_rel,
111 1,
112 format!("spec directory `{name}` is missing required `mod.rs`"),
113 ));
114 }
115
116 if !directory::inspect(&path.join("hooks.rs"))
117 .ok()
118 .flatten()
119 .is_some_and(|entry| entry.kind() == EntryKind::File)
120 {
121 violations.push(violation(
122 anchor_rel,
123 1,
124 format!("spec directory `{name}` is missing required `hooks.rs`"),
125 ));
126 }
127 }
128
129 violations
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135
136 #[gtest]
137 fn examples() -> Result<()> {
138 for (index, example) in EXAMPLES.iter().enumerate() {
139 let temporary =
140 wowlab_fs::temporary::Directory::with_prefix(&format!("tidy-layout-{index}-"))
141 .or_fail()?;
142 let root = temporary.path();
143 let hooks = root.join("hooks");
144
145 directory::ensure(&hooks).or_fail()?;
146
147 for fixture_path in example.code.lines() {
148 let path = hooks.join(fixture_path.trim_end_matches('/'));
149
150 if fixture_path.ends_with('/') {
151 directory::ensure(&path).or_fail()?;
152 } else {
153 if let Some(parent) = path.parent() {
154 directory::ensure(parent).or_fail()?;
155 }
156
157 wowlab_fs::file::write_text(&path, "").or_fail()?;
158 }
159 }
160
161 let violations = check_hooks_directory(&hooks, HOOKS_MOD);
162
163 verify_eq!(violations.is_empty(), example.pass)?;
164 }
165
166 Ok(())
167 }
168
169 #[gtest]
170 fn bug_declarations_must_live_in_bugs_rs() -> Result<()> {
171 let cfg = crate::infra::config::Config::generate_default(&[]);
172 let source =
173 "wowlab_engine_ports::define_game_bug! {\n static BUG_X = (\"x\", \"y\");\n}\n";
174 let lines: Vec<&str> = source.lines().collect();
175 let check = |rel: &str| {
176 check_bug_declaration_placement(&FileCtx {
177 rel,
178 path: Path::new(rel),
179 lines: &lines,
180 contents: source,
181 config: &cfg,
182 })
183 };
184
185 verify_false!(
186 check("crates/engine-content/src/hooks/elemental_shaman/hooks.rs").is_empty()
187 )?;
188 verify_true!(check("crates/engine-content/src/hooks/elemental_shaman/bugs.rs").is_empty())?;
189 verify_true!(check("crates/engine-ports/src/game_bugs.rs").is_empty())?;
190
191 Ok(())
192 }
193}