1use std::{collections::HashMap, 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 robots: RobotsConfig,
30
31 #[serde(default)]
33 pub taxonomies: TaxonomyConfig,
34
35 #[serde(default)]
37 pub languages: HashMap<String, LanguageConfig>,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct SiteConfig {
43 pub title: String,
45
46 pub base_url: String,
48
49 #[serde(default = "default_language")]
51 pub default_language: String,
52
53 #[serde(default)]
55 pub description: Option<String>,
56
57 #[serde(default)]
59 pub author: Option<String>,
60}
61
62#[derive(Debug, Clone, Default, Serialize, Deserialize)]
64pub struct LanguageConfig {
65 #[serde(default)]
67 pub name: Option<String>,
68
69 #[serde(default)]
71 pub title: Option<String>,
72
73 #[serde(default)]
75 pub description: Option<String>,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct BuildConfig {
81 #[serde(default = "default_output_dir")]
83 pub output_dir: String,
84
85 #[serde(default)]
87 pub minify: bool,
88
89 #[serde(default = "default_syntax_theme")]
91 pub syntax_theme: String,
92
93 #[serde(default)]
95 pub drafts: bool,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct SearchConfig {
101 #[serde(default = "default_true")]
103 pub enabled: bool,
104
105 #[serde(default = "default_index_fields")]
107 pub index_fields: Vec<String>,
108
109 #[serde(default = "default_chunk_size")]
111 pub chunk_size: usize,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct RssConfig {
117 #[serde(default = "default_true")]
119 pub enabled: bool,
120
121 #[serde(default = "default_rss_limit")]
123 pub limit: usize,
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct RobotsConfig {
129 #[serde(default = "default_true")]
131 pub enabled: bool,
132
133 #[serde(default)]
135 pub disallow: Vec<String>,
136
137 #[serde(default)]
139 pub allow: Vec<String>,
140}
141
142#[derive(Debug, Clone, Default, Serialize, Deserialize)]
144pub struct TaxonomyConfig {
145 #[serde(default)]
147 pub tags: TaxonomySettings,
148
149 #[serde(default)]
151 pub categories: TaxonomySettings,
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct TaxonomySettings {
157 #[serde(default = "default_paginate")]
159 pub paginate: usize,
160}
161
162fn default_language() -> String {
164 "en".to_string()
165}
166
167fn default_output_dir() -> String {
168 "public".to_string()
169}
170
171fn default_syntax_theme() -> String {
172 "base16-ocean.dark".to_string()
173}
174
175fn default_true() -> bool {
176 true
177}
178
179fn default_index_fields() -> Vec<String> {
180 vec!["title".to_string(), "body".to_string(), "tags".to_string()]
181}
182
183fn default_chunk_size() -> usize {
184 65536 }
186
187fn default_rss_limit() -> usize {
188 20
189}
190
191fn default_paginate() -> usize {
192 10
193}
194
195impl Default for BuildConfig {
196 fn default() -> Self {
197 Self {
198 output_dir: default_output_dir(),
199 minify: false,
200 syntax_theme: default_syntax_theme(),
201 drafts: false,
202 }
203 }
204}
205
206impl Default for SearchConfig {
207 fn default() -> Self {
208 Self {
209 enabled: true,
210 index_fields: default_index_fields(),
211 chunk_size: default_chunk_size(),
212 }
213 }
214}
215
216impl Default for RssConfig {
217 fn default() -> Self {
218 Self {
219 enabled: true,
220 limit: default_rss_limit(),
221 }
222 }
223}
224
225impl Default for RobotsConfig {
226 fn default() -> Self {
227 Self {
228 enabled: true,
229 disallow: Vec::new(),
230 allow: Vec::new(),
231 }
232 }
233}
234
235impl Default for TaxonomySettings {
236 fn default() -> Self {
237 Self {
238 paginate: default_paginate(),
239 }
240 }
241}
242
243impl Config {
244 pub fn load(path: &Path) -> Result<Self> {
246 if !path.exists() {
247 return Err(CoreError::config(format!(
248 "Configuration file not found: {}",
249 path.display()
250 )));
251 }
252
253 let content = std::fs::read_to_string(path)?;
254 let config: Config = toml::from_str(&content).map_err(|e| {
255 CoreError::config_with_source(
256 format!("Failed to parse config file: {}", path.display()),
257 e,
258 )
259 })?;
260
261 config.validate()?;
262 Ok(config)
263 }
264
265 pub fn load_with_env(path: &Path) -> Result<Self> {
267 let settings = config::Config::builder()
268 .add_source(config::File::from(path))
269 .add_source(config::Environment::with_prefix("TYPSTIFY").separator("__"))
270 .build()?;
271
272 let config: Config = settings.try_deserialize()?;
273 config.validate()?;
274 Ok(config)
275 }
276
277 fn validate(&self) -> Result<()> {
279 if self.site.title.is_empty() {
280 return Err(CoreError::config("site.title cannot be empty"));
281 }
282
283 if self.site.base_url.is_empty() {
284 return Err(CoreError::config("site.base_url cannot be empty"));
285 }
286
287 if self.site.base_url.ends_with('/') {
289 tracing::warn!("site.base_url should not have a trailing slash");
290 }
291
292 Ok(())
293 }
294
295 pub fn url_for(&self, path: &str) -> String {
297 let base = self.site.base_url.trim_end_matches('/');
298 let path = path.trim_start_matches('/');
299 format!("{base}/{path}")
300 }
301
302 #[must_use]
304 pub fn has_language(&self, lang: &str) -> bool {
305 lang == self.site.default_language || self.languages.contains_key(lang)
306 }
307
308 #[must_use]
310 pub fn all_languages(&self) -> Vec<&str> {
311 let mut langs: Vec<&str> = vec![self.site.default_language.as_str()];
312 for lang in self.languages.keys() {
313 if lang != &self.site.default_language {
314 langs.push(lang.as_str());
315 }
316 }
317 langs
318 }
319
320 #[must_use]
322 pub fn title_for_language(&self, lang: &str) -> &str {
323 self.languages
324 .get(lang)
325 .and_then(|lc| lc.title.as_deref())
326 .unwrap_or(&self.site.title)
327 }
328
329 #[must_use]
331 pub fn description_for_language(&self, lang: &str) -> Option<&str> {
332 self.languages
333 .get(lang)
334 .and_then(|lc| lc.description.as_deref())
335 .or(self.site.description.as_deref())
336 }
337
338 #[must_use]
340 pub fn language_name<'a>(&'a self, lang: &'a str) -> &'a str {
341 self.languages
342 .get(lang)
343 .and_then(|lc| lc.name.as_deref())
344 .unwrap_or(lang)
345 }
346}
347
348#[cfg(test)]
349mod tests {
350 use std::io::Write;
351
352 use super::*;
353
354 fn create_test_config() -> String {
355 r#"
356[site]
357title = "Test Site"
358base_url = "https://example.com"
359default_language = "en"
360
361[languages.zh]
362name = "中文"
363title = "测试站点"
364description = "一个测试站点"
365
366[build]
367output_dir = "dist"
368minify = true
369syntax_theme = "OneHalfDark"
370
371[search]
372enabled = true
373chunk_size = 32768
374
375[rss]
376limit = 15
377
378[taxonomies.tags]
379paginate = 20
380"#
381 .to_string()
382 }
383
384 #[test]
385 fn test_load_config() {
386 let dir = tempfile::tempdir().expect("create temp dir");
387 let config_path = dir.path().join("config.toml");
388 let mut file = std::fs::File::create(&config_path).expect("create file");
389 file.write_all(create_test_config().as_bytes())
390 .expect("write");
391
392 let config = Config::load(&config_path).expect("load config");
393
394 assert_eq!(config.site.title, "Test Site");
395 assert_eq!(config.site.base_url, "https://example.com");
396 assert_eq!(config.site.default_language, "en");
397 assert!(config.has_language("en"));
398 assert!(config.has_language("zh"));
399 assert!(!config.has_language("ja"));
400 assert_eq!(config.title_for_language("zh"), "测试站点");
401 assert_eq!(config.title_for_language("en"), "Test Site");
402 assert_eq!(config.language_name("zh"), "中文");
403 assert_eq!(config.build.output_dir, "dist");
404 assert!(config.build.minify);
405 assert_eq!(config.build.syntax_theme, "OneHalfDark");
406 assert!(config.search.enabled);
407 assert_eq!(config.search.chunk_size, 32768);
408 assert_eq!(config.rss.limit, 15);
409 assert_eq!(config.taxonomies.tags.paginate, 20);
410 }
411
412 #[test]
413 fn test_config_defaults() {
414 let dir = tempfile::tempdir().expect("create temp dir");
415 let config_path = dir.path().join("config.toml");
416 let minimal_config = r#"
417[site]
418title = "Minimal Site"
419base_url = "https://example.com"
420"#;
421 std::fs::write(&config_path, minimal_config).expect("write");
422
423 let config = Config::load(&config_path).expect("load config");
424
425 assert_eq!(config.site.default_language, "en");
426 assert_eq!(config.build.output_dir, "public");
427 assert!(!config.build.minify);
428 assert!(config.search.enabled);
429 assert_eq!(config.search.chunk_size, 65536);
430 assert_eq!(config.rss.limit, 20);
431 }
432
433 #[test]
434 fn test_url_for() {
435 let dir = tempfile::tempdir().expect("create temp dir");
436 let config_path = dir.path().join("config.toml");
437 let config_content = r#"
438[site]
439title = "Test"
440base_url = "https://example.com"
441"#;
442 std::fs::write(&config_path, config_content).expect("write");
443
444 let config = Config::load(&config_path).expect("load config");
445
446 assert_eq!(
447 config.url_for("/posts/hello"),
448 "https://example.com/posts/hello"
449 );
450 assert_eq!(
451 config.url_for("posts/hello"),
452 "https://example.com/posts/hello"
453 );
454 }
455
456 #[test]
457 fn test_config_validation_empty_title() {
458 let dir = tempfile::tempdir().expect("create temp dir");
459 let config_path = dir.path().join("config.toml");
460 let config_content = r#"
461[site]
462title = ""
463base_url = "https://example.com"
464"#;
465 std::fs::write(&config_path, config_content).expect("write");
466
467 let result = Config::load(&config_path);
468 assert!(result.is_err());
469 assert!(
470 result
471 .unwrap_err()
472 .to_string()
473 .contains("title cannot be empty")
474 );
475 }
476
477 #[test]
478 fn test_config_not_found() {
479 let result = Config::load(Path::new("/nonexistent/config.toml"));
480 assert!(result.is_err());
481 assert!(result.unwrap_err().to_string().contains("not found"));
482 }
483}