Skip to main content

stern4rust/rules/layout/
directory_subfolder_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 subfolders a reader can hold in their head.
14//
15// The counterweight to `directory-file-count`. That rule creates folders; this
16// one stops the creating from being the answer to everything, because a
17// directory with twenty subfolders is exactly as unreadable as one with a
18// hundred files and looks tidier while being worse.
19//
20// It is checked at every level rather than only at the root, so pushing the
21// sprawl one directory down does not escape it.
22//
23// Measured across eight repositories at the time it was written it finds
24// **nothing**: the deepest tree is two levels and no directory has more than one
25// subfolder. That is stated plainly because a rule that has never fired has not
26// yet earned the reader's trust, and this one is a guard against a shape the
27// family has not reached rather than a description of a problem it has.
28pub struct DirectorySubfolderCountRule {
29    limit: usize,
30}
31
32impl DirectorySubfolderCountRule {
33    pub const DEFAULT_LIMIT: usize = 5;
34
35    pub fn new(limit: usize) -> Self {
36        Self { limit }
37    }
38
39    fn offence(&self, tree: &PackageTree, directory: &Path, found: usize) -> Offence {
40        let subject = Self::shown(directory);
41        let carrier = tree
42            .registries_in(directory)
43            .first()
44            .map(|path| Self::shown(path))
45            .unwrap_or_else(|| subject.clone());
46        Offence::new(
47            &carrier,
48            1,
49            self.name(),
50            format!(
51                "{subject} holds {found} subfolders, more than the {} a directory may hold",
52                self.limit
53            ),
54            format!(
55                "group the subfolders of {subject} so that no directory holds more than {}",
56                self.limit
57            ),
58        )
59        .with_subject(&subject)
60    }
61
62    fn shown(path: &Path) -> String {
63        let shown = path.to_string_lossy().replace('\\', "/");
64        if shown.is_empty() || shown == "." {
65            return "the package root".to_string();
66        }
67        shown
68    }
69}
70
71impl Default for DirectorySubfolderCountRule {
72    fn default() -> Self {
73        Self::new(Self::DEFAULT_LIMIT)
74    }
75}
76
77impl Rule for DirectorySubfolderCountRule {
78    fn name(&self) -> &'static str {
79        "directory-subfolder-count"
80    }
81
82    fn check(&self, _file: &SourceFile) -> Vec<Offence> {
83        Vec::new()
84    }
85
86    fn check_workspace(&self, files: &[SourceFile]) -> Vec<Offence> {
87        let tree = PackageTree::of(files);
88        tree.directories()
89            .iter()
90            .filter_map(|directory| {
91                let found = tree.subdirectories_of(directory).len();
92                (found > self.limit).then(|| self.offence(&tree, directory, found))
93            })
94            .collect()
95    }
96
97    fn requirement(&self) -> Option<&'static str> {
98        None
99    }
100
101    fn is_configured(&self) -> bool {
102        true
103    }
104
105    fn explanation(&self) -> RuleExplanation {
106        RuleExplanation::new(
107            self.name(),
108            "A directory holds a number of subfolders a reader can hold in their head.",
109            "src/  -- 9 subfolders",
110            "src/  -- 4 subfolders, each grouping the rest",
111        )
112    }
113}