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
16pub 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}