Skip to main content

cssforge_core/
discovery.rs

1use anyhow::{Context, Result};
2use ignore::WalkBuilder;
3use std::{
4    path::{Path, PathBuf},
5    process::Command,
6};
7
8const IGNORED_DIRS: &[&str] = &[
9    "node_modules",
10    "target",
11    "dist",
12    "build",
13    "out",
14    ".next",
15    ".nuxt",
16    ".turbo",
17    ".svelte-kit",
18    "vendor",
19    ".git",
20    ".hg",
21    ".svn",
22    ".cache",
23];
24
25const IGNORED_FILE_SUFFIXES: &[&str] = &[
26    ".modern.css",
27    ".min.css",
28    ".bak.css",
29    ".backup.css",
30    ".bundle.css",
31    ".chunk.css",
32    ".map.css",
33];
34
35pub fn discover_css_files(root: &Path) -> Result<Vec<PathBuf>> {
36    if root.is_file() {
37        if root
38            .extension()
39            .and_then(|s| s.to_str())
40            .is_some_and(|ext| ext.eq_ignore_ascii_case("css"))
41        {
42            return Ok(vec![root.to_path_buf()]);
43        }
44        return Ok(Vec::new());
45    }
46
47    let mut files = Vec::new();
48    let walker = WalkBuilder::new(root)
49        .hidden(true)
50        .git_ignore(true)
51        .git_global(true)
52        .git_exclude(true)
53        .parents(true)
54        .filter_entry(|entry| {
55            if entry.file_type().is_some_and(|ft| ft.is_dir()) {
56                if let Some(name) = entry.file_name().to_str() {
57                    if IGNORED_DIRS.iter().any(|d| d.eq_ignore_ascii_case(name)) {
58                        return false;
59                    }
60                }
61            }
62            true
63        })
64        .build();
65
66    for entry in walker {
67        let entry = entry.with_context(|| format!("failed while walking {}", root.display()))?;
68        if !entry.file_type().is_some_and(|ft| ft.is_file()) {
69            continue;
70        }
71        let path = entry.into_path();
72        if is_eligible_css_file(&path) {
73            files.push(path);
74        }
75    }
76
77    files.sort();
78    Ok(files)
79}
80
81fn is_eligible_css_file(path: &Path) -> bool {
82    let Some(ext) = path.extension().and_then(|s| s.to_str()) else {
83        return false;
84    };
85    if !ext.eq_ignore_ascii_case("css") {
86        return false;
87    }
88
89    let file_name = path
90        .file_name()
91        .and_then(|s| s.to_str())
92        .unwrap_or_default()
93        .to_lowercase();
94
95    for suffix in IGNORED_FILE_SUFFIXES {
96        if file_name.ends_with(suffix) {
97            return false;
98        }
99    }
100
101    // Ensure no path component is an ignored dir
102    for comp in path.components() {
103        if let std::path::Component::Normal(os_str) = comp {
104            if let Some(s) = os_str.to_str() {
105                if IGNORED_DIRS.iter().any(|d| d.eq_ignore_ascii_case(s)) {
106                    return false;
107                }
108            }
109        }
110    }
111
112    true
113}
114
115pub fn is_git_dirty(path: &Path) -> bool {
116    let cwd = if path.is_dir() {
117        path
118    } else {
119        path.parent().unwrap_or_else(|| Path::new("."))
120    };
121    Command::new("git")
122        .arg("status")
123        .arg("--porcelain")
124        .current_dir(cwd)
125        .output()
126        .ok()
127        .filter(|out| out.status.success())
128        .is_some_and(|out| !out.stdout.is_empty())
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use std::fs;
135
136    #[test]
137    fn test_discover_css_files_filters_generated_and_ignored() -> Result<()> {
138        let temp_dir = std::env::temp_dir().join(format!("cssforge_disc_{}", std::process::id()));
139        let _ = fs::remove_dir_all(&temp_dir);
140        fs::create_dir_all(&temp_dir)?;
141
142        let regular_css = temp_dir.join("style.css");
143        let sub_css = temp_dir.join("sub").join("app.css");
144        let modern_css = temp_dir.join("demo.modern.css");
145        let min_css = temp_dir.join("bundle.min.css");
146        let bak_css = temp_dir.join("legacy.bak.css");
147        let node_modules_css = temp_dir.join("node_modules").join("pkg.css");
148        let target_css = temp_dir.join("target").join("out.css");
149
150        fs::create_dir_all(temp_dir.join("sub"))?;
151        fs::create_dir_all(temp_dir.join("node_modules"))?;
152        fs::create_dir_all(temp_dir.join("target"))?;
153
154        fs::write(&regular_css, "body { margin: 0; }")?;
155        fs::write(&sub_css, ".btn { color: red; }")?;
156        fs::write(&modern_css, ".modern { color: green; }")?;
157        fs::write(&min_css, ".min{color:blue;}")?;
158        fs::write(&bak_css, ".bak { color: purple; }")?;
159        fs::write(&node_modules_css, ".lib { color: pink; }")?;
160        fs::write(&target_css, ".target { color: orange; }")?;
161
162        let discovered = discover_css_files(&temp_dir)?;
163        let file_names: Vec<String> = discovered
164            .iter()
165            .map(|p| p.file_name().unwrap().to_string_lossy().to_string())
166            .collect();
167
168        assert_eq!(file_names.len(), 2);
169        assert!(file_names.contains(&"style.css".to_string()));
170        assert!(file_names.contains(&"app.css".to_string()));
171        assert!(!file_names.contains(&"demo.modern.css".to_string()));
172        assert!(!file_names.contains(&"bundle.min.css".to_string()));
173        assert!(!file_names.contains(&"legacy.bak.css".to_string()));
174        assert!(!file_names.contains(&"pkg.css".to_string()));
175        assert!(!file_names.contains(&"out.css".to_string()));
176
177        // Explicit file targeting still works
178        let explicit_modern = discover_css_files(&modern_css)?;
179        assert_eq!(explicit_modern.len(), 1);
180        assert_eq!(explicit_modern[0], modern_css);
181
182        let _ = fs::remove_dir_all(&temp_dir);
183        Ok(())
184    }
185}