cssforge_core/
discovery.rs1use 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 && let Some(name) = entry.file_name().to_str()
57 && IGNORED_DIRS.iter().any(|d| d.eq_ignore_ascii_case(name))
58 {
59 return false;
60 }
61 true
62 })
63 .build();
64
65 for entry in walker {
66 let entry = entry.with_context(|| format!("failed while walking {}", root.display()))?;
67 if !entry.file_type().is_some_and(|ft| ft.is_file()) {
68 continue;
69 }
70 let path = entry.into_path();
71 if is_eligible_css_file(&path) {
72 files.push(path);
73 }
74 }
75
76 files.sort();
77 Ok(files)
78}
79
80fn is_eligible_css_file(path: &Path) -> bool {
81 let Some(ext) = path.extension().and_then(|s| s.to_str()) else {
82 return false;
83 };
84 if !ext.eq_ignore_ascii_case("css") {
85 return false;
86 }
87
88 let file_name = path
89 .file_name()
90 .and_then(|s| s.to_str())
91 .unwrap_or_default()
92 .to_lowercase();
93
94 for suffix in IGNORED_FILE_SUFFIXES {
95 if file_name.ends_with(suffix) {
96 return false;
97 }
98 }
99
100 for comp in path.components() {
102 if let std::path::Component::Normal(os_str) = comp
103 && let Some(s) = os_str.to_str()
104 && IGNORED_DIRS.iter().any(|d| d.eq_ignore_ascii_case(s))
105 {
106 return false;
107 }
108 }
109
110 true
111}
112
113pub fn is_git_dirty(path: &Path) -> bool {
114 let cwd = if path.is_dir() {
115 path
116 } else {
117 path.parent().unwrap_or_else(|| Path::new("."))
118 };
119 Command::new("git")
120 .arg("status")
121 .arg("--porcelain")
122 .current_dir(cwd)
123 .output()
124 .ok()
125 .filter(|out| out.status.success())
126 .is_some_and(|out| !out.stdout.is_empty())
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132 use std::fs;
133
134 #[test]
135 fn test_discover_css_files_filters_generated_and_ignored() -> Result<()> {
136 let temp_dir = std::env::temp_dir().join(format!("cssforge_disc_{}", std::process::id()));
137 let _ = fs::remove_dir_all(&temp_dir);
138 fs::create_dir_all(&temp_dir)?;
139
140 let regular_css = temp_dir.join("style.css");
141 let sub_css = temp_dir.join("sub").join("app.css");
142 let modern_css = temp_dir.join("demo.modern.css");
143 let min_css = temp_dir.join("bundle.min.css");
144 let bak_css = temp_dir.join("legacy.bak.css");
145 let node_modules_css = temp_dir.join("node_modules").join("pkg.css");
146 let target_css = temp_dir.join("target").join("out.css");
147
148 fs::create_dir_all(temp_dir.join("sub"))?;
149 fs::create_dir_all(temp_dir.join("node_modules"))?;
150 fs::create_dir_all(temp_dir.join("target"))?;
151
152 fs::write(®ular_css, "body { margin: 0; }")?;
153 fs::write(&sub_css, ".btn { color: red; }")?;
154 fs::write(&modern_css, ".modern { color: green; }")?;
155 fs::write(&min_css, ".min{color:blue;}")?;
156 fs::write(&bak_css, ".bak { color: purple; }")?;
157 fs::write(&node_modules_css, ".lib { color: pink; }")?;
158 fs::write(&target_css, ".target { color: orange; }")?;
159
160 let discovered = discover_css_files(&temp_dir)?;
161 let file_names: Vec<String> = discovered
162 .iter()
163 .map(|p| p.file_name().unwrap().to_string_lossy().to_string())
164 .collect();
165
166 assert_eq!(file_names.len(), 2);
167 assert!(file_names.contains(&"style.css".to_string()));
168 assert!(file_names.contains(&"app.css".to_string()));
169 assert!(!file_names.contains(&"demo.modern.css".to_string()));
170 assert!(!file_names.contains(&"bundle.min.css".to_string()));
171 assert!(!file_names.contains(&"legacy.bak.css".to_string()));
172 assert!(!file_names.contains(&"pkg.css".to_string()));
173 assert!(!file_names.contains(&"out.css".to_string()));
174
175 let explicit_modern = discover_css_files(&modern_css)?;
177 assert_eq!(explicit_modern.len(), 1);
178 assert_eq!(explicit_modern[0], modern_css);
179
180 let _ = fs::remove_dir_all(&temp_dir);
181 Ok(())
182 }
183}