1use std::path::{Path, PathBuf};
2
3use serde::Deserialize;
4
5use crate::error::ConfigError;
6use crate::site::graph::{NavItem, SidebarGroup};
7
8pub fn load(project_root: &Path) -> Result<Config, ConfigError> {
11 let config_path = project_root.join("pyohwa.toml");
12 if !config_path.exists() {
13 return Ok(Config::default());
14 }
15 let content = std::fs::read_to_string(&config_path).map_err(|e| ConfigError::ReadError {
16 path: config_path.clone(),
17 source: e,
18 })?;
19 let config: Config = toml::from_str(&content).map_err(|e| ConfigError::ParseError {
20 path: config_path,
21 reason: e.to_string(),
22 })?;
23 Ok(config)
24}
25
26#[derive(Debug, Deserialize)]
27#[serde(default)]
28pub struct Config {
29 pub site: SiteConfig,
30 pub build: BuildConfig,
31 pub theme: ThemeConfig,
32 pub nav: Vec<NavItem>,
33 pub sidebar: SidebarConfig,
34 pub search: SearchConfig,
35 pub seo: SeoConfig,
36}
37
38impl Default for Config {
39 fn default() -> Self {
40 Self {
41 site: SiteConfig::default(),
42 build: BuildConfig::default(),
43 theme: ThemeConfig::default(),
44 nav: Vec::new(),
45 sidebar: SidebarConfig::default(),
46 search: SearchConfig::default(),
47 seo: SeoConfig::default(),
48 }
49 }
50}
51
52#[derive(Debug, Deserialize)]
53#[serde(default)]
54pub struct SiteConfig {
55 pub title: String,
56 pub description: String,
57 pub base_url: String,
58 pub language: String,
59}
60
61impl Default for SiteConfig {
62 fn default() -> Self {
63 Self {
64 title: "Documentation".to_string(),
65 description: String::new(),
66 base_url: "/".to_string(),
67 language: "en".to_string(),
68 }
69 }
70}
71
72#[derive(Debug, Deserialize)]
73#[serde(default)]
74pub struct BuildConfig {
75 pub content_dir: PathBuf,
76 pub output_dir: PathBuf,
77 pub static_dir: PathBuf,
78}
79
80impl Default for BuildConfig {
81 fn default() -> Self {
82 Self {
83 content_dir: PathBuf::from("content"),
84 output_dir: PathBuf::from("dist"),
85 static_dir: PathBuf::from("static"),
86 }
87 }
88}
89
90#[derive(Debug, Deserialize)]
91#[serde(default)]
92pub struct ThemeConfig {
93 pub name: String,
94 pub highlight_theme: String,
95 pub custom_css: Option<PathBuf>,
96}
97
98impl Default for ThemeConfig {
99 fn default() -> Self {
100 Self {
101 name: "default".to_string(),
102 highlight_theme: "one-dark".to_string(),
103 custom_css: None,
104 }
105 }
106}
107
108#[derive(Debug, Deserialize)]
109#[serde(default)]
110pub struct SidebarConfig {
111 pub auto: bool,
112 pub groups: Vec<SidebarGroup>,
113}
114
115impl Default for SidebarConfig {
116 fn default() -> Self {
117 Self {
118 auto: true,
119 groups: Vec::new(),
120 }
121 }
122}
123
124#[derive(Debug, Deserialize)]
125#[serde(default)]
126pub struct SearchConfig {
127 pub enabled: bool,
128}
129
130impl Default for SearchConfig {
131 fn default() -> Self {
132 Self { enabled: true }
133 }
134}
135
136#[derive(Debug, Deserialize)]
137#[serde(default)]
138pub struct SeoConfig {
139 pub sitemap: bool,
140 pub rss: bool,
141 pub og_image: Option<String>,
142}
143
144impl Default for SeoConfig {
145 fn default() -> Self {
146 Self {
147 sitemap: true,
148 rss: false,
149 og_image: None,
150 }
151 }
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157 use std::fs;
158
159 #[test]
160 fn missing_file_returns_default() {
161 let tmp = tempfile::tempdir().unwrap();
162 let config = load(tmp.path()).unwrap();
163 assert_eq!(config.site.title, "Documentation");
164 assert_eq!(config.build.content_dir, PathBuf::from("content"));
165 assert!(config.sidebar.auto);
166 }
167
168 #[test]
169 fn partial_config_fills_defaults() {
170 let tmp = tempfile::tempdir().unwrap();
171 fs::write(
172 tmp.path().join("pyohwa.toml"),
173 r#"
174[site]
175title = "My Docs"
176"#,
177 )
178 .unwrap();
179 let config = load(tmp.path()).unwrap();
180 assert_eq!(config.site.title, "My Docs");
181 assert_eq!(config.site.language, "en");
182 assert_eq!(config.build.output_dir, PathBuf::from("dist"));
183 }
184
185 #[test]
186 fn invalid_toml_returns_parse_error() {
187 let tmp = tempfile::tempdir().unwrap();
188 fs::write(tmp.path().join("pyohwa.toml"), "invalid = [[[").unwrap();
189 let err = load(tmp.path()).unwrap_err();
190 assert!(matches!(err, ConfigError::ParseError { .. }));
191 }
192
193 #[test]
194 fn full_config_parses_all_fields() {
195 let tmp = tempfile::tempdir().unwrap();
196 fs::write(
197 tmp.path().join("pyohwa.toml"),
198 r#"
199[site]
200title = "Full Site"
201description = "A full test"
202base_url = "/docs/"
203language = "ko"
204
205[build]
206content_dir = "src"
207output_dir = "build"
208static_dir = "public"
209
210[theme]
211name = "custom"
212highlight_theme = "monokai"
213
214[search]
215enabled = false
216
217[seo]
218sitemap = false
219rss = true
220og_image = "og.png"
221"#,
222 )
223 .unwrap();
224 let config = load(tmp.path()).unwrap();
225 assert_eq!(config.site.title, "Full Site");
226 assert_eq!(config.site.language, "ko");
227 assert_eq!(config.build.content_dir, PathBuf::from("src"));
228 assert_eq!(config.theme.highlight_theme, "monokai");
229 assert!(!config.search.enabled);
230 assert!(config.seo.rss);
231 assert_eq!(config.seo.og_image, Some("og.png".to_string()));
232 }
233}