rs_web/
config.rs

1//! Configuration loader for rs-web
2//!
3//! This module provides Lua-based configuration.
4//! Config files are written in Lua and can include computed values and custom filters.
5
6use anyhow::{Context, Result};
7use mlua::{Function, Lua, LuaSerdeExt, Table, Value};
8use serde::Deserialize;
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11
12use crate::tracker::{BuildTracker, SharedTracker};
13
14/// Configuration data structure (deserializable from Lua)
15#[derive(Debug, Clone)]
16pub struct ConfigData {
17    pub site: SiteConfig,
18    pub seo: SeoConfig,
19    pub build: BuildConfig,
20    pub paths: PathsConfig,
21}
22
23/// Main configuration structure with embedded Lua state
24pub struct Config {
25    // Configuration data
26    pub data: ConfigData,
27
28    // Lua runtime state
29    lua: Lua,
30    before_build: Option<mlua::RegistryKey>,
31    after_build: Option<mlua::RegistryKey>,
32
33    // Data-driven page generation
34    data_fn: Option<mlua::RegistryKey>,
35    pages_fn: Option<mlua::RegistryKey>,
36    update_data_fn: Option<mlua::RegistryKey>,
37
38    // Build dependency tracker
39    tracker: SharedTracker,
40}
41
42// Provide convenient access to data fields
43impl std::ops::Deref for Config {
44    type Target = ConfigData;
45    fn deref(&self) -> &Self::Target {
46        &self.data
47    }
48}
49
50impl std::ops::DerefMut for Config {
51    fn deref_mut(&mut self) -> &mut Self::Target {
52        &mut self.data
53    }
54}
55
56#[derive(Debug, Deserialize, Clone)]
57pub struct SiteConfig {
58    pub title: String,
59    pub description: String,
60    pub base_url: String,
61    pub author: String,
62}
63
64#[derive(Debug, Deserialize, Clone, Default)]
65pub struct SeoConfig {
66    pub twitter_handle: Option<String>,
67    pub default_og_image: Option<String>,
68}
69
70#[derive(Debug, Deserialize, Clone)]
71pub struct BuildConfig {
72    pub output_dir: String,
73}
74
75#[derive(Debug, Deserialize, Clone)]
76pub struct PathsConfig {
77    #[serde(default = "default_templates_dir")]
78    pub templates: String,
79}
80
81impl Default for PathsConfig {
82    fn default() -> Self {
83        Self {
84            templates: default_templates_dir(),
85        }
86    }
87}
88
89fn default_templates_dir() -> String {
90    "templates".to_string()
91}
92
93/// Page definition
94#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
95pub struct PageDef {
96    /// URL path (e.g., "/blog/hello/") - must end with /
97    pub path: String,
98    /// Template file to use (e.g., "post.html"). If not set, outputs html directly.
99    #[serde(default)]
100    pub template: Option<String>,
101    /// Page title (for `<title>` and ctx.page.title)
102    #[serde(default)]
103    pub title: Option<String>,
104    /// Meta description
105    #[serde(default)]
106    pub description: Option<String>,
107    /// OG image path
108    #[serde(default)]
109    pub image: Option<String>,
110    /// Raw markdown content to render (becomes ctx.page.content as HTML)
111    #[serde(default)]
112    pub content: Option<String>,
113    /// Pre-rendered HTML (skips markdown processing)
114    #[serde(default)]
115    pub html: Option<String>,
116    /// Page-specific data (available as ctx.page.data.*)
117    #[serde(default)]
118    pub data: Option<serde_json::Value>,
119}
120
121impl Config {
122    /// Create a Config with default Lua state (for testing)
123    #[cfg(test)]
124    pub fn from_data(data: ConfigData) -> Self {
125        let lua = Lua::new();
126        Self {
127            data,
128            lua,
129            before_build: None,
130            after_build: None,
131            data_fn: None,
132            pages_fn: None,
133            update_data_fn: None,
134            tracker: Arc::new(BuildTracker::disabled()),
135        }
136    }
137
138    /// Load config from a Lua file
139    pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
140        Self::load_with_tracker(path, Arc::new(BuildTracker::new()))
141    }
142
143    /// Load config with a custom tracker
144    pub fn load_with_tracker<P: AsRef<Path>>(path: P, tracker: SharedTracker) -> Result<Self> {
145        let path = path.as_ref();
146
147        // Determine actual config file path
148        let config_path = if path.is_dir() {
149            let lua_path = path.join("config.lua");
150            if lua_path.exists() {
151                lua_path
152            } else {
153                anyhow::bail!("No config.lua found in {:?}", path);
154            }
155        } else {
156            path.to_path_buf()
157        };
158
159        let lua = Lua::new();
160
161        // Get project root (directory containing config file)
162        let project_root = config_path
163            .parent()
164            .map(|p| {
165                if p.as_os_str().is_empty() {
166                    PathBuf::from(".")
167                } else {
168                    p.to_path_buf()
169                }
170            })
171            .unwrap_or_else(|| PathBuf::from("."));
172        let project_root = project_root
173            .canonicalize()
174            .unwrap_or_else(|_| project_root.clone());
175
176        // First pass: register functions without sandbox to load config
177        crate::lua::register(&lua, &project_root, false, tracker.clone(), None)
178            .map_err(|e| anyhow::anyhow!("Failed to register Lua functions: {}", e))?;
179
180        // Load and execute the config file
181        let content = std::fs::read_to_string(&config_path)
182            .with_context(|| format!("Failed to read config file: {:?}", config_path))?;
183
184        let config_table: Table = lua
185            .load(&content)
186            .set_name(config_path.to_string_lossy())
187            .eval()
188            .map_err(|e| {
189                anyhow::anyhow!("Failed to execute config file {:?}: {}", config_path, e)
190            })?;
191
192        // Check sandbox setting (default: true)
193        let sandbox = config_table
194            .get::<Table>("lua")
195            .ok()
196            .and_then(|t| t.get::<bool>("sandbox").ok())
197            .unwrap_or(true);
198
199        // Re-register functions with proper sandbox setting if sandbox is enabled
200        if sandbox {
201            crate::lua::register(&lua, &project_root, true, tracker.clone(), None)
202                .map_err(|e| anyhow::anyhow!("Failed to register Lua functions: {}", e))?;
203        }
204
205        // Parse the config table
206        let data = parse_config(&lua, &config_table)
207            .map_err(|e| anyhow::anyhow!("Failed to parse config: {}", e))?;
208
209        // Extract data-driven functions
210        let data_fn: Option<mlua::RegistryKey> = config_table
211            .get::<Function>("data")
212            .ok()
213            .map(|f| lua.create_registry_value(f))
214            .transpose()
215            .map_err(|e| anyhow::anyhow!("Failed to store data function: {}", e))?;
216
217        let pages_fn: Option<mlua::RegistryKey> = config_table
218            .get::<Function>("pages")
219            .ok()
220            .map(|f| lua.create_registry_value(f))
221            .transpose()
222            .map_err(|e| anyhow::anyhow!("Failed to store pages function: {}", e))?;
223
224        let update_data_fn: Option<mlua::RegistryKey> = config_table
225            .get::<Function>("update_data")
226            .ok()
227            .map(|f| lua.create_registry_value(f))
228            .transpose()
229            .map_err(|e| anyhow::anyhow!("Failed to store update_data function: {}", e))?;
230
231        // Extract hooks
232        let hooks: Option<Table> = config_table.get("hooks").ok();
233        let before_build = if let Some(ref h) = hooks {
234            h.get::<Function>("before_build")
235                .ok()
236                .map(|f| lua.create_registry_value(f))
237                .transpose()
238                .map_err(|e| anyhow::anyhow!("Failed to store before_build hook: {}", e))?
239        } else {
240            None
241        };
242        let after_build = if let Some(ref h) = hooks {
243            h.get::<Function>("after_build")
244                .ok()
245                .map(|f| lua.create_registry_value(f))
246                .transpose()
247                .map_err(|e| anyhow::anyhow!("Failed to store after_build hook: {}", e))?
248        } else {
249            None
250        };
251
252        Ok(Config {
253            data,
254            lua,
255            before_build,
256            after_build,
257            data_fn,
258            pages_fn,
259            update_data_fn,
260            tracker,
261        })
262    }
263
264    /// Get a reference to the build tracker
265    pub fn tracker(&self) -> &SharedTracker {
266        &self.tracker
267    }
268
269    /// Call before_build hook with ctx
270    pub fn call_before_build(&self) -> Result<()> {
271        if let Some(ref key) = self.before_build {
272            let func: Function = self
273                .lua
274                .registry_value(key)
275                .map_err(|e| anyhow::anyhow!("Failed to get before_build: {}", e))?;
276            let ctx = self.create_ctx(None)?;
277            func.call::<()>(ctx)
278                .map_err(|e| anyhow::anyhow!("before_build hook failed: {}", e))?;
279        }
280        Ok(())
281    }
282
283    /// Call after_build hook with ctx
284    pub fn call_after_build(&self) -> Result<()> {
285        if let Some(ref key) = self.after_build {
286            let func: Function = self
287                .lua
288                .registry_value(key)
289                .map_err(|e| anyhow::anyhow!("Failed to get after_build: {}", e))?;
290            let ctx = self.create_ctx(None)?;
291            func.call::<()>(ctx)
292                .map_err(|e| anyhow::anyhow!("after_build hook failed: {}", e))?;
293        }
294        Ok(())
295    }
296
297    /// Create context table for Lua functions
298    fn create_ctx(&self, data: Option<&serde_json::Value>) -> Result<Value> {
299        let ctx = self
300            .lua
301            .create_table()
302            .map_err(|e| anyhow::anyhow!("Failed to create ctx: {}", e))?;
303
304        ctx.set("output_dir", self.data.build.output_dir.as_str())
305            .map_err(|e| anyhow::anyhow!("Failed to set output_dir: {}", e))?;
306        ctx.set("base_url", self.data.site.base_url.as_str())
307            .map_err(|e| anyhow::anyhow!("Failed to set base_url: {}", e))?;
308
309        if let Some(data) = data {
310            let data_value: Value = self
311                .lua
312                .to_value(data)
313                .map_err(|e| anyhow::anyhow!("Failed to convert data to Lua: {}", e))?;
314            ctx.set("data", data_value)
315                .map_err(|e| anyhow::anyhow!("Failed to set data: {}", e))?;
316        }
317
318        Ok(Value::Table(ctx))
319    }
320
321    /// Call the data(ctx) function to get global template data
322    pub fn call_data(&self) -> Result<serde_json::Value> {
323        let key = match &self.data_fn {
324            Some(k) => k,
325            _ => return Ok(serde_json::Value::Object(serde_json::Map::new())),
326        };
327
328        let func: Function = self
329            .lua
330            .registry_value(key)
331            .map_err(|e| anyhow::anyhow!("Failed to get data function: {}", e))?;
332
333        let ctx = self.create_ctx(None)?;
334
335        let result: Value = func
336            .call(ctx)
337            .map_err(|e| anyhow::anyhow!("Failed to call data(): {}", e))?;
338        let json_value: serde_json::Value = self
339            .lua
340            .from_value(result)
341            .map_err(|e| anyhow::anyhow!("Failed to convert data() result: {}", e))?;
342
343        Ok(json_value)
344    }
345
346    /// Call the pages(ctx) function to get page definitions
347    /// ctx.data contains the result from data()
348    pub fn call_pages(&self, global_data: &serde_json::Value) -> Result<Vec<PageDef>> {
349        let key = match &self.pages_fn {
350            Some(k) => k,
351            _ => return Ok(Vec::new()),
352        };
353
354        let func: Function = self
355            .lua
356            .registry_value(key)
357            .map_err(|e| anyhow::anyhow!("Failed to get pages function: {}", e))?;
358
359        let ctx = self.create_ctx(Some(global_data))?;
360
361        let result: Value = func
362            .call(ctx)
363            .map_err(|e| anyhow::anyhow!("Failed to call pages(): {}", e))?;
364        let pages: Vec<PageDef> = self
365            .lua
366            .from_value(result)
367            .map_err(|e| anyhow::anyhow!("Failed to convert pages() result: {}", e))?;
368
369        Ok(pages)
370    }
371
372    /// Check if update_data function is defined
373    pub fn has_update_data(&self) -> bool {
374        self.update_data_fn.is_some()
375    }
376
377    /// Call update_data(ctx) for incremental updates
378    /// ctx.data = cached data, ctx.changed_paths = list of changed paths
379    pub fn call_update_data(
380        &self,
381        cached_data: &serde_json::Value,
382        changed_paths: &[std::path::PathBuf],
383    ) -> Result<serde_json::Value> {
384        let key = match &self.update_data_fn {
385            Some(k) => k,
386            None => return Err(anyhow::anyhow!("update_data function not defined")),
387        };
388
389        let func: Function = self
390            .lua
391            .registry_value(key)
392            .map_err(|e| anyhow::anyhow!("Failed to get update_data function: {}", e))?;
393
394        // Create ctx with cached data and changed paths
395        let ctx = self
396            .lua
397            .create_table()
398            .map_err(|e| anyhow::anyhow!("Failed to create ctx: {}", e))?;
399
400        ctx.set("output_dir", self.data.build.output_dir.as_str())
401            .map_err(|e| anyhow::anyhow!("Failed to set output_dir: {}", e))?;
402        ctx.set("base_url", self.data.site.base_url.as_str())
403            .map_err(|e| anyhow::anyhow!("Failed to set base_url: {}", e))?;
404
405        // Set cached data as ctx.data
406        let cached: Value = self
407            .lua
408            .to_value(cached_data)
409            .map_err(|e| anyhow::anyhow!("Failed to convert cached data to Lua: {}", e))?;
410        ctx.set("data", cached)
411            .map_err(|e| anyhow::anyhow!("Failed to set data: {}", e))?;
412
413        // Set changed paths as ctx.changed_paths
414        let paths_table = self.lua.create_table()?;
415        for (i, path) in changed_paths.iter().enumerate() {
416            paths_table.set(i + 1, path.to_string_lossy().to_string())?;
417        }
418        ctx.set("changed_paths", paths_table)
419            .map_err(|e| anyhow::anyhow!("Failed to set changed_paths: {}", e))?;
420
421        let result: Value = func
422            .call(Value::Table(ctx))
423            .map_err(|e| anyhow::anyhow!("Failed to call update_data(): {}", e))?;
424
425        let json_value: serde_json::Value = self
426            .lua
427            .from_value(result)
428            .map_err(|e| anyhow::anyhow!("Failed to convert update_data() result: {}", e))?;
429
430        Ok(json_value)
431    }
432}
433/// Parse the config table into ConfigData
434fn parse_config(_lua: &Lua, table: &Table) -> mlua::Result<ConfigData> {
435    let site = parse_site_config(table)?;
436    let seo = parse_seo_config(table)?;
437    let build = parse_build_config(table)?;
438    let paths = parse_paths_config(table)?;
439
440    Ok(ConfigData {
441        site,
442        seo,
443        build,
444        paths,
445    })
446}
447
448fn parse_site_config(table: &Table) -> mlua::Result<SiteConfig> {
449    let site: Table = table.get("site")?;
450
451    Ok(SiteConfig {
452        title: site.get("title").unwrap_or_default(),
453        description: site.get("description").unwrap_or_default(),
454        base_url: site.get("base_url").unwrap_or_default(),
455        author: site.get("author").unwrap_or_default(),
456    })
457}
458
459fn parse_seo_config(table: &Table) -> mlua::Result<SeoConfig> {
460    let seo: Table = table.get("seo").unwrap_or_else(|_| table.clone());
461
462    Ok(SeoConfig {
463        twitter_handle: seo.get("twitter_handle").ok(),
464        default_og_image: seo.get("default_og_image").ok(),
465    })
466}
467
468fn parse_build_config(table: &Table) -> mlua::Result<BuildConfig> {
469    let build: Table = table.get("build").unwrap_or_else(|_| table.clone());
470
471    Ok(BuildConfig {
472        output_dir: build
473            .get("output_dir")
474            .unwrap_or_else(|_| "dist".to_string()),
475    })
476}
477
478fn parse_paths_config(table: &Table) -> mlua::Result<PathsConfig> {
479    let paths: Table = table.get("paths").unwrap_or_else(|_| table.clone());
480
481    Ok(PathsConfig {
482        templates: paths
483            .get("templates")
484            .unwrap_or_else(|_| "templates".to_string()),
485    })
486}
487
488#[cfg(test)]
489mod tests {
490    use super::*;
491
492    fn test_project_root() -> PathBuf {
493        std::env::current_dir().expect("failed to get current directory")
494    }
495
496    #[test]
497    fn test_minimal_lua_config() {
498        let lua = Lua::new();
499        let root = test_project_root();
500        crate::lua::register(&lua, &root, false, Arc::new(BuildTracker::disabled()), None)
501            .expect("failed to register Lua functions");
502
503        let config_str = r#"
504            return {
505                site = {
506                    title = "Test Site",
507                    description = "A test site",
508                    base_url = "https://example.com",
509                    author = "Test Author",
510                },
511                build = {
512                    output_dir = "dist",
513                },
514            }
515        "#;
516
517        let table: Table = lua
518            .load(config_str)
519            .eval()
520            .expect("failed to load config string");
521        let config = parse_config(&lua, &table).expect("failed to parse config");
522
523        assert_eq!(config.site.title, "Test Site");
524        assert_eq!(config.site.base_url, "https://example.com");
525        assert_eq!(config.build.output_dir, "dist");
526    }
527
528    #[test]
529    fn test_lua_helper_functions() {
530        let lua = Lua::new();
531        let root = test_project_root();
532        crate::lua::register(&lua, &root, false, Arc::new(BuildTracker::disabled()), None)
533            .expect("failed to register Lua functions");
534
535        // Test file_exists
536        let result: bool = lua
537            .load("return rs.file_exists('Cargo.toml')")
538            .eval()
539            .expect("failed to eval file_exists for Cargo.toml");
540        assert!(result);
541
542        let result: bool = lua
543            .load("return rs.file_exists('nonexistent.file')")
544            .eval()
545            .expect("failed to eval file_exists for nonexistent.file");
546        assert!(!result);
547    }
548
549    #[test]
550    fn test_sandbox_blocks_outside_access() {
551        let lua = Lua::new();
552        let root = test_project_root();
553        crate::lua::register(&lua, &root, true, Arc::new(BuildTracker::disabled()), None)
554            .expect("failed to register Lua functions");
555
556        // Trying to access /etc/passwd should fail with sandbox enabled
557        let result = lua
558            .load("return rs.read_file('/etc/passwd')")
559            .eval::<Value>();
560        assert!(
561            result.is_err(),
562            "sandbox should block access to /etc/passwd"
563        );
564
565        // Trying to access parent directory should fail
566        let result = lua
567            .load("return rs.read_file('../some_file')")
568            .eval::<Value>();
569        assert!(
570            result.is_err(),
571            "sandbox should block access to parent directory"
572        );
573    }
574
575    #[test]
576    fn test_sandbox_allows_project_access() {
577        let lua = Lua::new();
578        let root = test_project_root();
579        crate::lua::register(&lua, &root, true, Arc::new(BuildTracker::disabled()), None)
580            .expect("failed to register Lua functions");
581
582        // Accessing files within project should work
583        let result: bool = lua
584            .load("return rs.file_exists('Cargo.toml')")
585            .eval()
586            .expect("sandbox should allow file_exists within project");
587        assert!(result);
588
589        // Reading files within project should work
590        let result = lua
591            .load("return rs.read_file('Cargo.toml')")
592            .eval::<Value>();
593        assert!(
594            result.is_ok(),
595            "sandbox should allow reading files within project"
596        );
597    }
598}