Skip to main content

wowlab_tidy/languages/rust/rules/hygiene/
spec_module_layout.rs

1#[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}