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