mempal_runtime/
path_filter.rs1use std::path::{Path, PathBuf};
2
3use ignore::WalkBuilder;
4use thiserror::Error;
5
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub struct ProjectPathFilterOptions {
8 pub respect_gitignore: bool,
9 pub respect_mempalignore: bool,
10 pub custom_ignore_files: Vec<PathBuf>,
11}
12
13impl Default for ProjectPathFilterOptions {
14 fn default() -> Self {
15 Self {
16 respect_gitignore: true,
17 respect_mempalignore: true,
18 custom_ignore_files: Vec::new(),
19 }
20 }
21}
22
23#[derive(Debug, Error)]
24pub enum ProjectPathFilterError {
25 #[error("custom ignore file does not exist: {path}")]
26 MissingCustomIgnoreFile { path: PathBuf },
27 #[error("project traversal failed for {path}")]
28 Walk {
29 path: PathBuf,
30 #[source]
31 source: ignore::Error,
32 },
33}
34
35pub fn project_walk(
36 root: &Path,
37 options: &ProjectPathFilterOptions,
38) -> Result<ignore::Walk, ProjectPathFilterError> {
39 for path in &options.custom_ignore_files {
40 if !path.is_file() {
41 return Err(ProjectPathFilterError::MissingCustomIgnoreFile { path: path.clone() });
42 }
43 }
44
45 let mut builder = WalkBuilder::new(root);
46 builder
47 .hidden(false)
48 .git_ignore(options.respect_gitignore)
49 .git_exclude(options.respect_gitignore)
50 .git_global(options.respect_gitignore)
51 .require_git(false)
52 .parents(true);
53
54 let mempalignore = root.join(".mempalignore");
55 if options.respect_mempalignore && mempalignore.is_file() {
56 builder.add_ignore(mempalignore);
57 }
58
59 for path in &options.custom_ignore_files {
60 builder.add_ignore(path);
61 }
62
63 builder.filter_entry(|entry| !is_hard_skipped_dir(entry.path()));
64 Ok(builder.build())
65}
66
67pub fn is_hard_skipped_dir(path: &Path) -> bool {
68 path.file_name()
69 .and_then(|name| name.to_str())
70 .map(|name| matches!(name, ".git" | "target" | "node_modules"))
71 .unwrap_or(false)
72}
73
74pub fn collect_relative_paths(
75 root: &Path,
76 options: &ProjectPathFilterOptions,
77) -> Result<Vec<String>, ProjectPathFilterError> {
78 let mut paths = Vec::new();
79 for entry in project_walk(root, options)? {
80 let entry = entry.map_err(|source| ProjectPathFilterError::Walk {
81 path: root.to_path_buf(),
82 source,
83 })?;
84 if entry.path() == root {
85 continue;
86 }
87 if let Ok(relative) = entry.path().strip_prefix(root) {
88 paths.push(relative.to_string_lossy().replace('\\', "/"));
89 }
90 }
91 paths.sort();
92 Ok(paths)
93}
94
95#[cfg(test)]
96mod tests {
97 use super::*;
98 use std::fs;
99 use tempfile::TempDir;
100
101 fn write(path: &std::path::Path, content: &str) {
102 fs::write(path, content).expect("write fixture");
103 }
104
105 #[test]
106 fn default_walk_respects_gitignore_and_mempalignore() {
107 let tmp = TempDir::new().expect("tempdir");
108 let root = tmp.path();
109 fs::create_dir_all(root.join("src/auth")).expect("src");
110 fs::create_dir_all(root.join("dist/build-room")).expect("dist");
111 fs::create_dir_all(root.join("generated/gen-room")).expect("generated");
112 fs::create_dir_all(root.join("target/release-room")).expect("target");
113 write(&root.join(".gitignore"), "dist/\n");
114 write(&root.join(".mempalignore"), "generated/\n");
115
116 let paths = collect_relative_paths(root, &ProjectPathFilterOptions::default())
117 .expect("walk succeeds");
118
119 assert!(paths.iter().any(|path| path == "src/auth"));
120 assert!(!paths.iter().any(|path| path.starts_with("dist/")));
121 assert!(!paths.iter().any(|path| path.starts_with("generated/")));
122 assert!(!paths.iter().any(|path| path.starts_with("target/")));
123 }
124
125 #[test]
126 fn no_gitignore_keeps_mempalignore() {
127 let tmp = TempDir::new().expect("tempdir");
128 let root = tmp.path();
129 fs::create_dir_all(root.join("dist/build-room")).expect("dist");
130 fs::create_dir_all(root.join("generated/gen-room")).expect("generated");
131 write(&root.join(".gitignore"), "dist/\n");
132 write(&root.join(".mempalignore"), "generated/\n");
133
134 let paths = collect_relative_paths(
135 root,
136 &ProjectPathFilterOptions {
137 respect_gitignore: false,
138 ..ProjectPathFilterOptions::default()
139 },
140 )
141 .expect("walk succeeds");
142
143 assert!(paths.iter().any(|path| path.starts_with("dist/")));
144 assert!(!paths.iter().any(|path| path.starts_with("generated/")));
145 }
146
147 #[test]
148 fn custom_ignore_file_is_validated_and_applied() {
149 let tmp = TempDir::new().expect("tempdir");
150 let root = tmp.path();
151 let custom = root.join("custom.ignore");
152 fs::create_dir_all(root.join("notes/api")).expect("notes");
153 write(&custom, "notes/\n");
154
155 let paths = collect_relative_paths(
156 root,
157 &ProjectPathFilterOptions {
158 custom_ignore_files: vec![custom],
159 ..ProjectPathFilterOptions::default()
160 },
161 )
162 .expect("walk succeeds");
163
164 assert!(!paths.iter().any(|path| path.starts_with("notes/")));
165 }
166
167 #[test]
168 fn missing_custom_ignore_file_is_an_error() {
169 let tmp = TempDir::new().expect("tempdir");
170 let missing = tmp.path().join("missing.ignore");
171 let err = collect_relative_paths(
172 tmp.path(),
173 &ProjectPathFilterOptions {
174 custom_ignore_files: vec![missing.clone()],
175 ..ProjectPathFilterOptions::default()
176 },
177 )
178 .expect_err("missing ignore file should fail");
179
180 assert!(err.to_string().contains(&missing.display().to_string()));
181 }
182}