rs_web/
config.rs

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/// RSS feed config
33#[derive(Debug, Deserialize, Clone)]
34pub struct RssConfig {
35    /// Enable RSS generation
36    #[serde(default = "default_true")]
37    pub enabled: bool,
38    /// Output filename
39    #[serde(default = "default_rss_filename")]
40    pub filename: String,
41    /// Sections to include (empty = all)
42    #[serde(default)]
43    pub sections: Vec<String>,
44    /// Maximum number of items
45    #[serde(default = "default_rss_limit")]
46    pub limit: usize,
47    /// Exclude posts with encrypted blocks
48    #[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/// Plain text output config for curl-friendly pages
73#[derive(Debug, Deserialize, Clone)]
74pub struct TextConfig {
75    /// Enable text generation (default: false)
76    #[serde(default)]
77    pub enabled: bool,
78    /// Sections to include (empty = all)
79    #[serde(default)]
80    pub sections: Vec<String>,
81    /// Exclude posts with encrypted content
82    #[serde(default)]
83    pub exclude_encrypted: bool,
84    /// Include home page
85    #[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/// Graph visualization config
101#[derive(Debug, Deserialize, Clone)]
102pub struct GraphConfig {
103    /// Enable graph generation
104    #[serde(default = "default_true")]
105    pub enabled: bool,
106    /// Template for the full graph page
107    #[serde(default = "default_graph_template")]
108    pub template: String,
109    /// Output path for the graph page (e.g., "graph" -> /graph/)
110    #[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/// Template mapping: section name -> template file
133#[derive(Debug, Deserialize, Clone, Default)]
134pub struct TemplatesConfig {
135    #[serde(flatten)]
136    pub sections: HashMap<String, String>,
137}
138
139/// Permalink patterns: section name -> pattern
140/// Patterns can use: :year, :month, :day, :slug, :title, :section
141#[derive(Debug, Deserialize, Clone, Default)]
142pub struct PermalinksConfig {
143    #[serde(flatten)]
144    pub sections: HashMap<String, String>,
145}
146
147/// Section-specific configuration
148/// Allows customizing how each section discovers and processes content
149#[derive(Debug, Deserialize, Clone, Default)]
150pub struct SectionsConfig {
151    #[serde(flatten)]
152    pub sections: HashMap<String, SectionConfig>,
153}
154
155/// Configuration for a single section
156#[derive(Debug, Deserialize, Clone)]
157pub struct SectionConfig {
158    /// How to iterate content: "files" (default) or "directories"
159    /// - "files": Look for .md/.html files (default behavior)
160    /// - "directories": Each subdirectory becomes a post with source_dir
161    #[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/// Encryption config for password-protected posts
178/// Password resolution order: SITE_PASSWORD env var → password_command → password
179#[derive(Debug, Deserialize, Clone, Default)]
180pub struct EncryptionConfig {
181    /// Command to execute to get the password (e.g., "pass show website/encrypted-notes")
182    pub password_command: Option<String>,
183    /// Raw password (less secure, prefer env var or command)
184    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    /// Patterns to exclude (supports regex). Matches both directories and files.
200    #[serde(default)]
201    pub exclude: Vec<String>,
202    /// Include default exclusions like README.md, LICENSE.md, etc. (default: true)
203    #[serde(default = "default_true")]
204    pub exclude_defaults: bool,
205    /// Respect .gitignore when discovering content (default: true)
206    #[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    /// Parse config from string (for testing)
313    #[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        // Paths defaults
421        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        // Templates and permalinks default to empty
431        assert!(config.templates.sections.is_empty());
432        assert!(config.permalinks.sections.is_empty());
433
434        // Build defaults
435        assert!(config.build.minify_css);
436
437        // Images defaults
438        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); // Disabled by default
560        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()); // All sections
598        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}