use anyhow::{Context, Result};
use mlua::{Function, Lua, LuaSerdeExt, Table, Value};
use serde::Deserialize;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::lua::{AssetManifest, create_manifest};
use crate::tracker::{BuildTracker, SharedTracker};
#[derive(Debug, Clone)]
pub struct ConfigData {
pub site: SiteConfig,
pub seo: SeoConfig,
pub build: BuildConfig,
pub paths: PathsConfig,
}
pub struct Config {
pub data: ConfigData,
lua: Lua,
before_build: Option<mlua::RegistryKey>,
after_build: Option<mlua::RegistryKey>,
data_fn: Option<mlua::RegistryKey>,
pages_fn: Option<mlua::RegistryKey>,
update_data_fn: Option<mlua::RegistryKey>,
tracker: SharedTracker,
pub asset_manifest: AssetManifest,
}
impl std::ops::Deref for Config {
type Target = ConfigData;
fn deref(&self) -> &Self::Target {
&self.data
}
}
impl std::ops::DerefMut for Config {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.data
}
}
#[derive(Debug, Deserialize, Clone)]
pub struct SiteConfig {
pub title: String,
pub description: String,
pub base_url: String,
pub author: String,
}
#[derive(Debug, Deserialize, Clone, Default)]
pub struct SeoConfig {
pub twitter_handle: Option<String>,
pub default_og_image: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct BuildConfig {
pub output_dir: String,
}
#[derive(Debug, Deserialize, Clone)]
pub struct PathsConfig {
#[serde(default = "default_templates_dir")]
pub templates: String,
}
impl Default for PathsConfig {
fn default() -> Self {
Self {
templates: default_templates_dir(),
}
}
}
fn default_templates_dir() -> String {
"templates".to_string()
}
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
pub struct PageDef {
pub path: String,
#[serde(default)]
pub template: Option<String>,
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub image: Option<String>,
#[serde(default)]
pub content: Option<String>,
#[serde(default)]
pub html: Option<String>,
#[serde(default)]
pub data: Option<serde_json::Value>,
#[serde(default = "default_minify")]
pub minify: bool,
}
fn default_minify() -> bool {
true
}
impl Config {
#[cfg(test)]
pub fn from_data(data: ConfigData) -> Self {
let lua = Lua::new();
Self {
data,
lua,
before_build: None,
after_build: None,
data_fn: None,
pages_fn: None,
update_data_fn: None,
tracker: Arc::new(BuildTracker::disabled()),
asset_manifest: create_manifest(),
}
}
pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
Self::load_with_tracker(path, Arc::new(BuildTracker::new()))
}
pub fn load_with_tracker<P: AsRef<Path>>(path: P, tracker: SharedTracker) -> Result<Self> {
let path = path.as_ref();
let config_path = if path.is_dir() {
let lua_path = path.join("config.lua");
if lua_path.exists() {
lua_path
} else {
anyhow::bail!("No config.lua found in {:?}", path);
}
} else {
path.to_path_buf()
};
let lua = Lua::new();
let project_root = config_path
.parent()
.map(|p| {
if p.as_os_str().is_empty() {
PathBuf::from(".")
} else {
p.to_path_buf()
}
})
.unwrap_or_else(|| PathBuf::from("."));
let project_root = project_root
.canonicalize()
.unwrap_or_else(|_| project_root.clone());
let asset_manifest = create_manifest();
crate::lua::register(
&lua,
&project_root,
false,
tracker.clone(),
None,
asset_manifest.clone(),
)
.map_err(|e| anyhow::anyhow!("Failed to register Lua functions: {}", e))?;
let content = std::fs::read_to_string(&config_path)
.with_context(|| format!("Failed to read config file: {:?}", config_path))?;
let config_table: Table = lua
.load(&content)
.set_name(config_path.to_string_lossy())
.eval()
.map_err(|e| {
anyhow::anyhow!("Failed to execute config file {:?}: {}", config_path, e)
})?;
let sandbox = config_table
.get::<Table>("lua")
.ok()
.and_then(|t| t.get::<bool>("sandbox").ok())
.unwrap_or(true);
if sandbox {
crate::lua::register(
&lua,
&project_root,
true,
tracker.clone(),
None,
asset_manifest.clone(),
)
.map_err(|e| anyhow::anyhow!("Failed to register Lua functions: {}", e))?;
}
let data = parse_config(&lua, &config_table)
.map_err(|e| anyhow::anyhow!("Failed to parse config: {}", e))?;
let data_fn: Option<mlua::RegistryKey> = config_table
.get::<Function>("data")
.ok()
.map(|f| lua.create_registry_value(f))
.transpose()
.map_err(|e| anyhow::anyhow!("Failed to store data function: {}", e))?;
let pages_fn: Option<mlua::RegistryKey> = config_table
.get::<Function>("pages")
.ok()
.map(|f| lua.create_registry_value(f))
.transpose()
.map_err(|e| anyhow::anyhow!("Failed to store pages function: {}", e))?;
let update_data_fn: Option<mlua::RegistryKey> = config_table
.get::<Function>("update_data")
.ok()
.map(|f| lua.create_registry_value(f))
.transpose()
.map_err(|e| anyhow::anyhow!("Failed to store update_data function: {}", e))?;
let hooks: Option<Table> = config_table.get("hooks").ok();
let before_build = if let Some(ref h) = hooks {
h.get::<Function>("before_build")
.ok()
.map(|f| lua.create_registry_value(f))
.transpose()
.map_err(|e| anyhow::anyhow!("Failed to store before_build hook: {}", e))?
} else {
None
};
let after_build = if let Some(ref h) = hooks {
h.get::<Function>("after_build")
.ok()
.map(|f| lua.create_registry_value(f))
.transpose()
.map_err(|e| anyhow::anyhow!("Failed to store after_build hook: {}", e))?
} else {
None
};
Ok(Config {
data,
lua,
before_build,
after_build,
data_fn,
pages_fn,
update_data_fn,
tracker,
asset_manifest,
})
}
pub fn tracker(&self) -> &SharedTracker {
&self.tracker
}
pub fn call_before_build(&self) -> Result<()> {
if let Some(ref key) = self.before_build {
let func: Function = self
.lua
.registry_value(key)
.map_err(|e| anyhow::anyhow!("Failed to get before_build: {}", e))?;
let ctx = self.create_ctx(None)?;
func.call::<()>(ctx)
.map_err(|e| anyhow::anyhow!("before_build hook failed: {}", e))?;
}
Ok(())
}
pub fn call_after_build(&self) -> Result<()> {
if let Some(ref key) = self.after_build {
let func: Function = self
.lua
.registry_value(key)
.map_err(|e| anyhow::anyhow!("Failed to get after_build: {}", e))?;
let ctx = self.create_ctx(None)?;
func.call::<()>(ctx)
.map_err(|e| anyhow::anyhow!("after_build hook failed: {}", e))?;
}
Ok(())
}
fn create_ctx(&self, data: Option<&serde_json::Value>) -> Result<Value> {
let ctx = self
.lua
.create_table()
.map_err(|e| anyhow::anyhow!("Failed to create ctx: {}", e))?;
ctx.set("output_dir", self.data.build.output_dir.as_str())
.map_err(|e| anyhow::anyhow!("Failed to set output_dir: {}", e))?;
ctx.set("base_url", self.data.site.base_url.as_str())
.map_err(|e| anyhow::anyhow!("Failed to set base_url: {}", e))?;
if let Some(data) = data {
let data_value: Value = self
.lua
.to_value(data)
.map_err(|e| anyhow::anyhow!("Failed to convert data to Lua: {}", e))?;
ctx.set("data", data_value)
.map_err(|e| anyhow::anyhow!("Failed to set data: {}", e))?;
}
Ok(Value::Table(ctx))
}
pub fn call_data(&self) -> Result<serde_json::Value> {
let key = match &self.data_fn {
Some(k) => k,
_ => return Ok(serde_json::Value::Object(serde_json::Map::new())),
};
let func: Function = self
.lua
.registry_value(key)
.map_err(|e| anyhow::anyhow!("Failed to get data function: {}", e))?;
let ctx = self.create_ctx(None)?;
let result: Value = func
.call(ctx)
.map_err(|e| anyhow::anyhow!("Failed to call data(): {}", e))?;
let json_value: serde_json::Value = self
.lua
.from_value(result)
.map_err(|e| anyhow::anyhow!("Failed to convert data() result: {}", e))?;
Ok(json_value)
}
pub fn call_pages(&self, global_data: &serde_json::Value) -> Result<Vec<PageDef>> {
let key = match &self.pages_fn {
Some(k) => k,
_ => return Ok(Vec::new()),
};
let func: Function = self
.lua
.registry_value(key)
.map_err(|e| anyhow::anyhow!("Failed to get pages function: {}", e))?;
let ctx = self.create_ctx(Some(global_data))?;
let result: Value = func
.call(ctx)
.map_err(|e| anyhow::anyhow!("Failed to call pages(): {}", e))?;
let pages: Vec<PageDef> = self
.lua
.from_value(result)
.map_err(|e| anyhow::anyhow!("Failed to convert pages() result: {}", e))?;
Ok(pages)
}
pub fn has_update_data(&self) -> bool {
self.update_data_fn.is_some()
}
pub fn call_update_data(
&self,
cached_data: &serde_json::Value,
changed_paths: &[std::path::PathBuf],
) -> Result<serde_json::Value> {
let key = match &self.update_data_fn {
Some(k) => k,
None => return Err(anyhow::anyhow!("update_data function not defined")),
};
let func: Function = self
.lua
.registry_value(key)
.map_err(|e| anyhow::anyhow!("Failed to get update_data function: {}", e))?;
let ctx = self
.lua
.create_table()
.map_err(|e| anyhow::anyhow!("Failed to create ctx: {}", e))?;
ctx.set("output_dir", self.data.build.output_dir.as_str())
.map_err(|e| anyhow::anyhow!("Failed to set output_dir: {}", e))?;
ctx.set("base_url", self.data.site.base_url.as_str())
.map_err(|e| anyhow::anyhow!("Failed to set base_url: {}", e))?;
let cached: Value = self
.lua
.to_value(cached_data)
.map_err(|e| anyhow::anyhow!("Failed to convert cached data to Lua: {}", e))?;
ctx.set("data", cached)
.map_err(|e| anyhow::anyhow!("Failed to set data: {}", e))?;
let paths_table = self.lua.create_table()?;
for (i, path) in changed_paths.iter().enumerate() {
paths_table.set(i + 1, path.to_string_lossy().to_string())?;
}
ctx.set("changed_paths", paths_table)
.map_err(|e| anyhow::anyhow!("Failed to set changed_paths: {}", e))?;
let result: Value = func
.call(Value::Table(ctx))
.map_err(|e| anyhow::anyhow!("Failed to call update_data(): {}", e))?;
let json_value: serde_json::Value = self
.lua
.from_value(result)
.map_err(|e| anyhow::anyhow!("Failed to convert update_data() result: {}", e))?;
Ok(json_value)
}
pub fn render_markdown(&self, content: &str) -> Result<String> {
let require: Function = self
.lua
.globals()
.get("require")
.map_err(|e| anyhow::anyhow!("Failed to get require function: {}", e))?;
let rs: Table = require
.call("rs-web")
.map_err(|e| anyhow::anyhow!("Failed to require rs-web module: {}", e))?;
let markdown: Table = rs
.get("markdown")
.map_err(|e| anyhow::anyhow!("Failed to get rs.markdown module: {}", e))?;
let render: Function = markdown
.get("render")
.map_err(|e| anyhow::anyhow!("Failed to get rs.markdown.render function: {}", e))?;
let result: mlua::String = render
.call(content)
.map_err(|e| anyhow::anyhow!("Failed to call rs.markdown.render: {}", e))?;
Ok(result.to_str()?.to_string())
}
}
fn parse_config(_lua: &Lua, table: &Table) -> mlua::Result<ConfigData> {
let site = parse_site_config(table)?;
let seo = parse_seo_config(table)?;
let build = parse_build_config(table)?;
let paths = parse_paths_config(table)?;
Ok(ConfigData {
site,
seo,
build,
paths,
})
}
fn parse_site_config(table: &Table) -> mlua::Result<SiteConfig> {
let site: Table = table.get("site")?;
Ok(SiteConfig {
title: site.get("title").unwrap_or_default(),
description: site.get("description").unwrap_or_default(),
base_url: site.get("base_url").unwrap_or_default(),
author: site.get("author").unwrap_or_default(),
})
}
fn parse_seo_config(table: &Table) -> mlua::Result<SeoConfig> {
let seo: Table = table.get("seo").unwrap_or_else(|_| table.clone());
Ok(SeoConfig {
twitter_handle: seo.get("twitter_handle").ok(),
default_og_image: seo.get("default_og_image").ok(),
})
}
fn parse_build_config(table: &Table) -> mlua::Result<BuildConfig> {
let build: Table = table.get("build").unwrap_or_else(|_| table.clone());
Ok(BuildConfig {
output_dir: build
.get("output_dir")
.unwrap_or_else(|_| "dist".to_string()),
})
}
fn parse_paths_config(table: &Table) -> mlua::Result<PathsConfig> {
let paths: Table = table.get("paths").unwrap_or_else(|_| table.clone());
Ok(PathsConfig {
templates: paths
.get("templates")
.unwrap_or_else(|_| "templates".to_string()),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn test_project_root() -> PathBuf {
std::env::current_dir().expect("failed to get current directory")
}
#[test]
fn test_minimal_lua_config() {
let lua = Lua::new();
let root = test_project_root();
crate::lua::register(
&lua,
&root,
false,
Arc::new(BuildTracker::disabled()),
None,
create_manifest(),
)
.expect("failed to register Lua functions");
let config_str = r#"
return {
site = {
title = "Test Site",
description = "A test site",
base_url = "https://example.com",
author = "Test Author",
},
build = {
output_dir = "dist",
},
}
"#;
let table: Table = lua
.load(config_str)
.eval()
.expect("failed to load config string");
let config = parse_config(&lua, &table).expect("failed to parse config");
assert_eq!(config.site.title, "Test Site");
assert_eq!(config.site.base_url, "https://example.com");
assert_eq!(config.build.output_dir, "dist");
}
#[test]
fn test_lua_helper_functions() {
let lua = Lua::new();
let root = test_project_root();
crate::lua::register(
&lua,
&root,
false,
Arc::new(BuildTracker::disabled()),
None,
create_manifest(),
)
.expect("failed to register Lua functions");
let result: bool = lua
.load("local rs = require('rs-web'); return rs.fs.exists('Cargo.toml')")
.call(())
.expect("failed to call fs.exists for Cargo.toml");
assert!(result);
let result: bool = lua
.load("local rs = require('rs-web'); return rs.fs.exists('nonexistent.file')")
.call(())
.expect("failed to call fs.exists for nonexistent.file");
assert!(!result);
}
#[test]
fn test_sandbox_blocks_outside_access() {
let lua = Lua::new();
let root = test_project_root();
crate::lua::register(
&lua,
&root,
true,
Arc::new(BuildTracker::disabled()),
None,
create_manifest(),
)
.expect("failed to register Lua functions");
let result = lua
.load("local rs = require('rs-web'); return rs.fs.read('/etc/passwd')")
.call::<Value>(());
assert!(
result.is_err(),
"sandbox should block access to /etc/passwd"
);
let result = lua
.load("local rs = require('rs-web'); return rs.fs.read('../some_file')")
.call::<Value>(());
assert!(
result.is_err(),
"sandbox should block access to parent directory"
);
}
#[test]
fn test_sandbox_allows_project_access() {
let lua = Lua::new();
let root = test_project_root();
crate::lua::register(
&lua,
&root,
true,
Arc::new(BuildTracker::disabled()),
None,
create_manifest(),
)
.expect("failed to register Lua functions");
let result: bool = lua
.load("local rs = require('rs-web'); return rs.fs.exists('Cargo.toml')")
.call(())
.expect("sandbox should allow fs.exists within project");
assert!(result);
let result = lua
.load("local rs = require('rs-web'); return rs.fs.read('Cargo.toml')")
.call::<Value>(());
assert!(
result.is_ok(),
"sandbox should allow reading files within project"
);
}
}