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    /// Whether to minify HTML output (default: true)
120    #[serde(default = "default_minify")]
121    pub minify: bool,
122}
123
124fn default_minify() -> bool {
125    true
126}
127
128impl Config {
129    /// Create a Config with default Lua state (for testing)
130    #[cfg(test)]
131    pub fn from_data(data: ConfigData) -> Self {
132        let lua = Lua::new();
133        Self {
134            data,
135            lua,
136            before_build: None,
137            after_build: None,
138            data_fn: None,
139            pages_fn: None,
140            update_data_fn: None,
141            tracker: Arc::new(BuildTracker::disabled()),
142        }
143    }
144
145    /// Load config from a Lua file
146    pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
147        Self::load_with_tracker(path, Arc::new(BuildTracker::new()))
148    }
149
150    /// Load config with a custom tracker
151    pub fn load_with_tracker<P: AsRef<Path>>(path: P, tracker: SharedTracker) -> Result<Self> {
152        let path = path.as_ref();
153
154        // Determine actual config file path
155        let config_path = if path.is_dir() {
156            let lua_path = path.join("config.lua");
157            if lua_path.exists() {
158                lua_path
159            } else {
160                anyhow::bail!("No config.lua found in {:?}", path);
161            }
162        } else {
163            path.to_path_buf()
164        };
165
166        let lua = Lua::new();
167
168        // Get project root (directory containing config file)
169        let project_root = config_path
170            .parent()
171            .map(|p| {
172                if p.as_os_str().is_empty() {
173                    PathBuf::from(".")
174                } else {
175                    p.to_path_buf()
176                }
177            })
178            .unwrap_or_else(|| PathBuf::from("."));
179        let project_root = project_root
180            .canonicalize()
181            .unwrap_or_else(|_| project_root.clone());
182
183        // First pass: register functions without sandbox to load config
184        crate::lua::register(&lua, &project_root, false, tracker.clone(), None)
185            .map_err(|e| anyhow::anyhow!("Failed to register Lua functions: {}", e))?;
186
187        // Load and execute the config file
188        let content = std::fs::read_to_string(&config_path)
189            .with_context(|| format!("Failed to read config file: {:?}", config_path))?;
190
191        let config_table: Table = lua
192            .load(&content)
193            .set_name(config_path.to_string_lossy())
194            .eval()
195            .map_err(|e| {
196                anyhow::anyhow!("Failed to execute config file {:?}: {}", config_path, e)
197            })?;
198
199        // Check sandbox setting (default: true)
200        let sandbox = config_table
201            .get::<Table>("lua")
202            .ok()
203            .and_then(|t| t.get::<bool>("sandbox").ok())
204            .unwrap_or(true);
205
206        // Re-register functions with proper sandbox setting if sandbox is enabled
207        if sandbox {
208            crate::lua::register(&lua, &project_root, true, tracker.clone(), None)
209                .map_err(|e| anyhow::anyhow!("Failed to register Lua functions: {}", e))?;
210        }
211
212        // Parse the config table
213        let data = parse_config(&lua, &config_table)
214            .map_err(|e| anyhow::anyhow!("Failed to parse config: {}", e))?;
215
216        // Extract data-driven functions
217        let data_fn: Option<mlua::RegistryKey> = config_table
218            .get::<Function>("data")
219            .ok()
220            .map(|f| lua.create_registry_value(f))
221            .transpose()
222            .map_err(|e| anyhow::anyhow!("Failed to store data function: {}", e))?;
223
224        let pages_fn: Option<mlua::RegistryKey> = config_table
225            .get::<Function>("pages")
226            .ok()
227            .map(|f| lua.create_registry_value(f))
228            .transpose()
229            .map_err(|e| anyhow::anyhow!("Failed to store pages function: {}", e))?;
230
231        let update_data_fn: Option<mlua::RegistryKey> = config_table
232            .get::<Function>("update_data")
233            .ok()
234            .map(|f| lua.create_registry_value(f))
235            .transpose()
236            .map_err(|e| anyhow::anyhow!("Failed to store update_data function: {}", e))?;
237
238        // Extract hooks
239        let hooks: Option<Table> = config_table.get("hooks").ok();
240        let before_build = if let Some(ref h) = hooks {
241            h.get::<Function>("before_build")
242                .ok()
243                .map(|f| lua.create_registry_value(f))
244                .transpose()
245                .map_err(|e| anyhow::anyhow!("Failed to store before_build hook: {}", e))?
246        } else {
247            None
248        };
249        let after_build = if let Some(ref h) = hooks {
250            h.get::<Function>("after_build")
251                .ok()
252                .map(|f| lua.create_registry_value(f))
253                .transpose()
254                .map_err(|e| anyhow::anyhow!("Failed to store after_build hook: {}", e))?
255        } else {
256            None
257        };
258
259        Ok(Config {
260            data,
261            lua,
262            before_build,
263            after_build,
264            data_fn,
265            pages_fn,
266            update_data_fn,
267            tracker,
268        })
269    }
270
271    /// Get a reference to the build tracker
272    pub fn tracker(&self) -> &SharedTracker {
273        &self.tracker
274    }
275
276    /// Call before_build hook with ctx
277    pub fn call_before_build(&self) -> Result<()> {
278        if let Some(ref key) = self.before_build {
279            let func: Function = self
280                .lua
281                .registry_value(key)
282                .map_err(|e| anyhow::anyhow!("Failed to get before_build: {}", e))?;
283            let ctx = self.create_ctx(None)?;
284            func.call::<()>(ctx)
285                .map_err(|e| anyhow::anyhow!("before_build hook failed: {}", e))?;
286        }
287        Ok(())
288    }
289
290    /// Call after_build hook with ctx
291    pub fn call_after_build(&self) -> Result<()> {
292        if let Some(ref key) = self.after_build {
293            let func: Function = self
294                .lua
295                .registry_value(key)
296                .map_err(|e| anyhow::anyhow!("Failed to get after_build: {}", e))?;
297            let ctx = self.create_ctx(None)?;
298            func.call::<()>(ctx)
299                .map_err(|e| anyhow::anyhow!("after_build hook failed: {}", e))?;
300        }
301        Ok(())
302    }
303
304    /// Create context table for Lua functions
305    fn create_ctx(&self, data: Option<&serde_json::Value>) -> Result<Value> {
306        let ctx = self
307            .lua
308            .create_table()
309            .map_err(|e| anyhow::anyhow!("Failed to create ctx: {}", e))?;
310
311        ctx.set("output_dir", self.data.build.output_dir.as_str())
312            .map_err(|e| anyhow::anyhow!("Failed to set output_dir: {}", e))?;
313        ctx.set("base_url", self.data.site.base_url.as_str())
314            .map_err(|e| anyhow::anyhow!("Failed to set base_url: {}", e))?;
315
316        if let Some(data) = data {
317            let data_value: Value = self
318                .lua
319                .to_value(data)
320                .map_err(|e| anyhow::anyhow!("Failed to convert data to Lua: {}", e))?;
321            ctx.set("data", data_value)
322                .map_err(|e| anyhow::anyhow!("Failed to set data: {}", e))?;
323        }
324
325        Ok(Value::Table(ctx))
326    }
327
328    /// Call the data(ctx) function to get global template data
329    pub fn call_data(&self) -> Result<serde_json::Value> {
330        let key = match &self.data_fn {
331            Some(k) => k,
332            _ => return Ok(serde_json::Value::Object(serde_json::Map::new())),
333        };
334
335        let func: Function = self
336            .lua
337            .registry_value(key)
338            .map_err(|e| anyhow::anyhow!("Failed to get data function: {}", e))?;
339
340        let ctx = self.create_ctx(None)?;
341
342        let result: Value = func
343            .call(ctx)
344            .map_err(|e| anyhow::anyhow!("Failed to call data(): {}", e))?;
345        let json_value: serde_json::Value = self
346            .lua
347            .from_value(result)
348            .map_err(|e| anyhow::anyhow!("Failed to convert data() result: {}", e))?;
349
350        Ok(json_value)
351    }
352
353    /// Call the pages(ctx) function to get page definitions
354    /// ctx.data contains the result from data()
355    pub fn call_pages(&self, global_data: &serde_json::Value) -> Result<Vec<PageDef>> {
356        let key = match &self.pages_fn {
357            Some(k) => k,
358            _ => return Ok(Vec::new()),
359        };
360
361        let func: Function = self
362            .lua
363            .registry_value(key)
364            .map_err(|e| anyhow::anyhow!("Failed to get pages function: {}", e))?;
365
366        let ctx = self.create_ctx(Some(global_data))?;
367
368        let result: Value = func
369            .call(ctx)
370            .map_err(|e| anyhow::anyhow!("Failed to call pages(): {}", e))?;
371        let pages: Vec<PageDef> = self
372            .lua
373            .from_value(result)
374            .map_err(|e| anyhow::anyhow!("Failed to convert pages() result: {}", e))?;
375
376        Ok(pages)
377    }
378
379    /// Check if update_data function is defined
380    pub fn has_update_data(&self) -> bool {
381        self.update_data_fn.is_some()
382    }
383
384    /// Call update_data(ctx) for incremental updates
385    /// ctx.data = cached data, ctx.changed_paths = list of changed paths
386    pub fn call_update_data(
387        &self,
388        cached_data: &serde_json::Value,
389        changed_paths: &[std::path::PathBuf],
390    ) -> Result<serde_json::Value> {
391        let key = match &self.update_data_fn {
392            Some(k) => k,
393            None => return Err(anyhow::anyhow!("update_data function not defined")),
394        };
395
396        let func: Function = self
397            .lua
398            .registry_value(key)
399            .map_err(|e| anyhow::anyhow!("Failed to get update_data function: {}", e))?;
400
401        // Create ctx with cached data and changed paths
402        let ctx = self
403            .lua
404            .create_table()
405            .map_err(|e| anyhow::anyhow!("Failed to create ctx: {}", e))?;
406
407        ctx.set("output_dir", self.data.build.output_dir.as_str())
408            .map_err(|e| anyhow::anyhow!("Failed to set output_dir: {}", e))?;
409        ctx.set("base_url", self.data.site.base_url.as_str())
410            .map_err(|e| anyhow::anyhow!("Failed to set base_url: {}", e))?;
411
412        // Set cached data as ctx.data
413        let cached: Value = self
414            .lua
415            .to_value(cached_data)
416            .map_err(|e| anyhow::anyhow!("Failed to convert cached data to Lua: {}", e))?;
417        ctx.set("data", cached)
418            .map_err(|e| anyhow::anyhow!("Failed to set data: {}", e))?;
419
420        // Set changed paths as ctx.changed_paths
421        let paths_table = self.lua.create_table()?;
422        for (i, path) in changed_paths.iter().enumerate() {
423            paths_table.set(i + 1, path.to_string_lossy().to_string())?;
424        }
425        ctx.set("changed_paths", paths_table)
426            .map_err(|e| anyhow::anyhow!("Failed to set changed_paths: {}", e))?;
427
428        let result: Value = func
429            .call(Value::Table(ctx))
430            .map_err(|e| anyhow::anyhow!("Failed to call update_data(): {}", e))?;
431
432        let json_value: serde_json::Value = self
433            .lua
434            .from_value(result)
435            .map_err(|e| anyhow::anyhow!("Failed to convert update_data() result: {}", e))?;
436
437        Ok(json_value)
438    }
439}
440/// Parse the config table into ConfigData
441fn parse_config(_lua: &Lua, table: &Table) -> mlua::Result<ConfigData> {
442    let site = parse_site_config(table)?;
443    let seo = parse_seo_config(table)?;
444    let build = parse_build_config(table)?;
445    let paths = parse_paths_config(table)?;
446
447    Ok(ConfigData {
448        site,
449        seo,
450        build,
451        paths,
452    })
453}
454
455fn parse_site_config(table: &Table) -> mlua::Result<SiteConfig> {
456    let site: Table = table.get("site")?;
457
458    Ok(SiteConfig {
459        title: site.get("title").unwrap_or_default(),
460        description: site.get("description").unwrap_or_default(),
461        base_url: site.get("base_url").unwrap_or_default(),
462        author: site.get("author").unwrap_or_default(),
463    })
464}
465
466fn parse_seo_config(table: &Table) -> mlua::Result<SeoConfig> {
467    let seo: Table = table.get("seo").unwrap_or_else(|_| table.clone());
468
469    Ok(SeoConfig {
470        twitter_handle: seo.get("twitter_handle").ok(),
471        default_og_image: seo.get("default_og_image").ok(),
472    })
473}
474
475fn parse_build_config(table: &Table) -> mlua::Result<BuildConfig> {
476    let build: Table = table.get("build").unwrap_or_else(|_| table.clone());
477
478    Ok(BuildConfig {
479        output_dir: build
480            .get("output_dir")
481            .unwrap_or_else(|_| "dist".to_string()),
482    })
483}
484
485fn parse_paths_config(table: &Table) -> mlua::Result<PathsConfig> {
486    let paths: Table = table.get("paths").unwrap_or_else(|_| table.clone());
487
488    Ok(PathsConfig {
489        templates: paths
490            .get("templates")
491            .unwrap_or_else(|_| "templates".to_string()),
492    })
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498
499    fn test_project_root() -> PathBuf {
500        std::env::current_dir().expect("failed to get current directory")
501    }
502
503    #[test]
504    fn test_minimal_lua_config() {
505        let lua = Lua::new();
506        let root = test_project_root();
507        crate::lua::register(&lua, &root, false, Arc::new(BuildTracker::disabled()), None)
508            .expect("failed to register Lua functions");
509
510        let config_str = r#"
511            return {
512                site = {
513                    title = "Test Site",
514                    description = "A test site",
515                    base_url = "https://example.com",
516                    author = "Test Author",
517                },
518                build = {
519                    output_dir = "dist",
520                },
521            }
522        "#;
523
524        let table: Table = lua
525            .load(config_str)
526            .eval()
527            .expect("failed to load config string");
528        let config = parse_config(&lua, &table).expect("failed to parse config");
529
530        assert_eq!(config.site.title, "Test Site");
531        assert_eq!(config.site.base_url, "https://example.com");
532        assert_eq!(config.build.output_dir, "dist");
533    }
534
535    #[test]
536    fn test_lua_helper_functions() {
537        let lua = Lua::new();
538        let root = test_project_root();
539        crate::lua::register(&lua, &root, false, Arc::new(BuildTracker::disabled()), None)
540            .expect("failed to register Lua functions");
541
542        // Test file_exists
543        let result: bool = lua
544            .load("return rs.file_exists('Cargo.toml')")
545            .eval()
546            .expect("failed to eval file_exists for Cargo.toml");
547        assert!(result);
548
549        let result: bool = lua
550            .load("return rs.file_exists('nonexistent.file')")
551            .eval()
552            .expect("failed to eval file_exists for nonexistent.file");
553        assert!(!result);
554    }
555
556    #[test]
557    fn test_sandbox_blocks_outside_access() {
558        let lua = Lua::new();
559        let root = test_project_root();
560        crate::lua::register(&lua, &root, true, Arc::new(BuildTracker::disabled()), None)
561            .expect("failed to register Lua functions");
562
563        // Trying to access /etc/passwd should fail with sandbox enabled
564        let result = lua
565            .load("return rs.read_file('/etc/passwd')")
566            .eval::<Value>();
567        assert!(
568            result.is_err(),
569            "sandbox should block access to /etc/passwd"
570        );
571
572        // Trying to access parent directory should fail
573        let result = lua
574            .load("return rs.read_file('../some_file')")
575            .eval::<Value>();
576        assert!(
577            result.is_err(),
578            "sandbox should block access to parent directory"
579        );
580    }
581
582    #[test]
583    fn test_sandbox_allows_project_access() {
584        let lua = Lua::new();
585        let root = test_project_root();
586        crate::lua::register(&lua, &root, true, Arc::new(BuildTracker::disabled()), None)
587            .expect("failed to register Lua functions");
588
589        // Accessing files within project should work
590        let result: bool = lua
591            .load("return rs.file_exists('Cargo.toml')")
592            .eval()
593            .expect("sandbox should allow file_exists within project");
594        assert!(result);
595
596        // Reading files within project should work
597        let result = lua
598            .load("return rs.read_file('Cargo.toml')")
599            .eval::<Value>();
600        assert!(
601            result.is_ok(),
602            "sandbox should allow reading files within project"
603        );
604    }
605}