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(v) = self.heading_scale {
140            vars.push(format!("--font-size-xl: {:.3}rem;", 1.25 * v));
141            vars.push(format!("--font-size-2xl: {:.3}rem;", 1.5 * v));
142            vars.push(format!("--font-size-3xl: {:.3}rem;", 2.0 * v));
143            vars.push(format!("--font-size-4xl: {:.3}rem;", 2.25 * v));
144        }
145        if let Some(ref v) = self.border_radius {
146            vars.push(format!("--radius: {};", v));
147            vars.push(format!("--radius-sm: {};", v));
148            vars.push(format!("--radius-lg: {};", v));
149        }
150
151        vars.join("\n    ")
152    }
153
154    pub fn has_customizations(&self) -> bool {
155        self.primary_color.is_some()
156            || self.primary_color_hover.is_some()
157            || self.accent_color.is_some()
158            || self.background_color.is_some()
159            || self.background_secondary.is_some()
160            || self.text_color.is_some()
161            || self.text_muted.is_some()
162            || self.border_color.is_some()
163            || self.link_color.is_some()
164            || self.font_family.is_some()
165            || self.heading_font_family.is_some()
166            || self.font_size.is_some()
167            || self.heading_scale.is_some()
168            || self.line_height.is_some()
169            || self.border_radius.is_some()
170    }
171}
172
173impl ThemeConfig {
174    pub const AVAILABLE_THEMES: [&'static str; 15] = [
175        "default", "minimal", "magazine", "brutalist", "neon",
176        "serif", "ocean", "midnight", "botanical", "monochrome",
177        "coral", "terminal", "nordic", "sunset", "typewriter",
178    ];
179
180    pub fn validate(&self) -> Result<()> {
181        if !Self::AVAILABLE_THEMES.contains(&self.name.as_str()) {
182            anyhow::bail!(
183                "Invalid theme '{}'. Available themes: {}",
184                self.name,
185                Self::AVAILABLE_THEMES.join(", ")
186            );
187        }
188        Ok(())
189    }
190
191    pub fn is_valid_theme(name: &str) -> bool {
192        Self::AVAILABLE_THEMES.contains(&name)
193    }
194}
195
196#[derive(Debug, Clone, Deserialize, Serialize)]
197pub struct AuthConfig {
198    #[serde(default = "default_session_lifetime")]
199    pub session_lifetime: String,
200}
201
202#[derive(Debug, Clone, Deserialize, Serialize)]
203pub struct AuditConfig {
204    #[serde(default = "default_audit_enabled")]
205    pub enabled: bool,
206    #[serde(default = "default_audit_retention_days")]
207    pub retention_days: u32,
208    #[serde(default = "default_audit_log_auth")]
209    pub log_auth_events: bool,
210    #[serde(default)]
211    pub log_content_views: bool,
212}
213
214impl Default for AuditConfig {
215    fn default() -> Self {
216        Self {
217            enabled: default_audit_enabled(),
218            retention_days: default_audit_retention_days(),
219            log_auth_events: default_audit_log_auth(),
220            log_content_views: false,
221        }
222    }
223}
224
225#[derive(Debug, Clone, Deserialize, Serialize)]
226pub struct HomepageConfig {
227    #[serde(default = "default_hero_layout")]
228    pub hero_layout: String,
229    #[serde(default)]
230    pub hero_image: Option<String>,
231    #[serde(default = "default_hero_height")]
232    pub hero_height: String,
233    #[serde(default = "default_hero_text_align")]
234    pub hero_text_align: String,
235    #[serde(default = "default_true")]
236    pub show_hero: bool,
237    #[serde(default = "default_true")]
238    pub show_pages: bool,
239    #[serde(default = "default_true")]
240    pub show_posts: bool,
241    #[serde(default = "default_posts_layout")]
242    pub posts_layout: String,
243    #[serde(default = "default_posts_columns")]
244    pub posts_columns: u8,
245    #[serde(default = "default_pages_layout")]
246    pub pages_layout: String,
247    #[serde(default)]
248    pub sections_order: Vec<String>,
249}
250
251impl Default for HomepageConfig {
252    fn default() -> Self {
253        Self {
254            hero_layout: default_hero_layout(),
255            hero_image: None,
256            hero_height: default_hero_height(),
257            hero_text_align: default_hero_text_align(),
258            show_hero: true,
259            show_pages: true,
260            show_posts: true,
261            posts_layout: default_posts_layout(),
262            posts_columns: default_posts_columns(),
263            pages_layout: default_pages_layout(),
264            sections_order: Vec::new(),
265        }
266    }
267}
268
269impl HomepageConfig {
270    pub fn get_sections_order(&self) -> Vec<&str> {
271        if self.sections_order.is_empty() {
272            vec!["hero", "pages", "posts"]
273        } else {
274            self.sections_order.iter().map(|s| s.as_str()).collect()
275        }
276    }
277}
278
279#[derive(Debug, Clone, Deserialize, Serialize)]
280pub struct ApiConfig {
281    #[serde(default = "default_false")]
282    pub enabled: bool,
283    #[serde(default = "default_api_page_size")]
284    pub default_page_size: usize,
285    #[serde(default = "default_api_max_page_size")]
286    pub max_page_size: usize,
287}
288
289impl Default for ApiConfig {
290    fn default() -> Self {
291        Self {
292            enabled: false,
293            default_page_size: default_api_page_size(),
294            max_page_size: default_api_max_page_size(),
295        }
296    }
297}
298
299#[derive(Debug, Clone, Deserialize, Serialize)]
300pub struct BackupConfig {
301    #[serde(default)]
302    pub auto_enabled: bool,
303    #[serde(default = "default_backup_interval")]
304    pub interval_hours: u64,
305    #[serde(default = "default_backup_retention")]
306    pub retention_count: usize,
307    #[serde(default = "default_backup_dir")]
308    pub directory: String,
309}
310
311impl Default for BackupConfig {
312    fn default() -> Self {
313        Self {
314            auto_enabled: false,
315            interval_hours: default_backup_interval(),
316            retention_count: default_backup_retention(),
317            directory: default_backup_dir(),
318        }
319    }
320}
321
322fn default_hero_layout() -> String {
323    "centered".to_string()
324}
325
326fn default_hero_height() -> String {
327    "medium".to_string()
328}
329
330fn default_hero_text_align() -> String {
331    "center".to_string()
332}
333
334fn default_posts_layout() -> String {
335    "grid".to_string()
336}
337
338fn default_posts_columns() -> u8 {
339    2
340}
341
342fn default_pages_layout() -> String {
343    "grid".to_string()
344}
345
346fn default_language() -> String {
347    "en".to_string()
348}
349
350fn default_host() -> String {
351    "127.0.0.1".to_string()
352}
353
354fn default_port() -> u16 {
355    3000
356}
357
358fn default_posts_per_page() -> usize {
359    10
360}
361
362fn default_pool_size() -> u32 {
363    10
364}
365
366fn default_excerpt_length() -> usize {
367    200
368}
369
370fn default_true() -> bool {
371    true
372}
373
374fn default_max_upload() -> String {
375    "10MB".to_string()
376}
377
378impl MediaConfig {
379    /// Parse max_upload_size string (e.g., "10MB", "500KB", "1GB") into bytes.
380    /// Falls back to 10MB on invalid input.
381    pub fn max_upload_bytes(&self) -> usize {
382        let s = self.max_upload_size.trim().to_uppercase();
383        if let Some(gb) = s.strip_suffix("GB") {
384            gb.parse::<usize>().unwrap_or(1) * 1024 * 1024 * 1024
385        } else if let Some(mb) = s.strip_suffix("MB") {
386            mb.parse::<usize>().unwrap_or(10) * 1024 * 1024
387        } else if let Some(kb) = s.strip_suffix("KB") {
388            kb.parse::<usize>().unwrap_or(10240) * 1024
389        } else {
390            s.parse::<usize>().unwrap_or(10 * 1024 * 1024)
391        }
392    }
393}
394
395fn default_theme() -> String {
396    "default".to_string()
397}
398
399fn default_session_lifetime() -> String {
400    "7d".to_string()
401}
402
403impl AuthConfig {
404    /// Parse session_lifetime string (e.g., "7d", "24h", "30d") into days.
405    /// Falls back to 7 days on invalid input.
406    pub fn session_lifetime_days(&self) -> i64 {
407        let s = self.session_lifetime.trim();
408        if let Some(days) = s.strip_suffix('d') {
409            days.parse::<i64>().unwrap_or(7)
410        } else if let Some(hours) = s.strip_suffix('h') {
411            let h = hours.parse::<i64>().unwrap_or(168);
412            (h + 23) / 24 // round up to whole days
413        } else {
414            s.parse::<i64>().unwrap_or(7)
415        }
416    }
417}
418
419fn default_version_retention() -> usize {
420    50
421}
422
423fn default_audit_enabled() -> bool {
424    true
425}
426
427fn default_audit_retention_days() -> u32 {
428    90
429}
430
431fn default_audit_log_auth() -> bool {
432    true
433}
434
435fn default_false() -> bool {
436    false
437}
438
439fn default_api_page_size() -> usize {
440    20
441}
442
443fn default_api_max_page_size() -> usize {
444    100
445}
446
447fn default_backup_interval() -> u64 {
448    24
449}
450
451fn default_backup_retention() -> usize {
452    7
453}
454
455fn default_backup_dir() -> String {
456    "./backups".to_string()
457}
458
459impl Config {
460    pub fn load(path: &Path) -> Result<Self> {
461        let content = std::fs::read_to_string(path).map_err(|e| {
462            anyhow::anyhow!(
463                "Could not read config file '{}': {}. Are you in a Pebble site directory?",
464                path.display(),
465                e
466            )
467        })?;
468        let config: Config = toml::from_str(&content)?;
469        config.validate()?;
470        Ok(config)
471    }
472
473    pub fn validate(&self) -> Result<()> {
474        if self.content.posts_per_page == 0 {
475            anyhow::bail!("content.posts_per_page must be greater than 0");
476        }
477        if self.content.posts_per_page > 100 {
478            anyhow::bail!("content.posts_per_page must be 100 or less");
479        }
480        if self.content.excerpt_length == 0 {
481            anyhow::bail!("content.excerpt_length must be greater than 0");
482        }
483        if self.content.excerpt_length > 10000 {
484            anyhow::bail!("content.excerpt_length must be 10000 or less");
485        }
486        self.theme.validate()?;
487        Ok(())
488    }
489}