Skip to main content

broccoli_cli/
dev_config.rs

1//! Development configuration for plugin builds and file watching.
2//!
3//! Reads optional `broccoli.dev.toml` from the plugin directory. When absent,
4//! all values fall back to sensible defaults derived from `plugin.toml`.
5//!
6//! ## File format
7//!
8//! ```toml
9//! [watch]
10//! # Extra glob patterns to ignore (extends built-in ignores).
11//! # Built-in: target/, .git/, node_modules/, and [web].root from plugin.toml
12//! ignore = ["*.log", "tmp/", "coverage/"]
13//!
14//! [build]
15//! # Directory containing the frontend source (has package.json).
16//! # Default: auto-detected from [web].root parent, then web/, frontend/, or root.
17//! frontend_dir = "client"
18//!
19//! # Command to install frontend dependencies. Default: "pnpm install --ignore-workspace"
20//! frontend_install_cmd = "npm install"
21//!
22//! # Command to build the frontend (one-shot). Default: "pnpm build"
23//! frontend_build_cmd = "npm run build"
24//!
25//! # Command to start the frontend dev server (long-running, used by `watch`).
26//! # Default: "pnpm dev"
27//! frontend_dev_cmd = "npm run dev"
28//! ```
29
30use std::path::{Path, PathBuf};
31
32use serde::Deserialize;
33
34/// Filename for the dev config.
35const DEV_CONFIG_FILE: &str = "broccoli.dev.toml";
36
37/// Directories always ignored by the watcher (non-configurable).
38pub const BUILTIN_IGNORE_DIRS: &[&str] = &["target", ".git", "node_modules"];
39
40/// The parsed dev config with defaults applied.
41pub struct ResolvedDevConfig {
42    /// Extra ignore patterns from the config file.
43    pub extra_ignores: Vec<String>,
44    /// Resolved frontend source directory (absolute path), if any.
45    pub frontend_dir: Option<PathBuf>,
46    /// Frontend install command + args (one-shot). Default: ["pnpm", "install",
47    /// "--ignore-workspace"]
48    pub frontend_install_cmd: Vec<String>,
49    /// Frontend build command + args (one-shot). Default: ["pnpm", "build"]
50    pub frontend_build_cmd: Vec<String>,
51    /// Frontend dev command + args (long-running, watch mode). Default: ["pnpm", "dev"]
52    pub frontend_dev_cmd: Vec<String>,
53}
54
55/// Raw config as deserialized from `broccoli.dev.toml`.
56#[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    /// Extra glob patterns to ignore.
67    ignore: Vec<String>,
68}
69
70#[derive(Deserialize, Default)]
71#[serde(default)]
72struct RawBuildConfig {
73    /// Explicit frontend source directory (relative to plugin root).
74    frontend_dir: Option<String>,
75    /// Frontend install command (one-shot). Default: "pnpm install --ignore-workspace"
76    frontend_install_cmd: Option<String>,
77    /// Frontend build command (one-shot). Default: "pnpm build"
78    frontend_build_cmd: Option<String>,
79    /// Frontend dev command (long-running, watch mode). Default: "pnpm dev"
80    frontend_dev_cmd: Option<String>,
81}
82
83/// Resolve the dev config for a plugin directory.
84///
85/// `web_root` is the `[web].root` value from `plugin.toml` (e.g. `"frontend/dist"`),
86/// used to derive the default frontend source directory.
87pub 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
117/// Load and parse the raw config file. Returns defaults if missing or invalid.
118fn 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
135/// Resolve the frontend source directory.
136///
137/// Priority:
138/// 1. Explicit `build.frontend_dir` from config
139/// 2. Derived from `[web].root` in plugin.toml (parent of the dist dir)
140/// 3. Convention: `web/`, `frontend/`, or root (whichever has `package.json`)
141fn 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
174/// Split a command string into program + args, respecting shell quoting.
175fn shell_words(cmd: &str) -> Vec<String> {
176    shlex::split(cmd).unwrap_or_else(|| cmd.split_whitespace().map(String::from).collect())
177}
178
179/// Check whether a path component matches any of the built-in or extra ignore patterns.
180///
181/// This checks directory names in the path. Glob-style patterns (e.g. `*.log`)
182/// are matched against the filename of the changed file.
183pub 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    // Check if any path component is a built-in ignore dir
191    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    // Check if path is inside web_root (build output)
199    if web_root_relative.is_some_and(|wr| relative.starts_with(wr)) {
200        return true;
201    }
202
203    // Check extra ignore patterns against path components and filename
204    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        // Directory pattern: check path components
210        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        // Glob pattern with * — match against filename
220        if pat.contains('*') {
221            if glob_match(pat, &filename) {
222                return true;
223            }
224            continue;
225        }
226
227        // Exact match against filename or directory component
228        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
241/// Minimal glob matching (supports `*` and `?` only).
242fn 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; // trailing * matches everything
252                }
253                // Try matching rest of pattern at each position
254                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
283/// Classify a changed file as backend, frontend, or unknown.
284///
285/// Uses file extension to determine the build target. The caller uses
286/// `frontend_dir` to disambiguate files that could be either (e.g. `.json`
287/// files exist in both Rust and JS projects, but we only treat them as
288/// frontend if they're inside the frontend directory).
289pub 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        // Unambiguously backend
310        "rs" => FileKind::Backend,
311        // Unambiguously frontend
312        "tsx" | "jsx" | "css" | "scss" | "less" | "svg" | "html" => FileKind::Frontend,
313        // Ambiguous — use location to disambiguate
314        "ts" | "js" | "json" => {
315            if in_fe_dir {
316                FileKind::Frontend
317            } else {
318                // .ts/.js outside frontend dir is unknown (could be tooling scripts)
319                // .toml outside frontend dir is backend (Cargo.toml, etc.)
320                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}