1use anyhow::{Context, Result};
2use serde::Deserialize;
3use std::collections::HashMap;
4use std::path::Path;
5
6#[derive(Debug, Deserialize, Clone)]
7pub struct Config {
8 pub site: SiteConfig,
9 pub seo: SeoConfig,
10 pub build: BuildConfig,
11 pub images: ImagesConfig,
12 #[serde(default)]
13 pub highlight: HighlightConfig,
14 #[serde(default)]
15 pub paths: PathsConfig,
16 #[serde(default)]
17 pub templates: TemplatesConfig,
18 #[serde(default)]
19 pub permalinks: PermalinksConfig,
20 #[serde(default)]
21 pub encryption: EncryptionConfig,
22 #[serde(default)]
23 pub graph: GraphConfig,
24 #[serde(default)]
25 pub rss: RssConfig,
26 #[serde(default)]
27 pub text: TextConfig,
28}
29
30#[derive(Debug, Deserialize, Clone)]
32pub struct RssConfig {
33 #[serde(default = "default_true")]
35 pub enabled: bool,
36 #[serde(default = "default_rss_filename")]
38 pub filename: String,
39 #[serde(default)]
41 pub sections: Vec<String>,
42 #[serde(default = "default_rss_limit")]
44 pub limit: usize,
45 #[serde(default)]
47 pub exclude_encrypted_blocks: bool,
48}
49
50impl Default for RssConfig {
51 fn default() -> Self {
52 Self {
53 enabled: true,
54 filename: default_rss_filename(),
55 sections: Vec::new(),
56 limit: default_rss_limit(),
57 exclude_encrypted_blocks: false,
58 }
59 }
60}
61
62fn default_rss_filename() -> String {
63 "rss.xml".to_string()
64}
65
66fn default_rss_limit() -> usize {
67 20
68}
69
70#[derive(Debug, Deserialize, Clone)]
72pub struct TextConfig {
73 #[serde(default)]
75 pub enabled: bool,
76 #[serde(default)]
78 pub sections: Vec<String>,
79 #[serde(default)]
81 pub exclude_encrypted: bool,
82 #[serde(default = "default_true")]
84 pub include_home: bool,
85}
86
87impl Default for TextConfig {
88 fn default() -> Self {
89 Self {
90 enabled: false,
91 sections: Vec::new(),
92 exclude_encrypted: false,
93 include_home: true,
94 }
95 }
96}
97
98#[derive(Debug, Deserialize, Clone)]
100pub struct GraphConfig {
101 #[serde(default = "default_true")]
103 pub enabled: bool,
104 #[serde(default = "default_graph_template")]
106 pub template: String,
107 #[serde(default = "default_graph_path")]
109 pub path: String,
110}
111
112impl Default for GraphConfig {
113 fn default() -> Self {
114 Self {
115 enabled: true,
116 template: default_graph_template(),
117 path: default_graph_path(),
118 }
119 }
120}
121
122fn default_graph_template() -> String {
123 "graph.html".to_string()
124}
125
126fn default_graph_path() -> String {
127 "graph".to_string()
128}
129
130#[derive(Debug, Deserialize, Clone, Default)]
132pub struct TemplatesConfig {
133 #[serde(flatten)]
134 pub sections: HashMap<String, String>,
135}
136
137#[derive(Debug, Deserialize, Clone, Default)]
140pub struct PermalinksConfig {
141 #[serde(flatten)]
142 pub sections: HashMap<String, String>,
143}
144
145#[derive(Debug, Deserialize, Clone, Default)]
148pub struct EncryptionConfig {
149 pub password_command: Option<String>,
151 pub password: Option<String>,
153}
154
155#[derive(Debug, Deserialize, Clone)]
156pub struct PathsConfig {
157 #[serde(default = "default_content_dir")]
158 pub content: String,
159 #[serde(default = "default_styles_dir")]
160 pub styles: String,
161 #[serde(default = "default_static_dir")]
162 pub static_files: String,
163 #[serde(default = "default_templates_dir")]
164 pub templates: String,
165 #[serde(default = "default_home_page")]
166 pub home: String,
167 #[serde(default)]
168 pub exclude: Vec<String>,
169 #[serde(default = "default_true")]
171 pub respect_gitignore: bool,
172}
173
174impl Default for PathsConfig {
175 fn default() -> Self {
176 Self {
177 content: default_content_dir(),
178 styles: default_styles_dir(),
179 static_files: default_static_dir(),
180 templates: default_templates_dir(),
181 home: default_home_page(),
182 exclude: Vec::new(),
183 respect_gitignore: true,
184 }
185 }
186}
187
188fn default_content_dir() -> String {
189 "content".to_string()
190}
191fn default_styles_dir() -> String {
192 "styles".to_string()
193}
194fn default_static_dir() -> String {
195 "static".to_string()
196}
197fn default_templates_dir() -> String {
198 "templates".to_string()
199}
200fn default_home_page() -> String {
201 "index.md".to_string()
202}
203
204#[derive(Debug, Deserialize, Clone, Default)]
205pub struct HighlightConfig {
206 #[serde(default)]
207 pub names: Vec<String>,
208 #[serde(default = "default_highlight_class")]
209 pub class: String,
210}
211
212fn default_highlight_class() -> String {
213 "me".to_string()
214}
215
216#[derive(Debug, Deserialize, Clone)]
217pub struct SiteConfig {
218 pub title: String,
219 pub description: String,
220 pub base_url: String,
221 pub author: String,
222}
223
224#[derive(Debug, Deserialize, Clone)]
225pub struct SeoConfig {
226 pub twitter_handle: Option<String>,
227 pub default_og_image: Option<String>,
228}
229
230#[derive(Debug, Deserialize, Clone)]
231pub struct BuildConfig {
232 #[allow(dead_code)]
233 pub output_dir: String,
234 #[serde(default = "default_true")]
235 pub minify_css: bool,
236 #[serde(default = "default_css_output")]
237 pub css_output: String,
238}
239
240fn default_css_output() -> String {
241 "rs.css".to_string()
242}
243
244#[derive(Debug, Deserialize, Clone)]
245pub struct ImagesConfig {
246 #[serde(default = "default_quality")]
247 pub quality: f32,
248 #[serde(default = "default_scale_factor")]
249 pub scale_factor: f64,
250}
251
252fn default_true() -> bool {
253 true
254}
255
256fn default_quality() -> f32 {
257 85.0
258}
259
260fn default_scale_factor() -> f64 {
261 1.0
262}
263
264impl Config {
265 pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
266 let content = std::fs::read_to_string(path.as_ref())
267 .with_context(|| format!("Failed to read config file: {:?}", path.as_ref()))?;
268
269 let config: Config =
270 toml::from_str(&content).with_context(|| "Failed to parse config file")?;
271
272 Ok(config)
273 }
274
275 #[cfg(test)]
277 pub fn from_str(content: &str) -> Result<Self> {
278 let config: Config = toml::from_str(content).with_context(|| "Failed to parse config")?;
279 Ok(config)
280 }
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286
287 fn minimal_config() -> &'static str {
288 r#"
289[site]
290title = "Test Site"
291description = "A test site"
292base_url = "https://example.com"
293author = "Test Author"
294
295[seo]
296
297[build]
298output_dir = "dist"
299
300[images]
301"#
302 }
303
304 #[test]
305 fn test_minimal_config() {
306 let config = Config::from_str(minimal_config()).unwrap();
307 assert_eq!(config.site.title, "Test Site");
308 assert_eq!(config.site.base_url, "https://example.com");
309 }
310
311 #[test]
312 fn test_config_with_permalinks() {
313 let content = format!(
314 r#"{}
315[permalinks]
316blog = "/:year/:month/:slug/"
317projects = "/:slug/"
318"#,
319 minimal_config()
320 );
321
322 let config = Config::from_str(&content).unwrap();
323 assert_eq!(
324 config.permalinks.sections.get("blog"),
325 Some(&"/:year/:month/:slug/".to_string())
326 );
327 assert_eq!(
328 config.permalinks.sections.get("projects"),
329 Some(&"/:slug/".to_string())
330 );
331 }
332
333 #[test]
334 fn test_config_with_templates() {
335 let content = format!(
336 r#"{}
337[templates]
338blog = "post.html"
339projects = "project.html"
340"#,
341 minimal_config()
342 );
343
344 let config = Config::from_str(&content).unwrap();
345 assert_eq!(
346 config.templates.sections.get("blog"),
347 Some(&"post.html".to_string())
348 );
349 assert_eq!(
350 config.templates.sections.get("projects"),
351 Some(&"project.html".to_string())
352 );
353 }
354
355 #[test]
356 fn test_config_with_paths() {
357 let content = format!(
358 r#"{}
359[paths]
360content = "my-content"
361styles = "my-styles"
362static_files = "my-static"
363templates = "my-templates"
364home = "home.md"
365exclude = ["drafts", "private"]
366"#,
367 minimal_config()
368 );
369
370 let config = Config::from_str(&content).unwrap();
371 assert_eq!(config.paths.content, "my-content");
372 assert_eq!(config.paths.styles, "my-styles");
373 assert_eq!(config.paths.static_files, "my-static");
374 assert_eq!(config.paths.templates, "my-templates");
375 assert_eq!(config.paths.home, "home.md");
376 assert_eq!(config.paths.exclude, vec!["drafts", "private"]);
377 }
378
379 #[test]
380 fn test_config_defaults() {
381 let config = Config::from_str(minimal_config()).unwrap();
382
383 assert_eq!(config.paths.content, "content");
385 assert_eq!(config.paths.styles, "styles");
386 assert_eq!(config.paths.static_files, "static");
387 assert_eq!(config.paths.templates, "templates");
388 assert_eq!(config.paths.home, "index.md");
389 assert!(config.paths.exclude.is_empty());
390 assert!(config.paths.respect_gitignore);
391
392 assert!(config.templates.sections.is_empty());
394 assert!(config.permalinks.sections.is_empty());
395
396 assert!(config.build.minify_css);
398
399 assert_eq!(config.images.quality, 85.0);
401 assert_eq!(config.images.scale_factor, 1.0);
402 }
403
404 #[test]
405 fn test_config_with_highlight() {
406 let content = format!(
407 r#"{}
408[highlight]
409names = ["John Doe", "Jane Doe"]
410class = "author"
411"#,
412 minimal_config()
413 );
414
415 let config = Config::from_str(&content).unwrap();
416 assert_eq!(config.highlight.names, vec!["John Doe", "Jane Doe"]);
417 assert_eq!(config.highlight.class, "author");
418 }
419
420 #[test]
421 fn test_config_with_encryption() {
422 let content = format!(
423 r#"{}
424[encryption]
425password_command = "pass show website/notes"
426"#,
427 minimal_config()
428 );
429
430 let config = Config::from_str(&content).unwrap();
431 assert_eq!(
432 config.encryption.password_command,
433 Some("pass show website/notes".to_string())
434 );
435 assert!(config.encryption.password.is_none());
436 }
437
438 #[test]
439 fn test_config_encryption_with_raw_password() {
440 let content = format!(
441 r#"{}
442[encryption]
443password = "secret123"
444"#,
445 minimal_config()
446 );
447
448 let config = Config::from_str(&content).unwrap();
449 assert!(config.encryption.password_command.is_none());
450 assert_eq!(config.encryption.password, Some("secret123".to_string()));
451 }
452
453 #[test]
454 fn test_config_encryption_defaults_to_none() {
455 let config = Config::from_str(minimal_config()).unwrap();
456 assert!(config.encryption.password_command.is_none());
457 assert!(config.encryption.password.is_none());
458 }
459
460 #[test]
461 fn test_config_graph_defaults() {
462 let config = Config::from_str(minimal_config()).unwrap();
463 assert!(config.graph.enabled);
464 assert_eq!(config.graph.template, "graph.html");
465 assert_eq!(config.graph.path, "graph");
466 }
467
468 #[test]
469 fn test_config_with_graph() {
470 let content = format!(
471 r#"{}
472[graph]
473enabled = false
474template = "custom-graph.html"
475path = "brain"
476"#,
477 minimal_config()
478 );
479
480 let config = Config::from_str(&content).unwrap();
481 assert!(!config.graph.enabled);
482 assert_eq!(config.graph.template, "custom-graph.html");
483 assert_eq!(config.graph.path, "brain");
484 }
485
486 #[test]
487 fn test_config_rss_defaults() {
488 let config = Config::from_str(minimal_config()).unwrap();
489 assert!(config.rss.enabled);
490 assert_eq!(config.rss.filename, "rss.xml");
491 assert!(config.rss.sections.is_empty());
492 assert_eq!(config.rss.limit, 20);
493 assert!(!config.rss.exclude_encrypted_blocks);
494 }
495
496 #[test]
497 fn test_config_with_rss() {
498 let content = format!(
499 r#"{}
500[rss]
501enabled = true
502filename = "feed.xml"
503sections = ["blog", "notes"]
504limit = 50
505exclude_encrypted_blocks = true
506"#,
507 minimal_config()
508 );
509
510 let config = Config::from_str(&content).unwrap();
511 assert!(config.rss.enabled);
512 assert_eq!(config.rss.filename, "feed.xml");
513 assert_eq!(config.rss.sections, vec!["blog", "notes"]);
514 assert_eq!(config.rss.limit, 50);
515 assert!(config.rss.exclude_encrypted_blocks);
516 }
517
518 #[test]
519 fn test_config_text_defaults() {
520 let config = Config::from_str(minimal_config()).unwrap();
521 assert!(!config.text.enabled); assert!(config.text.sections.is_empty());
523 assert!(!config.text.exclude_encrypted);
524 assert!(config.text.include_home);
525 }
526
527 #[test]
528 fn test_config_with_text() {
529 let content = format!(
530 r#"{}
531[text]
532enabled = true
533sections = ["blog", "notes"]
534exclude_encrypted = true
535include_home = false
536"#,
537 minimal_config()
538 );
539
540 let config = Config::from_str(&content).unwrap();
541 assert!(config.text.enabled);
542 assert_eq!(config.text.sections, vec!["blog", "notes"]);
543 assert!(config.text.exclude_encrypted);
544 assert!(!config.text.include_home);
545 }
546
547 #[test]
548 fn test_config_text_enabled_only() {
549 let content = format!(
550 r#"{}
551[text]
552enabled = true
553"#,
554 minimal_config()
555 );
556
557 let config = Config::from_str(&content).unwrap();
558 assert!(config.text.enabled);
559 assert!(config.text.sections.is_empty()); assert!(!config.text.exclude_encrypted);
561 assert!(config.text.include_home);
562 }
563}