use std::{collections::HashMap, path::Path};
use serde::{Deserialize, Serialize};
use crate::error::{CoreError, Result};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
pub site: SiteConfig,
#[serde(default)]
pub build: BuildConfig,
#[serde(default)]
pub search: SearchConfig,
#[serde(default)]
pub rss: RssConfig,
#[serde(default)]
pub robots: RobotsConfig,
#[serde(default)]
pub taxonomies: TaxonomyConfig,
#[serde(default)]
pub languages: HashMap<String, LanguageConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SiteConfig {
pub title: String,
pub base_url: String,
#[serde(default = "default_language")]
pub default_language: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub author: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct LanguageConfig {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BuildConfig {
#[serde(default = "default_output_dir")]
pub output_dir: String,
#[serde(default)]
pub minify: bool,
#[serde(default = "default_syntax_theme")]
pub syntax_theme: String,
#[serde(default)]
pub drafts: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_index_fields")]
pub index_fields: Vec<String>,
#[serde(default = "default_chunk_size")]
pub chunk_size: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RssConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_rss_limit")]
pub limit: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RobotsConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub disallow: Vec<String>,
#[serde(default)]
pub allow: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TaxonomyConfig {
#[serde(default)]
pub tags: TaxonomySettings,
#[serde(default)]
pub categories: TaxonomySettings,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaxonomySettings {
#[serde(default = "default_paginate")]
pub paginate: usize,
}
fn default_language() -> String {
"en".to_string()
}
fn default_output_dir() -> String {
"public".to_string()
}
fn default_syntax_theme() -> String {
"base16-ocean.dark".to_string()
}
fn default_true() -> bool {
true
}
fn default_index_fields() -> Vec<String> {
vec!["title".to_string(), "body".to_string(), "tags".to_string()]
}
fn default_chunk_size() -> usize {
65536 }
fn default_rss_limit() -> usize {
20
}
fn default_paginate() -> usize {
10
}
impl Default for BuildConfig {
fn default() -> Self {
Self {
output_dir: default_output_dir(),
minify: false,
syntax_theme: default_syntax_theme(),
drafts: false,
}
}
}
impl Default for SearchConfig {
fn default() -> Self {
Self {
enabled: true,
index_fields: default_index_fields(),
chunk_size: default_chunk_size(),
}
}
}
impl Default for RssConfig {
fn default() -> Self {
Self {
enabled: true,
limit: default_rss_limit(),
}
}
}
impl Default for RobotsConfig {
fn default() -> Self {
Self {
enabled: true,
disallow: Vec::new(),
allow: Vec::new(),
}
}
}
impl Default for TaxonomySettings {
fn default() -> Self {
Self {
paginate: default_paginate(),
}
}
}
impl Config {
pub fn load(path: &Path) -> Result<Self> {
if !path.exists() {
return Err(CoreError::config(format!(
"Configuration file not found: {}",
path.display()
)));
}
let content = std::fs::read_to_string(path)?;
let config: Config = toml::from_str(&content).map_err(|e| {
CoreError::config_with_source(
format!("Failed to parse config file: {}", path.display()),
e,
)
})?;
config.validate()?;
Ok(config)
}
pub fn load_with_env(path: &Path) -> Result<Self> {
let settings = config::Config::builder()
.add_source(config::File::from(path))
.add_source(config::Environment::with_prefix("TYPSTIFY").separator("__"))
.build()?;
let config: Config = settings.try_deserialize()?;
config.validate()?;
Ok(config)
}
fn validate(&self) -> Result<()> {
if self.site.title.is_empty() {
return Err(CoreError::config("site.title cannot be empty"));
}
if self.site.base_url.is_empty() {
return Err(CoreError::config("site.base_url cannot be empty"));
}
if self.site.base_url.ends_with('/') {
tracing::warn!("site.base_url should not have a trailing slash");
}
Ok(())
}
pub fn url_for(&self, path: &str) -> String {
let base = self.site.base_url.trim_end_matches('/');
let path = path.trim_start_matches('/');
format!("{base}/{path}")
}
#[must_use]
pub fn has_language(&self, lang: &str) -> bool {
lang == self.site.default_language || self.languages.contains_key(lang)
}
#[must_use]
pub fn all_languages(&self) -> Vec<&str> {
let mut langs: Vec<&str> = vec![self.site.default_language.as_str()];
for lang in self.languages.keys() {
if lang != &self.site.default_language {
langs.push(lang.as_str());
}
}
langs
}
#[must_use]
pub fn title_for_language(&self, lang: &str) -> &str {
self.languages
.get(lang)
.and_then(|lc| lc.title.as_deref())
.unwrap_or(&self.site.title)
}
#[must_use]
pub fn description_for_language(&self, lang: &str) -> Option<&str> {
self.languages
.get(lang)
.and_then(|lc| lc.description.as_deref())
.or(self.site.description.as_deref())
}
#[must_use]
pub fn language_name<'a>(&'a self, lang: &'a str) -> &'a str {
self.languages
.get(lang)
.and_then(|lc| lc.name.as_deref())
.unwrap_or(lang)
}
}
#[cfg(test)]
mod tests {
use std::io::Write;
use super::*;
fn create_test_config() -> String {
r#"
[site]
title = "Test Site"
base_url = "https://example.com"
default_language = "en"
[languages.zh]
name = "中文"
title = "测试站点"
description = "一个测试站点"
[build]
output_dir = "dist"
minify = true
syntax_theme = "OneHalfDark"
[search]
enabled = true
chunk_size = 32768
[rss]
limit = 15
[taxonomies.tags]
paginate = 20
"#
.to_string()
}
#[test]
fn test_load_config() {
let dir = tempfile::tempdir().expect("create temp dir");
let config_path = dir.path().join("config.toml");
let mut file = std::fs::File::create(&config_path).expect("create file");
file.write_all(create_test_config().as_bytes())
.expect("write");
let config = Config::load(&config_path).expect("load config");
assert_eq!(config.site.title, "Test Site");
assert_eq!(config.site.base_url, "https://example.com");
assert_eq!(config.site.default_language, "en");
assert!(config.has_language("en"));
assert!(config.has_language("zh"));
assert!(!config.has_language("ja"));
assert_eq!(config.title_for_language("zh"), "测试站点");
assert_eq!(config.title_for_language("en"), "Test Site");
assert_eq!(config.language_name("zh"), "中文");
assert_eq!(config.build.output_dir, "dist");
assert!(config.build.minify);
assert_eq!(config.build.syntax_theme, "OneHalfDark");
assert!(config.search.enabled);
assert_eq!(config.search.chunk_size, 32768);
assert_eq!(config.rss.limit, 15);
assert_eq!(config.taxonomies.tags.paginate, 20);
}
#[test]
fn test_config_defaults() {
let dir = tempfile::tempdir().expect("create temp dir");
let config_path = dir.path().join("config.toml");
let minimal_config = r#"
[site]
title = "Minimal Site"
base_url = "https://example.com"
"#;
std::fs::write(&config_path, minimal_config).expect("write");
let config = Config::load(&config_path).expect("load config");
assert_eq!(config.site.default_language, "en");
assert_eq!(config.build.output_dir, "public");
assert!(!config.build.minify);
assert!(config.search.enabled);
assert_eq!(config.search.chunk_size, 65536);
assert_eq!(config.rss.limit, 20);
}
#[test]
fn test_url_for() {
let dir = tempfile::tempdir().expect("create temp dir");
let config_path = dir.path().join("config.toml");
let config_content = r#"
[site]
title = "Test"
base_url = "https://example.com"
"#;
std::fs::write(&config_path, config_content).expect("write");
let config = Config::load(&config_path).expect("load config");
assert_eq!(
config.url_for("/posts/hello"),
"https://example.com/posts/hello"
);
assert_eq!(
config.url_for("posts/hello"),
"https://example.com/posts/hello"
);
}
#[test]
fn test_config_validation_empty_title() {
let dir = tempfile::tempdir().expect("create temp dir");
let config_path = dir.path().join("config.toml");
let config_content = r#"
[site]
title = ""
base_url = "https://example.com"
"#;
std::fs::write(&config_path, config_content).expect("write");
let result = Config::load(&config_path);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("title cannot be empty")
);
}
#[test]
fn test_config_not_found() {
let result = Config::load(Path::new("/nonexistent/config.toml"));
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not found"));
}
}