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 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 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 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 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 {
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 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 {
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 {
249 let media_dir = Path::new(&config.media.upload_dir);
250 if media_dir.exists() {
251 if media_dir.is_dir() {
252 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 #[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 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 {
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 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 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}