1use std::path::{Path, PathBuf};
31
32use serde::Deserialize;
33
34const DEV_CONFIG_FILE: &str = "broccoli.dev.toml";
36
37pub const BUILTIN_IGNORE_DIRS: &[&str] = &["target", ".git", "node_modules"];
39
40pub struct ResolvedDevConfig {
42 pub extra_ignores: Vec<String>,
44 pub frontend_dir: Option<PathBuf>,
46 pub frontend_install_cmd: Vec<String>,
49 pub frontend_build_cmd: Vec<String>,
51 pub frontend_dev_cmd: Vec<String>,
53}
54
55#[derive(Deserialize, Default)]
57#[serde(default)]
58struct RawDevConfig {
59 watch: RawWatchConfig,
60 build: RawBuildConfig,
61}
62
63#[derive(Deserialize, Default)]
64#[serde(default)]
65struct RawWatchConfig {
66 ignore: Vec<String>,
68}
69
70#[derive(Deserialize, Default)]
71#[serde(default)]
72struct RawBuildConfig {
73 frontend_dir: Option<String>,
75 frontend_install_cmd: Option<String>,
77 frontend_build_cmd: Option<String>,
79 frontend_dev_cmd: Option<String>,
81}
82
83pub fn resolve(plugin_dir: &Path, web_root: Option<&str>) -> ResolvedDevConfig {
88 let raw = load_raw(plugin_dir);
89
90 let frontend_dir =
91 resolve_frontend_dir(plugin_dir, web_root, raw.build.frontend_dir.as_deref());
92
93 let frontend_install_cmd = match raw.build.frontend_install_cmd {
94 Some(cmd) => shell_words(cmd.trim()),
95 None => vec!["pnpm".into(), "install".into(), "--ignore-workspace".into()],
96 };
97
98 let frontend_build_cmd = match raw.build.frontend_build_cmd {
99 Some(cmd) => shell_words(cmd.trim()),
100 None => vec!["pnpm".into(), "build".into()],
101 };
102
103 let frontend_dev_cmd = match raw.build.frontend_dev_cmd {
104 Some(cmd) => shell_words(cmd.trim()),
105 None => vec!["pnpm".into(), "dev".into()],
106 };
107
108 ResolvedDevConfig {
109 extra_ignores: raw.watch.ignore,
110 frontend_dir,
111 frontend_install_cmd,
112 frontend_build_cmd,
113 frontend_dev_cmd,
114 }
115}
116
117fn load_raw(plugin_dir: &Path) -> RawDevConfig {
119 let path = plugin_dir.join(DEV_CONFIG_FILE);
120 match std::fs::read_to_string(&path) {
121 Ok(content) => match toml::from_str(&content) {
122 Ok(config) => config,
123 Err(e) => {
124 eprintln!(
125 "Warning: failed to parse {}: {}. Using defaults.",
126 DEV_CONFIG_FILE, e
127 );
128 RawDevConfig::default()
129 }
130 },
131 Err(_) => RawDevConfig::default(),
132 }
133}
134
135fn resolve_frontend_dir(
142 plugin_dir: &Path,
143 web_root: Option<&str>,
144 explicit: Option<&str>,
145) -> Option<PathBuf> {
146 if let Some(dir) = explicit {
147 return Some(plugin_dir.join(dir));
148 }
149
150 if let Some(root) = web_root {
151 let root_path = Path::new(root);
152 if let Some(parent) = root_path.parent().filter(|p| !p.as_os_str().is_empty()) {
153 let candidate = plugin_dir.join(parent);
154 if candidate.join("package.json").exists() {
155 return Some(candidate);
156 }
157 }
158 }
159
160 for subdir in &["web", "frontend"] {
161 let candidate = plugin_dir.join(subdir);
162 if candidate.join("package.json").exists() {
163 return Some(candidate);
164 }
165 }
166
167 if plugin_dir.join("package.json").exists() {
168 return Some(plugin_dir.to_path_buf());
169 }
170
171 None
172}
173
174fn shell_words(cmd: &str) -> Vec<String> {
176 shlex::split(cmd).unwrap_or_else(|| cmd.split_whitespace().map(String::from).collect())
177}
178
179pub fn should_ignore(
184 relative: &Path,
185 extra_ignores: &[String],
186 web_root_relative: Option<&Path>,
187) -> bool {
188 let components: Vec<_> = relative.components().collect();
189
190 for comp in &components {
192 let s = comp.as_os_str().to_string_lossy();
193 if BUILTIN_IGNORE_DIRS.contains(&s.as_ref()) {
194 return true;
195 }
196 }
197
198 if web_root_relative.is_some_and(|wr| relative.starts_with(wr)) {
200 return true;
201 }
202
203 let filename = relative.file_name().unwrap_or_default().to_string_lossy();
205
206 for pattern in extra_ignores {
207 let pat = pattern.trim_end_matches('/');
208
209 if pattern.ends_with('/') {
211 for comp in &components {
212 if comp.as_os_str().to_string_lossy() == pat {
213 return true;
214 }
215 }
216 continue;
217 }
218
219 if pat.contains('*') {
221 if glob_match(pat, &filename) {
222 return true;
223 }
224 continue;
225 }
226
227 if filename == pat {
229 return true;
230 }
231 for comp in &components {
232 if comp.as_os_str().to_string_lossy() == pat {
233 return true;
234 }
235 }
236 }
237
238 false
239}
240
241fn glob_match(pattern: &str, text: &str) -> bool {
243 let mut p = pattern.chars().peekable();
244 let mut t = text.chars().peekable();
245
246 while p.peek().is_some() || t.peek().is_some() {
247 match p.peek() {
248 Some('*') => {
249 p.next();
250 if p.peek().is_none() {
251 return true; }
253 let remaining: String = p.collect();
255 let text_remaining: String = t.collect();
256 for i in 0..=text_remaining.len() {
257 if glob_match(&remaining, &text_remaining[i..]) {
258 return true;
259 }
260 }
261 return false;
262 }
263 Some('?') => {
264 p.next();
265 if t.next().is_none() {
266 return false;
267 }
268 }
269 Some(&pc) => {
270 p.next();
271 match t.next() {
272 Some(tc) if tc == pc => {}
273 _ => return false,
274 }
275 }
276 None => return false,
277 }
278 }
279
280 true
281}
282
283pub enum FileKind {
290 Backend,
291 Frontend,
292 PluginManifest,
293 Unknown,
294}
295
296pub fn classify_file(path: &Path, plugin_dir: &Path, frontend_dir: Option<&Path>) -> FileKind {
297 let relative = path.strip_prefix(plugin_dir).unwrap_or(path);
298 let filename = relative.file_name().unwrap_or_default().to_string_lossy();
299
300 if filename == "plugin.toml" {
301 return FileKind::PluginManifest;
302 }
303
304 let ext = path.extension().unwrap_or_default().to_string_lossy();
305
306 let in_fe_dir = frontend_dir.is_some_and(|fd| path.starts_with(fd));
307
308 match ext.as_ref() {
309 "rs" => FileKind::Backend,
311 "tsx" | "jsx" | "css" | "scss" | "less" | "svg" | "html" => FileKind::Frontend,
313 "ts" | "js" | "json" => {
315 if in_fe_dir {
316 FileKind::Frontend
317 } else {
318 FileKind::Unknown
321 }
322 }
323 "toml" => {
324 if in_fe_dir {
325 FileKind::Unknown
326 } else {
327 FileKind::Backend
328 }
329 }
330 _ => FileKind::Unknown,
331 }
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337
338 #[test]
339 fn test_glob_match() {
340 assert!(glob_match("*.log", "test.log"));
341 assert!(glob_match("*.log", "app.log"));
342 assert!(!glob_match("*.log", "test.txt"));
343 assert!(glob_match("*.rs", "lib.rs"));
344 assert!(glob_match("test_*", "test_foo"));
345 assert!(!glob_match("test_*", "prod_foo"));
346 assert!(glob_match("*", "anything"));
347 assert!(glob_match("?.rs", "a.rs"));
348 assert!(!glob_match("?.rs", "ab.rs"));
349 }
350
351 #[test]
352 fn test_should_ignore_builtin() {
353 assert!(should_ignore(Path::new("target/debug/foo.rs"), &[], None));
354 assert!(should_ignore(Path::new(".git/config"), &[], None));
355 assert!(should_ignore(
356 Path::new("node_modules/pkg/index.js"),
357 &[],
358 None
359 ));
360 assert!(!should_ignore(Path::new("src/lib.rs"), &[], None));
361 }
362
363 #[test]
364 fn test_should_ignore_web_root() {
365 let wr = Path::new("frontend/dist");
366 assert!(should_ignore(
367 Path::new("frontend/dist/index.js"),
368 &[],
369 Some(wr)
370 ));
371 assert!(!should_ignore(
372 Path::new("frontend/src/App.tsx"),
373 &[],
374 Some(wr)
375 ));
376 }
377
378 #[test]
379 fn test_should_ignore_extra_patterns() {
380 let extras = vec!["*.log".to_string(), "tmp/".to_string()];
381 assert!(should_ignore(Path::new("app.log"), &extras, None));
382 assert!(should_ignore(Path::new("tmp/cache"), &extras, None));
383 assert!(!should_ignore(Path::new("src/main.rs"), &extras, None));
384 }
385
386 #[test]
387 fn test_shell_words() {
388 assert_eq!(shell_words("pnpm build"), vec!["pnpm", "build"]);
389 assert_eq!(shell_words("npm run build"), vec!["npm", "run", "build"]);
390 assert_eq!(shell_words("bun build"), vec!["bun", "build"]);
391 assert_eq!(
392 shell_words(r#"sh -c "npm run build""#),
393 vec!["sh", "-c", "npm run build"]
394 );
395 }
396}