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}
27
28#[derive(Debug, Deserialize, Clone)]
30pub struct RssConfig {
31 #[serde(default = "default_true")]
33 pub enabled: bool,
34 #[serde(default = "default_rss_filename")]
36 pub filename: String,
37 #[serde(default)]
39 pub sections: Vec<String>,
40 #[serde(default = "default_rss_limit")]
42 pub limit: usize,
43 #[serde(default)]
45 pub exclude_encrypted_blocks: bool,
46}
47
48impl Default for RssConfig {
49 fn default() -> Self {
50 Self {
51 enabled: true,
52 filename: default_rss_filename(),
53 sections: Vec::new(),
54 limit: default_rss_limit(),
55 exclude_encrypted_blocks: false,
56 }
57 }
58}
59
60fn default_rss_filename() -> String {
61 "rss.xml".to_string()
62}
63
64fn default_rss_limit() -> usize {
65 20
66}
67
68#[derive(Debug, Deserialize, Clone)]
70pub struct GraphConfig {
71 #[serde(default = "default_true")]
73 pub enabled: bool,
74 #[serde(default = "default_graph_template")]
76 pub template: String,
77 #[serde(default = "default_graph_path")]
79 pub path: String,
80}
81
82impl Default for GraphConfig {
83 fn default() -> Self {
84 Self {
85 enabled: true,
86 template: default_graph_template(),
87 path: default_graph_path(),
88 }
89 }
90}
91
92fn default_graph_template() -> String {
93 "graph.html".to_string()
94}
95
96fn default_graph_path() -> String {
97 "graph".to_string()
98}
99
100#[derive(Debug, Deserialize, Clone, Default)]
102pub struct TemplatesConfig {
103 #[serde(flatten)]
104 pub sections: HashMap<String, String>,
105}
106
107#[derive(Debug, Deserialize, Clone, Default)]
110pub struct PermalinksConfig {
111 #[serde(flatten)]
112 pub sections: HashMap<String, String>,
113}
114
115#[derive(Debug, Deserialize, Clone, Default)]
118pub struct EncryptionConfig {
119 pub password_command: Option<String>,
121 pub password: Option<String>,
123}
124
125#[derive(Debug, Deserialize, Clone)]
126pub struct PathsConfig {
127 #[serde(default = "default_content_dir")]
128 pub content: String,
129 #[serde(default = "default_styles_dir")]
130 pub styles: String,
131 #[serde(default = "default_static_dir")]
132 pub static_files: String,
133 #[serde(default = "default_templates_dir")]
134 pub templates: String,
135 #[serde(default = "default_home_page")]
136 pub home: String,
137 #[serde(default)]
138 pub exclude: Vec<String>,
139 #[serde(default = "default_true")]
141 pub respect_gitignore: bool,
142}
143
144impl Default for PathsConfig {
145 fn default() -> Self {
146 Self {
147 content: default_content_dir(),
148 styles: default_styles_dir(),
149 static_files: default_static_dir(),
150 templates: default_templates_dir(),
151 home: default_home_page(),
152 exclude: Vec::new(),
153 respect_gitignore: true,
154 }
155 }
156}
157
158fn default_content_dir() -> String {
159 "content".to_string()
160}
161fn default_styles_dir() -> String {
162 "styles".to_string()
163}
164fn default_static_dir() -> String {
165 "static".to_string()
166}
167fn default_templates_dir() -> String {
168 "templates".to_string()
169}
170fn default_home_page() -> String {
171 "index.md".to_string()
172}
173
174#[derive(Debug, Deserialize, Clone, Default)]
175pub struct HighlightConfig {
176 #[serde(default)]
177 pub names: Vec<String>,
178 #[serde(default = "default_highlight_class")]
179 pub class: String,
180}
181
182fn default_highlight_class() -> String {
183 "me".to_string()
184}
185
186#[derive(Debug, Deserialize, Clone)]
187pub struct SiteConfig {
188 pub title: String,
189 pub description: String,
190 pub base_url: String,
191 pub author: String,
192}
193
194#[derive(Debug, Deserialize, Clone)]
195pub struct SeoConfig {
196 pub twitter_handle: Option<String>,
197 pub default_og_image: Option<String>,
198}
199
200#[derive(Debug, Deserialize, Clone)]
201pub struct BuildConfig {
202 #[allow(dead_code)]
203 pub output_dir: String,
204 #[serde(default = "default_true")]
205 pub minify_css: bool,
206}
207
208#[derive(Debug, Deserialize, Clone)]
209pub struct ImagesConfig {
210 #[serde(default = "default_quality")]
211 pub quality: f32,
212 #[serde(default = "default_scale_factor")]
213 pub scale_factor: f64,
214}
215
216fn default_true() -> bool {
217 true
218}
219
220fn default_quality() -> f32 {
221 85.0
222}
223
224fn default_scale_factor() -> f64 {
225 1.0
226}
227
228impl Config {
229 pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
230 let content = std::fs::read_to_string(path.as_ref())
231 .with_context(|| format!("Failed to read config file: {:?}", path.as_ref()))?;
232
233 let config: Config =
234 toml::from_str(&content).with_context(|| "Failed to parse config file")?;
235
236 Ok(config)
237 }
238
239 #[cfg(test)]
241 pub fn from_str(content: &str) -> Result<Self> {
242 let config: Config = toml::from_str(content).with_context(|| "Failed to parse config")?;
243 Ok(config)
244 }
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250
251 fn minimal_config() -> &'static str {
252 r#"
253[site]
254title = "Test Site"
255description = "A test site"
256base_url = "https://example.com"
257author = "Test Author"
258
259[seo]
260
261[build]
262output_dir = "dist"
263
264[images]
265"#
266 }
267
268 #[test]
269 fn test_minimal_config() {
270 let config = Config::from_str(minimal_config()).unwrap();
271 assert_eq!(config.site.title, "Test Site");
272 assert_eq!(config.site.base_url, "https://example.com");
273 }
274
275 #[test]
276 fn test_config_with_permalinks() {
277 let content = format!(
278 r#"{}
279[permalinks]
280blog = "/:year/:month/:slug/"
281projects = "/:slug/"
282"#,
283 minimal_config()
284 );
285
286 let config = Config::from_str(&content).unwrap();
287 assert_eq!(
288 config.permalinks.sections.get("blog"),
289 Some(&"/:year/:month/:slug/".to_string())
290 );
291 assert_eq!(
292 config.permalinks.sections.get("projects"),
293 Some(&"/:slug/".to_string())
294 );
295 }
296
297 #[test]
298 fn test_config_with_templates() {
299 let content = format!(
300 r#"{}
301[templates]
302blog = "post.html"
303projects = "project.html"
304"#,
305 minimal_config()
306 );
307
308 let config = Config::from_str(&content).unwrap();
309 assert_eq!(
310 config.templates.sections.get("blog"),
311 Some(&"post.html".to_string())
312 );
313 assert_eq!(
314 config.templates.sections.get("projects"),
315 Some(&"project.html".to_string())
316 );
317 }
318
319 #[test]
320 fn test_config_with_paths() {
321 let content = format!(
322 r#"{}
323[paths]
324content = "my-content"
325styles = "my-styles"
326static_files = "my-static"
327templates = "my-templates"
328home = "home.md"
329exclude = ["drafts", "private"]
330"#,
331 minimal_config()
332 );
333
334 let config = Config::from_str(&content).unwrap();
335 assert_eq!(config.paths.content, "my-content");
336 assert_eq!(config.paths.styles, "my-styles");
337 assert_eq!(config.paths.static_files, "my-static");
338 assert_eq!(config.paths.templates, "my-templates");
339 assert_eq!(config.paths.home, "home.md");
340 assert_eq!(config.paths.exclude, vec!["drafts", "private"]);
341 }
342
343 #[test]
344 fn test_config_defaults() {
345 let config = Config::from_str(minimal_config()).unwrap();
346
347 assert_eq!(config.paths.content, "content");
349 assert_eq!(config.paths.styles, "styles");
350 assert_eq!(config.paths.static_files, "static");
351 assert_eq!(config.paths.templates, "templates");
352 assert_eq!(config.paths.home, "index.md");
353 assert!(config.paths.exclude.is_empty());
354 assert!(config.paths.respect_gitignore);
355
356 assert!(config.templates.sections.is_empty());
358 assert!(config.permalinks.sections.is_empty());
359
360 assert!(config.build.minify_css);
362
363 assert_eq!(config.images.quality, 85.0);
365 assert_eq!(config.images.scale_factor, 1.0);
366 }
367
368 #[test]
369 fn test_config_with_highlight() {
370 let content = format!(
371 r#"{}
372[highlight]
373names = ["John Doe", "Jane Doe"]
374class = "author"
375"#,
376 minimal_config()
377 );
378
379 let config = Config::from_str(&content).unwrap();
380 assert_eq!(config.highlight.names, vec!["John Doe", "Jane Doe"]);
381 assert_eq!(config.highlight.class, "author");
382 }
383
384 #[test]
385 fn test_config_with_encryption() {
386 let content = format!(
387 r#"{}
388[encryption]
389password_command = "pass show website/notes"
390"#,
391 minimal_config()
392 );
393
394 let config = Config::from_str(&content).unwrap();
395 assert_eq!(
396 config.encryption.password_command,
397 Some("pass show website/notes".to_string())
398 );
399 assert!(config.encryption.password.is_none());
400 }
401
402 #[test]
403 fn test_config_encryption_with_raw_password() {
404 let content = format!(
405 r#"{}
406[encryption]
407password = "secret123"
408"#,
409 minimal_config()
410 );
411
412 let config = Config::from_str(&content).unwrap();
413 assert!(config.encryption.password_command.is_none());
414 assert_eq!(config.encryption.password, Some("secret123".to_string()));
415 }
416
417 #[test]
418 fn test_config_encryption_defaults_to_none() {
419 let config = Config::from_str(minimal_config()).unwrap();
420 assert!(config.encryption.password_command.is_none());
421 assert!(config.encryption.password.is_none());
422 }
423
424 #[test]
425 fn test_config_graph_defaults() {
426 let config = Config::from_str(minimal_config()).unwrap();
427 assert!(config.graph.enabled);
428 assert_eq!(config.graph.template, "graph.html");
429 assert_eq!(config.graph.path, "graph");
430 }
431
432 #[test]
433 fn test_config_with_graph() {
434 let content = format!(
435 r#"{}
436[graph]
437enabled = false
438template = "custom-graph.html"
439path = "brain"
440"#,
441 minimal_config()
442 );
443
444 let config = Config::from_str(&content).unwrap();
445 assert!(!config.graph.enabled);
446 assert_eq!(config.graph.template, "custom-graph.html");
447 assert_eq!(config.graph.path, "brain");
448 }
449
450 #[test]
451 fn test_config_rss_defaults() {
452 let config = Config::from_str(minimal_config()).unwrap();
453 assert!(config.rss.enabled);
454 assert_eq!(config.rss.filename, "rss.xml");
455 assert!(config.rss.sections.is_empty());
456 assert_eq!(config.rss.limit, 20);
457 assert!(!config.rss.exclude_encrypted_blocks);
458 }
459
460 #[test]
461 fn test_config_with_rss() {
462 let content = format!(
463 r#"{}
464[rss]
465enabled = true
466filename = "feed.xml"
467sections = ["blog", "notes"]
468limit = 50
469exclude_encrypted_blocks = true
470"#,
471 minimal_config()
472 );
473
474 let config = Config::from_str(&content).unwrap();
475 assert!(config.rss.enabled);
476 assert_eq!(config.rss.filename, "feed.xml");
477 assert_eq!(config.rss.sections, vec!["blog", "notes"]);
478 assert_eq!(config.rss.limit, 50);
479 assert!(config.rss.exclude_encrypted_blocks);
480 }
481}