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)]
169 pub exclude: Vec<String>,
170 #[serde(default = "default_true")]
172 pub exclude_defaults: bool,
173 #[serde(default = "default_true")]
175 pub respect_gitignore: bool,
176}
177
178impl Default for PathsConfig {
179 fn default() -> Self {
180 Self {
181 content: default_content_dir(),
182 styles: default_styles_dir(),
183 static_files: default_static_dir(),
184 templates: default_templates_dir(),
185 home: default_home_page(),
186 exclude: Vec::new(),
187 exclude_defaults: true,
188 respect_gitignore: true,
189 }
190 }
191}
192
193fn default_content_dir() -> String {
194 "content".to_string()
195}
196fn default_styles_dir() -> String {
197 "styles".to_string()
198}
199fn default_static_dir() -> String {
200 "static".to_string()
201}
202fn default_templates_dir() -> String {
203 "templates".to_string()
204}
205fn default_home_page() -> String {
206 "index.md".to_string()
207}
208
209#[derive(Debug, Deserialize, Clone, Default)]
210pub struct HighlightConfig {
211 #[serde(default)]
212 pub names: Vec<String>,
213 #[serde(default = "default_highlight_class")]
214 pub class: String,
215}
216
217fn default_highlight_class() -> String {
218 "me".to_string()
219}
220
221#[derive(Debug, Deserialize, Clone)]
222pub struct SiteConfig {
223 pub title: String,
224 pub description: String,
225 pub base_url: String,
226 pub author: String,
227}
228
229#[derive(Debug, Deserialize, Clone)]
230pub struct SeoConfig {
231 pub twitter_handle: Option<String>,
232 pub default_og_image: Option<String>,
233}
234
235#[derive(Debug, Deserialize, Clone)]
236pub struct BuildConfig {
237 #[allow(dead_code)]
238 pub output_dir: String,
239 #[serde(default = "default_true")]
240 pub minify_css: bool,
241 #[serde(default = "default_css_output")]
242 pub css_output: String,
243}
244
245fn default_css_output() -> String {
246 "rs.css".to_string()
247}
248
249#[derive(Debug, Deserialize, Clone)]
250pub struct ImagesConfig {
251 #[serde(default = "default_quality")]
252 pub quality: f32,
253 #[serde(default = "default_scale_factor")]
254 pub scale_factor: f64,
255}
256
257fn default_true() -> bool {
258 true
259}
260
261fn default_quality() -> f32 {
262 85.0
263}
264
265fn default_scale_factor() -> f64 {
266 1.0
267}
268
269impl Config {
270 pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
271 let content = std::fs::read_to_string(path.as_ref())
272 .with_context(|| format!("Failed to read config file: {:?}", path.as_ref()))?;
273
274 let config: Config =
275 toml::from_str(&content).with_context(|| "Failed to parse config file")?;
276
277 Ok(config)
278 }
279
280 #[cfg(test)]
282 pub fn from_str(content: &str) -> Result<Self> {
283 let config: Config = toml::from_str(content).with_context(|| "Failed to parse config")?;
284 Ok(config)
285 }
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291
292 fn minimal_config() -> &'static str {
293 r#"
294[site]
295title = "Test Site"
296description = "A test site"
297base_url = "https://example.com"
298author = "Test Author"
299
300[seo]
301
302[build]
303output_dir = "dist"
304
305[images]
306"#
307 }
308
309 #[test]
310 fn test_minimal_config() {
311 let config = Config::from_str(minimal_config()).unwrap();
312 assert_eq!(config.site.title, "Test Site");
313 assert_eq!(config.site.base_url, "https://example.com");
314 }
315
316 #[test]
317 fn test_config_with_permalinks() {
318 let content = format!(
319 r#"{}
320[permalinks]
321blog = "/:year/:month/:slug/"
322projects = "/:slug/"
323"#,
324 minimal_config()
325 );
326
327 let config = Config::from_str(&content).unwrap();
328 assert_eq!(
329 config.permalinks.sections.get("blog"),
330 Some(&"/:year/:month/:slug/".to_string())
331 );
332 assert_eq!(
333 config.permalinks.sections.get("projects"),
334 Some(&"/:slug/".to_string())
335 );
336 }
337
338 #[test]
339 fn test_config_with_templates() {
340 let content = format!(
341 r#"{}
342[templates]
343blog = "post.html"
344projects = "project.html"
345"#,
346 minimal_config()
347 );
348
349 let config = Config::from_str(&content).unwrap();
350 assert_eq!(
351 config.templates.sections.get("blog"),
352 Some(&"post.html".to_string())
353 );
354 assert_eq!(
355 config.templates.sections.get("projects"),
356 Some(&"project.html".to_string())
357 );
358 }
359
360 #[test]
361 fn test_config_with_paths() {
362 let content = format!(
363 r#"{}
364[paths]
365content = "my-content"
366styles = "my-styles"
367static_files = "my-static"
368templates = "my-templates"
369home = "home.md"
370exclude = ["drafts", "private"]
371"#,
372 minimal_config()
373 );
374
375 let config = Config::from_str(&content).unwrap();
376 assert_eq!(config.paths.content, "my-content");
377 assert_eq!(config.paths.styles, "my-styles");
378 assert_eq!(config.paths.static_files, "my-static");
379 assert_eq!(config.paths.templates, "my-templates");
380 assert_eq!(config.paths.home, "home.md");
381 assert_eq!(config.paths.exclude, vec!["drafts", "private"]);
382 }
383
384 #[test]
385 fn test_config_defaults() {
386 let config = Config::from_str(minimal_config()).unwrap();
387
388 assert_eq!(config.paths.content, "content");
390 assert_eq!(config.paths.styles, "styles");
391 assert_eq!(config.paths.static_files, "static");
392 assert_eq!(config.paths.templates, "templates");
393 assert_eq!(config.paths.home, "index.md");
394 assert!(config.paths.exclude.is_empty());
395 assert!(config.paths.exclude_defaults);
396 assert!(config.paths.respect_gitignore);
397
398 assert!(config.templates.sections.is_empty());
400 assert!(config.permalinks.sections.is_empty());
401
402 assert!(config.build.minify_css);
404
405 assert_eq!(config.images.quality, 85.0);
407 assert_eq!(config.images.scale_factor, 1.0);
408 }
409
410 #[test]
411 fn test_config_with_highlight() {
412 let content = format!(
413 r#"{}
414[highlight]
415names = ["John Doe", "Jane Doe"]
416class = "author"
417"#,
418 minimal_config()
419 );
420
421 let config = Config::from_str(&content).unwrap();
422 assert_eq!(config.highlight.names, vec!["John Doe", "Jane Doe"]);
423 assert_eq!(config.highlight.class, "author");
424 }
425
426 #[test]
427 fn test_config_with_encryption() {
428 let content = format!(
429 r#"{}
430[encryption]
431password_command = "pass show website/notes"
432"#,
433 minimal_config()
434 );
435
436 let config = Config::from_str(&content).unwrap();
437 assert_eq!(
438 config.encryption.password_command,
439 Some("pass show website/notes".to_string())
440 );
441 assert!(config.encryption.password.is_none());
442 }
443
444 #[test]
445 fn test_config_encryption_with_raw_password() {
446 let content = format!(
447 r#"{}
448[encryption]
449password = "secret123"
450"#,
451 minimal_config()
452 );
453
454 let config = Config::from_str(&content).unwrap();
455 assert!(config.encryption.password_command.is_none());
456 assert_eq!(config.encryption.password, Some("secret123".to_string()));
457 }
458
459 #[test]
460 fn test_config_encryption_defaults_to_none() {
461 let config = Config::from_str(minimal_config()).unwrap();
462 assert!(config.encryption.password_command.is_none());
463 assert!(config.encryption.password.is_none());
464 }
465
466 #[test]
467 fn test_config_graph_defaults() {
468 let config = Config::from_str(minimal_config()).unwrap();
469 assert!(config.graph.enabled);
470 assert_eq!(config.graph.template, "graph.html");
471 assert_eq!(config.graph.path, "graph");
472 }
473
474 #[test]
475 fn test_config_with_graph() {
476 let content = format!(
477 r#"{}
478[graph]
479enabled = false
480template = "custom-graph.html"
481path = "brain"
482"#,
483 minimal_config()
484 );
485
486 let config = Config::from_str(&content).unwrap();
487 assert!(!config.graph.enabled);
488 assert_eq!(config.graph.template, "custom-graph.html");
489 assert_eq!(config.graph.path, "brain");
490 }
491
492 #[test]
493 fn test_config_rss_defaults() {
494 let config = Config::from_str(minimal_config()).unwrap();
495 assert!(config.rss.enabled);
496 assert_eq!(config.rss.filename, "rss.xml");
497 assert!(config.rss.sections.is_empty());
498 assert_eq!(config.rss.limit, 20);
499 assert!(!config.rss.exclude_encrypted_blocks);
500 }
501
502 #[test]
503 fn test_config_with_rss() {
504 let content = format!(
505 r#"{}
506[rss]
507enabled = true
508filename = "feed.xml"
509sections = ["blog", "notes"]
510limit = 50
511exclude_encrypted_blocks = true
512"#,
513 minimal_config()
514 );
515
516 let config = Config::from_str(&content).unwrap();
517 assert!(config.rss.enabled);
518 assert_eq!(config.rss.filename, "feed.xml");
519 assert_eq!(config.rss.sections, vec!["blog", "notes"]);
520 assert_eq!(config.rss.limit, 50);
521 assert!(config.rss.exclude_encrypted_blocks);
522 }
523
524 #[test]
525 fn test_config_text_defaults() {
526 let config = Config::from_str(minimal_config()).unwrap();
527 assert!(!config.text.enabled); assert!(config.text.sections.is_empty());
529 assert!(!config.text.exclude_encrypted);
530 assert!(config.text.include_home);
531 }
532
533 #[test]
534 fn test_config_with_text() {
535 let content = format!(
536 r#"{}
537[text]
538enabled = true
539sections = ["blog", "notes"]
540exclude_encrypted = true
541include_home = false
542"#,
543 minimal_config()
544 );
545
546 let config = Config::from_str(&content).unwrap();
547 assert!(config.text.enabled);
548 assert_eq!(config.text.sections, vec!["blog", "notes"]);
549 assert!(config.text.exclude_encrypted);
550 assert!(!config.text.include_home);
551 }
552
553 #[test]
554 fn test_config_text_enabled_only() {
555 let content = format!(
556 r#"{}
557[text]
558enabled = true
559"#,
560 minimal_config()
561 );
562
563 let config = Config::from_str(&content).unwrap();
564 assert!(config.text.enabled);
565 assert!(config.text.sections.is_empty()); assert!(!config.text.exclude_encrypted);
567 assert!(config.text.include_home);
568 }
569}