1use crate::error::{Error, Result};
4use serde::Deserialize;
5use std::path::{Path, PathBuf};
6
7#[derive(Debug, Clone, Deserialize)]
9pub struct RileyCmsConfig {
10 pub content: ContentConfig,
11 pub storage: StorageConfig,
12 pub server: Option<ServerConfig>,
13 pub git: Option<GitConfig>,
14 pub webhooks: Option<WebhooksConfig>,
15 pub auth: Option<AuthConfig>,
16}
17
18#[derive(Debug, Clone, Deserialize)]
20pub struct GitConfig {
21 pub backend_path: Option<PathBuf>,
23 #[serde(default = "default_git_max_body_size")]
25 pub max_body_size: u64,
26 #[serde(default = "default_git_cgi_timeout_secs")]
28 pub cgi_timeout_secs: u64,
29}
30
31fn default_git_max_body_size() -> u64 {
32 100 * 1024 * 1024 }
34
35fn default_git_cgi_timeout_secs() -> u64 {
36 300 }
38
39#[derive(Debug, Clone, Deserialize)]
41pub struct ContentConfig {
42 pub repo_path: PathBuf,
43 #[serde(default = "default_content_dir")]
44 pub content_dir: String,
45 #[serde(default = "default_max_content_file_size")]
48 pub max_content_file_size: u64,
49 #[serde(default = "default_max_total_content_size")]
53 pub max_total_content_size: u64,
54}
55
56fn default_content_dir() -> String {
57 "content".to_string()
58}
59
60fn default_max_content_file_size() -> u64 {
61 5 * 1024 * 1024 }
63
64fn default_max_total_content_size() -> u64 {
65 100 * 1024 * 1024 }
67
68#[derive(Debug, Clone, Deserialize)]
70pub struct StorageConfig {
71 #[serde(default = "default_backend")]
72 pub backend: String,
73 pub bucket: String,
74 #[serde(default = "default_region")]
75 pub region: String,
76 pub endpoint: Option<String>,
77 pub public_url_base: String,
78}
79
80fn default_backend() -> String {
81 "s3".to_string()
82}
83
84fn default_region() -> String {
85 "auto".to_string()
86}
87
88#[derive(Debug, Clone, Deserialize)]
90pub struct ServerConfig {
91 #[serde(default = "default_host")]
92 pub host: String,
93 #[serde(default = "default_port")]
94 pub port: u16,
95 #[serde(default)]
96 pub cors_origins: Vec<String>,
97 #[serde(default = "default_cache_max_age")]
98 pub cache_max_age: u32,
99 #[serde(default = "default_cache_stale_while_revalidate")]
100 pub cache_stale_while_revalidate: u32,
101 #[serde(default)]
105 pub behind_proxy: bool,
106}
107
108fn default_host() -> String {
109 "0.0.0.0".to_string()
110}
111
112fn default_port() -> u16 {
113 8080
114}
115
116fn default_cache_max_age() -> u32 {
117 60
118}
119
120fn default_cache_stale_while_revalidate() -> u32 {
121 300
122}
123
124impl Default for ServerConfig {
125 fn default() -> Self {
126 Self {
127 host: default_host(),
128 port: default_port(),
129 cors_origins: vec![],
130 cache_max_age: default_cache_max_age(),
131 cache_stale_while_revalidate: default_cache_stale_while_revalidate(),
132 behind_proxy: false,
133 }
134 }
135}
136
137#[derive(Debug, Clone, Deserialize)]
139pub struct WebhooksConfig {
140 #[serde(default)]
141 pub on_content_update: Vec<String>,
142 pub secret: Option<ConfigValue>,
147}
148
149#[derive(Debug, Clone, Deserialize)]
151pub struct AuthConfig {
152 pub git_token: Option<ConfigValue>,
153 pub api_token: Option<ConfigValue>,
154}
155
156#[derive(Debug, Clone, Deserialize)]
158#[serde(untagged)]
159pub enum ConfigValue {
160 Literal(String),
161}
162
163impl ConfigValue {
164 pub fn resolve(&self) -> Result<String> {
166 match self {
167 ConfigValue::Literal(s) => {
168 if let Some(var_name) = s.strip_prefix("env:") {
169 std::env::var(var_name).map_err(|_| {
170 Error::Config(format!("Environment variable {} not set", var_name))
171 })
172 } else {
173 Ok(s.clone())
174 }
175 }
176 }
177 }
178}
179
180pub struct Config;
182
183impl Config {
184 pub fn from_path(path: &Path) -> Result<RileyCmsConfig> {
186 let content = std::fs::read_to_string(path)?;
187 toml::from_str(&content).map_err(|e| Error::ConfigParse {
188 path: path.to_path_buf(),
189 source: e,
190 })
191 }
192}
193
194pub fn resolve_config(explicit_path: Option<&Path>) -> Result<RileyCmsConfig> {
202 let mut searched = Vec::new();
203
204 if let Some(path) = explicit_path {
206 if path.exists() {
207 return Config::from_path(path);
208 }
209 searched.push(path.to_path_buf());
210 }
211
212 if let Ok(env_path) = std::env::var("RILEY_CMS_CONFIG") {
214 let path = PathBuf::from(&env_path);
215 if path.exists() {
216 return Config::from_path(&path);
217 }
218 searched.push(path);
219 }
220
221 if let Ok(cwd) = std::env::current_dir() {
223 let mut dir = Some(cwd.as_path());
224 while let Some(d) = dir {
225 let config_path = d.join("riley_cms.toml");
226 if config_path.exists() {
227 return Config::from_path(&config_path);
228 }
229 searched.push(config_path);
230 dir = d.parent();
231 }
232 }
233
234 if let Some(config_dir) = dirs::config_dir() {
236 let user_config = config_dir.join("riley_cms").join("config.toml");
237 if user_config.exists() {
238 return Config::from_path(&user_config);
239 }
240 searched.push(user_config);
241 }
242
243 let system_config = PathBuf::from("/etc/riley_cms/config.toml");
245 if system_config.exists() {
246 return Config::from_path(&system_config);
247 }
248 searched.push(system_config);
249
250 Err(Error::ConfigNotFound { searched })
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256 use tempfile::TempDir;
257
258 #[test]
259 fn test_config_value_literal() {
260 let val = ConfigValue::Literal("test".to_string());
261 assert_eq!(val.resolve().unwrap(), "test");
262 }
263
264 #[test]
265 fn test_config_value_env() {
266 temp_env::with_var("TEST_RILEY_VAR", Some("from_env"), || {
267 let val = ConfigValue::Literal("env:TEST_RILEY_VAR".to_string());
268 assert_eq!(val.resolve().unwrap(), "from_env");
269 });
270 }
271
272 #[test]
273 fn test_config_value_env_missing() {
274 let val = ConfigValue::Literal("env:NONEXISTENT_RILEY_VAR_12345".to_string());
275 assert!(val.resolve().is_err());
276 }
277
278 #[test]
279 fn test_parse_minimal_config() {
280 let toml = r#"
281[content]
282repo_path = "/data/repo"
283
284[storage]
285bucket = "my-bucket"
286public_url_base = "https://assets.example.com"
287"#;
288 let config: RileyCmsConfig = toml::from_str(toml).unwrap();
289 assert_eq!(config.content.repo_path, PathBuf::from("/data/repo"));
290 assert_eq!(config.content.content_dir, "content"); assert_eq!(config.storage.bucket, "my-bucket");
292 assert_eq!(config.storage.backend, "s3"); assert_eq!(config.storage.region, "auto"); assert!(config.server.is_none());
295 assert!(config.webhooks.is_none());
296 assert!(config.auth.is_none());
297 }
298
299 #[test]
300 fn test_parse_full_config() {
301 let toml = r#"
302[content]
303repo_path = "/data/repo"
304content_dir = "posts"
305
306[storage]
307backend = "s3"
308bucket = "my-bucket"
309region = "us-east-1"
310endpoint = "https://s3.amazonaws.com"
311public_url_base = "https://assets.example.com"
312
313[server]
314host = "127.0.0.1"
315port = 3000
316cors_origins = ["https://example.com", "https://dev.example.com"]
317cache_max_age = 120
318cache_stale_while_revalidate = 600
319
320[webhooks]
321on_content_update = ["https://example.com/webhook"]
322
323[auth]
324git_token = "secret123"
325api_token = "env:API_TOKEN"
326"#;
327 let config: RileyCmsConfig = toml::from_str(toml).unwrap();
328 assert_eq!(config.content.content_dir, "posts");
329 assert_eq!(config.storage.region, "us-east-1");
330 assert_eq!(
331 config.storage.endpoint,
332 Some("https://s3.amazonaws.com".to_string())
333 );
334
335 let server = config.server.unwrap();
336 assert_eq!(server.host, "127.0.0.1");
337 assert_eq!(server.port, 3000);
338 assert_eq!(server.cors_origins.len(), 2);
339 assert_eq!(server.cache_max_age, 120);
340
341 let webhooks = config.webhooks.unwrap();
342 assert_eq!(webhooks.on_content_update.len(), 1);
343
344 let auth = config.auth.unwrap();
345 assert!(auth.git_token.is_some());
346 assert!(auth.api_token.is_some());
347 }
348
349 #[test]
350 fn test_server_config_defaults() {
351 let server = ServerConfig::default();
352 assert_eq!(server.host, "0.0.0.0");
353 assert_eq!(server.port, 8080);
354 assert!(server.cors_origins.is_empty());
355 assert_eq!(server.cache_max_age, 60);
356 assert_eq!(server.cache_stale_while_revalidate, 300);
357 }
358
359 #[test]
360 fn test_load_config_from_file() {
361 let temp_dir = TempDir::new().unwrap();
362 let config_path = temp_dir.path().join("riley_cms.toml");
363 std::fs::write(
364 &config_path,
365 r#"
366[content]
367repo_path = "/test"
368
369[storage]
370bucket = "test-bucket"
371public_url_base = "https://test.com"
372"#,
373 )
374 .unwrap();
375
376 let config = Config::from_path(&config_path).unwrap();
377 assert_eq!(config.content.repo_path, PathBuf::from("/test"));
378 }
379
380 #[test]
381 fn test_load_config_invalid_toml() {
382 let temp_dir = TempDir::new().unwrap();
383 let config_path = temp_dir.path().join("invalid.toml");
384 std::fs::write(&config_path, "this is not valid toml {{{").unwrap();
385
386 let result = Config::from_path(&config_path);
387 assert!(result.is_err());
388 }
389
390 #[test]
391 fn test_load_config_missing_required_field() {
392 let temp_dir = TempDir::new().unwrap();
393 let config_path = temp_dir.path().join("incomplete.toml");
394 std::fs::write(
395 &config_path,
396 r#"
397[content]
398repo_path = "/test"
399# Missing [storage] section
400"#,
401 )
402 .unwrap();
403
404 let result = Config::from_path(&config_path);
405 assert!(result.is_err());
406 }
407}