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::{AssetManifest, create_manifest};
13use crate::tracker::{BuildTracker, SharedTracker};
14
15#[derive(Debug, Clone)]
17pub struct ConfigData {
18 pub site: SiteConfig,
19 pub seo: SeoConfig,
20 pub build: BuildConfig,
21 pub paths: PathsConfig,
22}
23
24pub struct Config {
26 pub data: ConfigData,
28
29 lua: Lua,
31 before_build: Option<mlua::RegistryKey>,
32 after_build: Option<mlua::RegistryKey>,
33
34 data_fn: Option<mlua::RegistryKey>,
36 pages_fn: Option<mlua::RegistryKey>,
37 update_data_fn: Option<mlua::RegistryKey>,
38
39 tracker: SharedTracker,
41
42 pub asset_manifest: AssetManifest,
44}
45
46impl 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#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
99pub struct PageDef {
100 pub path: String,
102 #[serde(default)]
104 pub template: Option<String>,
105 #[serde(default)]
107 pub title: Option<String>,
108 #[serde(default)]
110 pub description: Option<String>,
111 #[serde(default)]
113 pub image: Option<String>,
114 #[serde(default)]
116 pub content: Option<String>,
117 #[serde(default)]
119 pub html: Option<String>,
120 #[serde(default)]
122 pub data: Option<serde_json::Value>,
123 #[serde(default = "default_minify")]
125 pub minify: bool,
126}
127
128fn default_minify() -> bool {
129 true
130}
131
132impl Config {
133 #[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 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 let asset_manifest = create_manifest();
190
191 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 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 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 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 let data = parse_config(&lua, &config_table)
236 .map_err(|e| anyhow::anyhow!("Failed to parse config: {}", e))?;
237
238 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 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 pub fn tracker(&self) -> &SharedTracker {
296 &self.tracker
297 }
298
299 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 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 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 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 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 pub fn has_update_data(&self) -> bool {
404 self.update_data_fn.is_some()
405 }
406
407 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 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 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 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 pub fn render_markdown(&self, content: &str) -> Result<String> {
465 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 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}
492fn 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 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 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 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 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 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}