Skip to main content

stern4rust/rules/layout/
directory_file_count_rule.rs

1// Copyright 2025 Umberto Gotti <umberto.gotti@umbertogotti.dev>
2// Licensed under the MIT License
3// SPDX-License-Identifier: MIT
4
5use std::path::Path;
6
7use crate::finding::model::package_tree::PackageTree;
8use crate::reporting::offence::Offence;
9use crate::reporting::rule_explanation::RuleExplanation;
10use crate::rule::Rule;
11use crate::source_file::SourceFile;
12
13// A directory holds a number of files a reader can hold in their head.
14//
15// This is the one rule whose limit is a matter of taste rather than a fact, and
16// it is the reason the limit is configuration rather than a constant. Twenty is
17// where a directory stops being a list and starts being a wall; somebody else's
18// twenty is thirty.
19//
20// It is also the rule most in tension with the rest of this tool. One struct per
21// file, one implemented type per file, and one test file per source file all
22// manufacture files by design -- so the limit has to be generous enough that the
23// conventions producing the files are not themselves the offence. A limit that
24// punished its own standards would be worked around rather than kept.
25//
26// Registries do not count. A `mod.rs`, `lib.rs` or `all_tests.rs` is an index of
27// the directory rather than something in it, and counting the list against the
28// length of the list makes no sense. `main.rs` does count: it is an entry point
29// holding real code, which is why this list is shorter than the one
30// `PackageTree` uses for deciding what may declare a module.
31pub struct DirectoryFileCountRule {
32    limit: usize,
33}
34
35impl DirectoryFileCountRule {
36    pub const DEFAULT_LIMIT: usize = 20;
37    pub const INDEXES: [&'static str; 3] = ["all_tests.rs", "lib.rs", "mod.rs"];
38
39    pub fn new(limit: usize) -> Self {
40        Self { limit }
41    }
42
43    fn counted_in(tree: &PackageTree, directory: &Path) -> usize {
44        tree.files_in(directory)
45            .into_iter()
46            .filter(|path| !Self::is_index(path))
47            .count()
48    }
49
50    fn is_index(path: &Path) -> bool {
51        path.file_name()
52            .and_then(|name| name.to_str())
53            .is_some_and(|name| Self::INDEXES.contains(&name))
54    }
55
56    // Reported against the directory's own index where it has one, because that
57    // is the file a split has to edit anyway. A directory with no index is named
58    // by its path.
59    fn offence(&self, tree: &PackageTree, directory: &Path, found: usize) -> Offence {
60        let subject = Self::shown(directory);
61        let carrier = tree
62            .registries_in(directory)
63            .first()
64            .map(|path| Self::shown(path))
65            .unwrap_or_else(|| subject.clone());
66        Offence::new(
67            &carrier,
68            1,
69            self.name(),
70            format!(
71                "{subject} holds {found} files, more than the {} a directory may hold",
72                self.limit
73            ),
74            format!(
75                "group the files of {subject} into subfolders of at most {} each, each with its \
76                 own mod.rs declared by this index",
77                self.limit
78            ),
79        )
80        .with_subject(&subject)
81    }
82
83    fn shown(path: &Path) -> String {
84        let shown = path.to_string_lossy().replace('\\', "/");
85        if shown.is_empty() || shown == "." {
86            return "the package root".to_string();
87        }
88        shown
89    }
90}
91
92impl Default for DirectoryFileCountRule {
93    fn default() -> Self {
94        Self::new(Self::DEFAULT_LIMIT)
95    }
96}
97
98impl Rule for DirectoryFileCountRule {
99    fn name(&self) -> &'static str {
100        "directory-file-count"
101    }
102
103    fn check(&self, _file: &SourceFile) -> Vec<Offence> {
104        Vec::new()
105    }
106
107    // A fact about a directory, so it cannot be answered a file at a time: no
108    // single file is the one too many.
109    fn check_workspace(&self, files: &[SourceFile]) -> Vec<Offence> {
110        let tree = PackageTree::of(files);
111        tree.directories()
112            .iter()
113            .filter_map(|directory| {
114                let found = Self::counted_in(&tree, directory);
115                (found > self.limit).then(|| self.offence(&tree, directory, found))
116            })
117            .collect()
118    }
119
120    fn requirement(&self) -> Option<&'static str> {
121        None
122    }
123
124    fn is_configured(&self) -> bool {
125        true
126    }
127
128    fn explanation(&self) -> RuleExplanation {
129        RuleExplanation::new(
130            self.name(),
131            "A directory holds a number of files a reader can hold in their head.",
132            "src/parsing/  -- 24 files",
133            "src/parsing/  -- 12 files\nsrc/parsing/naming/  -- 12 files",
134        )
135    }
136}