Skip to main content

pyohwa_core/
config.rs

1use std::path::{Path, PathBuf};
2
3use serde::Deserialize;
4
5use crate::error::ConfigError;
6use crate::site::graph::{NavItem, SidebarGroup};
7
8/// Load config from pyohwa.toml at project root.
9/// Returns default Config if file does not exist.
10pub 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}