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