Skip to main content

stern4rust/finding/model/
package_tree.rs

1// Copyright 2025 Umberto Gotti <umberto.gotti@umbertogotti.dev>
2// Licensed under the MIT License
3// SPDX-License-Identifier: MIT
4
5use std::collections::BTreeMap;
6use std::path::Path;
7use std::path::PathBuf;
8
9use crate::source_file::SourceFile;
10
11// The walked files arranged as the directories they sit in.
12//
13// A registry declares the files beside it and the folders directly beneath it,
14// so the question "is this file reached" can only be asked of a directory as a
15// whole. This is that shape: every directory the run saw, the files in it, and
16// which of those files is a registry.
17//
18// `main.rs` counts as a registry alongside `lib.rs`. It is an entry point
19// rather than an index and legitimately holds code, but it may still declare
20// modules -- and a file declared only from `main.rs` is reached. Treating the
21// registries of a directory as one set is what keeps that from being reported
22// as an orphan.
23pub struct PackageTree {
24    directories: BTreeMap<PathBuf, Vec<PathBuf>>,
25}
26
27impl PackageTree {
28    pub const REGISTRY_NAMES: [&'static str; 4] = ["all_tests.rs", "lib.rs", "main.rs", "mod.rs"];
29
30    // Every ancestor is a key too, even when it holds no file of its own.
31    // `src/` whose sources all live one level down is still a directory, and a
32    // rule counting what it contains has to be able to ask about it -- without
33    // this, a tree of nothing but subfolders was invisible to the walk.
34    pub fn of(files: &[SourceFile]) -> Self {
35        let mut directories: BTreeMap<PathBuf, Vec<PathBuf>> = BTreeMap::new();
36        for file in files {
37            let path = PathBuf::from(file.relative_path().replace('\\', "/"));
38            let parent = path.parent().unwrap_or(Path::new("")).to_path_buf();
39            directories.entry(parent.clone()).or_default().push(path);
40            let mut ancestor = parent;
41            while let Some(above) = ancestor.parent() {
42                let above = above.to_path_buf();
43                directories.entry(above.clone()).or_default();
44                ancestor = above;
45            }
46        }
47        Self { directories }
48    }
49
50    pub fn directories(&self) -> Vec<&Path> {
51        self.directories.keys().map(PathBuf::as_path).collect()
52    }
53
54    // The registries of one directory, in REGISTRY_NAMES order, so the offence
55    // lands on `lib.rs` rather than `main.rs` when a package has both.
56    pub fn registries_in(&self, directory: &Path) -> Vec<&Path> {
57        let mut found: Vec<&Path> = self
58            .files_in(directory)
59            .into_iter()
60            .filter(|path| Self::is_registry(path))
61            .collect();
62        found.sort_by_key(|path| Self::registry_rank(path));
63        found
64    }
65
66    // What the registries of this directory have to declare for everything
67    // beside them to be compiled: each sibling module file, and each subfolder
68    // that is itself a module.
69    pub fn expected_modules_in(&self, directory: &Path) -> Vec<String> {
70        let mut expected: Vec<String> = self
71            .files_in(directory)
72            .into_iter()
73            .filter(|path| !Self::is_registry(path))
74            .filter_map(Self::module_name)
75            .collect();
76        expected.extend(self.submodules_of(directory));
77        expected.sort();
78        expected
79    }
80
81    // Every directory the walk saw directly beneath this one. Directories
82    // holding no `.rs` file are not keys and so are not counted: the tool
83    // cannot see them, and a folder of documentation is nobody's module.
84    pub fn subdirectories_of(&self, directory: &Path) -> Vec<&Path> {
85        self.directories
86            .keys()
87            .filter(|candidate| candidate.parent() == Some(directory))
88            .map(PathBuf::as_path)
89            .collect()
90    }
91
92    pub fn files_in(&self, directory: &Path) -> Vec<&Path> {
93        self.directories
94            .get(directory)
95            .map(|paths| paths.iter().map(PathBuf::as_path).collect())
96            .unwrap_or_default()
97    }
98
99    // A subfolder is a module only if it has a registry of its own. One without
100    // is tests-layout's finding, and reporting it here as undeclared would
101    // instruct the reader to declare a folder that cannot be declared yet.
102    fn submodules_of(&self, directory: &Path) -> Vec<String> {
103        self.directories
104            .keys()
105            .filter(|candidate| candidate.parent() == Some(directory))
106            .filter(|candidate| !self.registries_in(candidate).is_empty())
107            .filter_map(|candidate| Self::directory_name(candidate))
108            .collect()
109    }
110
111    fn directory_name(path: &Path) -> Option<String> {
112        path.file_name()
113            .and_then(|name| name.to_str())
114            .map(str::to_string)
115    }
116
117    fn is_registry(path: &Path) -> bool {
118        path.file_name()
119            .and_then(|name| name.to_str())
120            .is_some_and(|name| Self::REGISTRY_NAMES.contains(&name))
121    }
122
123    fn module_name(path: &Path) -> Option<String> {
124        path.file_stem()
125            .and_then(|stem| stem.to_str())
126            .map(str::to_string)
127    }
128
129    fn registry_rank(path: &Path) -> usize {
130        path.file_name()
131            .and_then(|name| name.to_str())
132            .and_then(|name| Self::REGISTRY_NAMES.iter().position(|known| *known == name))
133            .unwrap_or(usize::MAX)
134    }
135}