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