wowlab_tidy/infra/
walk.rs1use std::collections::HashSet;
4
5use gix::bstr::ByteSlice;
6#[cfg(test)]
7use googletest::prelude::*;
8use wowlab_fs::{
9 directory::{self, EntryKind},
10 path::{Path, PathBuf},
11};
12
13#[derive(Debug, thiserror::Error)]
14#[error("{context}: {details}")]
15pub(crate) struct PathDiscoveryError {
16 context: Box<str>,
17 details: Box<str>,
18}
19
20impl PathDiscoveryError {
21 fn new(context: impl Into<Box<str>>, mut failures: Vec<String>) -> Self {
22 failures.sort_unstable();
23 failures.dedup();
24
25 Self {
26 context: context.into(),
27 details: failures.join("; ").into_boxed_str(),
28 }
29 }
30}
31
32pub(crate) fn git_dirty_paths(
34 root: &Path,
35 crates_dir: &Path,
36 crate_filter: &[String],
37 extensions: &[&str],
38) -> Result<Vec<PathBuf>, PathDiscoveryError> {
39 let repo = gix::open(root).map_err(|error| {
40 PathDiscoveryError::new(
41 format!("failed to inspect dirty files under {}", root.display()),
42 vec![format!("failed to open repository: {error}")],
43 )
44 })?;
45 let platform = repo.status(gix::progress::Discard).map_err(|error| {
46 PathDiscoveryError::new(
47 format!("failed to inspect dirty files under {}", root.display()),
48 vec![format!("failed to prepare git status: {error}")],
49 )
50 })?;
51 let iter = platform
52 .into_iter(Vec::<gix::bstr::BString>::new())
53 .map_err(|error| {
54 PathDiscoveryError::new(
55 format!("failed to inspect dirty files under {}", root.display()),
56 vec![format!("failed to iterate git status: {error}")],
57 )
58 })?;
59
60 let mut seen = HashSet::new();
61 let mut paths = Vec::new();
62
63 let mut failures = Vec::new();
64
65 for item in iter {
66 let item = match item {
67 Ok(item) => item,
68 Err(error) => {
69 failures.push(error.to_string());
71 continue;
72 }
73 };
74 let rel_bstr = item.location();
75 let abs = root.join(rel_bstr.to_path_lossy());
76
77 if abs
78 .extension()
79 .is_none_or(|extension| !extensions.iter().any(|expected| extension == *expected))
80 {
81 continue;
82 }
83
84 let is_file = directory::inspect(&abs)
85 .ok()
86 .flatten()
87 .is_some_and(|entry| entry.kind() == EntryKind::File);
88
89 if !is_file || !abs.starts_with(crates_dir) {
90 continue;
91 }
92
93 if !crate_filter.is_empty() {
94 let Ok(rel_to_crates) = abs.strip_prefix(crates_dir) else {
95 continue;
96 };
97 let crate_name = match rel_to_crates.components().next() {
98 Some(component) => component.as_os_str().to_string_lossy().into_owned(),
99 None => continue,
100 };
101
102 if !crate_filter.iter().any(|filter| filter == &crate_name) {
103 continue;
104 }
105 }
106
107 if seen.insert(abs.clone()) {
109 paths.push(abs);
110 }
111 }
112
113 if failures.is_empty() {
114 paths.sort_unstable();
115
116 Ok(paths)
117 } else {
118 Err(PathDiscoveryError::new(
119 format!("failed to inspect dirty files under {}", root.display()),
120 failures,
121 ))
122 }
123}
124
125pub(crate) fn source_paths(
127 root: &Path,
128 crate_filter: &[String],
129 extensions: &[&str],
130) -> Result<Vec<PathBuf>, PathDiscoveryError> {
131 if crate_filter.is_empty() {
132 walk_dir(root, extensions)
133 } else {
134 let mut all = Vec::new();
135
136 for name in crate_filter {
137 let crate_dir = root.join(name);
138
139 if directory::inspect(&crate_dir)
140 .ok()
141 .flatten()
142 .is_some_and(|entry| entry.kind() == EntryKind::Directory)
143 {
144 all.extend(walk_dir(&crate_dir, extensions)?);
145 }
146 }
147
148 all.sort_unstable();
149 all.dedup();
150
151 Ok(all)
152 }
153}
154
155pub(crate) fn rs_paths(
157 root: &Path,
158 crate_filter: &[String],
159) -> Result<Vec<PathBuf>, PathDiscoveryError> {
160 source_paths(root, crate_filter, &["rs"])
161}
162
163fn walk_dir(root: &Path, extensions: &[&str]) -> Result<Vec<PathBuf>, PathDiscoveryError> {
164 let report = wowlab_fs::walk::visible_source_files(root, extensions, ".tidyignore");
165 let (paths, failures) = report.into_parts();
166
167 if failures.is_empty() {
168 Ok(paths)
169 } else {
170 Err(PathDiscoveryError::new(
171 format!("failed to walk source tree {}", root.display()),
172 failures
173 .into_iter()
174 .map(|failure| failure.to_string())
175 .collect(),
176 ))
177 }
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183
184 #[gtest]
185 fn finds_rust_files() -> Result<()> {
186 let directory = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures");
187 let paths = rs_paths(&directory, &[]).or_fail()?;
188
189 verify_true!(paths.len() >= 2)?;
190 verify_true!(
191 paths
192 .iter()
193 .all(|path| path.extension().is_some_and(|extension| extension == "rs"))
194 )?;
195
196 Ok(())
197 }
198
199 #[gtest]
200 fn filters_requested_languages() -> Result<()> {
201 let directory = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures");
202 let paths = source_paths(&directory, &[], &["rs", "toml"]).or_fail()?;
203
204 verify_true!(
205 paths
206 .iter()
207 .any(|path| path.extension().is_some_and(|extension| extension == "rs"))
208 )?;
209 verify_true!(paths.iter().all(|path| matches!(
210 path.extension().and_then(|value| value.to_str()),
211 Some("rs" | "toml")
212 )))?;
213
214 Ok(())
215 }
216
217 #[gtest]
218 fn missing_source_root_is_a_fatal_walk_error() -> Result<()> {
219 let directory = wowlab_fs::temporary::Directory::new().or_fail()?;
220 let missing = directory.path().join("missing");
221 let error = rs_paths(&missing, &[]).unwrap_err().to_string();
222
223 verify_that!(
224 error.as_str(),
225 contains_substring("failed to walk source tree")
226 )?;
227
228 verify_that!(error.as_str(), contains_substring("missing"))
229 }
230}