Skip to main content

cssforge_core/
discovery.rs

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