Skip to main content

wowlab_tidy/infra/
walk.rs

1// #t(file: rust_default_hasher) dedup set on a cold walk path; fast-hasher dependency not warranted
2
3use 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
32/// Get uncommitted source files with one of `extensions` via `gix`.
33pub(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                // #t(rust_alloc_in_loop) preserve each independent traversal failure for one diagnostic
70                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        // #t(rust_clone_in_loop) need one copy in seen and one in paths
108        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
125/// Walk the workspace in parallel for files with one of `extensions`.
126pub(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
155/// Walk the workspace in parallel for `.rs` paths.
156pub(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}