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}
27
28/// RSS feed config
29#[derive(Debug, Deserialize, Clone)]
30pub struct RssConfig {
31    /// Enable RSS generation
32    #[serde(default = "default_true")]
33    pub enabled: bool,
34    /// Output filename
35    #[serde(default = "default_rss_filename")]
36    pub filename: String,
37    /// Sections to include (empty = all)
38    #[serde(default)]
39    pub sections: Vec<String>,
40    /// Maximum number of items
41    #[serde(default = "default_rss_limit")]
42    pub limit: usize,
43    /// Exclude posts with encrypted blocks
44    #[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/// Graph visualization config
69#[derive(Debug, Deserialize, Clone)]
70pub struct GraphConfig {
71    /// Enable graph generation
72    #[serde(default = "default_true")]
73    pub enabled: bool,
74    /// Template for the full graph page
75    #[serde(default = "default_graph_template")]
76    pub template: String,
77    /// Output path for the graph page (e.g., "graph" -> /graph/)
78    #[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/// Template mapping: section name -> template file
101#[derive(Debug, Deserialize, Clone, Default)]
102pub struct TemplatesConfig {
103    #[serde(flatten)]
104    pub sections: HashMap<String, String>,
105}
106
107/// Permalink patterns: section name -> pattern
108/// Patterns can use: :year, :month, :day, :slug, :title, :section
109#[derive(Debug, Deserialize, Clone, Default)]
110pub struct PermalinksConfig {
111    #[serde(flatten)]
112    pub sections: HashMap<String, String>,
113}
114
115/// Encryption config for password-protected posts
116/// Password resolution order: SITE_PASSWORD env var → password_command → password
117#[derive(Debug, Deserialize, Clone, Default)]
118pub struct EncryptionConfig {
119    /// Command to execute to get the password (e.g., "pass show website/encrypted-notes")
120    pub password_command: Option<String>,
121    /// Raw password (less secure, prefer env var or command)
122    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    /// Respect .gitignore when discovering content (default: true)
140    #[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    /// Parse config from string (for testing)
240    #[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        // Paths defaults
348        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        // Templates and permalinks default to empty
357        assert!(config.templates.sections.is_empty());
358        assert!(config.permalinks.sections.is_empty());
359
360        // Build defaults
361        assert!(config.build.minify_css);
362
363        // Images defaults
364        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}