1use std::path::Path;
4
5use serde::{Deserialize, Serialize};
6
7use crate::error::{CoreError, Result};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct Config {
12 pub site: SiteConfig,
14
15 #[serde(default)]
17 pub build: BuildConfig,
18
19 #[serde(default)]
21 pub search: SearchConfig,
22
23 #[serde(default)]
25 pub rss: RssConfig,
26
27 #[serde(default)]
29 pub taxonomies: TaxonomyConfig,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct SiteConfig {
35 pub title: String,
37
38 pub base_url: String,
40
41 #[serde(default = "default_language")]
43 pub default_language: String,
44
45 #[serde(default = "default_languages")]
47 pub languages: Vec<String>,
48
49 #[serde(default)]
51 pub description: Option<String>,
52
53 #[serde(default)]
55 pub author: Option<String>,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct BuildConfig {
61 #[serde(default = "default_output_dir")]
63 pub output_dir: String,
64
65 #[serde(default)]
67 pub minify: bool,
68
69 #[serde(default = "default_syntax_theme")]
71 pub syntax_theme: String,
72
73 #[serde(default)]
75 pub drafts: bool,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct SearchConfig {
81 #[serde(default = "default_true")]
83 pub enabled: bool,
84
85 #[serde(default = "default_index_fields")]
87 pub index_fields: Vec<String>,
88
89 #[serde(default = "default_chunk_size")]
91 pub chunk_size: usize,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct RssConfig {
97 #[serde(default = "default_true")]
99 pub enabled: bool,
100
101 #[serde(default = "default_rss_limit")]
103 pub limit: usize,
104}
105
106#[derive(Debug, Clone, Default, Serialize, Deserialize)]
108pub struct TaxonomyConfig {
109 #[serde(default)]
111 pub tags: TaxonomySettings,
112
113 #[serde(default)]
115 pub categories: TaxonomySettings,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct TaxonomySettings {
121 #[serde(default = "default_paginate")]
123 pub paginate: usize,
124}
125
126fn 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 }
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 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 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 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 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 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}