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::lua::{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_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#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
107pub struct PageDef {
108 pub path: String,
110 #[serde(default)]
112 pub template: Option<String>,
113 #[serde(default)]
115 pub title: Option<String>,
116 #[serde(default)]
118 pub description: Option<String>,
119 #[serde(default)]
121 pub image: Option<String>,
122 #[serde(default)]
124 pub content: Option<String>,
125 #[serde(default)]
127 pub html: Option<String>,
128 #[serde(default)]
130 pub data: Option<serde_json::Value>,
131}
132
133impl Config {
134 #[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 pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
152 Self::load_with_tracker(path, Arc::new(BuildTracker::new()))
153 }
154
155 pub fn load_with_tracker<P: AsRef<Path>>(path: P, tracker: SharedTracker) -> Result<Self> {
157 let path = path.as_ref();
158
159 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 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 crate::lua::register(&lua, &project_root, false, tracker.clone(), None)
190 .map_err(|e| anyhow::anyhow!("Failed to register Lua functions: {}", e))?;
191
192 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 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 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 let data = parse_config(&lua, &config_table)
219 .map_err(|e| anyhow::anyhow!("Failed to parse config: {}", e))?;
220
221 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 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 pub fn tracker(&self) -> &SharedTracker {
278 &self.tracker
279 }
280
281 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 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 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 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 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 pub fn has_update_data(&self) -> bool {
386 self.update_data_fn.is_some()
387 }
388
389 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 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 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 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}
445fn 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 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 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 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 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 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}