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}
29
30/// RSS feed config
31#[derive(Debug, Deserialize, Clone)]
32pub struct RssConfig {
33    /// Enable RSS generation
34    #[serde(default = "default_true")]
35    pub enabled: bool,
36    /// Output filename
37    #[serde(default = "default_rss_filename")]
38    pub filename: String,
39    /// Sections to include (empty = all)
40    #[serde(default)]
41    pub sections: Vec<String>,
42    /// Maximum number of items
43    #[serde(default = "default_rss_limit")]
44    pub limit: usize,
45    /// Exclude posts with encrypted blocks
46    #[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/// Plain text output config for curl-friendly pages
71#[derive(Debug, Deserialize, Clone)]
72pub struct TextConfig {
73    /// Enable text generation (default: false)
74    #[serde(default)]
75    pub enabled: bool,
76    /// Sections to include (empty = all)
77    #[serde(default)]
78    pub sections: Vec<String>,
79    /// Exclude posts with encrypted content
80    #[serde(default)]
81    pub exclude_encrypted: bool,
82    /// Include home page
83    #[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/// Graph visualization config
99#[derive(Debug, Deserialize, Clone)]
100pub struct GraphConfig {
101    /// Enable graph generation
102    #[serde(default = "default_true")]
103    pub enabled: bool,
104    /// Template for the full graph page
105    #[serde(default = "default_graph_template")]
106    pub template: String,
107    /// Output path for the graph page (e.g., "graph" -> /graph/)
108    #[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/// Template mapping: section name -> template file
131#[derive(Debug, Deserialize, Clone, Default)]
132pub struct TemplatesConfig {
133    #[serde(flatten)]
134    pub sections: HashMap<String, String>,
135}
136
137/// Permalink patterns: section name -> pattern
138/// Patterns can use: :year, :month, :day, :slug, :title, :section
139#[derive(Debug, Deserialize, Clone, Default)]
140pub struct PermalinksConfig {
141    #[serde(flatten)]
142    pub sections: HashMap<String, String>,
143}
144
145/// Encryption config for password-protected posts
146/// Password resolution order: SITE_PASSWORD env var → password_command → password
147#[derive(Debug, Deserialize, Clone, Default)]
148pub struct EncryptionConfig {
149    /// Command to execute to get the password (e.g., "pass show website/encrypted-notes")
150    pub password_command: Option<String>,
151    /// Raw password (less secure, prefer env var or command)
152    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    /// Respect .gitignore when discovering content (default: true)
170    #[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    /// Parse config from string (for testing)
276    #[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        // Paths defaults
384        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        // Templates and permalinks default to empty
393        assert!(config.templates.sections.is_empty());
394        assert!(config.permalinks.sections.is_empty());
395
396        // Build defaults
397        assert!(config.build.minify_css);
398
399        // Images defaults
400        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); // Disabled by default
522        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()); // All sections
560        assert!(!config.text.exclude_encrypted);
561        assert!(config.text.include_home);
562    }
563}