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