Skip to main content

mempal_runtime/
path_filter.rs

1use 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}