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