1use 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#[derive(Debug, Clone)]
16pub struct ConfigData {
17 pub site: SiteConfig,
18 pub seo: SeoConfig,
19 pub build: BuildConfig,
20 pub paths: PathsConfig,
21}
22
23pub struct Config {
25 pub data: ConfigData,
27
28 lua: Lua,
30 before_build: Option<mlua::RegistryKey>,
31 after_build: Option<mlua::RegistryKey>,
32
33 data_fn: Option<mlua::RegistryKey>,
35 pages_fn: Option<mlua::RegistryKey>,
36 update_data_fn: Option<mlua::RegistryKey>,
37
38 tracker: SharedTracker,
40}
41
42impl 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#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
95pub struct PageDef {
96 pub path: String,
98 #[serde(default)]
100 pub template: Option<String>,
101 #[serde(default)]
103 pub title: Option<String>,
104 #[serde(default)]
106 pub description: Option<String>,
107 #[serde(default)]
109 pub image: Option<String>,
110 #[serde(default)]
112 pub content: Option<String>,
113 #[serde(default)]
115 pub html: Option<String>,
116 #[serde(default)]
118 pub data: Option<serde_json::Value>,
119 #[serde(default = "default_minify")]
121 pub minify: bool,
122}
123
124fn default_minify() -> bool {
125 true
126}
127
128impl Config {
129 #[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 pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
147 Self::load_with_tracker(path, Arc::new(BuildTracker::new()))
148 }
149
150 pub fn load_with_tracker<P: AsRef<Path>>(path: P, tracker: SharedTracker) -> Result<Self> {
152 let path = path.as_ref();
153
154 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 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 crate::lua::register(&lua, &project_root, false, tracker.clone(), None)
185 .map_err(|e| anyhow::anyhow!("Failed to register Lua functions: {}", e))?;
186
187 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 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 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 let data = parse_config(&lua, &config_table)
214 .map_err(|e| anyhow::anyhow!("Failed to parse config: {}", e))?;
215
216 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 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 pub fn tracker(&self) -> &SharedTracker {
273 &self.tracker
274 }
275
276 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 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 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 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 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 pub fn has_update_data(&self) -> bool {
381 self.update_data_fn.is_some()
382 }
383
384 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 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 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 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}
440fn 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 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 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 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 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 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}