Skip to main content

pebble_cms/config/
mod.rs

1use anyhow::Result;
2use serde::{Deserialize, Serialize};
3use std::path::Path;
4
5#[derive(Debug, Clone, Deserialize, Serialize)]
6pub struct Config {
7    pub site: SiteConfig,
8    pub server: ServerConfig,
9    pub database: DatabaseConfig,
10    pub content: ContentConfig,
11    pub media: MediaConfig,
12    pub theme: ThemeConfig,
13    pub auth: AuthConfig,
14    #[serde(default)]
15    pub audit: AuditConfig,
16    #[serde(default)]
17    pub homepage: HomepageConfig,
18    #[serde(default)]
19    pub api: ApiConfig,
20    #[serde(default)]
21    pub backup: BackupConfig,
22}
23
24#[derive(Debug, Clone, Deserialize, Serialize)]
25pub struct SiteConfig {
26    pub title: String,
27    pub description: String,
28    pub url: String,
29    #[serde(default = "default_language")]
30    pub language: String,
31}
32
33#[derive(Debug, Clone, Deserialize, Serialize)]
34pub struct ServerConfig {
35    #[serde(default = "default_host")]
36    pub host: String,
37    #[serde(default = "default_port")]
38    pub port: u16,
39}
40
41#[derive(Debug, Clone, Deserialize, Serialize)]
42pub struct DatabaseConfig {
43    pub path: String,
44    #[serde(default = "default_pool_size")]
45    pub pool_size: u32,
46}
47
48#[derive(Debug, Clone, Deserialize, Serialize)]
49pub struct ContentConfig {
50    #[serde(default = "default_posts_per_page")]
51    pub posts_per_page: usize,
52    #[serde(default = "default_excerpt_length")]
53    pub excerpt_length: usize,
54    #[serde(default = "default_true")]
55    pub auto_excerpt: bool,
56    /// Number of versions to keep per content item (0 = unlimited)
57    #[serde(default = "default_version_retention")]
58    pub version_retention: usize,
59}
60
61#[derive(Debug, Clone, Deserialize, Serialize)]
62pub struct MediaConfig {
63    pub upload_dir: String,
64    #[serde(default = "default_max_upload")]
65    pub max_upload_size: String,
66}
67
68#[derive(Debug, Clone, Deserialize, Serialize)]
69pub struct ThemeConfig {
70    #[serde(default = "default_theme")]
71    pub name: String,
72    #[serde(default)]
73    pub custom: CustomThemeOptions,
74}
75
76#[derive(Debug, Clone, Default, Deserialize, Serialize)]
77pub struct CustomThemeOptions {
78    pub primary_color: Option<String>,
79    pub primary_color_hover: Option<String>,
80    pub accent_color: Option<String>,
81    pub background_color: Option<String>,
82    pub background_secondary: Option<String>,
83    pub text_color: Option<String>,
84    pub text_muted: Option<String>,
85    pub border_color: Option<String>,
86    pub link_color: Option<String>,
87    pub font_family: Option<String>,
88    pub heading_font_family: Option<String>,
89    pub font_size: Option<String>,
90    pub heading_scale: Option<f32>,
91    pub line_height: Option<f32>,
92    pub border_radius: Option<String>,
93}
94
95impl CustomThemeOptions {
96    pub fn to_css_variables(&self) -> String {
97        let mut vars = Vec::new();
98
99        if let Some(ref v) = self.primary_color {
100            vars.push(format!("--color-primary: {};", v));
101            vars.push(format!("--color-primary-light: {}1a;", v));
102        }
103        if let Some(ref v) = self.primary_color_hover {
104            vars.push(format!("--color-primary-hover: {};", v));
105        }
106        if let Some(ref v) = self.accent_color {
107            vars.push(format!("--color-accent: {};", v));
108        }
109        if let Some(ref v) = self.background_color {
110            vars.push(format!("--bg: {};", v));
111        }
112        if let Some(ref v) = self.background_secondary {
113            vars.push(format!("--bg-secondary: {};", v));
114        }
115        if let Some(ref v) = self.text_color {
116            vars.push(format!("--text: {};", v));
117        }
118        if let Some(ref v) = self.text_muted {
119            vars.push(format!("--text-muted: {};", v));
120        }
121        if let Some(ref v) = self.border_color {
122            vars.push(format!("--border: {};", v));
123        }
124        if let Some(ref v) = self.link_color {
125            vars.push(format!("--color-link: {};", v));
126        }
127        if let Some(ref v) = self.font_family {
128            vars.push(format!("--font-sans: {};", v));
129        }
130        if let Some(ref v) = self.heading_font_family {
131            vars.push(format!("--font-display: {};", v));
132        }
133        if let Some(ref v) = self.font_size {
134            vars.push(format!("--font-size-base: {};", v));
135        }
136        if let Some(v) = self.line_height {
137            vars.push(format!("--line-height-normal: {};", v));
138        }
139        if let Some(ref v) = self.border_radius {
140            vars.push(format!("--radius: {};", v));
141            vars.push(format!("--radius-sm: {};", v));
142            vars.push(format!("--radius-lg: {};", v));
143        }
144
145        vars.join("\n    ")
146    }
147
148    pub fn has_customizations(&self) -> bool {
149        self.primary_color.is_some()
150            || self.primary_color_hover.is_some()
151            || self.accent_color.is_some()
152            || self.background_color.is_some()
153            || self.background_secondary.is_some()
154            || self.text_color.is_some()
155            || self.text_muted.is_some()
156            || self.border_color.is_some()
157            || self.link_color.is_some()
158            || self.font_family.is_some()
159            || self.heading_font_family.is_some()
160            || self.font_size.is_some()
161            || self.heading_scale.is_some()
162            || self.line_height.is_some()
163            || self.border_radius.is_some()
164    }
165}
166
167impl ThemeConfig {
168    pub const AVAILABLE_THEMES: [&'static str; 15] = [
169        "default", "minimal", "magazine", "brutalist", "neon",
170        "serif", "ocean", "midnight", "botanical", "monochrome",
171        "coral", "terminal", "nordic", "sunset", "typewriter",
172    ];
173
174    pub fn validate(&self) -> Result<()> {
175        if !Self::AVAILABLE_THEMES.contains(&self.name.as_str()) {
176            anyhow::bail!(
177                "Invalid theme '{}'. Available themes: {}",
178                self.name,
179                Self::AVAILABLE_THEMES.join(", ")
180            );
181        }
182        Ok(())
183    }
184
185    pub fn is_valid_theme(name: &str) -> bool {
186        Self::AVAILABLE_THEMES.contains(&name)
187    }
188}
189
190#[derive(Debug, Clone, Deserialize, Serialize)]
191pub struct AuthConfig {
192    #[serde(default = "default_session_lifetime")]
193    pub session_lifetime: String,
194}
195
196#[derive(Debug, Clone, Deserialize, Serialize)]
197pub struct AuditConfig {
198    #[serde(default = "default_audit_enabled")]
199    pub enabled: bool,
200    #[serde(default = "default_audit_retention_days")]
201    pub retention_days: u32,
202    #[serde(default = "default_audit_log_auth")]
203    pub log_auth_events: bool,
204    #[serde(default)]
205    pub log_content_views: bool,
206}
207
208impl Default for AuditConfig {
209    fn default() -> Self {
210        Self {
211            enabled: default_audit_enabled(),
212            retention_days: default_audit_retention_days(),
213            log_auth_events: default_audit_log_auth(),
214            log_content_views: false,
215        }
216    }
217}
218
219#[derive(Debug, Clone, Deserialize, Serialize)]
220pub struct HomepageConfig {
221    #[serde(default = "default_hero_layout")]
222    pub hero_layout: String,
223    #[serde(default)]
224    pub hero_image: Option<String>,
225    #[serde(default = "default_hero_height")]
226    pub hero_height: String,
227    #[serde(default = "default_hero_text_align")]
228    pub hero_text_align: String,
229    #[serde(default = "default_true")]
230    pub show_hero: bool,
231    #[serde(default = "default_true")]
232    pub show_pages: bool,
233    #[serde(default = "default_true")]
234    pub show_posts: bool,
235    #[serde(default = "default_posts_layout")]
236    pub posts_layout: String,
237    #[serde(default = "default_posts_columns")]
238    pub posts_columns: u8,
239    #[serde(default = "default_pages_layout")]
240    pub pages_layout: String,
241    #[serde(default)]
242    pub sections_order: Vec<String>,
243}
244
245impl Default for HomepageConfig {
246    fn default() -> Self {
247        Self {
248            hero_layout: default_hero_layout(),
249            hero_image: None,
250            hero_height: default_hero_height(),
251            hero_text_align: default_hero_text_align(),
252            show_hero: true,
253            show_pages: true,
254            show_posts: true,
255            posts_layout: default_posts_layout(),
256            posts_columns: default_posts_columns(),
257            pages_layout: default_pages_layout(),
258            sections_order: Vec::new(),
259        }
260    }
261}
262
263impl HomepageConfig {
264    pub fn get_sections_order(&self) -> Vec<&str> {
265        if self.sections_order.is_empty() {
266            vec!["hero", "pages", "posts"]
267        } else {
268            self.sections_order.iter().map(|s| s.as_str()).collect()
269        }
270    }
271}
272
273#[derive(Debug, Clone, Deserialize, Serialize)]
274pub struct ApiConfig {
275    #[serde(default = "default_false")]
276    pub enabled: bool,
277    #[serde(default = "default_api_page_size")]
278    pub default_page_size: usize,
279    #[serde(default = "default_api_max_page_size")]
280    pub max_page_size: usize,
281}
282
283impl Default for ApiConfig {
284    fn default() -> Self {
285        Self {
286            enabled: false,
287            default_page_size: default_api_page_size(),
288            max_page_size: default_api_max_page_size(),
289        }
290    }
291}
292
293#[derive(Debug, Clone, Deserialize, Serialize)]
294pub struct BackupConfig {
295    #[serde(default)]
296    pub auto_enabled: bool,
297    #[serde(default = "default_backup_interval")]
298    pub interval_hours: u64,
299    #[serde(default = "default_backup_retention")]
300    pub retention_count: usize,
301    #[serde(default = "default_backup_dir")]
302    pub directory: String,
303}
304
305impl Default for BackupConfig {
306    fn default() -> Self {
307        Self {
308            auto_enabled: false,
309            interval_hours: default_backup_interval(),
310            retention_count: default_backup_retention(),
311            directory: default_backup_dir(),
312        }
313    }
314}
315
316fn default_hero_layout() -> String {
317    "centered".to_string()
318}
319
320fn default_hero_height() -> String {
321    "medium".to_string()
322}
323
324fn default_hero_text_align() -> String {
325    "center".to_string()
326}
327
328fn default_posts_layout() -> String {
329    "grid".to_string()
330}
331
332fn default_posts_columns() -> u8 {
333    2
334}
335
336fn default_pages_layout() -> String {
337    "grid".to_string()
338}
339
340fn default_language() -> String {
341    "en".to_string()
342}
343
344fn default_host() -> String {
345    "127.0.0.1".to_string()
346}
347
348fn default_port() -> u16 {
349    3000
350}
351
352fn default_posts_per_page() -> usize {
353    10
354}
355
356fn default_pool_size() -> u32 {
357    10
358}
359
360fn default_excerpt_length() -> usize {
361    200
362}
363
364fn default_true() -> bool {
365    true
366}
367
368fn default_max_upload() -> String {
369    "10MB".to_string()
370}
371
372fn default_theme() -> String {
373    "default".to_string()
374}
375
376fn default_session_lifetime() -> String {
377    "7d".to_string()
378}
379
380fn default_version_retention() -> usize {
381    50
382}
383
384fn default_audit_enabled() -> bool {
385    true
386}
387
388fn default_audit_retention_days() -> u32 {
389    90
390}
391
392fn default_audit_log_auth() -> bool {
393    true
394}
395
396fn default_false() -> bool {
397    false
398}
399
400fn default_api_page_size() -> usize {
401    20
402}
403
404fn default_api_max_page_size() -> usize {
405    100
406}
407
408fn default_backup_interval() -> u64 {
409    24
410}
411
412fn default_backup_retention() -> usize {
413    7
414}
415
416fn default_backup_dir() -> String {
417    "./backups".to_string()
418}
419
420impl Config {
421    pub fn load(path: &Path) -> Result<Self> {
422        let content = std::fs::read_to_string(path).map_err(|e| {
423            anyhow::anyhow!(
424                "Could not read config file '{}': {}. Are you in a Pebble site directory?",
425                path.display(),
426                e
427            )
428        })?;
429        let config: Config = toml::from_str(&content)?;
430        config.validate()?;
431        Ok(config)
432    }
433
434    pub fn validate(&self) -> Result<()> {
435        if self.content.posts_per_page == 0 {
436            anyhow::bail!("content.posts_per_page must be greater than 0");
437        }
438        if self.content.posts_per_page > 100 {
439            anyhow::bail!("content.posts_per_page must be 100 or less");
440        }
441        if self.content.excerpt_length == 0 {
442            anyhow::bail!("content.excerpt_length must be greater than 0");
443        }
444        if self.content.excerpt_length > 10000 {
445            anyhow::bail!("content.excerpt_length must be 10000 or less");
446        }
447        self.theme.validate()?;
448        Ok(())
449    }
450}