use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Config {
pub base_url: String,
#[serde(default)]
pub title: String,
#[serde(default)]
pub description: String,
#[serde(default = "default_en")]
pub default_language: String,
#[serde(default = "default_true", skip_serializing)]
pub compile_sass: bool,
#[serde(default)]
pub generate_feed: bool,
#[serde(default = "default_true", skip_serializing)]
pub generate_sitemap: bool,
#[serde(default = "default_true", skip_serializing)]
pub generate_llms_txt: bool,
#[serde(default)]
pub markdown: MarkdownConfig,
#[serde(default, skip_serializing)]
pub theme: Option<String>,
#[serde(default = "default_toml_table", serialize_with = "serialize_extra")]
pub extra: toml::Value,
#[serde(default, skip_serializing)]
pub taxonomies: Vec<TaxonomyConfig>,
#[serde(default)]
pub generate_search: bool,
#[serde(default)]
pub generate_md_files: bool,
#[serde(default)]
pub compile_all_themes: bool,
#[serde(default, skip_serializing)]
pub content_dirs: Vec<ContentDirConfig>,
#[serde(default, skip_serializing)]
pub cache: CacheConfig,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AnchorLinks {
#[default]
None,
Right,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SortBy {
#[default]
Date,
Title,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct MarkdownConfig {
#[serde(default = "default_true")]
pub highlight_code: bool,
#[serde(default)]
pub insert_anchor_links: AnchorLinks,
#[serde(default)]
pub highlight_theme: Option<String>,
#[serde(default)]
pub external_links_target_blank: bool,
#[serde(default)]
pub external_links_no_follow: bool,
#[serde(default)]
pub external_links_no_referrer: bool,
#[serde(default)]
pub smart_punctuation: bool,
}
impl Default for MarkdownConfig {
fn default() -> Self {
Self {
highlight_code: true,
insert_anchor_links: AnchorLinks::None,
highlight_theme: None,
external_links_target_blank: false,
external_links_no_follow: false,
external_links_no_referrer: false,
smart_punctuation: false,
}
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct CacheConfig {
#[serde(default)]
pub enable: bool,
}
#[derive(Debug, Clone, Deserialize)]
pub struct TaxonomyConfig {
pub name: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ContentDirConfig {
pub path: String,
pub url_prefix: String,
#[serde(default = "default_page_html")]
pub template: String,
#[serde(default = "default_section_html")]
pub section_template: String,
#[serde(default)]
pub sort_by: Option<SortBy>,
#[serde(default)]
pub rewrite_links: bool,
#[serde(default)]
pub exclude: Vec<String>,
}
fn default_page_html() -> String {
"page.html".to_string()
}
fn default_section_html() -> String {
"section.html".to_string()
}
fn default_true() -> bool {
true
}
fn default_en() -> String {
"en".to_string()
}
pub(crate) fn default_toml_table() -> toml::Value {
toml::Value::Table(toml::map::Map::new())
}
fn serialize_extra<S: serde::Serializer>(v: &toml::Value, s: S) -> Result<S::Ok, S::Error> {
crate::content::toml_to_json(v).serialize(s)
}
impl Config {
pub fn load(root: &Path) -> anyhow::Result<Self> {
let config_path = root.join("config.toml");
let content = std::fs::read_to_string(&config_path)
.map_err(|e| anyhow::anyhow!("cannot read {}: {e}", config_path.display()))?;
let mut config: Config =
toml::from_str(&content).map_err(|e| anyhow::anyhow!("invalid config.toml: {e}"))?;
if config.taxonomies.is_empty() {
config.taxonomies.push(TaxonomyConfig {
name: "tags".to_string(),
});
}
config.base_url = config.base_url.trim_end_matches('/').to_string();
if let Some(ref theme_name) = config.theme {
if crate::themes::Theme::from_name(theme_name).is_none() {
let available = crate::themes::Theme::available();
if available.is_empty() {
anyhow::bail!(
"Unknown theme '{theme_name}'. No built-in themes are available \
(theme features may be disabled)."
);
} else {
anyhow::bail!(
"Unknown theme '{theme_name}', available: {}",
available.join(", ")
);
}
}
}
Ok(config)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn write_config(tmp: &TempDir, content: &str) {
std::fs::write(tmp.path().join("config.toml"), content).unwrap();
}
#[test]
fn test_load_minimal_config() {
let tmp = TempDir::new().unwrap();
write_config(&tmp, r#"base_url = "https://example.com""#);
let config = Config::load(tmp.path()).unwrap();
assert_eq!(config.base_url, "https://example.com");
assert_eq!(config.title, "");
assert_eq!(config.description, "");
assert!(config.compile_sass);
assert!(config.generate_sitemap);
assert!(config.generate_llms_txt);
assert_eq!(config.markdown.insert_anchor_links, AnchorLinks::None);
assert!(config.markdown.highlight_code);
assert_eq!(config.taxonomies.len(), 1);
assert_eq!(config.taxonomies[0].name, "tags");
}
#[test]
fn test_load_full_config() {
let tmp = TempDir::new().unwrap();
write_config(
&tmp,
r#"
base_url = "https://example.com"
title = "My Site"
default_language = "fr"
compile_sass = false
[markdown]
highlight_code = false
insert_anchor_links = "right"
external_links_target_blank = true
[[taxonomies]]
name = "categories"
feed = true
"#,
);
let config = Config::load(tmp.path()).unwrap();
assert_eq!(config.title, "My Site");
assert_eq!(config.default_language, "fr");
assert!(!config.compile_sass);
assert!(!config.markdown.highlight_code);
assert_eq!(config.markdown.insert_anchor_links, AnchorLinks::Right);
assert!(config.markdown.external_links_target_blank);
assert_eq!(config.taxonomies.len(), 1);
assert_eq!(config.taxonomies[0].name, "categories");
}
#[test]
fn test_trailing_slash_stripped() {
let tmp = TempDir::new().unwrap();
write_config(&tmp, r#"base_url = "https://example.com/""#);
let config = Config::load(tmp.path()).unwrap();
assert_eq!(config.base_url, "https://example.com");
}
#[test]
fn test_default_language_fallback() {
let tmp = TempDir::new().unwrap();
write_config(&tmp, r#"base_url = "https://example.com""#);
let config = Config::load(tmp.path()).unwrap();
assert_eq!(config.default_language, "en");
}
#[test]
fn test_default_language_set() {
let tmp = TempDir::new().unwrap();
write_config(
&tmp,
r#"
base_url = "https://example.com"
default_language = "ja"
"#,
);
let config = Config::load(tmp.path()).unwrap();
assert_eq!(config.default_language, "ja");
}
#[test]
fn test_missing_config_file() {
let tmp = TempDir::new().unwrap();
let result = Config::load(tmp.path());
assert!(result.is_err());
}
#[test]
fn test_invalid_toml_syntax() {
let tmp = TempDir::new().unwrap();
write_config(&tmp, "base_url = ");
let result = Config::load(tmp.path());
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("invalid config.toml")
);
}
#[test]
fn test_missing_base_url() {
let tmp = TempDir::new().unwrap();
write_config(&tmp, r#"title = "No base URL""#);
let result = Config::load(tmp.path());
assert!(result.is_err());
}
#[test]
fn test_invalid_theme_name() {
let tmp = TempDir::new().unwrap();
write_config(
&tmp,
r#"
base_url = "https://example.com"
theme = "nonexistent-theme"
"#,
);
let result = Config::load(tmp.path());
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Unknown theme"));
}
#[test]
fn test_multiple_trailing_slashes_stripped() {
let tmp = TempDir::new().unwrap();
write_config(&tmp, r#"base_url = "https://example.com///""#);
let config = Config::load(tmp.path()).unwrap();
assert_eq!(config.base_url, "https://example.com");
}
#[test]
fn test_multiple_taxonomies() {
let tmp = TempDir::new().unwrap();
write_config(
&tmp,
r#"
base_url = "https://example.com"
[[taxonomies]]
name = "tags"
[[taxonomies]]
name = "categories"
"#,
);
let config = Config::load(tmp.path()).unwrap();
assert_eq!(config.taxonomies.len(), 2);
assert_eq!(config.taxonomies[0].name, "tags");
assert_eq!(config.taxonomies[1].name, "categories");
}
#[test]
fn test_default_values() {
let tmp = TempDir::new().unwrap();
write_config(&tmp, r#"base_url = "https://example.com""#);
let config = Config::load(tmp.path()).unwrap();
assert!(config.compile_sass);
assert!(!config.generate_feed);
assert!(config.generate_sitemap);
assert!(config.generate_llms_txt);
assert!(!config.generate_search);
assert!(!config.generate_md_files);
assert!(!config.compile_all_themes);
assert_eq!(config.default_language, "en");
assert!(config.theme.is_none());
assert!(config.content_dirs.is_empty());
}
#[test]
fn test_markdown_config_defaults() {
let tmp = TempDir::new().unwrap();
write_config(&tmp, r#"base_url = "https://example.com""#);
let config = Config::load(tmp.path()).unwrap();
assert!(config.markdown.highlight_code);
assert_eq!(config.markdown.insert_anchor_links, AnchorLinks::None);
assert!(config.markdown.highlight_theme.is_none());
assert!(!config.markdown.external_links_target_blank);
assert!(!config.markdown.external_links_no_follow);
assert!(!config.markdown.external_links_no_referrer);
assert!(!config.markdown.smart_punctuation);
}
#[test]
fn test_full_markdown_config() {
let tmp = TempDir::new().unwrap();
write_config(
&tmp,
r#"
base_url = "https://example.com"
[markdown]
highlight_code = true
insert_anchor_links = "right"
highlight_theme = "InspiredGitHub"
external_links_target_blank = true
external_links_no_follow = true
external_links_no_referrer = true
smart_punctuation = true
"#,
);
let config = Config::load(tmp.path()).unwrap();
assert!(config.markdown.highlight_code);
assert_eq!(config.markdown.insert_anchor_links, AnchorLinks::Right);
assert_eq!(
config.markdown.highlight_theme.as_deref(),
Some("InspiredGitHub")
);
assert!(config.markdown.external_links_target_blank);
assert!(config.markdown.external_links_no_follow);
assert!(config.markdown.external_links_no_referrer);
assert!(config.markdown.smart_punctuation);
}
#[test]
fn test_extra_config_values() {
let tmp = TempDir::new().unwrap();
write_config(
&tmp,
r#"
base_url = "https://example.com"
[extra]
author = "Cody"
year = 2025
social = { github = "user", twitter = "user" }
"#,
);
let config = Config::load(tmp.path()).unwrap();
let extra = &config.extra;
assert_eq!(extra.get("author").and_then(|v| v.as_str()), Some("Cody"));
assert_eq!(extra.get("year").and_then(|v| v.as_integer()), Some(2025));
let social = extra.get("social").unwrap().as_table().unwrap();
assert_eq!(social.get("github").and_then(|v| v.as_str()), Some("user"));
}
#[test]
fn test_content_dirs_config() {
let tmp = TempDir::new().unwrap();
write_config(
&tmp,
r#"
base_url = "https://example.com"
[[content_dirs]]
path = "../docs"
url_prefix = "docs"
template = "doc.html"
section_template = "doc-section.html"
sort_by = "title"
rewrite_links = true
exclude = ["internal.md", "draft.md"]
"#,
);
let config = Config::load(tmp.path()).unwrap();
assert_eq!(config.content_dirs.len(), 1);
let dir = &config.content_dirs[0];
assert_eq!(dir.path, "../docs");
assert_eq!(dir.url_prefix, "docs");
assert_eq!(dir.template, "doc.html");
assert_eq!(dir.section_template, "doc-section.html");
assert_eq!(dir.sort_by, Some(SortBy::Title));
assert!(dir.rewrite_links);
assert_eq!(dir.exclude, vec!["internal.md", "draft.md"]);
}
#[test]
fn test_content_dirs_defaults() {
let tmp = TempDir::new().unwrap();
write_config(
&tmp,
r#"
base_url = "https://example.com"
[[content_dirs]]
path = "../docs"
url_prefix = "docs"
"#,
);
let config = Config::load(tmp.path()).unwrap();
let dir = &config.content_dirs[0];
assert_eq!(dir.template, "page.html");
assert_eq!(dir.section_template, "section.html");
assert!(dir.sort_by.is_none());
assert!(!dir.rewrite_links);
assert!(dir.exclude.is_empty());
}
#[test]
fn test_generate_feed_enabled() {
let tmp = TempDir::new().unwrap();
write_config(
&tmp,
r#"
base_url = "https://example.com"
generate_feed = true
"#,
);
let config = Config::load(tmp.path()).unwrap();
assert!(config.generate_feed);
}
#[test]
fn test_generate_md_files_enabled() {
let tmp = TempDir::new().unwrap();
write_config(
&tmp,
r#"
base_url = "https://example.com"
generate_md_files = true
"#,
);
let config = Config::load(tmp.path()).unwrap();
assert!(config.generate_md_files);
}
#[test]
fn test_unknown_top_level_keys_accepted() {
let tmp = TempDir::new().unwrap();
write_config(
&tmp,
r#"
base_url = "https://example.com"
some_future_field = true
"#,
);
let config = Config::load(tmp.path()).expect("unknown top-level keys should be accepted");
assert_eq!(config.base_url, "https://example.com");
}
}