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    /// Patterns to exclude (supports regex). Matches both directories and files.
168    #[serde(default)]
169    pub exclude: Vec<String>,
170    /// Include default exclusions like README.md, LICENSE.md, etc. (default: true)
171    #[serde(default = "default_true")]
172    pub exclude_defaults: bool,
173    /// Respect .gitignore when discovering content (default: true)
174    #[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    /// Parse config from string (for testing)
281    #[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        // Paths defaults
389        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        // Templates and permalinks default to empty
399        assert!(config.templates.sections.is_empty());
400        assert!(config.permalinks.sections.is_empty());
401
402        // Build defaults
403        assert!(config.build.minify_css);
404
405        // Images defaults
406        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); // Disabled by default
528        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()); // All sections
566        assert!(!config.text.exclude_encrypted);
567        assert!(config.text.include_home);
568    }
569}