use std::{
io,
path::{Path, PathBuf},
};
pub fn normalize_display_path(path: impl AsRef<Path>) -> String {
let rendered = path.as_ref().to_string_lossy();
if rendered.contains('\\') {
rendered.replace('\\', "/")
} else {
rendered.into_owned()
}
}
pub fn paths_equal_normalized(left: Option<&str>, right: Option<&str>) -> bool {
let (left, right) = (left.unwrap_or_default(), right.unwrap_or_default());
let norm = |b: u8| if b == b'\\' { b'/' } else { b };
left.len() == right.len()
&& std::iter::zip(left.bytes(), right.bytes())
.all(|(left, right)| norm(left) == norm(right))
}
pub fn normalize_path_key(path: &str, cwd: &Path) -> String {
use std::path::Component;
let p = Path::new(path);
let abs = if p.is_absolute() {
p.to_path_buf()
} else {
cwd.join(p)
};
let mut folded = PathBuf::new();
for comp in abs.components() {
match comp {
Component::CurDir => {}
Component::ParentDir => {
folded.pop();
}
other => folded.push(other),
}
}
let key = normalize_display_path(&folded);
#[cfg(windows)]
let key = key
.strip_prefix("//?/")
.unwrap_or(&key)
.to_ascii_lowercase();
key
}
pub fn path_to_include_str_literal(path: impl AsRef<Path>) -> String {
normalize_display_path(path)
}
#[cfg(test)]
pub fn collect_files(folder_path: &Path) -> io::Result<Vec<PathBuf>> {
Ok(collect_files_with_mtimes(folder_path)?
.into_iter()
.map(|(path, _)| path)
.collect())
}
pub fn collect_files_with_mtimes(folder_path: &Path) -> io::Result<Vec<(PathBuf, u64)>> {
let mut files = Vec::new();
collect_with_mtimes_into(folder_path, &mut files)?;
Ok(files)
}
pub fn mtime_fingerprint(modified: Option<std::time::SystemTime>) -> u64 {
modified.map_or(0, |t| {
let nanos = t
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
u64::try_from(nanos).unwrap_or(u64::MAX)
})
}
pub fn combine_fingerprint(mtime: u64, len: u64) -> u64 {
mtime.rotate_left(1).wrapping_mul(0x9E37_79B9_7F4A_7C15)
^ len.wrapping_mul(0xD1B5_4A32_D192_ED03)
}
pub fn file_fingerprint(meta: &std::fs::Metadata) -> u64 {
combine_fingerprint(mtime_fingerprint(meta.modified().ok()), meta.len())
}
pub fn entry_fingerprint(meta: &std::io::Result<std::fs::Metadata>) -> u64 {
meta.as_ref().map_or(0, file_fingerprint)
}
fn collect_with_mtimes_into(folder_path: &Path, out: &mut Vec<(PathBuf, u64)>) -> io::Result<()> {
for entry in std::fs::read_dir(folder_path)? {
let entry = entry?;
let file_type = entry.file_type()?;
let path = entry.path();
if file_type.is_file() {
let mtime = if path.extension().is_some_and(|e| e == "rs") {
entry_fingerprint(&entry.metadata())
} else {
0
};
out.push((path, mtime));
} else if file_type.is_dir() {
collect_with_mtimes_into(&path, out)?;
}
}
Ok(())
}
pub fn file_to_segments(file: &Path, base_path: &Path) -> Vec<String> {
let file_stem = file
.strip_prefix(base_path)
.map_or_else(|_| normalize_display_path(file), normalize_display_path);
let file_stem = file_stem
.strip_suffix(".rs")
.unwrap_or(&file_stem)
.replace('\\', "/");
let mut segments: Vec<String> = file_stem
.split('/')
.filter(|s| !s.is_empty())
.map(std::string::ToString::to_string)
.collect();
if let Some(last) = segments.last()
&& last == "mod"
{
segments.pop();
}
segments
}
#[cfg(test)]
mod tests {
use std::{fs, path::PathBuf};
use rstest::rstest;
use tempfile::TempDir;
use super::*;
#[rstest]
#[case("routes/users.rs", "routes", vec!["users"])]
#[case("routes/posts.rs", "routes", vec!["posts"])]
#[case("routes/users.rs", "routes/", vec!["users"])]
#[case("routes/admin/users.rs", "routes", vec!["admin", "users"])]
#[case("routes/api/v1/users.rs", "routes", vec!["api", "v1", "users"])]
#[case("routes/admin/settings.rs", "routes", vec!["admin", "settings"])]
#[case("routes/api/v1/users/profile.rs", "routes", vec!["api", "v1", "users", "profile"])]
#[case("routes/mod.rs", "routes", vec![])]
#[case("routes/admin/mod.rs", "routes", vec!["admin"])]
#[case("routes/api/v1/mod.rs", "routes", vec!["api", "v1"])]
#[case("routes/mod_users.rs", "routes", vec!["mod_users"])]
#[case("routes\\users.rs", "routes", vec!["users"])]
#[case("routes\\admin\\users.rs", "routes", vec!["admin", "users"])]
#[case("routes\\mod.rs", "routes", vec![])]
#[case("routes/users", "routes", vec!["users"])]
#[case("routes/admin/users", "routes", vec!["admin", "users"])]
#[case("routes//users.rs", "routes", vec!["users"])]
#[case("routes///admin//users.rs", "routes", vec!["admin", "users"])]
#[case("/absolute/path/users.rs", "routes", vec!["absolute", "path", "users"])]
#[case("different/path/users.rs", "routes", vec!["different", "path", "users"])]
#[case("users.rs", ".", vec!["users"])]
#[case("mod.rs", ".", vec![])]
fn test_file_to_segments(
#[case] file_path: &str,
#[case] base_path: &str,
#[case] expected: Vec<&str>,
) {
let normalized_file_path = file_path.replace('\\', "/");
let normalized_base_path = base_path.replace('\\', "/");
let file = PathBuf::from(normalized_file_path);
let base = PathBuf::from(normalized_base_path);
let result = file_to_segments(&file, &base);
let expected_vec: Vec<String> = expected
.iter()
.map(std::string::ToString::to_string)
.collect();
assert_eq!(
result, expected_vec,
"Failed for file: {file_path}, base: {base_path}"
);
}
fn create_test_structure(
temp_dir: &TempDir,
structure: &[(&str, bool)],
) -> Result<(), std::io::Error> {
for (path, is_file) in structure {
let full_path = temp_dir.path().join(path);
if *is_file {
if let Some(parent) = full_path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&full_path, "test content")?;
} else {
fs::create_dir_all(&full_path)?;
}
}
Ok(())
}
fn normalize_paths(paths: &[PathBuf], base: &Path) -> Vec<String> {
let mut normalized: Vec<String> = paths
.iter()
.map(|p| {
p.strip_prefix(base)
.unwrap_or(p)
.to_string_lossy()
.replace('\\', "/")
})
.collect();
normalized.sort();
normalized
}
#[rstest]
#[case(vec![], vec![])]
#[case(vec![("file1.rs", true)], vec!["file1.rs"])]
#[case(
vec![("file1.rs", true), ("file2.rs", true), ("file3.rs", true)],
vec!["file1.rs", "file2.rs", "file3.rs"]
)]
#[case(
vec![("subdir", false), ("subdir/file.rs", true)],
vec!["subdir/file.rs"]
)]
#[case(
vec![
("dir1", false),
("dir1/file1.rs", true),
("dir2", false),
("dir2/file2.rs", true),
],
vec!["dir1/file1.rs", "dir2/file2.rs"]
)]
#[case(
vec![
("a", false),
("a/b", false),
("a/b/c", false),
("a/b/c/file.rs", true),
],
vec!["a/b/c/file.rs"]
)]
#[case(
vec![
("root.rs", true),
("dir1", false),
("dir1/file1.rs", true),
("dir1/file2.rs", true),
("dir2", false),
("dir2/subdir", false),
("dir2/subdir/file.rs", true),
],
vec!["dir1/file1.rs", "dir1/file2.rs", "dir2/subdir/file.rs", "root.rs"]
)]
#[case(
vec![
("file.rs", true),
("file.txt", true),
("file.md", true),
],
vec!["file.md", "file.rs", "file.txt"]
)]
#[case(
vec![
("empty_dir", false),
("file.rs", true),
],
vec!["file.rs"]
)]
fn test_collect_files(#[case] structure: Vec<(&str, bool)>, #[case] expected_files: Vec<&str>) {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
create_test_structure(&temp_dir, &structure).expect("Failed to create test structure");
let result = collect_files(temp_dir.path()).expect("collect_files failed");
let mut normalized_result = normalize_paths(&result, temp_dir.path());
normalized_result.sort();
let mut expected_normalized: Vec<String> = expected_files
.iter()
.map(std::string::ToString::to_string)
.collect();
expected_normalized.sort();
assert_eq!(
normalized_result, expected_normalized,
"Failed for structure: {structure:?}"
);
temp_dir.close().expect("Failed to close temp dir");
}
#[test]
fn test_collect_files_nonexistent_directory() {
let nonexistent = PathBuf::from("/nonexistent/path/that/does/not/exist");
let result = collect_files(&nonexistent);
assert!(result.is_err());
}
#[test]
fn test_collect_files_recursive_deep() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let mut path = temp_dir.path().to_path_buf();
for i in 0..5 {
path = path.join(format!("level{i}"));
fs::create_dir_all(&path).expect("Failed to create nested dir");
}
let file_path = path.join("deep_file.rs");
fs::write(&file_path, "content").expect("Failed to write file");
let result = collect_files(temp_dir.path()).expect("collect_files failed");
assert_eq!(result.len(), 1);
assert!(result[0].ends_with("deep_file.rs"));
temp_dir.close().expect("Failed to close temp dir");
}
#[test]
fn mtime_fingerprint_distinguishes_subsecond_edits() {
use std::time::{Duration, UNIX_EPOCH};
let base = UNIX_EPOCH + Duration::new(1_700_000_000, 0);
let same_second_later = base + Duration::from_millis(1);
assert_ne!(
mtime_fingerprint(Some(base)),
mtime_fingerprint(Some(same_second_later)),
"same-second edits must produce distinct cache fingerprints"
);
let next_second = base + Duration::from_secs(1);
assert_ne!(
mtime_fingerprint(Some(base)),
mtime_fingerprint(Some(next_second))
);
assert_eq!(mtime_fingerprint(None), 0);
}
#[test]
fn entry_fingerprint_falls_back_to_the_zero_sentinel_on_a_metadata_error() {
let temp = TempDir::new().expect("temp dir");
let file = temp.path().join("lib.rs");
fs::write(&file, "pub struct App;").expect("write fixture");
assert_ne!(entry_fingerprint(&fs::metadata(&file)), 0);
assert_eq!(
entry_fingerprint(&Err(std::io::Error::other("metadata vanished mid-scan"))),
0
);
}
#[test]
fn combine_fingerprint_is_sensitive_to_mtime_and_size() {
assert_ne!(
combine_fingerprint(42, 100),
combine_fingerprint(42, 101),
"same mtime + different size must differ (stale-cache guard)"
);
assert_ne!(combine_fingerprint(42, 100), combine_fingerprint(43, 100));
assert_eq!(combine_fingerprint(42, 100), combine_fingerprint(42, 100));
}
#[test]
fn normalize_path_key_absolutizes_and_folds_dot_segments() {
let cwd = Path::new(if cfg!(windows) {
r"C:\work\project"
} else {
"/work/project"
});
let relative = normalize_path_key("./src/./routes/../models/user.rs", cwd);
assert!(
relative.ends_with("/work/project/src/models/user.rs"),
"`.` must vanish and `..` must pop the previous segment: {relative}"
);
let absolute = normalize_path_key(
&normalize_display_path(cwd.join("a/../b.rs")),
Path::new("/elsewhere"),
);
assert!(
absolute.ends_with("/work/project/b.rs"),
"an absolute path must not be re-rooted at cwd: {absolute}"
);
assert_eq!(
normalize_path_key("./routes/./users.rs", Path::new(".")),
"routes/users.rs"
);
}
#[test]
fn normalized_path_helpers_cover_borrowed_and_separator_paths() {
assert_eq!(normalize_display_path("routes/users.rs"), "routes/users.rs");
assert!(paths_equal_normalized(
Some("routes\\users.rs"),
Some("routes/users.rs")
));
assert!(!paths_equal_normalized(
Some("routes/a.rs"),
Some("routes/b.rs")
));
}
}