Skip to main content

pebble_cms/cli/
doctor.rs

1use crate::services::database::{analyze_database, get_database_stats, run_integrity_check};
2use crate::Config;
3use crate::Database;
4use anyhow::Result;
5use std::path::Path;
6
7#[derive(Debug)]
8enum CheckStatus {
9    Ok,
10    Warn,
11    Fail,
12}
13
14impl std::fmt::Display for CheckStatus {
15    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
16        match self {
17            CheckStatus::Ok => write!(f, "\x1b[32m✓ OK\x1b[0m"),
18            CheckStatus::Warn => write!(f, "\x1b[33m⚠ WARN\x1b[0m"),
19            CheckStatus::Fail => write!(f, "\x1b[31m✗ FAIL\x1b[0m"),
20        }
21    }
22}
23
24struct CheckResult {
25    name: String,
26    status: CheckStatus,
27    detail: String,
28}
29
30pub async fn run(config_path: &Path) -> Result<()> {
31    println!("\n  Pebble Doctor — System Health Check\n");
32
33    let mut results: Vec<CheckResult> = Vec::new();
34    let mut has_failure = false;
35
36    // 1. Config validity
37    let config = match Config::load(config_path) {
38        Ok(c) => {
39            match c.validate() {
40                Ok(()) => {
41                    results.push(CheckResult {
42                        name: "Configuration".into(),
43                        status: CheckStatus::Ok,
44                        detail: format!("Loaded from {}", config_path.display()),
45                    });
46                }
47                Err(e) => {
48                    results.push(CheckResult {
49                        name: "Configuration".into(),
50                        status: CheckStatus::Fail,
51                        detail: format!("Validation error: {}", e),
52                    });
53                    has_failure = true;
54                }
55            }
56            Some(c)
57        }
58        Err(e) => {
59            results.push(CheckResult {
60                name: "Configuration".into(),
61                status: CheckStatus::Fail,
62                detail: format!("Failed to load: {}", e),
63            });
64            has_failure = true;
65            None
66        }
67    };
68
69    // If config failed, we can't proceed with DB checks
70    let config = match config {
71        Some(c) => c,
72        None => {
73            print_results(&results);
74            if has_failure {
75                println!("\n  \x1b[31mSome checks failed. Fix the issues above before deploying.\x1b[0m\n");
76            }
77            return Ok(());
78        }
79    };
80
81    // 2. Database connectivity
82    let db = match Database::open(&config.database.path) {
83        Ok(db) => {
84            match db.health_check() {
85                Ok(true) => {
86                    results.push(CheckResult {
87                        name: "Database connectivity".into(),
88                        status: CheckStatus::Ok,
89                        detail: format!("Connected to {}", config.database.path),
90                    });
91                }
92                _ => {
93                    results.push(CheckResult {
94                        name: "Database connectivity".into(),
95                        status: CheckStatus::Fail,
96                        detail: "Health check returned unexpected result".into(),
97                    });
98                    has_failure = true;
99                }
100            }
101            Some(db)
102        }
103        Err(e) => {
104            results.push(CheckResult {
105                name: "Database connectivity".into(),
106                status: CheckStatus::Fail,
107                detail: format!("Cannot open: {}", e),
108            });
109            has_failure = true;
110            None
111        }
112    };
113
114    let db = match db {
115        Some(d) => d,
116        None => {
117            print_results(&results);
118            if has_failure {
119                println!("\n  \x1b[31mSome checks failed. Fix the issues above before deploying.\x1b[0m\n");
120            }
121            return Ok(());
122        }
123    };
124
125    // 3. Database integrity
126    match run_integrity_check(&db) {
127        Ok(ref msgs) if msgs.len() == 1 && msgs[0] == "ok" => {
128            results.push(CheckResult {
129                name: "Database integrity".into(),
130                status: CheckStatus::Ok,
131                detail: "PRAGMA integrity_check passed".into(),
132            });
133        }
134        Ok(msgs) => {
135            let detail = msgs.join("; ");
136            results.push(CheckResult {
137                name: "Database integrity".into(),
138                status: CheckStatus::Fail,
139                detail: format!("Issues found: {}", detail),
140            });
141            has_failure = true;
142        }
143        Err(e) => {
144            results.push(CheckResult {
145                name: "Database integrity".into(),
146                status: CheckStatus::Fail,
147                detail: format!("Check failed: {}", e),
148            });
149            has_failure = true;
150        }
151    }
152
153    // 4. Migration status
154    {
155        let conn = db.get()?;
156        let current_version: i32 = conn
157            .query_row(
158                "SELECT COALESCE(MAX(version), 0) FROM schema_migrations",
159                [],
160                |row| row.get(0),
161            )
162            .unwrap_or(0);
163
164        let latest_version = crate::db::MIGRATION_COUNT;
165        if current_version >= latest_version {
166            results.push(CheckResult {
167                name: "Migration status".into(),
168                status: CheckStatus::Ok,
169                detail: format!("All {} migrations applied", latest_version),
170            });
171        } else if current_version == 0 {
172            results.push(CheckResult {
173                name: "Migration status".into(),
174                status: CheckStatus::Warn,
175                detail: "No migrations applied. Run `pebble migrate`".into(),
176            });
177        } else {
178            results.push(CheckResult {
179                name: "Migration status".into(),
180                status: CheckStatus::Warn,
181                detail: format!(
182                    "At version {}/{}. Run `pebble migrate` to apply pending migrations",
183                    current_version, latest_version
184                ),
185            });
186        }
187    }
188
189    // 5. Database fragmentation
190    match analyze_database(&db, &config.database.path) {
191        Ok(analysis) => {
192            if analysis.fragmentation_percent > 10.0 {
193                results.push(CheckResult {
194                    name: "Database fragmentation".into(),
195                    status: CheckStatus::Warn,
196                    detail: format!(
197                        "{:.1}% fragmented ({} wasted). Consider running VACUUM",
198                        analysis.fragmentation_percent, analysis.wasted_space_human
199                    ),
200                });
201            } else {
202                results.push(CheckResult {
203                    name: "Database fragmentation".into(),
204                    status: CheckStatus::Ok,
205                    detail: format!("{:.1}% fragmented", analysis.fragmentation_percent),
206                });
207            }
208        }
209        Err(e) => {
210            results.push(CheckResult {
211                name: "Database fragmentation".into(),
212                status: CheckStatus::Warn,
213                detail: format!("Could not analyze: {}", e),
214            });
215        }
216    }
217
218    // 6. Database file permissions
219    {
220        let db_path = Path::new(&config.database.path);
221        match std::fs::metadata(db_path) {
222            Ok(meta) => {
223                if meta.permissions().readonly() {
224                    results.push(CheckResult {
225                        name: "Database permissions".into(),
226                        status: CheckStatus::Warn,
227                        detail: "Database file is read-only".into(),
228                    });
229                } else {
230                    results.push(CheckResult {
231                        name: "Database permissions".into(),
232                        status: CheckStatus::Ok,
233                        detail: "Writable".into(),
234                    });
235                }
236            }
237            Err(e) => {
238                results.push(CheckResult {
239                    name: "Database permissions".into(),
240                    status: CheckStatus::Warn,
241                    detail: format!("Cannot stat file: {}", e),
242                });
243            }
244        }
245    }
246
247    // 7. Media directory
248    {
249        let media_dir = Path::new(&config.media.upload_dir);
250        if media_dir.exists() {
251            if media_dir.is_dir() {
252                // Try to check writability by attempting to create a temp file
253                let test_path = media_dir.join(".pebble_doctor_test");
254                match std::fs::write(&test_path, b"test") {
255                    Ok(()) => {
256                        let _ = std::fs::remove_file(&test_path);
257                        results.push(CheckResult {
258                            name: "Media directory".into(),
259                            status: CheckStatus::Ok,
260                            detail: format!("{} (writable)", config.media.upload_dir),
261                        });
262                    }
263                    Err(_) => {
264                        results.push(CheckResult {
265                            name: "Media directory".into(),
266                            status: CheckStatus::Warn,
267                            detail: format!("{} exists but is not writable", config.media.upload_dir),
268                        });
269                    }
270                }
271            } else {
272                results.push(CheckResult {
273                    name: "Media directory".into(),
274                    status: CheckStatus::Warn,
275                    detail: format!("{} exists but is not a directory", config.media.upload_dir),
276                });
277            }
278        } else {
279            results.push(CheckResult {
280                name: "Media directory".into(),
281                status: CheckStatus::Warn,
282                detail: format!("{} does not exist. It will be created on first upload", config.media.upload_dir),
283            });
284        }
285    }
286
287    // 8. Disk space (Unix only)
288    #[cfg(unix)]
289    {
290        let db_path = Path::new(&config.database.path);
291        if let Some(parent) = db_path.parent() {
292            if parent.exists() {
293                // Use libc::statvfs for disk space checking
294                match check_disk_space(parent) {
295                    Some(available_mb) => {
296                        if available_mb < 100 {
297                            results.push(CheckResult {
298                                name: "Disk space".into(),
299                                status: CheckStatus::Warn,
300                                detail: format!("Only {} MB available", available_mb),
301                            });
302                        } else {
303                            results.push(CheckResult {
304                                name: "Disk space".into(),
305                                status: CheckStatus::Ok,
306                                detail: format!("{} MB available", available_mb),
307                            });
308                        }
309                    }
310                    None => {
311                        results.push(CheckResult {
312                            name: "Disk space".into(),
313                            status: CheckStatus::Warn,
314                            detail: "Could not determine available disk space".into(),
315                        });
316                    }
317                }
318            }
319        }
320    }
321
322    #[cfg(not(unix))]
323    {
324        results.push(CheckResult {
325            name: "Disk space".into(),
326            status: CheckStatus::Ok,
327            detail: "Check skipped (non-Unix platform)".into(),
328        });
329    }
330
331    // 9. Port availability (uses configured host:port)
332    {
333        let test_addr = format!("{}:{}", config.server.host, config.server.port);
334        match std::net::TcpListener::bind(&test_addr) {
335            Ok(_listener) => {
336                results.push(CheckResult {
337                    name: format!("Port ({})", config.server.port),
338                    status: CheckStatus::Ok,
339                    detail: format!("{} is available", test_addr),
340                });
341            }
342            Err(_) => {
343                results.push(CheckResult {
344                    name: format!("Port ({})", config.server.port),
345                    status: CheckStatus::Warn,
346                    detail: format!("{} is in use. Use --port to specify an alternative", test_addr),
347                });
348            }
349        }
350    }
351
352    // 10. Database stats (INFO only)
353    match get_database_stats(&db, &config.database.path) {
354        Ok(stats) => {
355            let total_rows: i64 = stats.tables.iter().map(|t| t.row_count).sum();
356            results.push(CheckResult {
357                name: "Database stats".into(),
358                status: CheckStatus::Ok,
359                detail: format!(
360                    "{}, {} tables, {} rows, SQLite {}",
361                    stats.file_size_human,
362                    stats.tables.len(),
363                    total_rows,
364                    stats.sqlite_version
365                ),
366            });
367        }
368        Err(e) => {
369            results.push(CheckResult {
370                name: "Database stats".into(),
371                status: CheckStatus::Warn,
372                detail: format!("Could not gather stats: {}", e),
373            });
374        }
375    }
376
377    print_results(&results);
378
379    if has_failure {
380        println!("\n  \x1b[31mSome checks failed. Fix the issues above before deploying.\x1b[0m\n");
381    } else {
382        println!("\n  \x1b[32mAll checks passed. Ready to deploy.\x1b[0m\n");
383    }
384
385    Ok(())
386}
387
388fn print_results(results: &[CheckResult]) {
389    // Find the longest name for alignment
390    let max_name_len = results.iter().map(|r| r.name.len()).max().unwrap_or(20);
391
392    for (i, result) in results.iter().enumerate() {
393        println!(
394            "  {:>2}. {:<width$}  {}  {}",
395            i + 1,
396            result.name,
397            result.status,
398            result.detail,
399            width = max_name_len,
400        );
401    }
402}
403
404#[cfg(unix)]
405fn check_disk_space(path: &Path) -> Option<u64> {
406    use std::ffi::CString;
407    use std::mem::MaybeUninit;
408
409    let c_path = CString::new(path.to_str()?).ok()?;
410    let mut stat = MaybeUninit::<libc::statvfs>::uninit();
411
412    let result = unsafe { libc::statvfs(c_path.as_ptr(), stat.as_mut_ptr()) };
413
414    if result == 0 {
415        let stat = unsafe { stat.assume_init() };
416        let available_bytes = stat.f_bavail as u64 * stat.f_frsize as u64;
417        Some(available_bytes / (1024 * 1024))
418    } else {
419        None
420    }
421}