typstify_core/
config.rs

1//! Site configuration management.
2
3use std::path::Path;
4
5use serde::{Deserialize, Serialize};
6
7use crate::error::{CoreError, Result};
8
9/// Main configuration structure for Typstify.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct Config {
12    /// Site-wide settings.
13    pub site: SiteConfig,
14
15    /// Build settings.
16    #[serde(default)]
17    pub build: BuildConfig,
18
19    /// Search settings.
20    #[serde(default)]
21    pub search: SearchConfig,
22
23    /// RSS feed settings.
24    #[serde(default)]
25    pub rss: RssConfig,
26
27    /// Taxonomy settings.
28    #[serde(default)]
29    pub taxonomies: TaxonomyConfig,
30}
31
32/// Site-wide configuration.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct SiteConfig {
35    /// Site title.
36    pub title: String,
37
38    /// Base URL for the site (e.g., "https://example.com").
39    pub base_url: String,
40
41    /// Default language code.
42    #[serde(default = "default_language")]
43    pub default_language: String,
44
45    /// List of supported languages.
46    #[serde(default = "default_languages")]
47    pub languages: Vec<String>,
48
49    /// Site description for meta tags.
50    #[serde(default)]
51    pub description: Option<String>,
52
53    /// Site author name.
54    #[serde(default)]
55    pub author: Option<String>,
56}
57
58/// Build configuration.
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct BuildConfig {
61    /// Output directory for generated site.
62    #[serde(default = "default_output_dir")]
63    pub output_dir: String,
64
65    /// Whether to minify HTML output.
66    #[serde(default)]
67    pub minify: bool,
68
69    /// Syntax highlighting theme name.
70    #[serde(default = "default_syntax_theme")]
71    pub syntax_theme: String,
72
73    /// Whether to generate drafts.
74    #[serde(default)]
75    pub drafts: bool,
76}
77
78/// Search configuration.
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct SearchConfig {
81    /// Whether search is enabled.
82    #[serde(default = "default_true")]
83    pub enabled: bool,
84
85    /// Fields to include in search index.
86    #[serde(default = "default_index_fields")]
87    pub index_fields: Vec<String>,
88
89    /// Chunk size for index splitting (bytes).
90    #[serde(default = "default_chunk_size")]
91    pub chunk_size: usize,
92}
93
94/// RSS feed configuration.
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct RssConfig {
97    /// Whether RSS feed is enabled.
98    #[serde(default = "default_true")]
99    pub enabled: bool,
100
101    /// Maximum number of items in feed.
102    #[serde(default = "default_rss_limit")]
103    pub limit: usize,
104}
105
106/// Taxonomy configuration.
107#[derive(Debug, Clone, Default, Serialize, Deserialize)]
108pub struct TaxonomyConfig {
109    /// Tags taxonomy settings.
110    #[serde(default)]
111    pub tags: TaxonomySettings,
112
113    /// Categories taxonomy settings.
114    #[serde(default)]
115    pub categories: TaxonomySettings,
116}
117
118/// Settings for a single taxonomy.
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct TaxonomySettings {
121    /// Number of items per page.
122    #[serde(default = "default_paginate")]
123    pub paginate: usize,
124}
125
126// Default value functions
127fn default_language() -> String {
128    "en".to_string()
129}
130
131fn default_languages() -> Vec<String> {
132    vec!["en".to_string()]
133}
134
135fn default_output_dir() -> String {
136    "public".to_string()
137}
138
139fn default_syntax_theme() -> String {
140    "base16-ocean.dark".to_string()
141}
142
143fn default_true() -> bool {
144    true
145}
146
147fn default_index_fields() -> Vec<String> {
148    vec!["title".to_string(), "body".to_string(), "tags".to_string()]
149}
150
151fn default_chunk_size() -> usize {
152    65536 // 64KB
153}
154
155fn default_rss_limit() -> usize {
156    20
157}
158
159fn default_paginate() -> usize {
160    10
161}
162
163impl Default for BuildConfig {
164    fn default() -> Self {
165        Self {
166            output_dir: default_output_dir(),
167            minify: false,
168            syntax_theme: default_syntax_theme(),
169            drafts: false,
170        }
171    }
172}
173
174impl Default for SearchConfig {
175    fn default() -> Self {
176        Self {
177            enabled: true,
178            index_fields: default_index_fields(),
179            chunk_size: default_chunk_size(),
180        }
181    }
182}
183
184impl Default for RssConfig {
185    fn default() -> Self {
186        Self {
187            enabled: true,
188            limit: default_rss_limit(),
189        }
190    }
191}
192
193impl Default for TaxonomySettings {
194    fn default() -> Self {
195        Self {
196            paginate: default_paginate(),
197        }
198    }
199}
200
201impl Config {
202    /// Load configuration from a TOML file.
203    pub fn load(path: &Path) -> Result<Self> {
204        if !path.exists() {
205            return Err(CoreError::config(format!(
206                "Configuration file not found: {}",
207                path.display()
208            )));
209        }
210
211        let content = std::fs::read_to_string(path)?;
212        let config: Config = toml::from_str(&content).map_err(|e| {
213            CoreError::config_with_source(
214                format!("Failed to parse config file: {}", path.display()),
215                e,
216            )
217        })?;
218
219        config.validate()?;
220        Ok(config)
221    }
222
223    /// Load configuration using the config crate for more flexibility.
224    pub fn load_with_env(path: &Path) -> Result<Self> {
225        let settings = config::Config::builder()
226            .add_source(config::File::from(path))
227            .add_source(config::Environment::with_prefix("TYPSTIFY").separator("__"))
228            .build()?;
229
230        let config: Config = settings.try_deserialize()?;
231        config.validate()?;
232        Ok(config)
233    }
234
235    /// Validate the configuration.
236    fn validate(&self) -> Result<()> {
237        if self.site.title.is_empty() {
238            return Err(CoreError::config("site.title cannot be empty"));
239        }
240
241        if self.site.base_url.is_empty() {
242            return Err(CoreError::config("site.base_url cannot be empty"));
243        }
244
245        // Ensure base_url doesn't have trailing slash
246        if self.site.base_url.ends_with('/') {
247            tracing::warn!("site.base_url should not have a trailing slash");
248        }
249
250        Ok(())
251    }
252
253    /// Get the full URL for a path.
254    pub fn url_for(&self, path: &str) -> String {
255        let base = self.site.base_url.trim_end_matches('/');
256        let path = path.trim_start_matches('/');
257        format!("{base}/{path}")
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use std::io::Write;
264
265    use super::*;
266
267    fn create_test_config() -> String {
268        r#"
269[site]
270title = "Test Site"
271base_url = "https://example.com"
272default_language = "en"
273languages = ["en", "zh"]
274
275[build]
276output_dir = "dist"
277minify = true
278syntax_theme = "OneHalfDark"
279
280[search]
281enabled = true
282chunk_size = 32768
283
284[rss]
285limit = 15
286
287[taxonomies.tags]
288paginate = 20
289"#
290        .to_string()
291    }
292
293    #[test]
294    fn test_load_config() {
295        let dir = tempfile::tempdir().expect("create temp dir");
296        let config_path = dir.path().join("config.toml");
297        let mut file = std::fs::File::create(&config_path).expect("create file");
298        file.write_all(create_test_config().as_bytes())
299            .expect("write");
300
301        let config = Config::load(&config_path).expect("load config");
302
303        assert_eq!(config.site.title, "Test Site");
304        assert_eq!(config.site.base_url, "https://example.com");
305        assert_eq!(config.site.default_language, "en");
306        assert_eq!(config.site.languages, vec!["en", "zh"]);
307        assert_eq!(config.build.output_dir, "dist");
308        assert!(config.build.minify);
309        assert_eq!(config.build.syntax_theme, "OneHalfDark");
310        assert!(config.search.enabled);
311        assert_eq!(config.search.chunk_size, 32768);
312        assert_eq!(config.rss.limit, 15);
313        assert_eq!(config.taxonomies.tags.paginate, 20);
314    }
315
316    #[test]
317    fn test_config_defaults() {
318        let dir = tempfile::tempdir().expect("create temp dir");
319        let config_path = dir.path().join("config.toml");
320        let minimal_config = r#"
321[site]
322title = "Minimal Site"
323base_url = "https://example.com"
324"#;
325        std::fs::write(&config_path, minimal_config).expect("write");
326
327        let config = Config::load(&config_path).expect("load config");
328
329        assert_eq!(config.site.default_language, "en");
330        assert_eq!(config.build.output_dir, "public");
331        assert!(!config.build.minify);
332        assert!(config.search.enabled);
333        assert_eq!(config.search.chunk_size, 65536);
334        assert_eq!(config.rss.limit, 20);
335    }
336
337    #[test]
338    fn test_url_for() {
339        let dir = tempfile::tempdir().expect("create temp dir");
340        let config_path = dir.path().join("config.toml");
341        let config_content = r#"
342[site]
343title = "Test"
344base_url = "https://example.com"
345"#;
346        std::fs::write(&config_path, config_content).expect("write");
347
348        let config = Config::load(&config_path).expect("load config");
349
350        assert_eq!(
351            config.url_for("/posts/hello"),
352            "https://example.com/posts/hello"
353        );
354        assert_eq!(
355            config.url_for("posts/hello"),
356            "https://example.com/posts/hello"
357        );
358    }
359
360    #[test]
361    fn test_config_validation_empty_title() {
362        let dir = tempfile::tempdir().expect("create temp dir");
363        let config_path = dir.path().join("config.toml");
364        let config_content = r#"
365[site]
366title = ""
367base_url = "https://example.com"
368"#;
369        std::fs::write(&config_path, config_content).expect("write");
370
371        let result = Config::load(&config_path);
372        assert!(result.is_err());
373        assert!(
374            result
375                .unwrap_err()
376                .to_string()
377                .contains("title cannot be empty")
378        );
379    }
380
381    #[test]
382    fn test_config_not_found() {
383        let result = Config::load(Path::new("/nonexistent/config.toml"));
384        assert!(result.is_err());
385        assert!(result.unwrap_err().to_string().contains("not found"));
386    }
387}