Skip to main content

hblank_cli/
discovery.rs

1use std::path::{Path, PathBuf};
2
3use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
4use thiserror::Error;
5use walkdir::WalkDir;
6
7use crate::Config;
8
9#[derive(Clone, Debug, PartialEq, Eq)]
10pub struct DiscoveredFixtureFile {
11    pub relative_path: PathBuf,
12    pub absolute_path: PathBuf,
13    pub module_name: String,
14}
15
16/// Finds configured fixture files in deterministic relative-path order.
17///
18/// # Errors
19/// Returns an error for invalid globs, unreadable directory entries, or non-UTF-8 paths.
20pub fn discover_fixture_files(
21    project_root: &Path,
22    config: &Config,
23) -> Result<Vec<DiscoveredFixtureFile>, DiscoveryError> {
24    let include = build_glob_set(&config.fixtures)?;
25    let ignore = build_glob_set(&config.ignore)?;
26    let mut fixture_files = Vec::new();
27
28    for entry in WalkDir::new(project_root).follow_links(false) {
29        let entry = entry.map_err(DiscoveryError::Walk)?;
30        if !entry.file_type().is_file() {
31            continue;
32        }
33        let absolute_path = entry.path().to_path_buf();
34        let relative_path = absolute_path
35            .strip_prefix(project_root)
36            .map_err(|_| DiscoveryError::OutsideProject(absolute_path.clone()))?
37            .to_path_buf();
38        let relative = portable_path(&relative_path)?;
39        if include.is_match(&relative) && !ignore.is_match(&relative) {
40            fixture_files.push(DiscoveredFixtureFile {
41                module_name: module_name(&relative),
42                relative_path,
43                absolute_path,
44            });
45        }
46    }
47
48    fixture_files.sort_by(|left, right| left.relative_path.cmp(&right.relative_path));
49    Ok(fixture_files)
50}
51
52fn build_glob_set(patterns: &[String]) -> Result<GlobSet, DiscoveryError> {
53    let mut builder = GlobSetBuilder::new();
54    for pattern in patterns {
55        let glob = GlobBuilder::new(pattern)
56            .literal_separator(true)
57            .build()
58            .map_err(|source| DiscoveryError::Glob {
59                pattern: pattern.clone(),
60                source,
61            })?;
62        builder.add(glob);
63    }
64    builder.build().map_err(DiscoveryError::GlobSet)
65}
66
67fn portable_path(path: &Path) -> Result<String, DiscoveryError> {
68    let path = path
69        .to_str()
70        .ok_or_else(|| DiscoveryError::NonUtf8Path(path.to_path_buf()))?;
71    Ok(path.replace(std::path::MAIN_SEPARATOR, "/"))
72}
73
74fn module_name(relative_path: &str) -> String {
75    let mut sanitized = String::with_capacity(relative_path.len());
76    for character in relative_path.chars() {
77        if character.is_ascii_alphanumeric() {
78            sanitized.push(character.to_ascii_lowercase());
79        } else if !sanitized.ends_with('_') {
80            sanitized.push('_');
81        }
82    }
83    let sanitized = sanitized.trim_matches('_');
84    format!(
85        "__hblank_{sanitized}_{:016x}",
86        stable_hash(relative_path.as_bytes())
87    )
88}
89
90const fn stable_hash(bytes: &[u8]) -> u64 {
91    let mut hash = 0xcbf2_9ce4_8422_2325_u64;
92    let mut index = 0;
93    while index < bytes.len() {
94        hash ^= bytes[index] as u64;
95        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
96        index += 1;
97    }
98    hash
99}
100
101#[derive(Debug, Error)]
102pub enum DiscoveryError {
103    #[error("invalid fixture file glob '{pattern}': {source}")]
104    Glob {
105        pattern: String,
106        source: globset::Error,
107    },
108    #[error("could not build fixture file glob matcher: {0}")]
109    GlobSet(globset::Error),
110    #[error("could not walk project files: {0}")]
111    Walk(walkdir::Error),
112    #[error("discovered path is outside the project: {0}")]
113    OutsideProject(PathBuf),
114    #[error("Hblank requires UTF-8 source paths, received {0}")]
115    NonUtf8Path(PathBuf),
116}